diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1698847 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Firebase config (required) +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_STORAGE_BUCKET= +VITE_FIREBASE_MESSAGING_SENDER_ID= +VITE_FIREBASE_APP_ID= + +# VITE_ADMIN_PASSWORD has been removed. +# Admin authentication is now handled via Firebase Auth. +# Create an admin user in your Firebase Console → Authentication. diff --git a/.gitignore b/.gitignore index a547bf3..b50664c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules dist dist-ssr *.local +.env +.env.* +!.env.example # Editor directories and files .vscode/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a65eb9..2deb686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,8 +89,8 @@ VITE_FIREBASE_APP_ID=your_app_id # WalletConnect — Get a free Project ID at https://cloud.walletconnect.com VITE_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id -# Admin Dashboard (can be any string for local dev) -VITE_ADMIN_PASSWORD=any_local_password +# Admin Dashboard — Authentication is handled via Firebase Auth. +# Create an admin user in your Firebase Console → Authentication. ``` > ⚠️ **Never commit `.env.local` or any real API keys to the repository.** The `.gitignore` already excludes it, but please double-check before pushing. diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..c1f4cb8 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,23 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // Anyone can submit feedback; only authenticated admin can manage it + match /feedback/{docId} { + allow create: if true; + allow read, update, delete: if request.auth != null + && request.auth.uid == "ADMIN_UID_PLACEHOLDER"; + } + + // Users collection — only the owner can read their own doc + match /users/{userId} { + allow read, write: if request.auth != null + && request.auth.uid == userId; + } + + // Deny everything else by default + match /{document=**} { + allow read, write: if false; + } + } +} diff --git a/package-lock.json b/package-lock.json index 594525e..3a16502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -421,7 +420,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -829,7 +827,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.10.tgz", "integrity": "sha512-PlPhdtjgWUra+LImQTnXOUqUa/jcufZhizdR93ZjlQSS3ahCtDTG6pJw7j0OwFal18DQjICXfeVNsUUrcNisfA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.2", "@firebase/logger": "0.5.0", @@ -896,7 +893,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.10.tgz", "integrity": "sha512-tFmBuZL0/v1h6eyKRgWI58ucft6dEJmAi9nhPUXoAW4ZbPSTlnsh31AuEwUoRTz+wwRk9gmgss9GZV05ZM9Kug==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.10", "@firebase/component": "0.7.2", @@ -912,8 +908,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.12.2", @@ -1364,7 +1359,6 @@ "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -2100,7 +2094,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -2451,7 +2444,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2957,7 +2949,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -3330,7 +3321,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -3655,7 +3645,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -4362,7 +4351,6 @@ "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", "license": "MIT", - "peer": true, "dependencies": { "@solana/accounts": "5.5.1", "@solana/addresses": "5.5.1", @@ -5377,7 +5365,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.2" }, @@ -5459,7 +5446,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5648,7 +5634,6 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-2.22.1.tgz", "integrity": "sha512-cG/xwQWsBEcKgRTkQVhH29cbpbs/TdcUJVFXCyri3ZknxhMyGv0YEjTcrNpRgt2SaswL1KrvslSNYKKo+5YEAg==", "license": "MIT", - "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -6249,7 +6234,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6336,7 +6320,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6520,7 +6503,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -6655,7 +6637,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6709,7 +6690,6 @@ "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -6990,7 +6970,6 @@ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", - "peer": true, "dependencies": { "node-fetch": "^2.7.0" } @@ -7308,7 +7287,6 @@ "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", "license": "MIT", - "peer": true, "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", @@ -7498,7 +7476,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7862,8 +7839,7 @@ "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eventemitter3": { "version": "5.0.1", @@ -9758,7 +9734,6 @@ "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", "license": "MIT", - "peer": true, "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", @@ -9797,7 +9772,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9984,7 +9958,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10278,7 +10251,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10288,7 +10260,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10702,7 +10673,6 @@ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", "license": "MIT", - "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", @@ -11255,11 +11225,24 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utf-8-validate": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -11293,7 +11276,6 @@ "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", "integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==", "license": "MIT", - "peer": true, "dependencies": { "derive-valtio": "0.1.0", "proxy-compare": "2.6.0", @@ -11335,7 +11317,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -11361,7 +11342,6 @@ "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -11439,7 +11419,6 @@ "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.19.5.tgz", "integrity": "sha512-RQUfKMv6U+EcSNNGiPbdkDtJwtuFxZWLmvDiQmjjBgkuPulUwDJsKhi7gjynzJdsx2yDqhHCXkKsbbfbIsHfcQ==", "license": "MIT", - "peer": true, "dependencies": { "@wagmi/connectors": "6.2.0", "@wagmi/core": "2.22.1", @@ -11592,7 +11571,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -11687,7 +11665,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/lib/firebase.js b/src/lib/firebase.js index 67d8137..1a85125 100644 --- a/src/lib/firebase.js +++ b/src/lib/firebase.js @@ -1,5 +1,6 @@ import { initializeApp, getApps } from "firebase/app"; import { getFirestore } from "firebase/firestore"; +import { getAuth } from "firebase/auth"; const firebaseConfig = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY, @@ -14,3 +15,4 @@ const firebaseConfig = { const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; export const db = getFirestore(app); +export const auth = getAuth(app); diff --git a/src/pages/Admin/Admin.jsx b/src/pages/Admin/Admin.jsx index c359615..aab5889 100644 --- a/src/pages/Admin/Admin.jsx +++ b/src/pages/Admin/Admin.jsx @@ -9,10 +9,12 @@ import { collection, onSnapshot, doc, updateDoc, deleteDoc, query, orderBy, } from "firebase/firestore"; -import { db } from "../../lib/firebase"; - - -const ADMIN_PASSWORD = import.meta.env.VITE_ADMIN_PASSWORD; +import { + signInWithEmailAndPassword, + onAuthStateChanged, + signOut +} from "firebase/auth"; +import { db, auth } from "../../lib/firebase"; const TYPE_META = { bug: { label: "Bug", Icon: Bug, colorClass: "text-red-400", bgClass: "bg-red-500/10 border-red-500/20" }, @@ -31,14 +33,24 @@ function Stars({ n }) { return {"★".repeat(n)}{"☆".repeat(5 - n)}; } -function LoginScreen({ onAuth }) { - const [pw, setPw] = useState(""); - const [err, setErr] = useState(false); +function LoginScreen() { + const [email, setEmail] = useState(""); + const [pw, setPw] = useState(""); + const [err, setErr] = useState(false); + const [loading, setLoading] = useState(false); - function submit(e) { + async function submit(e) { e.preventDefault(); - if (pw === ADMIN_PASSWORD) { onAuth(); } - else { setErr(true); setTimeout(() => setErr(false), 1500); } + setLoading(true); + setErr(false); + try { + await signInWithEmailAndPassword(auth, email, pw); + } catch (err) { + setErr(true); + setTimeout(() => setErr(false), 3000); + } finally { + setLoading(false); + } } return ( @@ -53,17 +65,24 @@ function LoginScreen({ onAuth }) {
QuickPDF feedback dashboard
@@ -154,13 +173,22 @@ function FeedbackCard({ item, onToggleResolved, onDelete }) { } export function Admin() { - const [authed, setAuthed] = useState(() => sessionStorage.getItem("qp_admin") === "1"); + const [authed, setAuthed] = useState(false); + const [authLoading, setAuthLoading] = useState(true); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [filterType, setFilterType] = useState("all"); const [filterStatus, setFilterStatus] = useState("open"); const [sortOrder, setSortOrder] = useState("desc"); + useEffect(() => { + const unsub = onAuthStateChanged(auth, (user) => { + setAuthed(!!user); + setAuthLoading(false); + }); + return unsub; + }, []); + useEffect(() => { if (!authed) return; const q = query(collection(db, "feedback"), orderBy("createdAt", sortOrder)); @@ -171,8 +199,6 @@ export function Admin() { return unsub; }, [authed, sortOrder]); - function handleAuth() { sessionStorage.setItem("qp_admin", "1"); setAuthed(true); } - async function toggleResolved(id, current) { await updateDoc(doc(db, "feedback", id), { resolved: !current }); } @@ -180,7 +206,15 @@ export function Admin() { await deleteDoc(doc(db, "feedback", id)); } - if (!authed) return