Skip to content

Commit 6827d1e

Browse files
byw1claude
andcommitted
feat: restructure the admin panel into six tabs, add read-only view-as
The admin panel was one page of stacked cards. It is now General / Apps / AI / Config / Health / Enterprise, with the active tab in the URL so a tab is a link you can send. General — an About block (running version, latest release, public URL) and an administrator access matrix showing, per person, whether their role grants super access, admin-panel access and view-as. Plus the feature those columns describe: View as a user (read-only impersonation) - A signed, short-lived cookie ticket carrying BOTH the actor and the target; getCurrentDbUser resolves the target so every page renders exactly what that person sees, with the real actor named in a permanent banner. - Read-only by construction. requireWriter() is the new guard on every mutating server action and refuses while a ticket is active; requirePermission blocks writes too, with an explicit requirePermissionForRead opt-out for the handful of permissioned reads. Writes are blocked by DEFAULT, so an action added later is safe until someone says otherwise. - Rails: only holders of the new system.impersonate permission may start one; accounts with full access can never be viewed as (wearing an owner's session is indistinguishable from being one); no nesting; no self-impersonation; disabled accounts excluded; the ticket is bound to the actor's session, so signing out ends it by construction; both start and stop are written to the previously-unused audit_logs table. Config — SMTP settings are now editable at runtime, stored encrypted in app_settings and overriding the environment, with a test that actually sends a message. States the honest limitation in the UI: magic-link sign-in reads its providers at startup, so it picks these up on restart; app-sent mail uses a per-send transport and applies immediately. Health — an operational/degraded/outage rollup (datastores are load-bearing, a dead worker is degraded not an outage), the web app's own uptime/memory, and migration-state detection: applied migrations vs what the build ships, which catches the deploy where the pre-deploy migration step didn't run. Verified the query against a real Postgres 16 after running the actual migrator. Enterprise — licence key stored encrypted, gating flagged features. Honest copy: an honour-system switch on a self-hosted MIT product, not a cryptographic lock, and nothing phones home. AI — unchanged provider management; usage reporting added as coming-soon and enterprise-gated, with an explicit note that nothing is metered today. New tests cover ticket signing/expiry/tampering and every impersonation rail; EXPECTED_MIGRATION_COUNT is asserted against the journal so adding a migration without bumping it fails CI. 275 tests pass, typecheck and build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018A8f7g1t5FMFkYgx12aiu9
1 parent e878b90 commit 6827d1e

37 files changed

Lines changed: 2728 additions & 804 deletions

ROADMAP.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ What makes Comms enterprise rather than a personal bridge.
8080
- ✅ Split inbox by correspondent — People / Unknown / Automated / Verification
8181
codes, classified from traffic at ingest, with a copy-the-code chip on OTP rows.
8282
Shipped ON by default (seeded shared folders, toggleable in Settings → Workspace)
83-
-**Admin panel** (Settings → Other) — version + update check, administrators
84-
and recent users, AI provider management, runtime-tunable config (undo window,
85-
send caps — no redeploy), and a Health tab: Postgres/Redis latency, queue
86-
depths, worker heartbeat, bridge status
87-
- ✅ Automations can route to a team, mute a thread, and condition on the
88-
correspondent kind
83+
-**Admin panel** (Settings → Other) — six tabs, linkable by URL:
84+
- **General** — about/version + update check, an administrator access matrix
85+
(super · admin panel · view-as), and read-only **view as a user**
86+
- **Apps** — catalogue placeholder
87+
- **AI** — multi-provider keys and models; usage reporting flagged enterprise
88+
- **Config** — runtime-editable email/SMTP (no redeploy) with a real test
89+
send, send caps and the undo window, plus read-only deployment config
90+
- **Health** — operational/degraded/outage rollup, Postgres/Redis/worker/web,
91+
queue depths, bridge status, and migration state (schema drift detection)
92+
- **Enterprise** — licence key storage and feature gating
93+
- ✅ Automations can mute a thread and condition on the correspondent kind
8994
- ⬜ Merge/split/link conversations; cross-handle entity resolution
9095
- ⬜ Full business-hours-aware SLA windows
9196

apps/web/src/app/(app)/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { Sidebar } from '@/components/app/sidebar';
1010
import { RealtimeProvider } from '@/components/app/realtime-provider';
1111
import { ChannelHealthBanner } from '@/components/app/channel-health-banner';
12+
import { ImpersonationBanner } from '@/components/app/impersonation-banner';
1213
import { CommandPalette } from '@/components/app/command-palette';
1314
import { pendingCount } from '@/server/actions/scheduled';
1415
import { KeymapProvider } from '@/components/app/keymap-provider';
@@ -70,6 +71,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
7071
>
7172
<KeymapProvider preference={user.preferences?.keymap as KeymapPreference | undefined}>
7273
<div className="flex h-dvh flex-col overflow-hidden">
74+
{user.impersonatedBy && (
75+
<ImpersonationBanner
76+
viewingAs={user.name ?? user.email}
77+
actorName={user.impersonatedBy.name ?? user.impersonatedBy.email}
78+
/>
79+
)}
7380
<ChannelHealthBanner initial={unhealthy} />
7481
<MobileTopBar />
7582
<div className="flex min-h-0 flex-1">

apps/web/src/app/(app)/settings/(admin)/admin/page.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,23 @@ import {
88
getVersionInfo,
99
listAiProvidersSafe,
1010
} from '@/server/system';
11+
import { getEmailStatus } from '@/server/smtp';
12+
import { getLicense } from '@/server/license';
1113
import { AdminPanel } from '@/components/settings/admin-panel';
1214

1315
export const dynamic = 'force-dynamic';
1416

1517
export default async function AdminPanelPage() {
16-
await requirePermissionPage('system.admin');
18+
const me = await requirePermissionPage('system.admin');
1719
const cfg = loadConfig();
18-
const [version, health, overview, ai, overrides] = await Promise.all([
20+
const [version, health, overview, ai, overrides, email, license] = await Promise.all([
1921
getVersionInfo(),
2022
getSystemHealth(),
2123
getAdminOverview(),
2224
listAiProvidersSafe(),
2325
getRuntimeOverrides(),
26+
getEmailStatus(),
27+
getLicense(),
2428
]);
2529

2630
// What the deployment is, in one glance — values only, never secrets.
@@ -29,7 +33,6 @@ export default async function AdminPanelPage() {
2933
{ label: 'Database', value: cfg.DATABASE_URL ? 'configured' : 'missing' },
3034
{ label: 'Redis', value: cfg.REDIS_URL ? 'configured' : 'missing' },
3135
{ label: 'Attachments (S3)', value: cfg.storageEnabled ? 'configured' : 'not configured' },
32-
{ label: 'Email (SMTP)', value: cfg.smtpEnabled ? 'configured' : 'not configured' },
3336
{
3437
label: 'Google sign-in',
3538
value: process.env.GOOGLE_CLIENT_ID ? 'configured' : 'not configured',
@@ -49,19 +52,27 @@ export default async function AdminPanelPage() {
4952
<div>
5053
<h2 className="text-lg font-semibold">Admin panel</h2>
5154
<p className="text-sm text-muted-foreground">
52-
The machine room: version, providers, runtime settings and service health.
55+
The machine room: what this instance is running, who administers it, and whether the
56+
pieces are alive.
5357
</p>
5458
</div>
5559

5660
<AdminPanel
5761
version={version}
58-
health={health}
59-
admins={overview.admins.map((a) => ({
60-
id: a.id,
61-
name: a.name,
62-
email: a.email,
63-
role: a.role,
64-
image: a.image,
62+
health={{
63+
...health,
64+
worker: {
65+
...health.worker,
66+
lastSeenAt: health.worker.lastSeenAt,
67+
},
68+
}}
69+
administrators={overview.administrators.map((a) => ({
70+
...a,
71+
lastSeenAt: a.lastSeenAt?.toISOString() ?? null,
72+
}))}
73+
allUsers={overview.allUsers.map((a) => ({
74+
...a,
75+
lastSeenAt: a.lastSeenAt?.toISOString() ?? null,
6576
}))}
6677
recentUsers={overview.recent.map((u) => ({
6778
id: u.id,
@@ -71,6 +82,8 @@ export default async function AdminPanelPage() {
7182
status: u.status,
7283
lastSeenAt: u.lastSeenAt?.toISOString() ?? null,
7384
}))}
85+
currentUserId={me.id}
86+
canImpersonate={me.permissions.includes('*') || me.permissions.includes('system.impersonate')}
7487
providers={ai.providers}
7588
envAnthropicKey={ai.envAnthropicKey}
7689
envModel={ai.envModel}
@@ -83,6 +96,9 @@ export default async function AdminPanelPage() {
8396
}}
8497
readOnlyConfig={readOnlyConfig}
8598
suggestedModels={SUGGESTED_MODELS}
99+
email={email}
100+
license={license}
101+
orgName={cfg.appUrl}
86102
/>
87103
</div>
88104
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use client';
2+
3+
import { useTransition } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { Eye, X } from 'lucide-react';
6+
import { stopImpersonation } from '@/server/actions/impersonation';
7+
8+
/**
9+
* A permanent, unmissable strip across the top of every page while a view-as
10+
* session is running.
11+
*
12+
* Deliberately loud and never dismissible: the entire danger of impersonation
13+
* is forgetting you are in it, and a banner you can close is a banner that
14+
* will be closed.
15+
*/
16+
export function ImpersonationBanner({
17+
viewingAs,
18+
actorName,
19+
}: {
20+
viewingAs: string;
21+
actorName: string;
22+
}) {
23+
const router = useRouter();
24+
const [pending, start] = useTransition();
25+
26+
return (
27+
<div className="flex shrink-0 items-center gap-2 bg-warning px-3 py-1.5 text-warning-foreground">
28+
<Eye className="h-3.5 w-3.5 shrink-0" />
29+
<p className="min-w-0 flex-1 truncate text-[12.5px] font-medium">
30+
Viewing as <span className="font-semibold">{viewingAs}</span> — read only. Signed in as{' '}
31+
{actorName}.
32+
</p>
33+
<button
34+
type="button"
35+
disabled={pending}
36+
onClick={() =>
37+
start(async () => {
38+
await stopImpersonation();
39+
router.push('/settings/admin?tab=general');
40+
router.refresh();
41+
})
42+
}
43+
className="flex shrink-0 items-center gap-1 rounded-md bg-warning-foreground/10 px-2 py-0.5 text-[12px] font-semibold transition-colors hover:bg-warning-foreground/20 disabled:opacity-60"
44+
>
45+
<X className="h-3 w-3" />
46+
{pending ? 'Stopping…' : 'Stop viewing'}
47+
</button>
48+
</div>
49+
);
50+
}

0 commit comments

Comments
 (0)