diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a678283 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Firebase web client configuration. +# Copy this file to `.env.local` and fill in the values from your Firebase +# project settings (Project settings → General → Your apps → SDK setup). +# +# Note: these "web API keys" are safe to expose in client bundles. Security is +# enforced by Firebase Authentication and Firestore Security Rules, NOT by +# keeping these values secret. See docs/ADMIN.md for the security model. +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_FIREBASE_MEASUREMENT_ID= + +# Local preview only: set to `true` to bypass Firebase auth and view the admin +# panel as a fake super_admin with demo data (see docs/ADMIN.md §5.5). This is +# hard-gated on `import.meta.env.DEV`, so it can NEVER activate in a production +# build. Leave empty/false otherwise. +VITE_ADMIN_DEV_MOCK= diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 0000000..e7916cd --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,308 @@ +# Servio Admin Portal + +An internal administration portal with Firebase Authentication, role-based +access control (RBAC), dynamic navigation, protected routes, and a security-PIN +layer for sensitive actions. + +> Implements GitHub issue #64 — "Build Admin Dashboard Foundation with +> Role-Based Access Control (RBAC) and Security PIN Support". + +--- + +## 1. Architecture overview + +The portal lives entirely in the existing Vite + React SPA under +[`src/admin/`](../src/admin) and is mounted at **`/admin`** by the app router. + +``` +Browser ──▶ AuthProvider (Firebase Auth) ← existing, app-wide + └─ /admin/* ──▶ AdminApp + ├─ AdminProvider loads admins/{uid}, exposes role + can() + └─ PinGateProvider session PIN verification + └─ Routes + ├─ /admin/login (public) + ├─ /admin/unauthorized (public) + └─ ProtectedAdminRoute requires a valid admin + └─ AdminLayout (sidebar + topbar) + ├─ /admin/dashboard + ├─ /admin/projects + ├─ /admin/clients + ├─ /admin/messages + ├─ /admin/audit (audit:view) + └─ /admin/settings (super_admin) +``` + +Access is enforced in **two layers**: + +1. **Client guards** (`src/admin`) — for UX: redirect unauthenticated/unauthorized + users, hide controls a role can't use, prompt for a PIN. These are + convenience only and **can be bypassed** by a determined user. +2. **Firestore Security Rules** ([`firestore.rules`](../firestore.rules)) — the + real, server-side enforcement. The role→capability matrix is mirrored there. + +> ⚠️ Because this is a client-only SPA (Firebase Hosting, no backend server), +> **the security rules are what actually protect your data.** Always deploy them. + +### Directory map + +| Path | Responsibility | +| --- | --- | +| `src/admin/types.ts` | Domain types for all collections | +| `src/admin/rbac/permissions.ts` | `Permission` union, role→permission matrix, `hasPermission`, sensitive set | +| `src/admin/rbac/roles.ts` | Role list, display metadata, `isAdminRole` guard | +| `src/admin/rbac/navigation.ts` | Sidebar items + the permission each needs | +| `src/admin/lib/collections.ts` | Collection refs + safe Firestore→type parsers | +| `src/admin/lib/pin.ts` | PBKDF2 PIN hashing / verification (Web Crypto) | +| `src/admin/lib/audit.ts` | `writeAuditLog()` helper | +| `src/admin/lib/format.ts` | Date / currency formatting | +| `src/admin/context/AdminContext.tsx` | Loads `admins/{uid}`, exposes role + `can()` | +| `src/admin/context/PinProvider.tsx` | Session PIN gate + renders `PinDialog` | +| `src/admin/hooks/useAdminData.ts` | Real-time collection hooks (`useProjects`, …) | +| `src/admin/hooks/useSensitiveAction.ts` | Wrap an action behind a PIN challenge | +| `src/admin/components/guards/*` | `ProtectedAdminRoute`, `RequirePermission` | +| `src/admin/components/*` | Layout, sidebar, PIN dialog, shared UI | +| `src/admin/pages/*` | Login, Unauthorized, Dashboard, Projects, Clients, Messages, Audit, Settings | +| `src/admin/AdminApp.tsx` | The `/admin/*` route tree + providers | + +--- + +## 2. Roles & permissions (RBAC) + +Four roles are defined. Capabilities are expressed as fine-grained +`resource:action` permissions; routes and controls gate on **permissions**, not +roles directly, so the mapping can evolve in one place +(`src/admin/rbac/permissions.ts`). + +| Permission | super_admin | frontend_dev | backend_dev | qa_delivery | +| --- | :---: | :---: | :---: | :---: | +| `dashboard:view` | ✅ | ✅ | ✅ | ✅ | +| `projects:view` | ✅ | ✅ | ✅ | ✅ | +| `projects:edit` | ✅ | ✅ | ✅ | — | +| `projects:assign` | ✅ | — | ✅ | — | +| `projects:delete` 🔒 | ✅ | — | — | — | +| `clients:view` | ✅ | ✅ | ✅ | ✅ | +| `clients:edit` | ✅ | — | ✅ | — | +| `messages:view` | ✅ | ✅ | ✅ | ✅ | +| `messages:reply` | ✅ | ✅ | ✅ | ✅ | +| `settings:view` 🔒 | ✅ | — | — | — | +| `admins:manage` 🔒 | ✅ | — | — | — | +| `audit:view` | ✅ | — | ✅ | — | +| `business:view_sensitive` 🔒 | ✅ | — | — | — | + +🔒 = **sensitive** — requires a security-PIN challenge in addition to the +permission (see `SENSITIVE_PERMISSIONS`). + +This satisfies the issue's examples: +- `frontend_dev` cannot access settings or manage admins. +- `backend_dev` cannot manage admins. +- `qa_delivery` cannot modify project assignments (`projects:assign`/`edit`). +- `super_admin` has full access. + +--- + +## 3. Firestore data model + +| Collection | Doc id | Purpose | +| --- | --- | --- | +| `admins` | Firebase Auth `uid` | Admin profile, role, hashed PIN | +| `projects` | auto | Delivery projects | +| `clients` | auto | Client directory | +| `messages` | auto | Inbound contact/quote submissions | +| `audit_logs` | auto | Append-only record of sensitive actions | + +### `admins/{uid}` +```ts +{ + email: string, + displayName: string, + role: 'super_admin' | 'frontend_dev' | 'backend_dev' | 'qa_delivery', + disabled: boolean, + // security PIN (set by the admin; never store the raw PIN) + pinHash?: string, // PBKDF2-SHA256, hex + pinSalt?: string, // hex + pinIterations?: number, + createdAt?: Timestamp, + updatedAt?: Timestamp, + lastLoginAt?: Timestamp, +} +``` + +### `projects/{id}` +```ts +{ name, clientId?, clientName?, status, assignedTo: string[], budget?, description?, createdAt?, updatedAt? } +// status: 'lead' | 'active' | 'on_hold' | 'completed' | 'archived' +``` + +### `clients/{id}` +```ts +{ name, company?, email, phone?, notes?, createdAt?, updatedAt? } +``` + +### `messages/{id}` +```ts +{ name, email, subject?, body, status, createdAt? } +// status: 'new' | 'read' | 'replied' | 'archived' +``` + +### `audit_logs/{id}` +```ts +{ actorUid, actorEmail, action, targetType?, targetId?, metadata?, createdAt? } +// e.g. action: 'project.delete', 'admin.role_change', 'admin.pin_set' +``` + +--- + +## 4. Security PIN + +Sensitive actions (delete project, change admin roles, open system config, view +sensitive business data) require an extra PIN challenge on top of the role +check. + +- The PIN is a 6-digit code, hashed with **PBKDF2-SHA256** + a per-admin random + salt via the Web Crypto API (`src/admin/lib/pin.ts`). Only the hash, salt and + iteration count are stored on `admins/{uid}` — never the raw PIN. +- Verification is **session-based**: once verified, it stays valid for + `PIN_SESSION_TTL_MS` (5 minutes) before the gate prompts again. +- First use: if no PIN is configured, the dialog runs in **setup** mode (enter + + confirm) and saves the credential. +- Usage in code: + ```tsx + const runSensitive = useSensitiveAction(); + await runSensitive(async () => { + await deleteDoc(doc(db, 'projects', id)); // only runs after PIN verified + }); + ``` + +### Production hardening (recommended) + +Client-side hashing protects the PIN **at rest in Firestore**, but a determined +user with a valid session could bypass the client check. For real protection of +sensitive writes, move verification server-side: + +1. Add a Cloud Function `verifyAdminPin` that checks the PIN and mints a + short-lived custom claim / token. +2. Tighten `firestore.rules` so sensitive writes require that claim. + +The PIN module is intentionally small so only `hashPin`/`verifyPin` need to +change. + +--- + +## 5. Setup + +### 5.1 Environment variables + +Firebase web config is read from Vite env vars. Copy the template and fill it in: + +```bash +cp .env.example .env.local +``` + +`.env.local` (git-ignored) is already populated for the `servio-0` project. +These "web API keys" are safe to ship in the client bundle — access is gated by +Auth + Security Rules, not by key secrecy. + +### 5.2 Enable Firebase services + +In the [Firebase console](https://console.firebase.google.com/project/servio-0): +1. **Authentication** → enable the **Email/Password** provider (and Google if + desired). +2. **Firestore Database** → create the database (production mode). + +### 5.3 Bootstrap the first super admin + +The rules only let an existing `super_admin` create admin docs, so seed the +first one by hand: + +1. **Authentication → Users → Add user** — create the admin's email + password + (or have them sign up). Copy the generated **User UID**. +2. **Firestore → Start collection** `admins` → **Document ID = that UID**, fields: + | Field | Type | Value | + | --- | --- | --- | + | `email` | string | the admin's email | + | `displayName` | string | e.g. `Harsh Goswami` | + | `role` | string | `super_admin` | + | `disabled` | boolean | `false` | + | `createdAt` | timestamp | now | +3. Visit `/admin/login`, sign in — you now have full access and can add the rest + of the team from **Settings → Admin users**. + +### 5.4 Deploy security rules + +```bash +firebase deploy --only firestore:rules +# (.firebaserc already targets the `servio-0` project) +``` + +### 5.5 Local preview without a backend (dev mock) + +To browse the admin UI locally without configuring Firebase Auth/Firestore, set +`VITE_ADMIN_DEV_MOCK=true` in `.env.local` and run `npm run dev`. Visiting +`/admin` then signs you in as a fake **super_admin** ("Dev Admin") with demo +data across every page. + +- **Hard-gated on `import.meta.env.DEV`**, which is `false` in `vite build`, so + the mock — and its demo data/fake credentials — can never activate in or be + bundled into a production build (the mock args fold away and are tree-shaken). +- It's a **read-only preview**: writes (create/delete/role change) hit real + Firestore and will fail without a backend. +- Turn it off by clearing the env var and restarting the dev server. + +--- + +## 6. Routes + +| Route | Access | +| --- | --- | +| `/admin` | redirects to `/admin/dashboard` | +| `/admin/login` | public | +| `/admin/unauthorized` | public (shown to non-admins) | +| `/admin/dashboard` | any admin | +| `/admin/projects` | `projects:view` | +| `/admin/clients` | `clients:view` | +| `/admin/messages` | `messages:view` | +| `/admin/audit` | `audit:view` (super_admin + backend_dev) | +| `/admin/settings` | `settings:view` (super_admin) | + +The sidebar is generated from `ADMIN_NAV`, filtered by the signed-in admin's +permissions — each role sees only what it can use. + +--- + +## 7. Acceptance criteria → where it lives + +| Criterion | Implementation | +| --- | --- | +| Admin authentication | `pages/AdminLogin.tsx`, `context/AdminContext.tsx` | +| Dedicated `/admin` route | `AdminApp.tsx`, wired in `src/app/App.tsx` | +| Dashboard layout & navigation | `components/AdminLayout.tsx`, `AdminSidebar.tsx` | +| RBAC system | `rbac/permissions.ts`, `rbac/roles.ts` | +| Protected routes enforce permissions | `components/guards/*`, `firestore.rules` | +| Dynamic sidebar based on role | `AdminSidebar.tsx` + `rbac/navigation.ts` | +| Security PIN architecture | `lib/pin.ts`, `context/PinProvider.tsx`, `components/PinDialog.tsx` | +| Firestore collections designed & documented | this file + `firestore.rules` | +| Unauthorized access handling | `pages/Unauthorized.tsx`, guards, rules | +| Ready for future features | typed data layer, hooks, audit log | + +--- + +## 8. Known limitations / future work + +- **PIN verification is client-side** (see §4 hardening). Relatedly, the PIN + hash/salt live on the readable `admins/{uid}` document, so a super_admin can + read (and offline-brute-force) another admin's 6-digit PIN. This is a + defense-in-depth gap, not a privilege escalation (the PIN is a UX gate only, + never enforced in rules). Hardening: move `pinHash/pinSalt/pinIterations` into + an owner-only subdocument (e.g. `admins/{uid}/security/pin`) and/or verify in a + Cloud Function. +- Collection hooks subscribe to whole collections and sort client-side — fine + for the foundation; add pagination/queries + composite indexes as data grows. +- Admin creation is manual for the first super admin (by design). +- The last-enabled-super-admin guard is enforced client-side; for true + guarantees move admin role/disable mutations behind a Cloud Function that + performs the count-and-block transactionally. +- The public `messages` create rule is hardened (server timestamp required, + bounded field sizes, basic email shape) but is still unauthenticated by + design. For scripted-spam protection, enable Firebase App Check and/or route + submissions through a rate-limited callable function. Wire the marketing + contact/quote forms to `addDoc(messagesCollection, …)` to populate it. diff --git a/firebase.json b/firebase.json index 51273e0..ca3a0dd 100644 --- a/firebase.json +++ b/firebase.json @@ -1,5 +1,6 @@ { "firestore": { + "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "hosting": { diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..c324826 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,166 @@ +rules_version = '2'; + +// Servio admin portal — Firestore Security Rules. +// +// These rules are the AUTHORITATIVE access control for the admin data. The +// client-side guards in src/admin (route guards, `can()` checks) are UX only +// and can be bypassed; THESE rules are what actually protect the data on the +// server. The role -> capability matrix below mirrors +// src/admin/rbac/permissions.ts — keep the two in sync. + +service cloud.firestore { + match /databases/{database}/documents { + + // ---- helpers ------------------------------------------------------- + + function isSignedIn() { + return request.auth != null; + } + + function adminPath() { + return /databases/$(database)/documents/admins/$(request.auth.uid); + } + + function adminData() { + return get(adminPath()).data; + } + + // A valid, enabled admin account. + function isAdmin() { + return isSignedIn() + && exists(adminPath()) + && adminData().disabled != true; + } + + function role() { + return adminData().role; + } + + function isSuperAdmin() { + return isAdmin() && role() == 'super_admin'; + } + + // Capability check mirroring the role -> permission matrix. + function can(perm) { + return isAdmin() && ( + role() == 'super_admin' + || (role() == 'frontend_dev' && perm in [ + 'dashboard:view', 'projects:view', 'projects:edit', + 'clients:view', 'messages:view', 'messages:reply' + ]) + || (role() == 'backend_dev' && perm in [ + 'dashboard:view', 'projects:view', 'projects:edit', 'projects:assign', + 'clients:view', 'clients:edit', 'messages:view', 'messages:reply', + 'audit:view' + ]) + || (role() == 'qa_delivery' && perm in [ + 'dashboard:view', 'projects:view', 'clients:view', + 'messages:view', 'messages:reply' + ]) + ); + } + + // ---- admins -------------------------------------------------------- + + match /admins/{uid} { + // Read your own profile; super admins can read everyone. + allow get: if isSignedIn() && (request.auth.uid == uid || isSuperAdmin()); + allow list: if isSuperAdmin(); + + // Creating/removing admin accounts is super-admin only. + allow create, delete: if isSuperAdmin(); + + // Updates: super admins may change anything (role, disabled, ...). + // The account owner may only touch their own profile/PIN fields — the + // key allowlist makes it impossible to change role/disabled (or inject + // any unexpected field), so no self-escalation is possible. + allow update: if isSuperAdmin() + || ( + request.auth.uid == uid + && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'displayName', 'email', + 'pinHash', 'pinSalt', 'pinIterations', + 'updatedAt', 'lastLoginAt' + ]) + ); + } + + // ---- projects ------------------------------------------------------ + + match /projects/{id} { + allow read: if can('projects:view'); + allow create, update: if can('projects:edit'); + allow delete: if can('projects:delete'); + } + + // ---- clients ------------------------------------------------------- + + match /clients/{id} { + allow read: if can('clients:view'); + allow create, update: if can('clients:edit'); + allow delete: if isSuperAdmin(); + } + + // ---- messages ------------------------------------------------------ + + match /messages/{id} { + // The public website may submit a contact/quote message. Lock the shape + // down hard: required server timestamp (blocks inbox-ordering tricks), + // bounded string sizes (blocks oversized/DoS writes), basic email shape, + // and status pinned to 'new'. For scripted-spam protection beyond rules, + // enable Firebase App Check and/or a rate-limited callable function. + allow create: if + request.resource.data.keys().hasOnly( + ['name', 'email', 'subject', 'body', 'status', 'createdAt'] + ) + && request.resource.data.keys().hasAll( + ['name', 'email', 'body', 'status', 'createdAt'] + ) + && request.resource.data.status == 'new' + && request.resource.data.createdAt == request.time + && request.resource.data.name is string + && request.resource.data.name.size() > 0 + && request.resource.data.name.size() < 200 + && request.resource.data.email is string + && request.resource.data.email.size() < 320 + && request.resource.data.email.matches('^[^@]+@[^@]+[.][^@]+$') + && request.resource.data.body is string + && request.resource.data.body.size() > 0 + && request.resource.data.body.size() < 5000 + && ( + !('subject' in request.resource.data) + || (request.resource.data.subject is string + && request.resource.data.subject.size() < 300) + ); + + allow read: if can('messages:view'); + allow update: if can('messages:reply'); + allow delete: if isSuperAdmin(); + } + + // ---- audit_logs ---------------------------------------------------- + + match /audit_logs/{id} { + allow read: if can('audit:view'); + + // Append-only and unforgeable: an admin may write log entries only for + // themselves, with the actor email bound to their auth token and the + // timestamp bound to the server clock (no spoofing / backdating). + // Entries can never be edited or deleted. + allow create: if isAdmin() + && request.resource.data.keys().hasOnly([ + 'actorUid', 'actorEmail', 'action', + 'targetType', 'targetId', 'metadata', 'createdAt' + ]) + && request.resource.data.actorUid == request.auth.uid + && request.resource.data.actorEmail == request.auth.token.email + && request.resource.data.createdAt == request.time + && request.resource.data.action is string + && request.resource.data.action.size() > 0 + && request.resource.data.action.size() < 200; + allow update, delete: if false; + } + + // Everything else is denied by default. + } +} diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx new file mode 100644 index 0000000..e448079 --- /dev/null +++ b/src/admin/AdminApp.tsx @@ -0,0 +1,88 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { AdminProvider } from "./context/AdminContext"; +import { PinGateProvider } from "./context/PinProvider"; +import { AdminLayout } from "./components/AdminLayout"; +import { ProtectedAdminRoute } from "./components/guards/ProtectedAdminRoute"; +import { RequirePermission } from "./components/guards/RequirePermission"; +import { AdminLogin } from "./pages/AdminLogin"; +import { Unauthorized } from "./pages/Unauthorized"; +import { Dashboard } from "./pages/Dashboard"; +import { Projects } from "./pages/Projects"; +import { Clients } from "./pages/Clients"; +import { Messages } from "./pages/Messages"; +import { Audit } from "./pages/Audit"; +import { Settings } from "./pages/Settings"; + +/** + * The `/admin/*` route subtree. Mounted from the app router under the shared + * , it layers admin role/permission state (AdminProvider) and the + * security-PIN gate (PinGateProvider) on top, then defines the protected routes. + */ +export function AdminApp() { + return ( + + + + } /> + } /> + + }> + }> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } /> + + + + ); +} diff --git a/src/admin/components/AdminLayout.tsx b/src/admin/components/AdminLayout.tsx new file mode 100644 index 0000000..09572b4 --- /dev/null +++ b/src/admin/components/AdminLayout.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Outlet } from "react-router-dom"; +import { Menu } from "lucide-react"; +import { cn } from "@/app/components/ui/utils"; +import { Toaster } from "@/app/components/ui/sonner"; +import { AdminSidebar } from "./AdminSidebar"; + +export function AdminLayout() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+ {/* Desktop sidebar (fixed) */} +
+ +
+ + {/* Mobile slide-over */} +
+
setMobileOpen(false)} + /> +
+ setMobileOpen(false)} + /> +
+
+ +
+
+ + + Admin Portal + +
+ +
+ +
+
+ + +
+ ); +} diff --git a/src/admin/components/AdminLoading.tsx b/src/admin/components/AdminLoading.tsx new file mode 100644 index 0000000..7b12091 --- /dev/null +++ b/src/admin/components/AdminLoading.tsx @@ -0,0 +1,14 @@ +import { Loader2 } from "lucide-react"; + +export function AdminLoading({ label = "Loading…" }: { label?: string }) { + return ( +
+
+
+
+ ); +} diff --git a/src/admin/components/AdminSidebar.tsx b/src/admin/components/AdminSidebar.tsx new file mode 100644 index 0000000..29f8841 --- /dev/null +++ b/src/admin/components/AdminSidebar.tsx @@ -0,0 +1,99 @@ +import { NavLink, useNavigate } from "react-router-dom"; +import { signOut } from "firebase/auth"; +import { LogOut, ShieldCheck } from "lucide-react"; +import { auth } from "@/Firebase/firebase"; +import { cn } from "@/app/components/ui/utils"; +import { ADMIN_NAV } from "../rbac/navigation"; +import { useAdmin } from "../context/useAdmin"; +import { RoleBadge } from "./RoleBadge"; +import { initials } from "../lib/format"; + +export function AdminSidebar({ + className, + onNavigate, +}: { + className?: string; + onNavigate?: () => void; +}) { + const { admin, can } = useAdmin(); + const navigate = useNavigate(); + const items = ADMIN_NAV.filter((item) => can(item.permission)); + + const handleSignOut = async () => { + await signOut(auth); + onNavigate?.(); + navigate("/admin/login", { replace: true }); + }; + + return ( + + ); +} diff --git a/src/admin/components/EmptyState.tsx b/src/admin/components/EmptyState.tsx new file mode 100644 index 0000000..19701e3 --- /dev/null +++ b/src/admin/components/EmptyState.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; +import { LucideIcon } from "lucide-react"; + +export function EmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon: LucideIcon; + title: string; + description?: string; + action?: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description && ( +

+ {description} +

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/src/admin/components/PageHeader.tsx b/src/admin/components/PageHeader.tsx new file mode 100644 index 0000000..e7a6de4 --- /dev/null +++ b/src/admin/components/PageHeader.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/admin/components/PinDialog.tsx b/src/admin/components/PinDialog.tsx new file mode 100644 index 0000000..1d820a7 --- /dev/null +++ b/src/admin/components/PinDialog.tsx @@ -0,0 +1,256 @@ +import { useContext, useEffect, useState } from "react"; +import { OTPInput, OTPInputContext, REGEXP_ONLY_DIGITS } from "input-otp"; +import { doc, serverTimestamp, updateDoc } from "firebase/firestore"; +import { Loader2, ShieldCheck } from "lucide-react"; +import { db } from "@/Firebase/firebase"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/app/components/ui/dialog"; +import { Button } from "@/app/components/ui/button"; +import { cn } from "@/app/components/ui/utils"; +import { COLLECTIONS } from "../lib/collections"; +import { + createPinCredential, + isValidPin, + PIN_LENGTH, + verifyPin, +} from "../lib/pin"; +import { writeAuditLog } from "../lib/audit"; +import { AdminProfile } from "../types"; + +interface PinDialogProps { + open: boolean; + admin: AdminProfile | null; + onCancel: () => void; + onSuccess: () => void; +} + +/** Masked PIN slots — render the digit count, never the digits. */ +function PinSlots({ count, invalid }: { count: number; invalid: boolean }) { + const ctx = useContext(OTPInputContext); + return ( +
+ {Array.from({ length: PIN_LENGTH }).map((_, i) => { + const active = ctx?.slots[i]?.isActive ?? false; + const filled = i < count; + return ( + + ); + })} +
+ ); +} + +export function PinDialog({ open, admin, onCancel, onSuccess }: PinDialogProps) { + // Require BOTH hash and salt to be in "verify" mode — a record with a hash + // but a missing/corrupt salt would otherwise be an unrecoverable dead end; + // instead we fall back to "setup" so the admin can re-establish a PIN. + const hasUsablePin = Boolean(admin?.pinHash && admin?.pinSalt); + const mode: "verify" | "setup" = hasUsablePin ? "verify" : "setup"; + const [pin, setPin] = useState(""); + const [confirmPin, setConfirmPin] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + // Clear transient state whenever the dialog (re)opens. + useEffect(() => { + if (open) { + setPin(""); + setConfirmPin(""); + setError(null); + setBusy(false); + } + }, [open]); + + const handleSubmit = async () => { + if (busy) return; + if (!admin) { + setError("No admin account is loaded."); + return; + } + setError(null); + + if (mode === "verify") { + if (!admin.pinHash || !admin.pinSalt) { + setError("No PIN is configured for this account."); + return; + } + if (!isValidPin(pin)) { + setError(`Enter your ${PIN_LENGTH}-digit PIN.`); + return; + } + setBusy(true); + try { + const ok = await verifyPin(pin, { + hash: admin.pinHash, + salt: admin.pinSalt, + iterations: admin.pinIterations, + }); + if (ok) { + onSuccess(); + } else { + setError("Incorrect PIN. Please try again."); + setPin(""); + } + } catch (err) { + console.error("[pin] verification failed", err); + setError("Could not verify PIN. Please try again."); + } finally { + setBusy(false); + } + return; + } + + // setup mode + if (!isValidPin(pin)) { + setError(`Choose a ${PIN_LENGTH}-digit PIN.`); + return; + } + if (pin !== confirmPin) { + setError("PINs do not match."); + setConfirmPin(""); + return; + } + setBusy(true); + try { + const cred = await createPinCredential(pin); + await updateDoc(doc(db, COLLECTIONS.admins, admin.uid), { + pinHash: cred.hash, + pinSalt: cred.salt, + pinIterations: cred.iterations, + updatedAt: serverTimestamp(), + }); + await writeAuditLog({ + actorUid: admin.uid, + actorEmail: admin.email, + action: "admin.pin_set", + }); + onSuccess(); + } catch (err) { + console.error("[pin] could not save PIN", err); + setError("Could not save PIN. Please try again."); + } finally { + setBusy(false); + } + }; + + const canSubmit = + !busy && + isValidPin(pin) && + (mode === "verify" || isValidPin(confirmPin)); + + return ( + { + if (!next && !busy) onCancel(); + }} + > + + +
+
+ + {mode === "setup" ? "Set your security PIN" : "Security verification"} + + + {mode === "setup" + ? `Create a ${PIN_LENGTH}-digit PIN. You'll be asked for it before sensitive actions.` + : "Enter your security PIN to continue with this sensitive action."} + +
+ +
{ + e.preventDefault(); + void handleSubmit(); + }} + className="space-y-5" + > +
+ {mode === "setup" && ( + + )} + + + +
+ + {mode === "setup" && ( +
+ + + + +
+ )} + + {error && ( +

+ {error} +

+ )} + + + + + +
+
+
+ ); +} diff --git a/src/admin/components/RoleBadge.tsx b/src/admin/components/RoleBadge.tsx new file mode 100644 index 0000000..d93342d --- /dev/null +++ b/src/admin/components/RoleBadge.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/app/components/ui/utils"; +import { AdminRole } from "../types"; +import { ROLE_META } from "../rbac/roles"; + +export function RoleBadge({ + role, + className, +}: { + role: AdminRole; + className?: string; +}) { + const meta = ROLE_META[role]; + return ( + + {meta.label} + + ); +} diff --git a/src/admin/components/StatCard.tsx b/src/admin/components/StatCard.tsx new file mode 100644 index 0000000..6c71d2b --- /dev/null +++ b/src/admin/components/StatCard.tsx @@ -0,0 +1,24 @@ +import { LucideIcon } from "lucide-react"; + +export function StatCard({ + icon: Icon, + label, + value, + hint, +}: { + icon: LucideIcon; + label: string; + value: string | number; + hint?: string; +}) { + return ( +
+
+

{label}

+
+

{value}

+ {hint &&

{hint}

} +
+ ); +} diff --git a/src/admin/components/guards/ProtectedAdminRoute.tsx b/src/admin/components/guards/ProtectedAdminRoute.tsx new file mode 100644 index 0000000..f4fc3ff --- /dev/null +++ b/src/admin/components/guards/ProtectedAdminRoute.tsx @@ -0,0 +1,30 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAdmin } from "../../context/useAdmin"; +import { AdminLoading } from "../AdminLoading"; + +/** + * Gate for the whole authenticated admin area. Renders nested routes only when + * a signed-in user has a valid, enabled admin profile. Otherwise redirects: + * - not signed in → /admin/login + * - signed in, not admin → /admin/unauthorized + * + * This is a UX guard; real enforcement lives in firestore.rules. + */ +export function ProtectedAdminRoute() { + const { firebaseUser, isAdmin, loading } = useAdmin(); + const location = useLocation(); + + if (loading) { + return ; + } + + if (!firebaseUser) { + return ; + } + + if (!isAdmin) { + return ; + } + + return ; +} diff --git a/src/admin/components/guards/RequirePermission.tsx b/src/admin/components/guards/RequirePermission.tsx new file mode 100644 index 0000000..6130b33 --- /dev/null +++ b/src/admin/components/guards/RequirePermission.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAdmin } from "../../context/useAdmin"; +import { Permission } from "../../rbac/permissions"; + +interface RequirePermissionProps { + permission: Permission; + children: ReactNode; + /** Where to send users lacking the permission. */ + redirectTo?: string; +} + +/** + * Route-level capability gate. Wrap a page element to require a permission; + * users without it are redirected (default: the unauthorized page). + */ +export function RequirePermission({ + permission, + children, + redirectTo = "/admin/unauthorized", +}: RequirePermissionProps) { + const { can } = useAdmin(); + if (!can(permission)) { + return ; + } + return <>{children}; +} diff --git a/src/admin/context/AdminContext.tsx b/src/admin/context/AdminContext.tsx new file mode 100644 index 0000000..815da7f --- /dev/null +++ b/src/admin/context/AdminContext.tsx @@ -0,0 +1,99 @@ +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { doc, onSnapshot } from "firebase/firestore"; +import { db } from "@/Firebase/firebase"; +import { useAuth } from "@/Firebase/useAuth"; +import { AdminContext, AdminContextValue } from "./AdminContextObject"; +import { COLLECTIONS, parseAdminProfile } from "../lib/collections"; +import { hasPermission, Permission } from "../rbac/permissions"; +import { DEV_MOCK_ENABLED, MOCK_ADMIN, MOCK_USER } from "../lib/devMock"; +import { AdminProfile } from "../types"; + +/** + * Loads the signed-in user's `admins/{uid}` document and exposes role + + * permission state to the admin portal. Subscribes in real time so role + * changes (e.g. an admin being disabled) take effect without a reload. + * + * Must be rendered inside the app-level . + */ +export function AdminProvider({ children }: { children: ReactNode }) { + const { currentUser, loading: authLoading } = useAuth(); + const [admin, setAdmin] = useState(null); + const [docLoading, setDocLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Local preview: skip Firebase entirely and inject a fake super_admin. + if (DEV_MOCK_ENABLED) { + setAdmin(MOCK_ADMIN); + setDocLoading(false); + setError(null); + return; + } + + if (authLoading) return; + + if (!currentUser) { + setAdmin(null); + setError(null); + setDocLoading(false); + return; + } + + setDocLoading(true); + setError(null); + const ref = doc(db, COLLECTIONS.admins, currentUser.uid); + const unsubscribe = onSnapshot( + ref, + (snapshot) => { + setAdmin( + snapshot.exists() + ? parseAdminProfile(currentUser.uid, snapshot.data()) + : null, + ); + setDocLoading(false); + }, + (err) => { + setError(err.message || "Failed to load admin profile."); + setAdmin(null); + setDocLoading(false); + }, + ); + + return unsubscribe; + }, [currentUser, authLoading]); + + // In mock mode the fake admin must be available synchronously on the first + // render (before the effect runs), or the route guard bounces to /unauthorized. + const effectiveAdmin = DEV_MOCK_ENABLED ? MOCK_ADMIN : admin; + const isAdmin = effectiveAdmin !== null && !effectiveAdmin.disabled; + const role = isAdmin ? effectiveAdmin.role : null; + + const can = useCallback( + (permission: Permission) => hasPermission(role, permission), + [role], + ); + + const value = useMemo( + () => ({ + firebaseUser: DEV_MOCK_ENABLED ? MOCK_USER : currentUser, + admin: effectiveAdmin, + role, + loading: DEV_MOCK_ENABLED ? false : authLoading || docLoading, + error, + isAdmin, + can, + }), + [ + currentUser, + effectiveAdmin, + role, + authLoading, + docLoading, + error, + isAdmin, + can, + ], + ); + + return {children}; +} diff --git a/src/admin/context/AdminContextObject.ts b/src/admin/context/AdminContextObject.ts new file mode 100644 index 0000000..44bfbec --- /dev/null +++ b/src/admin/context/AdminContextObject.ts @@ -0,0 +1,23 @@ +import { createContext } from "react"; +import { User } from "firebase/auth"; +import { AdminProfile, AdminRole } from "../types"; +import { Permission } from "../rbac/permissions"; + +export interface AdminContextValue { + /** The Firebase Auth user, if signed in (may not be an admin). */ + firebaseUser: User | null; + /** Resolved admin profile, or null when the user is not a valid admin. */ + admin: AdminProfile | null; + /** Effective role — null when not an admin or the account is disabled. */ + role: AdminRole | null; + /** True while auth state and/or the admin document are still resolving. */ + loading: boolean; + /** Set when loading the admin document failed (e.g. permission denied). */ + error: string | null; + /** True when the user has a valid, enabled admin profile. */ + isAdmin: boolean; + /** Permission check against the effective role. */ + can: (permission: Permission) => boolean; +} + +export const AdminContext = createContext(null); diff --git a/src/admin/context/PinContextObject.ts b/src/admin/context/PinContextObject.ts new file mode 100644 index 0000000..9ed69ae --- /dev/null +++ b/src/admin/context/PinContextObject.ts @@ -0,0 +1,25 @@ +import { createContext } from "react"; + +export interface PinGateValue { + /** + * Ensure the security PIN has been verified for the current session. + * Resolves true once verified (immediately if still within the window), + * or false if the user cancels the challenge. + */ + ensureVerified: () => Promise; + /** + * Force a fresh PIN challenge, ignoring any active verification window. + * Used by the explicit "Re-verify / Set up PIN" affordance so it always + * opens the dialog. Resolves true on success, false if cancelled. + */ + reverify: () => Promise; + /** True while inside the active verification window. */ + isVerified: boolean; + /** Clear the current verification (e.g. on sign-out). */ + reset: () => void; +} + +export const PinGateContext = createContext(null); + +/** How long a single PIN verification stays valid. */ +export const PIN_SESSION_TTL_MS = 5 * 60 * 1000; diff --git a/src/admin/context/PinProvider.tsx b/src/admin/context/PinProvider.tsx new file mode 100644 index 0000000..8892ed1 --- /dev/null +++ b/src/admin/context/PinProvider.tsx @@ -0,0 +1,88 @@ +import { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + PinGateContext, + PinGateValue, + PIN_SESSION_TTL_MS, +} from "./PinContextObject"; +import { useAdmin } from "./useAdmin"; +import { PinDialog } from "../components/PinDialog"; + +/** + * Provides session-based security-PIN verification. Wrap the authenticated + * admin area with this provider, then call `usePinGate().ensureVerified()` + * before a sensitive action: + * + * const { ensureVerified } = usePinGate(); + * if (!(await ensureVerified())) return; // user cancelled + * await deleteProject(id); + */ +export function PinGateProvider({ children }: { children: ReactNode }) { + const { admin } = useAdmin(); + const [open, setOpen] = useState(false); + const [verifiedUntil, setVerifiedUntil] = useState(null); + const resolverRef = useRef<((ok: boolean) => void) | null>(null); + + const settle = useCallback((ok: boolean) => { + const resolve = resolverRef.current; + resolverRef.current = null; + setOpen(false); + if (ok) setVerifiedUntil(Date.now() + PIN_SESSION_TTL_MS); + resolve?.(ok); + }, []); + + // When the signed-in admin changes (incl. sign-out), drop any verification + // and abandon an in-flight challenge so a stale dialog/promise never lingers. + useEffect(() => { + setVerifiedUntil(null); + if (resolverRef.current) { + resolverRef.current(false); + resolverRef.current = null; + } + setOpen(false); + }, [admin?.uid]); + + // Open the dialog and wait for the user to verify or cancel. Replaces any + // pending challenge (resolving the previous one false). + const challenge = useCallback(() => { + return new Promise((resolve) => { + resolverRef.current?.(false); + resolverRef.current = resolve; + setOpen(true); + }); + }, []); + + const ensureVerified = useCallback(() => { + if (verifiedUntil !== null && Date.now() < verifiedUntil) { + return Promise.resolve(true); + } + return challenge(); + }, [verifiedUntil, challenge]); + + const reset = useCallback(() => setVerifiedUntil(null), []); + + const isVerified = verifiedUntil !== null && Date.now() < verifiedUntil; + + const value = useMemo( + () => ({ ensureVerified, reverify: challenge, isVerified, reset }), + [ensureVerified, challenge, isVerified, reset], + ); + + return ( + + {children} + settle(false)} + onSuccess={() => settle(true)} + /> + + ); +} diff --git a/src/admin/context/useAdmin.ts b/src/admin/context/useAdmin.ts new file mode 100644 index 0000000..8a4fa19 --- /dev/null +++ b/src/admin/context/useAdmin.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AdminContext, AdminContextValue } from "./AdminContextObject"; + +export function useAdmin(): AdminContextValue { + const ctx = useContext(AdminContext); + if (!ctx) { + throw new Error("useAdmin must be used within an ."); + } + return ctx; +} diff --git a/src/admin/context/usePinGate.ts b/src/admin/context/usePinGate.ts new file mode 100644 index 0000000..49a3900 --- /dev/null +++ b/src/admin/context/usePinGate.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { PinGateContext, PinGateValue } from "./PinContextObject"; + +export function usePinGate(): PinGateValue { + const ctx = useContext(PinGateContext); + if (!ctx) { + throw new Error("usePinGate must be used within a ."); + } + return ctx; +} diff --git a/src/admin/hooks/useAdminData.ts b/src/admin/hooks/useAdminData.ts new file mode 100644 index 0000000..a7686d2 --- /dev/null +++ b/src/admin/hooks/useAdminData.ts @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { + CollectionReference, + DocumentData, + onSnapshot, + Timestamp, +} from "firebase/firestore"; +import { + adminsCollection, + auditLogsCollection, + clientsCollection, + messagesCollection, + parseAdminProfile, + parseAuditLog, + parseClient, + parseMessage, + parseProject, + projectsCollection, +} from "../lib/collections"; +import { + AdminProfile, + AuditLogEntry, + Client, + ContactMessage, + Project, +} from "../types"; +import { + DEV_MOCK_ENABLED, + MOCK_ADMINS, + MOCK_AUDIT, + MOCK_CLIENTS, + MOCK_MESSAGES, + MOCK_PROJECTS, +} from "../lib/devMock"; + +export interface CollectionState { + data: T[]; + loading: boolean; + error: string | null; +} + +function millis(value?: Timestamp): number { + return value ? value.toMillis() : 0; +} + +function byCreatedDesc( + a: { createdAt?: Timestamp }, + b: { createdAt?: Timestamp }, +): number { + return millis(b.createdAt) - millis(a.createdAt); +} + +/** + * Subscribe to a whole collection in real time, parsing + sorting client-side. + * Collections are small for the admin foundation, so this avoids composite + * indexes and the orderBy "missing field" pitfall. `ref`/`parse`/`compare` are + * stable module-level values, so the subscription is set up once. + */ +function useCollectionData( + ref: CollectionReference, + parse: (id: string, data: DocumentData) => T | null, + compare?: (a: T, b: T) => number, + mock?: T[], +): CollectionState { + const [state, setState] = useState>({ + data: [], + loading: true, + error: null, + }); + + useEffect(() => { + // Local preview: serve demo data instead of subscribing to Firestore. + if (DEV_MOCK_ENABLED) { + const data = mock ? [...mock] : []; + if (compare) data.sort(compare); + setState({ data, loading: false, error: null }); + return; + } + + const unsubscribe = onSnapshot( + ref, + (snapshot) => { + const data: T[] = []; + snapshot.forEach((docSnap) => { + const item = parse(docSnap.id, docSnap.data()); + if (item) data.push(item); + }); + if (compare) data.sort(compare); + setState({ data, loading: false, error: null }); + }, + (err) => setState({ data: [], loading: false, error: err.message }), + ); + return unsubscribe; + }, [ref, parse, compare, mock]); + + return state; +} + +// The mock args are gated on DEV_MOCK_ENABLED (which folds to `false` in a +// production build) so the demo data + fake credentials are tree-shaken out of +// the prod bundle entirely. +export function useProjects(): CollectionState { + return useCollectionData( + projectsCollection, + parseProject, + byCreatedDesc, + DEV_MOCK_ENABLED ? MOCK_PROJECTS : undefined, + ); +} + +export function useClients(): CollectionState { + return useCollectionData( + clientsCollection, + parseClient, + byCreatedDesc, + DEV_MOCK_ENABLED ? MOCK_CLIENTS : undefined, + ); +} + +export function useMessages(): CollectionState { + return useCollectionData( + messagesCollection, + parseMessage, + byCreatedDesc, + DEV_MOCK_ENABLED ? MOCK_MESSAGES : undefined, + ); +} + +export function useAuditLogs(): CollectionState { + return useCollectionData( + auditLogsCollection, + parseAuditLog, + byCreatedDesc, + DEV_MOCK_ENABLED ? MOCK_AUDIT : undefined, + ); +} + +export function useAdmins(): CollectionState { + return useCollectionData( + adminsCollection, + parseAdminProfile, + undefined, + DEV_MOCK_ENABLED ? MOCK_ADMINS : undefined, + ); +} diff --git a/src/admin/hooks/useSensitiveAction.ts b/src/admin/hooks/useSensitiveAction.ts new file mode 100644 index 0000000..0b95b79 --- /dev/null +++ b/src/admin/hooks/useSensitiveAction.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +import { usePinGate } from "../context/usePinGate"; + +/** + * Returns a runner that gates an action behind security-PIN verification. + * + * const runSensitive = useSensitiveAction(); + * await runSensitive(async () => deleteProject(id)); + * + * Resolves true if the action ran, false if the PIN challenge was cancelled. + */ +export function useSensitiveAction() { + const { ensureVerified } = usePinGate(); + return useCallback( + async (action: () => void | Promise): Promise => { + const ok = await ensureVerified(); + if (!ok) return false; + await action(); + return true; + }, + [ensureVerified], + ); +} diff --git a/src/admin/lib/audit.ts b/src/admin/lib/audit.ts new file mode 100644 index 0000000..c8aef0a --- /dev/null +++ b/src/admin/lib/audit.ts @@ -0,0 +1,33 @@ +import { addDoc, serverTimestamp } from "firebase/firestore"; +import { auditLogsCollection } from "./collections"; + +export interface AuditInput { + actorUid: string; + actorEmail: string; + /** Stable action key, e.g. `project.delete`, `admin.role_change`. */ + action: string; + targetType?: string; + targetId?: string; + metadata?: Record; +} + +/** + * Append an entry to the `audit_logs` collection. Audit logging is best-effort + * and must never break the user-facing action that triggered it, so failures + * are swallowed (and surfaced to the console) rather than thrown. + */ +export async function writeAuditLog(input: AuditInput): Promise { + try { + await addDoc(auditLogsCollection, { + actorUid: input.actorUid, + actorEmail: input.actorEmail, + action: input.action, + targetType: input.targetType ?? null, + targetId: input.targetId ?? null, + metadata: input.metadata ?? {}, + createdAt: serverTimestamp(), + }); + } catch (err) { + console.error("[audit] failed to write log entry", input.action, err); + } +} diff --git a/src/admin/lib/authError.ts b/src/admin/lib/authError.ts new file mode 100644 index 0000000..1ca75e1 --- /dev/null +++ b/src/admin/lib/authError.ts @@ -0,0 +1,23 @@ +/** Map a Firebase Auth error to a user-friendly message. */ +export function authErrorMessage(err: unknown): string { + if (typeof err === "object" && err !== null && "code" in err) { + const code = String((err as { code: unknown }).code); + switch (code) { + case "auth/invalid-credential": + case "auth/wrong-password": + case "auth/user-not-found": + return "Invalid email or password."; + case "auth/too-many-requests": + return "Too many attempts. Please try again in a few minutes."; + case "auth/invalid-email": + return "Enter a valid email address."; + case "auth/user-disabled": + return "This account has been disabled."; + case "auth/network-request-failed": + return "Network error. Check your connection and try again."; + default: + return "Could not sign in. Please try again."; + } + } + return "Could not sign in. Please try again."; +} diff --git a/src/admin/lib/collections.ts b/src/admin/lib/collections.ts new file mode 100644 index 0000000..28cee22 --- /dev/null +++ b/src/admin/lib/collections.ts @@ -0,0 +1,143 @@ +import { collection, Timestamp, type DocumentData } from "firebase/firestore"; +import { db } from "@/Firebase/firebase"; +import { isAdminRole } from "../rbac/roles"; +import { + AdminProfile, + AuditLogEntry, + Client, + ContactMessage, + MessageStatus, + Project, + ProjectStatus, +} from "../types"; + +/** Firestore collection names — single source of truth. */ +export const COLLECTIONS = { + admins: "admins", + projects: "projects", + clients: "clients", + messages: "messages", + auditLogs: "audit_logs", +} as const; + +export const adminsCollection = collection(db, COLLECTIONS.admins); +export const projectsCollection = collection(db, COLLECTIONS.projects); +export const clientsCollection = collection(db, COLLECTIONS.clients); +export const messagesCollection = collection(db, COLLECTIONS.messages); +export const auditLogsCollection = collection(db, COLLECTIONS.auditLogs); + +const PROJECT_STATUSES: readonly ProjectStatus[] = [ + "lead", + "active", + "on_hold", + "completed", + "archived", +]; +const MESSAGE_STATUSES: readonly MessageStatus[] = [ + "new", + "read", + "replied", + "archived", +]; + +function str(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function optionalStr(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function ts(value: unknown): Timestamp | undefined { + return value instanceof Timestamp ? value : undefined; +} + +/** + * Parse an `admins` document. Returns null when the role is missing or invalid + * — such a document is treated as "not a valid admin" by the access guards. + */ +export function parseAdminProfile( + uid: string, + data: DocumentData, +): AdminProfile | null { + if (!isAdminRole(data.role)) return null; + const email = str(data.email); + return { + uid, + email, + displayName: str(data.displayName, email || "Admin"), + role: data.role, + disabled: data.disabled === true, + pinHash: optionalStr(data.pinHash), + pinSalt: optionalStr(data.pinSalt), + pinIterations: + typeof data.pinIterations === "number" ? data.pinIterations : undefined, + createdAt: ts(data.createdAt), + updatedAt: ts(data.updatedAt), + lastLoginAt: ts(data.lastLoginAt), + }; +} + +export function parseProject(id: string, data: DocumentData): Project { + const status = PROJECT_STATUSES.includes(data.status as ProjectStatus) + ? (data.status as ProjectStatus) + : "lead"; + return { + id, + name: str(data.name, "Untitled project"), + clientId: optionalStr(data.clientId), + clientName: optionalStr(data.clientName), + status, + assignedTo: Array.isArray(data.assignedTo) + ? data.assignedTo.filter((v): v is string => typeof v === "string") + : [], + budget: typeof data.budget === "number" ? data.budget : undefined, + description: optionalStr(data.description), + createdAt: ts(data.createdAt), + updatedAt: ts(data.updatedAt), + }; +} + +export function parseClient(id: string, data: DocumentData): Client { + return { + id, + name: str(data.name, "Unnamed client"), + company: optionalStr(data.company), + email: str(data.email), + phone: optionalStr(data.phone), + notes: optionalStr(data.notes), + createdAt: ts(data.createdAt), + updatedAt: ts(data.updatedAt), + }; +} + +export function parseMessage(id: string, data: DocumentData): ContactMessage { + const status = MESSAGE_STATUSES.includes(data.status as MessageStatus) + ? (data.status as MessageStatus) + : "new"; + return { + id, + name: str(data.name, "Anonymous"), + email: str(data.email), + subject: optionalStr(data.subject), + body: str(data.body), + status, + createdAt: ts(data.createdAt), + }; +} + +export function parseAuditLog(id: string, data: DocumentData): AuditLogEntry { + return { + id, + actorUid: str(data.actorUid), + actorEmail: str(data.actorEmail), + action: str(data.action, "unknown"), + targetType: optionalStr(data.targetType), + targetId: optionalStr(data.targetId), + metadata: + data.metadata && typeof data.metadata === "object" + ? (data.metadata as Record) + : undefined, + createdAt: ts(data.createdAt), + }; +} diff --git a/src/admin/lib/devMock.ts b/src/admin/lib/devMock.ts new file mode 100644 index 0000000..a5cd0a5 --- /dev/null +++ b/src/admin/lib/devMock.ts @@ -0,0 +1,196 @@ +import { Timestamp } from "firebase/firestore"; +import type { User } from "firebase/auth"; +import { + AdminProfile, + AuditLogEntry, + Client, + ContactMessage, + Project, +} from "../types"; + +/** + * Local-only "fake credential" for previewing the admin panel without setting + * up Firebase Auth + Firestore. + * + * SAFETY: gated on `import.meta.env.DEV`, which is `true` only under the Vite + * dev server and ALWAYS `false` in `vite build`. So this can never activate in + * a production build, regardless of the env var. Enable it locally by setting + * `VITE_ADMIN_DEV_MOCK=true` in `.env.local` (git-ignored). + * + * When enabled: AdminContext provides a fake super_admin and the data hooks + * return the demo data below. Real Firestore writes (create/delete/role change) + * will still fail without a backend — this mode is for *viewing* the UI. + */ +export const DEV_MOCK_ENABLED = + import.meta.env.DEV && import.meta.env.VITE_ADMIN_DEV_MOCK === "true"; + +function ts(iso: string): Timestamp { + return Timestamp.fromDate(new Date(iso)); +} + +export const MOCK_USER = { + uid: "dev-mock-uid", + email: "dev-admin@servio.local", + displayName: "Dev Admin", + emailVerified: true, + isAnonymous: false, +} as unknown as User; + +export const MOCK_ADMIN: AdminProfile = { + uid: "dev-mock-uid", + email: "dev-admin@servio.local", + displayName: "Dev Admin", + role: "super_admin", + disabled: false, +}; + +export const MOCK_ADMINS: AdminProfile[] = [ + MOCK_ADMIN, + { + uid: "dev-fe", + email: "frontend@servio.local", + displayName: "Priya Frontend", + role: "frontend_dev", + disabled: false, + }, + { + uid: "dev-be", + email: "backend@servio.local", + displayName: "Arjun Backend", + role: "backend_dev", + disabled: false, + }, + { + uid: "dev-qa", + email: "qa@servio.local", + displayName: "Meera QA", + role: "qa_delivery", + disabled: true, + }, +]; + +export const MOCK_PROJECTS: Project[] = [ + { + id: "p1", + name: "Acme Website Redesign", + clientName: "Acme Inc.", + status: "active", + assignedTo: ["dev-fe", "dev-be"], + budget: 450000, + createdAt: ts("2026-05-02"), + updatedAt: ts("2026-06-10"), + }, + { + id: "p2", + name: "Globex Mobile App", + clientName: "Globex", + status: "lead", + assignedTo: [], + budget: 800000, + createdAt: ts("2026-06-01"), + }, + { + id: "p3", + name: "Initech Dashboard", + clientName: "Initech", + status: "completed", + assignedTo: ["dev-be"], + budget: 300000, + createdAt: ts("2026-03-15"), + }, + { + id: "p4", + name: "Umbrella CRM", + clientName: "Umbrella Co.", + status: "on_hold", + assignedTo: ["dev-fe"], + budget: 120000, + createdAt: ts("2026-04-20"), + }, +]; + +export const MOCK_CLIENTS: Client[] = [ + { + id: "c1", + name: "Acme Inc.", + company: "Acme", + email: "hello@acme.test", + phone: "+91 90000 00001", + createdAt: ts("2026-04-01"), + }, + { + id: "c2", + name: "Globex", + company: "Globex Corp", + email: "contact@globex.test", + phone: "+91 90000 00002", + createdAt: ts("2026-05-12"), + }, + { + id: "c3", + name: "Initech", + company: "Initech", + email: "team@initech.test", + createdAt: ts("2026-02-20"), + }, +]; + +export const MOCK_MESSAGES: ContactMessage[] = [ + { + id: "m1", + name: "Rahul Sharma", + email: "rahul@example.com", + subject: "Need a landing page", + body: "Hi, we'd like a quote for a marketing landing page.", + status: "new", + createdAt: ts("2026-06-18"), + }, + { + id: "m2", + name: "Sara Khan", + email: "sara@example.com", + subject: "E-commerce build", + body: "Looking for a full storefront with payments and inventory.", + status: "read", + createdAt: ts("2026-06-15"), + }, + { + id: "m3", + name: "Tom Lee", + email: "tom@example.com", + body: "Following up on my previous enquiry — any update?", + status: "replied", + createdAt: ts("2026-06-10"), + }, +]; + +export const MOCK_AUDIT: AuditLogEntry[] = [ + { + id: "a1", + actorUid: "dev-mock-uid", + actorEmail: "dev-admin@servio.local", + action: "project.create", + targetType: "project", + targetId: "p2", + createdAt: ts("2026-06-01"), + }, + { + id: "a2", + actorUid: "dev-mock-uid", + actorEmail: "dev-admin@servio.local", + action: "admin.role_change", + targetType: "admin", + targetId: "dev-be", + metadata: { from: "frontend_dev", to: "backend_dev" }, + createdAt: ts("2026-05-20"), + }, + { + id: "a3", + actorUid: "dev-mock-uid", + actorEmail: "dev-admin@servio.local", + action: "project.delete", + targetType: "project", + targetId: "p9", + createdAt: ts("2026-05-10"), + }, +]; diff --git a/src/admin/lib/format.ts b/src/admin/lib/format.ts new file mode 100644 index 0000000..99cf2a6 --- /dev/null +++ b/src/admin/lib/format.ts @@ -0,0 +1,28 @@ +import { Timestamp } from "firebase/firestore"; +import { format, formatDistanceToNow } from "date-fns"; + +export function formatDate(value?: Timestamp, pattern = "MMM d, yyyy"): string { + if (!value) return "—"; + return format(value.toDate(), pattern); +} + +export function formatRelative(value?: Timestamp): string { + if (!value) return "—"; + return formatDistanceToNow(value.toDate(), { addSuffix: true }); +} + +export function formatCurrency(amount?: number): string { + if (typeof amount !== "number") return "—"; + return new Intl.NumberFormat("en-IN", { + style: "currency", + currency: "INR", + maximumFractionDigits: 0, + }).format(amount); +} + +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} diff --git a/src/admin/lib/pin.ts b/src/admin/lib/pin.ts new file mode 100644 index 0000000..d874648 --- /dev/null +++ b/src/admin/lib/pin.ts @@ -0,0 +1,95 @@ +/** + * Security-PIN hashing for sensitive admin actions. + * + * PINs are hashed with PBKDF2-SHA256 (Web Crypto) using a per-admin random + * salt, and only the hash + salt + iteration count are stored in Firestore. + * + * IMPORTANT — security model: client-side hashing protects the PIN at rest in + * the database, but it is NOT a substitute for server-side verification. A + * production hardening step is to move `verifyPin` into a Cloud Function and + * gate sensitive writes on a short-lived verification claim. This module is + * intentionally written so that swapping the backend only touches these + * functions. See docs/ADMIN.md → "Security PIN". + */ + +const PIN_ITERATIONS = 150_000; +const DERIVED_BITS = 256; +const encoder = new TextEncoder(); + +/** Required PIN length (digits). */ +export const PIN_LENGTH = 6; + +const PIN_PATTERN = new RegExp(`^\\d{${PIN_LENGTH}}$`); + +export function isValidPin(pin: string): boolean { + return PIN_PATTERN.test(pin); +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +/** Cryptographically-random hex salt. */ +export function generateSalt(byteLength = 16): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return bytesToHex(bytes); +} + +export async function hashPin( + pin: string, + salt: string, + iterations: number = PIN_ITERATIONS, +): Promise { + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(pin), + { name: "PBKDF2" }, + false, + ["deriveBits"], + ); + const derived = await crypto.subtle.deriveBits( + { name: "PBKDF2", salt: encoder.encode(salt), iterations, hash: "SHA-256" }, + keyMaterial, + DERIVED_BITS, + ); + return bytesToHex(new Uint8Array(derived)); +} + +/** Length-stable comparison to avoid leaking match position via timing. */ +export function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +export interface PinCredential { + hash: string; + salt: string; + iterations: number; +} + +/** Produce a storable credential from a fresh PIN. */ +export async function createPinCredential(pin: string): Promise { + const salt = generateSalt(); + const hash = await hashPin(pin, salt, PIN_ITERATIONS); + return { hash, salt, iterations: PIN_ITERATIONS }; +} + +/** Verify a candidate PIN against a stored credential. */ +export async function verifyPin( + pin: string, + credential: { hash: string; salt: string; iterations?: number }, +): Promise { + const actual = await hashPin( + pin, + credential.salt, + credential.iterations ?? PIN_ITERATIONS, + ); + return constantTimeEqual(actual, credential.hash); +} diff --git a/src/admin/pages/AdminLogin.tsx b/src/admin/pages/AdminLogin.tsx new file mode 100644 index 0000000..24c7ff7 --- /dev/null +++ b/src/admin/pages/AdminLogin.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { signInWithEmailAndPassword, signOut } from "firebase/auth"; +import { Home, Loader2, ShieldCheck } from "lucide-react"; +import { auth } from "@/Firebase/firebase"; +import { Button } from "@/app/components/ui/button"; +import { useAdmin } from "../context/useAdmin"; +import { AdminLoading } from "../components/AdminLoading"; +import { authErrorMessage } from "../lib/authError"; + +interface LocationState { + from?: { pathname?: string }; +} + +export function AdminLogin() { + const { firebaseUser, isAdmin, loading } = useAdmin(); + const navigate = useNavigate(); + const location = useLocation(); + const from = + (location.state as LocationState | null)?.from?.pathname ?? + "/admin/dashboard"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!loading && firebaseUser && isAdmin) { + navigate(from, { replace: true }); + } + }, [loading, firebaseUser, isAdmin, from, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await signInWithEmailAndPassword(auth, email, password); + // Redirect is handled by the effect once the admin profile resolves. + } catch (err) { + setError(authErrorMessage(err)); + setBusy(false); + } + }; + + const handleSignOut = async () => { + await signOut(auth); + setBusy(false); + setError(null); + }; + + if (loading) { + return ; + } + + const signedInNotAdmin = Boolean(firebaseUser) && !isAdmin; + + return ( +
+ +
+ ); +} diff --git a/src/admin/pages/Audit.tsx b/src/admin/pages/Audit.tsx new file mode 100644 index 0000000..34d052b --- /dev/null +++ b/src/admin/pages/Audit.tsx @@ -0,0 +1,65 @@ +import { ClipboardList } from "lucide-react"; +import { PageHeader } from "../components/PageHeader"; +import { EmptyState } from "../components/EmptyState"; +import { useAuditLogs } from "../hooks/useAdminData"; +import { formatRelative } from "../lib/format"; + +export function Audit() { + const auditLogs = useAuditLogs(); + + return ( +
+ + +
+ {auditLogs.loading ? ( +

Loading…

+ ) : auditLogs.data.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {auditLogs.data.slice(0, 50).map((entry) => ( + + + + + + + ))} + +
ActionActorTargetWhen
+ + {entry.action} + + + {entry.actorEmail || "—"} + + {entry.targetType + ? `${entry.targetType}/${entry.targetId ?? "—"}` + : "—"} + + {formatRelative(entry.createdAt)} +
+
+ )} +
+
+ ); +} diff --git a/src/admin/pages/Clients.tsx b/src/admin/pages/Clients.tsx new file mode 100644 index 0000000..6119643 --- /dev/null +++ b/src/admin/pages/Clients.tsx @@ -0,0 +1,371 @@ +import { useState } from "react"; +import { Building2, Mail, Pencil, Phone, Plus, Users } from "lucide-react"; +import { + addDoc, + doc, + serverTimestamp, + updateDoc, +} from "firebase/firestore"; +import { toast } from "sonner"; +import { Button } from "@/app/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/app/components/ui/dialog"; +import { db } from "@/Firebase/firebase"; +import { PageHeader } from "../components/PageHeader"; +import { EmptyState } from "../components/EmptyState"; +import { useAdmin } from "../context/useAdmin"; +import { useClients } from "../hooks/useAdminData"; +import { COLLECTIONS, clientsCollection } from "../lib/collections"; +import { writeAuditLog } from "../lib/audit"; +import { formatRelative, initials } from "../lib/format"; +import { Client } from "../types"; + +const INPUT_CLASS = + "block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/40"; + +interface ClientForm { + name: string; + company: string; + email: string; + phone: string; + notes: string; +} + +const EMPTY_FORM: ClientForm = { + name: "", + company: "", + email: "", + phone: "", + notes: "", +}; + +function toForm(client: Client): ClientForm { + return { + name: client.name, + company: client.company ?? "", + email: client.email, + phone: client.phone ?? "", + notes: client.notes ?? "", + }; +} + +export function Clients() { + const { admin, can } = useAdmin(); + const clients = useClients(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + const canEdit = can("clients:edit"); + + function openCreate() { + setEditing(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + } + + function openEdit(client: Client) { + setEditing(client); + setForm(toForm(client)); + setDialogOpen(true); + } + + async function handleSubmit() { + if (!admin) return; + const name = form.name.trim(); + const email = form.email.trim(); + if (!name || !email) return; + + const company = form.company.trim(); + const phone = form.phone.trim(); + const notes = form.notes.trim(); + + setSaving(true); + try { + const fields = { + name, + company: company || null, + email, + phone: phone || null, + notes: notes || null, + updatedAt: serverTimestamp(), + }; + + if (editing) { + await updateDoc(doc(db, COLLECTIONS.clients, editing.id), fields); + await writeAuditLog({ + actorUid: admin.uid, + actorEmail: admin.email, + action: "client.update", + targetType: "client", + targetId: editing.id, + metadata: { name }, + }); + } else { + await addDoc(clientsCollection, { + ...fields, + createdAt: serverTimestamp(), + }); + await writeAuditLog({ + actorUid: admin.uid, + actorEmail: admin.email, + action: "client.create", + targetType: "client", + metadata: { name }, + }); + } + setDialogOpen(false); + setEditing(null); + setForm(EMPTY_FORM); + toast.success(editing ? "Client updated." : "Client created."); + } catch (err) { + console.error(err); + toast.error("Couldn't save the client. Please try again."); + } finally { + setSaving(false); + } + } + + return ( +
+ +