[object Object]

← 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

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 →