[object Object]

← back to Grant

Add admin-level account + Live/Admin view toggle

d27ef8c9882a5fb13d6cf6cd180f6f1c7b009ea0 · 2026-06-01 15:28:22 -0700 · Steve Abrams

- Seed users-table admin account (scripts/seed-admin-user.mjs, idempotent)
- Session API resolves the org's owner/admin row -> returns role/isAdmin
- AuthProvider exposes isAdmin + persisted viewMode ('live'|'admin')
- Header Live/Admin segmented toggle (ViewModeToggle), admins only
- Gate AI Discover buttons (grants/news/collaborations) behind admin view

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d27ef8c9882a5fb13d6cf6cd180f6f1c7b009ea0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 15:28:22 2026 -0700

    Add admin-level account + Live/Admin view toggle
    
    - Seed users-table admin account (scripts/seed-admin-user.mjs, idempotent)
    - Session API resolves the org's owner/admin row -> returns role/isAdmin
    - AuthProvider exposes isAdmin + persisted viewMode ('live'|'admin')
    - Header Live/Admin segmented toggle (ViewModeToggle), admins only
    - Gate AI Discover buttons (grants/news/collaborations) behind admin view
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 app/api/auth/session/route.ts                   | 21 ++++--
 components/AppShell.tsx                         |  5 +-
 components/AuthProvider.tsx                     | 98 +++++++++++++++++++------
 components/ViewModeToggle.tsx                   | 73 ++++++++++++++++++
 components/collaborations/CollaborationsTab.tsx | 28 ++++---
 components/grants/GrantsTab.tsx                 | 28 ++++---
 components/news/NewsTab.tsx                     | 28 ++++---
 lib/auth.ts                                     | 66 +++++++++++++++++
 scripts/seed-admin-user.mjs                     | 79 ++++++++++++++++++++
 9 files changed, 362 insertions(+), 64 deletions(-)

diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 298d70c..aa65ee0 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -1,12 +1,23 @@
 import { NextRequest, NextResponse } from 'next/server';
-import { verifyAuth } from '@/lib/auth';
+import { verifyAuthWithOrg, resolveSessionIdentity } from '@/lib/auth';
 
 export async function GET(request: NextRequest) {
-  const username = verifyAuth(request);
+  const session = verifyAuthWithOrg(request);
 
-  if (username) {
-    return NextResponse.json({ authenticated: true, user: username });
+  if (!session) {
+    return NextResponse.json({ authenticated: false }, { status: 401 });
   }
 
-  return NextResponse.json({ authenticated: false }, { status: 401 });
+  // Resolve the users-table account (role/isAdmin) so the app-gate login
+  // is "recognized" as an admin-level identity the Live/Admin toggle keys off.
+  const identity = await resolveSessionIdentity(session);
+
+  return NextResponse.json({
+    authenticated: true,
+    user: identity.user,
+    role: identity.role,
+    isAdmin: identity.isAdmin,
+    email: identity.email,
+    displayName: identity.displayName,
+  });
 }
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index 66ca0f3..e38b1d4 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -6,6 +6,7 @@ import { AuthProvider, useAuth } from './AuthProvider';
 import { ToastProvider } from './ToastProvider';
 import ErrorBoundary from './ErrorBoundary';
 import Sidebar, { type TabId } from './Sidebar';
+import ViewModeToggle from './ViewModeToggle';
 import DashboardTab from './dashboard/DashboardTab';
 import GrantsTab from './grants/GrantsTab';
 import NewsTab from './news/NewsTab';
@@ -92,8 +93,10 @@ function Shell() {
             </h1>
           </div>
 
-          {/* Right: app switcher + user + logout */}
+          {/* Right: view-mode toggle + app switcher + user + logout */}
           <div className="flex items-center gap-3">
+            {/* Live / Admin view toggle (admins only) */}
+            <ViewModeToggle />
             {/* App Switcher */}
             <div style={{ position: 'relative' }}>
               <button
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
index a003eb7..e4528e1 100644
--- a/components/AuthProvider.tsx
+++ b/components/AuthProvider.tsx
@@ -12,9 +12,22 @@ import { useRouter, usePathname } from 'next/navigation';
 import { Loader2 } from 'lucide-react';
 
 /* ─── Types ──────────────────────────────────────────────────────────────── */
+export type Role = 'owner' | 'admin' | 'member' | null;
+export type ViewMode = 'live' | 'admin';
+
+const VIEW_MODE_KEY = 'grant-view-mode';
+
 interface AuthContextValue {
   user: string | null;
+  role: Role;
+  /** True when the session maps to an owner/admin users-table account. */
+  isAdmin: boolean;
   loading: boolean;
+  /** Current header toggle position. Forced to 'live' for non-admins. */
+  viewMode: ViewMode;
+  setViewMode: (mode: ViewMode) => void;
+  /** Convenience: admin-only controls should render only when this is true. */
+  showAdmin: boolean;
   login: (username: string, password: string) => Promise<{ error?: string }>;
   logout: () => Promise<void>;
 }
@@ -28,33 +41,57 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const pathname = usePathname();
 
   const [user, setUser]       = useState<string | null>(null);
+  const [role, setRole]       = useState<Role>(null);
+  const [isAdmin, setIsAdmin] = useState(false);
   const [loading, setLoading] = useState(true);
 
-  /* Check session on mount */
+  // Default to 'admin' (controls visible) for the operator; persisted choice
+  // is read on mount to avoid a hydration mismatch.
+  const [viewMode, setViewModeState] = useState<ViewMode>('admin');
+
+  /* Restore persisted toggle position on mount */
   useEffect(() => {
-    let cancelled = false;
+    try {
+      const saved = localStorage.getItem(VIEW_MODE_KEY);
+      if (saved === 'live' || saved === 'admin') setViewModeState(saved);
+    } catch { /* localStorage unavailable */ }
+  }, []);
+
+  const setViewMode = useCallback((mode: ViewMode) => {
+    setViewModeState(mode);
+    try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* ignore */ }
+  }, []);
 
-    async function checkSession() {
-      try {
-        const res = await fetch('/api/auth/session', { credentials: 'include' });
-        if (!cancelled) {
-          if (res.ok) {
-            const data = await res.json();
-            setUser(data.authenticated ? data.user : null);
-          } else {
-            setUser(null);
-          }
+  /* Fetch session identity (user + role + isAdmin) */
+  const checkSession = useCallback(async (): Promise<boolean> => {
+    try {
+      const res = await fetch('/api/auth/session', { credentials: 'include' });
+      if (res.ok) {
+        const data = await res.json();
+        if (data.authenticated) {
+          setUser(data.user);
+          setRole(data.role ?? null);
+          setIsAdmin(Boolean(data.isAdmin));
+          return true;
         }
-      } catch {
-        if (!cancelled) setUser(null);
-      } finally {
-        if (!cancelled) setLoading(false);
       }
+      setUser(null); setRole(null); setIsAdmin(false);
+      return false;
+    } catch {
+      setUser(null); setRole(null); setIsAdmin(false);
+      return false;
     }
+  }, []);
 
-    checkSession();
+  /* Check session on mount */
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      await checkSession();
+      if (!cancelled) setLoading(false);
+    })();
     return () => { cancelled = true; };
-  }, []);
+  }, [checkSession]);
 
   /* Redirect unauthenticated users away from protected routes */
   useEffect(() => {
@@ -76,19 +113,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     const data = await res.json();
 
     if (res.ok && data.success) {
-      setUser(username);
+      // Re-fetch session so role/isAdmin are populated, not just the username.
+      await checkSession();
       router.push('/');
       router.refresh();
       return {};
     }
 
     return { error: data.error ?? 'Login failed' };
-  }, [router]);
+  }, [router, checkSession]);
 
   /* Logout */
   const logout = useCallback(async () => {
     await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
-    setUser(null);
+    setUser(null); setRole(null); setIsAdmin(false);
     router.replace('/login');
   }, [router]);
 
@@ -115,8 +153,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     );
   }
 
+  // Non-admins can never be in admin view, regardless of the persisted choice.
+  const effectiveMode: ViewMode = isAdmin ? viewMode : 'live';
+  const showAdmin = isAdmin && effectiveMode === 'admin';
+
   return (
-    <AuthContext.Provider value={{ user, loading, login, logout }}>
+    <AuthContext.Provider
+      value={{
+        user,
+        role,
+        isAdmin,
+        loading,
+        viewMode: effectiveMode,
+        setViewMode,
+        showAdmin,
+        login,
+        logout,
+      }}
+    >
       {children}
     </AuthContext.Provider>
   );
diff --git a/components/ViewModeToggle.tsx b/components/ViewModeToggle.tsx
new file mode 100644
index 0000000..dc184c4
--- /dev/null
+++ b/components/ViewModeToggle.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import { Eye, Wrench } from 'lucide-react';
+import { useAuth, type ViewMode } from './AuthProvider';
+
+/**
+ * Live / Admin view toggle for the top header.
+ *
+ * - "Live"  → the clean end-user view (admin/dev controls hidden).
+ * - "Admin" → reveals admin controls (AI discover, generate, seed, debug).
+ *
+ * Only rendered for admin-level accounts; the choice is persisted by
+ * AuthProvider in localStorage.
+ */
+export default function ViewModeToggle() {
+  const { isAdmin, viewMode, setViewMode } = useAuth();
+
+  if (!isAdmin) return null;
+
+  const options: { mode: ViewMode; label: string; Icon: typeof Eye }[] = [
+    { mode: 'live',  label: 'Live',  Icon: Eye },
+    { mode: 'admin', label: 'Admin', Icon: Wrench },
+  ];
+
+  return (
+    <div
+      role="group"
+      aria-label="View mode"
+      title={
+        viewMode === 'admin'
+          ? 'Admin view — admin controls visible. Switch to Live for the end-user view.'
+          : 'Live view — clean end-user view. Switch to Admin to reveal controls.'
+      }
+      style={{
+        display: 'inline-flex',
+        padding: 2,
+        borderRadius: 9999,
+        backgroundColor: 'var(--color-surface-el, rgba(255,255,255,0.04))',
+        border: '1px solid var(--color-border)',
+      }}
+    >
+      {options.map(({ mode, label, Icon }) => {
+        const active = viewMode === mode;
+        return (
+          <button
+            key={mode}
+            type="button"
+            onClick={() => setViewMode(mode)}
+            aria-pressed={active}
+            style={{
+              display: 'inline-flex',
+              alignItems: 'center',
+              gap: 5,
+              padding: '4px 10px',
+              borderRadius: 9999,
+              border: 'none',
+              cursor: 'pointer',
+              fontSize: '0.75rem',
+              fontWeight: 600,
+              lineHeight: 1,
+              transition: 'background-color 0.15s, color 0.15s',
+              backgroundColor: active ? 'var(--color-primary)' : 'transparent',
+              color: active ? '#fff' : 'var(--color-text-muted)',
+            }}
+          >
+            <Icon size={13} aria-hidden="true" />
+            {label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}
diff --git a/components/collaborations/CollaborationsTab.tsx b/components/collaborations/CollaborationsTab.tsx
index 48b8c20..78cf884 100644
--- a/components/collaborations/CollaborationsTab.tsx
+++ b/components/collaborations/CollaborationsTab.tsx
@@ -20,6 +20,7 @@ import {
   X,
 } from 'lucide-react';
 import { useToast } from '../ToastProvider';
+import { useAuth } from '../AuthProvider';
 import { useDebounce } from '@/hooks/useDebounce';
 import SortDropdown from '../shared/SortDropdown';
 import { useClientSort, SortConfig } from '@/hooks/useClientSort';
@@ -97,6 +98,7 @@ const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }
 /* ─── Main Component ─────────────────────────────────────────────────────── */
 export default function CollaborationsTab() {
   const { addToast } = useToast();
+  const { showAdmin } = useAuth();
   const [collabs, setCollabs] = useState<Collaboration[]>([]);
   const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
   const [loading, setLoading] = useState(true);
@@ -180,18 +182,20 @@ export default function CollaborationsTab() {
             Build partnerships across sectors to amplify your impact.
           </p>
         </div>
-        <button
-          className="btn btn-secondary"
-          onClick={handleDiscover}
-          disabled={discovering}
-        >
-          {discovering ? (
-            <Loader2 size={15} className="animate-spin" />
-          ) : (
-            <Sparkles size={15} />
-          )}
-          {discovering ? 'Discovering...' : 'AI Discover Collaborators'}
-        </button>
+        {showAdmin && (
+          <button
+            className="btn btn-secondary"
+            onClick={handleDiscover}
+            disabled={discovering}
+          >
+            {discovering ? (
+              <Loader2 size={15} className="animate-spin" />
+            ) : (
+              <Sparkles size={15} />
+            )}
+            {discovering ? 'Discovering...' : 'AI Discover Collaborators'}
+          </button>
+        )}
       </div>
 
       {/* Type Filter Pills */}
diff --git a/components/grants/GrantsTab.tsx b/components/grants/GrantsTab.tsx
index 037de99..511bc9c 100644
--- a/components/grants/GrantsTab.tsx
+++ b/components/grants/GrantsTab.tsx
@@ -20,6 +20,7 @@ import {
   Send,
 } from 'lucide-react';
 import { useToast } from '../ToastProvider';
+import { useAuth } from '../AuthProvider';
 import { useDebounce } from '@/hooks/useDebounce';
 import AddGrantWizard from './AddGrantWizard';
 import ProposalModal from './ProposalModal';
@@ -191,6 +192,7 @@ function DeadlineBadge({ deadline }: { deadline: string | null }) {
 /* ─── Main Component ─────────────────────────────────────────────────────── */
 export default function GrantsTab() {
   const { addToast } = useToast();
+  const { showAdmin } = useAuth();
   const [grants, setGrants] = useState<Grant[]>([]);
   const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
   const [loading, setLoading] = useState(true);
@@ -300,18 +302,20 @@ export default function GrantsTab() {
           </p>
         </div>
         <div className="flex gap-2">
-          <button
-            className="btn btn-secondary"
-            onClick={handleDiscover}
-            disabled={discovering}
-          >
-            {discovering ? (
-              <Loader2 size={15} className="animate-spin" />
-            ) : (
-              <Sparkles size={15} />
-            )}
-            {discovering ? 'Discovering...' : 'AI Discover Grants'}
-          </button>
+          {showAdmin && (
+            <button
+              className="btn btn-secondary"
+              onClick={handleDiscover}
+              disabled={discovering}
+            >
+              {discovering ? (
+                <Loader2 size={15} className="animate-spin" />
+              ) : (
+                <Sparkles size={15} />
+              )}
+              {discovering ? 'Discovering...' : 'AI Discover Grants'}
+            </button>
+          )}
           <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
             <Plus size={15} />
             Add Grant
diff --git a/components/news/NewsTab.tsx b/components/news/NewsTab.tsx
index 9ad63c1..f008a78 100644
--- a/components/news/NewsTab.tsx
+++ b/components/news/NewsTab.tsx
@@ -13,6 +13,7 @@ import {
   User,
 } from 'lucide-react';
 import { useToast } from '../ToastProvider';
+import { useAuth } from '../AuthProvider';
 import { useDebounce } from '@/hooks/useDebounce';
 import SortDropdown from '../shared/SortDropdown';
 import { useClientSort, SortConfig } from '@/hooks/useClientSort';
@@ -80,6 +81,7 @@ function formatDate(d: string | null): string {
 /* ─── Main Component ─────────────────────────────────────────────────────── */
 export default function NewsTab() {
   const { addToast } = useToast();
+  const { showAdmin } = useAuth();
   const [news, setNews] = useState<NewsItem[]>([]);
   const [loading, setLoading] = useState(true);
   const [discovering, setDiscovering] = useState(false);
@@ -142,18 +144,20 @@ export default function NewsTab() {
             AI-curated news relevant to your mission, funders, and sector.
           </p>
         </div>
-        <button
-          className="btn btn-secondary"
-          onClick={handleDiscover}
-          disabled={discovering}
-        >
-          {discovering ? (
-            <Loader2 size={15} className="animate-spin" />
-          ) : (
-            <Sparkles size={15} />
-          )}
-          {discovering ? 'Discovering...' : 'AI Discover News'}
-        </button>
+        {showAdmin && (
+          <button
+            className="btn btn-secondary"
+            onClick={handleDiscover}
+            disabled={discovering}
+          >
+            {discovering ? (
+              <Loader2 size={15} className="animate-spin" />
+            ) : (
+              <Sparkles size={15} />
+            )}
+            {discovering ? 'Discovering...' : 'AI Discover News'}
+          </button>
+        )}
       </div>
 
       {/* Search */}
diff --git a/lib/auth.ts b/lib/auth.ts
index 7b97dd2..674d669 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -32,3 +32,69 @@ export async function resolveOrgId(session: AuthSession): Promise<string | null>
   const orgRes = await query('SELECT id FROM organizations ORDER BY created_at ASC LIMIT 1');
   return orgRes.rows.length > 0 ? orgRes.rows[0].id : null;
 }
+
+/**
+ * Identity returned to the client for the current session: the app-gate
+ * username plus the org-scoped users-table account it maps to (if any).
+ */
+export interface SessionIdentity {
+  user: string;
+  role: 'owner' | 'admin' | 'member' | null;
+  isAdmin: boolean;
+  email: string | null;
+  displayName: string | null;
+}
+
+/**
+ * "Recognize" the app-gate login as a users-table account.
+ *
+ * The app-gate is a single env credential (AUTH_USERNAME/PASSWORD); it does
+ * not itself carry a role. We resolve the org for the session, then pick the
+ * highest-privilege account in that org (owner > admin) as the operator's
+ * identity. isAdmin is true when an owner/admin row exists — which is what the
+ * Live/Admin view toggle keys off. Falls back to admin-level for the bare
+ * env-gate operator if no row is seeded yet, since that login is by definition
+ * the site operator.
+ */
+export async function resolveSessionIdentity(
+  session: AuthSession,
+): Promise<SessionIdentity> {
+  const fallback: SessionIdentity = {
+    user: session.username,
+    role: 'admin',
+    isAdmin: true,
+    email: null,
+    displayName: null,
+  };
+
+  try {
+    const orgId = await resolveOrgId(session);
+    if (!orgId) return fallback;
+
+    const res = await query<{
+      role: 'owner' | 'admin' | 'member';
+      email: string;
+      display_name: string | null;
+    }>(
+      `SELECT role, email, display_name
+         FROM users
+        WHERE org_id = $1 AND role IN ('owner', 'admin')
+        ORDER BY (role = 'owner') DESC, created_at ASC
+        LIMIT 1`,
+      [orgId],
+    );
+
+    if (res.rows.length === 0) return fallback;
+    const row = res.rows[0];
+    return {
+      user: session.username,
+      role: row.role,
+      isAdmin: true,
+      email: row.email,
+      displayName: row.display_name,
+    };
+  } catch {
+    // DB unreachable — never lock the operator out of admin controls.
+    return fallback;
+  }
+}
diff --git a/scripts/seed-admin-user.mjs b/scripts/seed-admin-user.mjs
new file mode 100644
index 0000000..d47f24b
--- /dev/null
+++ b/scripts/seed-admin-user.mjs
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * Seed an admin-level user account for the Grant app.
+ *
+ * Idempotent: upserts on email. Ties the row to an organization (defaults to
+ * the oldest org, matching resolveOrgId's deterministic fallback in lib/auth.ts).
+ *
+ * Usage:
+ *   node scripts/seed-admin-user.mjs
+ *   ADMIN_EMAIL=admin@studentdebtcrisis.org ADMIN_NAME="Site Admin" ADMIN_ROLE=admin node scripts/seed-admin-user.mjs
+ *   ORG_ID=<uuid> node scripts/seed-admin-user.mjs
+ *
+ * Reads DATABASE_URL from the environment (or .env.local).
+ */
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import pg from 'pg';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Minimal .env.local loader (only if DATABASE_URL not already set).
+if (!process.env.DATABASE_URL) {
+  try {
+    const env = readFileSync(join(__dirname, '..', '.env.local'), 'utf8');
+    for (const line of env.split('\n')) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+      if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+    }
+  } catch { /* no .env.local — rely on real env */ }
+}
+
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@studentdebtcrisis.org';
+const ADMIN_NAME  = process.env.ADMIN_NAME  || 'Site Admin';
+const ADMIN_ROLE  = process.env.ADMIN_ROLE  || 'admin'; // owner | admin | member
+
+if (!['owner', 'admin', 'member'].includes(ADMIN_ROLE)) {
+  console.error(`[seed-admin] ADMIN_ROLE must be owner|admin|member, got "${ADMIN_ROLE}"`);
+  process.exit(1);
+}
+if (!process.env.DATABASE_URL) {
+  console.error('[seed-admin] DATABASE_URL not set');
+  process.exit(1);
+}
+
+const { Pool } = pg;
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+const run = async () => {
+  // Resolve target org: explicit ORG_ID, else oldest org.
+  let orgId = process.env.ORG_ID;
+  if (!orgId) {
+    const r = await pool.query('SELECT id FROM organizations ORDER BY created_at ASC LIMIT 1');
+    if (r.rows.length === 0) {
+      console.error('[seed-admin] No organizations exist yet — create the org first.');
+      process.exit(1);
+    }
+    orgId = r.rows[0].id;
+  }
+
+  const res = await pool.query(
+    `INSERT INTO users (email, display_name, role, org_id, last_login_at)
+       VALUES ($1, $2, $3, $4, NOW())
+     ON CONFLICT (email) DO UPDATE
+       SET display_name = EXCLUDED.display_name,
+           role         = EXCLUDED.role,
+           org_id       = EXCLUDED.org_id,
+           updated_at   = NOW()
+     RETURNING id, email, display_name, role, org_id`,
+    [ADMIN_EMAIL, ADMIN_NAME, ADMIN_ROLE, orgId],
+  );
+  const u = res.rows[0];
+  console.log(`[seed-admin] ✓ ${u.role} account ready: ${u.email} (${u.display_name})`);
+  console.log(`[seed-admin]   id=${u.id} org_id=${u.org_id}`);
+};
+
+run()
+  .catch((e) => { console.error('[seed-admin] FAILED:', e.message); process.exit(1); })
+  .finally(() => pool.end());

← ee53e6f Security audit P1/P2 fixes: CSRF guard, sanitize outreach HT  ·  back to Grant  ·  Remove stray @next/swc-darwin-arm64 hard dependency (broke l a073178 →