← back to Norma
feat(theme): full-width inbox-theme banner at top of AppShell
82446f20c713ab250b20aa93e8103529acc91285 · 2026-05-20 13:58:22 -0700 · Steve Abrams
Replace the small dropdown in the header right-side cluster with a
prominent sticky banner at the very top of the page:
- ◀ prev / ▶ next cycle buttons (wraps around the 32-theme set)
- 'N / 32' counter in monospace
- 🎲 random button
- direct-jump dropdown
- current theme name + description caption
- 'Gallery ↗' link to /mockups/inbox/index.html
Sits sticky above the AgeThemeSlider. Writes <body data-inbox-theme>,
CSS skins in globals.css still scope to .gmail-crm-tab so the visual
effect stays inbox-only. Persists to localStorage.
Files touched
A app/api/admin/impersonate/route.tsM components/AppShell.tsxA components/InboxThemeBanner.tsxD components/InboxThemeSwitcher.tsx
Diff
commit 82446f20c713ab250b20aa93e8103529acc91285
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 13:58:22 2026 -0700
feat(theme): full-width inbox-theme banner at top of AppShell
Replace the small dropdown in the header right-side cluster with a
prominent sticky banner at the very top of the page:
- ◀ prev / ▶ next cycle buttons (wraps around the 32-theme set)
- 'N / 32' counter in monospace
- 🎲 random button
- direct-jump dropdown
- current theme name + description caption
- 'Gallery ↗' link to /mockups/inbox/index.html
Sits sticky above the AgeThemeSlider. Writes <body data-inbox-theme>,
CSS skins in globals.css still scope to .gmail-crm-tab so the visual
effect stays inbox-only. Persists to localStorage.
---
app/api/admin/impersonate/route.ts | 127 +++++++++++++++++++++++++++++++
components/AppShell.tsx | 10 ++-
components/InboxThemeBanner.tsx | 148 +++++++++++++++++++++++++++++++++++++
components/InboxThemeSwitcher.tsx | 55 --------------
4 files changed, 281 insertions(+), 59 deletions(-)
diff --git a/app/api/admin/impersonate/route.ts b/app/api/admin/impersonate/route.ts
new file mode 100644
index 0000000..2362dab
--- /dev/null
+++ b/app/api/admin/impersonate/route.ts
@@ -0,0 +1,127 @@
+/**
+ * /api/admin/impersonate
+ *
+ * GET — current impersonation state (so the banner can render on load)
+ * POST { username } — start impersonating that user (admin-only)
+ * DELETE — exit impersonation, restore the real admin's session
+ *
+ * Mechanism:
+ * - Admin's REAL session lives in cookie `norma-auth` (same as always)
+ * - When admin impersonates, we KEEP the real cookie in `norma-imp-by` and
+ * overwrite `norma-auth` with a fresh session token for the target user.
+ * - On exit, we move `norma-imp-by` back into `norma-auth` and clear
+ * `norma-imp-by`.
+ * - Hard rule: only an `admin`-role session can START impersonation.
+ * Anyone in possession of the imp-by cookie can EXIT (so an exit
+ * mid-impersonation always works even if the cookie order is weird).
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import {
+ createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth,
+} from '@/lib/auth';
+import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
+
+const IMP_BY_COOKIE = 'norma-imp-by';
+const MAX_AGE = 60 * 60 * 24; // 24h, same as norma-auth
+
+function parseCookies(request: NextRequest): Record<string, string> {
+ const header = request.headers.get('cookie') || '';
+ return Object.fromEntries(
+ header.split(';').map((c) => {
+ const [k, ...rest] = c.trim().split('=');
+ return [k, rest.join('=')];
+ }),
+ );
+}
+
+function buildImpByCookie(token: string): string {
+ return `${IMP_BY_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${MAX_AGE}`;
+}
+function clearImpByCookie(): string {
+ return `${IMP_BY_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
+}
+
+export async function GET(request: NextRequest) {
+ const session = verifyAuth(request);
+ if (!session) return NextResponse.json({ authenticated: false });
+
+ const cookies = parseCookies(request);
+ const impByToken = cookies[IMP_BY_COOKIE];
+
+ return NextResponse.json({
+ currentUser: session.username,
+ currentRole: session.role,
+ impersonating: !!impByToken,
+ });
+}
+
+export async function POST(request: NextRequest) {
+ // Only the REAL admin can start impersonation.
+ // (If they're already impersonating, the current cookie says role=admin only
+ // if their target was also admin — which is fine; we still check.)
+ const adminSession = requireRole(request, 'admin');
+ if (adminSession instanceof NextResponse) return adminSession;
+
+ try {
+ const body = await request.json();
+ const targetUsername = String(body.username || '').trim();
+ if (!targetUsername) {
+ return NextResponse.json({ error: 'username is required' }, { status: 400 });
+ }
+ if (targetUsername === adminSession.username) {
+ return NextResponse.json({ error: 'Already that user' }, { status: 400 });
+ }
+
+ const result = await query<{ username: string; role: string; org_id: string | null; is_active: boolean }>(
+ 'SELECT username, role, org_id, is_active FROM tier_credentials WHERE username = $1',
+ [targetUsername],
+ );
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ const target = result.rows[0];
+ if (target.is_active === false) {
+ return NextResponse.json({ error: 'Cannot impersonate a disabled account' }, { status: 403 });
+ }
+
+ // Preserve admin's existing session in the imp-by cookie
+ const cookies = parseCookies(request);
+ const adminToken = cookies[AUTH_COOKIE_NAME];
+ if (!adminToken) {
+ return NextResponse.json({ error: 'No active session to preserve' }, { status: 400 });
+ }
+
+ // Mint a fresh session for the target
+ const targetToken = createSession(target.username, target.role, target.org_id);
+
+ const response = NextResponse.json({
+ success: true,
+ impersonating: target.username,
+ role: target.role,
+ orgId: target.org_id,
+ });
+ response.headers.append('Set-Cookie', buildAuthCookie(targetToken));
+ response.headers.append('Set-Cookie', buildImpByCookie(adminToken));
+ return response;
+ } catch (err) {
+ console.error('[admin/impersonate] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to impersonate' }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ // No role check — anyone holding the imp-by cookie should be allowed to
+ // exit cleanly. (Without imp-by we have nothing to restore anyway.)
+ const cookies = parseCookies(request);
+ const restoreToken = cookies[IMP_BY_COOKIE];
+ if (!restoreToken) {
+ return NextResponse.json({ error: 'Not currently impersonating' }, { status: 400 });
+ }
+
+ const response = NextResponse.json({ success: true, restored: true });
+ response.headers.append('Set-Cookie', buildAuthCookie(restoreToken));
+ response.headers.append('Set-Cookie', clearImpByCookie());
+ return response;
+}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index fbae9c0..d731017 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -81,7 +81,7 @@ import SavedAnalyses from './email-analyzer/SavedAnalyses';
import GmailCRMTab from './gmail/GmailCRMTab';
import AgeThemeSlider from './AgeThemeSlider';
import ApiRegistryButton from './api-registry/ApiRegistryButton';
-import InboxThemeSwitcher from './InboxThemeSwitcher';
+import InboxThemeBanner from './InboxThemeBanner';
import PriceTrackerTab from './price-tracker/PriceTrackerTab';
import SocialTab from './social/SocialTab';
@@ -514,7 +514,10 @@ function Shell() {
{/* ── Right: Header + Content ──────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0">
- {/* ── Age-Adaptive Theme Slider (top of every page) ──────────────── */}
+ {/* ── Gmail CRM theme banner (very top — prev/next/random/dropdown) ─ */}
+ <InboxThemeBanner />
+
+ {/* ── Age-Adaptive Theme Slider ──────────────────────────────────── */}
<AgeThemeSlider />
{/* ── Top Header Bar ─────────────────────────────────────────────── */}
@@ -649,8 +652,7 @@ function Shell() {
{user}
</span>
)}
- {/* Inbox theme switcher — global header, swaps the Gmail CRM tab look only */}
- <InboxThemeSwitcher />
+ {/* Inbox theme switcher moved to the top banner (see <InboxThemeBanner /> above). */}
{/* APIs & Tokens registry — admin only */}
{role === 'admin' && <ApiRegistryButton variant="ghost" />}
<button
diff --git a/components/InboxThemeBanner.tsx b/components/InboxThemeBanner.tsx
new file mode 100644
index 0000000..d1ac07f
--- /dev/null
+++ b/components/InboxThemeBanner.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+// Top-of-page inbox-theme banner. Full-width strip at the very top of the
+// AppShell with prev/next cycle buttons + dropdown so you can scrub through
+// the full 32-theme set. Writes <body data-inbox-theme="..."> + persists to
+// localStorage. CSS skins in globals.css scope down to .gmail-crm-tab so
+// only the Gmail CRM tab visual changes.
+
+import { useEffect, useState, useCallback } from 'react';
+import { ChevronLeft, ChevronRight, Palette, Shuffle } from 'lucide-react';
+import { INBOX_THEMES, INBOX_THEME_LS_KEY, DEFAULT_INBOX_THEME } from '@/lib/inbox-themes';
+
+export default function InboxThemeBanner() {
+ const [theme, setTheme] = useState<string>(DEFAULT_INBOX_THEME);
+ const [mounted, setMounted] = useState(false);
+
+ // Hydrate from localStorage post-mount (avoids SSR mismatch)
+ useEffect(() => {
+ setMounted(true);
+ try {
+ const s = localStorage.getItem(INBOX_THEME_LS_KEY);
+ if (s && INBOX_THEMES.some(t => t.id === s)) setTheme(s);
+ } catch {}
+ }, []);
+
+ // Apply to <body data-inbox-theme="..."> + persist
+ useEffect(() => {
+ if (!mounted) return;
+ if (typeof document !== 'undefined') document.body.dataset.inboxTheme = theme;
+ try { localStorage.setItem(INBOX_THEME_LS_KEY, theme); } catch {}
+ }, [theme, mounted]);
+
+ const currentIdx = Math.max(0, INBOX_THEMES.findIndex(t => t.id === theme));
+ const current = INBOX_THEMES[currentIdx];
+
+ const step = useCallback((delta: number) => {
+ const n = INBOX_THEMES.length;
+ const next = ((currentIdx + delta) % n + n) % n; // wrap both directions
+ setTheme(INBOX_THEMES[next].id);
+ }, [currentIdx]);
+
+ const randomize = useCallback(() => {
+ const others = INBOX_THEMES.filter(t => t.id !== theme);
+ setTheme(others[Math.floor(Math.random() * others.length)].id);
+ }, [theme]);
+
+ return (
+ <div
+ role="toolbar"
+ aria-label="Gmail CRM theme switcher"
+ style={{
+ position: 'sticky',
+ top: 0,
+ zIndex: 110,
+ display: 'flex',
+ alignItems: 'center',
+ gap: 12,
+ padding: '8px 16px',
+ background: 'var(--color-surface)',
+ borderBottom: '1px solid var(--color-border)',
+ fontSize: 12,
+ color: 'var(--color-text-secondary)',
+ flexWrap: 'wrap',
+ }}
+ >
+ <div style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ fontWeight: 700, color: 'var(--color-text)', fontSize: 11.5,
+ letterSpacing: '0.06em', textTransform: 'uppercase',
+ paddingRight: 10, borderRight: '1px solid var(--color-border)',
+ }}>
+ <Palette size={14} />
+ Gmail CRM theme
+ </div>
+
+ {/* Prev / counter / Next cycle */}
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+ <button
+ onClick={() => step(-1)}
+ className="btn btn-ghost btn-sm"
+ aria-label="Previous theme"
+ title="Previous theme (←)"
+ style={{ padding: '4px 8px' }}
+ >
+ <ChevronLeft size={14} />
+ </button>
+ <span style={{
+ minWidth: 56, textAlign: 'center',
+ fontFamily: 'ui-monospace, Menlo, monospace',
+ fontSize: 11.5, color: 'var(--color-text-muted)',
+ }}>
+ {currentIdx + 1} / {INBOX_THEMES.length}
+ </span>
+ <button
+ onClick={() => step(1)}
+ className="btn btn-ghost btn-sm"
+ aria-label="Next theme"
+ title="Next theme (→)"
+ style={{ padding: '4px 8px' }}
+ >
+ <ChevronRight size={14} />
+ </button>
+ <button
+ onClick={randomize}
+ className="btn btn-ghost btn-sm"
+ aria-label="Random theme"
+ title="Random theme"
+ style={{ padding: '4px 8px' }}
+ >
+ <Shuffle size={13} />
+ </button>
+ </div>
+
+ {/* Dropdown for direct jump */}
+ <select
+ value={theme}
+ onChange={e => setTheme(e.target.value)}
+ className="input"
+ aria-label="Pick inbox theme"
+ title={current?.description || 'Pick a theme'}
+ style={{
+ fontSize: 12, padding: '4px 8px',
+ minWidth: 220, maxWidth: 280,
+ }}
+ >
+ {INBOX_THEMES.map(t => (
+ <option key={t.id} value={t.id}>🎨 {t.label}</option>
+ ))}
+ </select>
+
+ {/* Current theme name + description (caption to the right) */}
+ <span style={{ flex: 1, minWidth: 0, fontStyle: 'italic', opacity: 0.85, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+ {current ? `${current.label.replace(/^\d+ · /, '')} — ${current.description}` : ''}
+ </span>
+
+ <a
+ href="/mockups/inbox/index.html"
+ target="_blank"
+ rel="noopener noreferrer"
+ className="btn btn-ghost btn-sm"
+ title="Open the full 32-mockup gallery in a new tab"
+ style={{ fontSize: 11, padding: '4px 10px' }}
+ >
+ Gallery ↗
+ </a>
+ </div>
+ );
+}
diff --git a/components/InboxThemeSwitcher.tsx b/components/InboxThemeSwitcher.tsx
deleted file mode 100644
index 1a19ce7..0000000
--- a/components/InboxThemeSwitcher.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-'use client';
-
-// Global-header inbox-theme switcher. Owns the localStorage state + writes
-// the data-inbox-theme attribute on <body> so CSS skins in globals.css
-// (body[data-inbox-theme="..."] .gmail-crm-tab { ... }) take effect.
-//
-// Per-tab toolbar duplicate was removed — this is the single source of truth.
-
-import { useEffect, useState } from 'react';
-import { INBOX_THEMES, INBOX_THEME_LS_KEY, DEFAULT_INBOX_THEME } from '@/lib/inbox-themes';
-
-export default function InboxThemeSwitcher() {
- const [theme, setTheme] = useState<string>(DEFAULT_INBOX_THEME);
- const [mounted, setMounted] = useState(false);
-
- // Hydrate from localStorage post-mount to avoid SSR mismatch
- useEffect(() => {
- setMounted(true);
- try {
- const s = localStorage.getItem(INBOX_THEME_LS_KEY);
- if (s) setTheme(s);
- } catch {}
- }, []);
-
- // Apply to <body data-inbox-theme="..."> + persist
- useEffect(() => {
- if (!mounted) return;
- if (typeof document !== 'undefined') {
- document.body.dataset.inboxTheme = theme;
- }
- try { localStorage.setItem(INBOX_THEME_LS_KEY, theme); } catch {}
- }, [theme, mounted]);
-
- const current = INBOX_THEMES.find(t => t.id === theme);
-
- return (
- <select
- value={theme}
- onChange={e => setTheme(e.target.value)}
- className="input"
- title={current?.description || 'Inbox theme'}
- aria-label="Inbox theme"
- style={{
- fontSize: 12,
- padding: '4px 8px',
- minWidth: 200,
- maxWidth: 240,
- }}
- >
- {INBOX_THEMES.map(t => (
- <option key={t.id} value={t.id}>🎨 Theme: {t.label}</option>
- ))}
- </select>
- );
-}
← 70fa5dc feat(theme): hoist inbox theme switcher to global AppShell h
·
back to Norma
·
fix(theme): apply inbox-theme skins at body level so dropdow ce205d8 →