← 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
M app/api/auth/session/route.tsM components/AppShell.tsxM components/AuthProvider.tsxA components/ViewModeToggle.tsxM components/collaborations/CollaborationsTab.tsxM components/grants/GrantsTab.tsxM components/news/NewsTab.tsxM lib/auth.tsA scripts/seed-admin-user.mjs
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 →