sessions-client.tsx280 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useCallback, useEffect, useState } from 'react';
5
6import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
7
8interface RedactedUser {
9 id: string;
10 emailDomainHint?: string;
11 lastSeenAt?: string | null;
12 nameInitial?: string | null;
13}
14
15interface DeviceRow {
16 id: string;
17 hint: string;
18 createdAt: string;
19 updatedAt: string;
20}
21
22interface SessionRow {
23 id: string;
24 createdAt: string;
25 expiresAt: string | null;
26 hint: string;
27}
28
29function shortTime(iso: string | null | undefined): string {
30 if (!iso) return '—';
31 try {
32 return new Date(iso).toLocaleString();
33 } catch {
34 return iso;
35 }
36}
37
38/**
39 * Pick a user, see known devices + live sessions.
40 * Full manage (unlink accounts) lives under users.
41 */
42export function AuthSessionsClient({ projects }: { projects: AuthV2ProjectRow[] }) {
43 const enabled = projects.filter((p) => p.authEnabled);
44 const [projectId, setProjectId] = useState(enabled[0]?.id ?? '');
45 const [users, setUsers] = useState<RedactedUser[]>([]);
46 const [userId, setUserId] = useState('');
47 const [devices, setDevices] = useState<DeviceRow[]>([]);
48 const [sessions, setSessions] = useState<SessionRow[]>([]);
49 const [busy, setBusy] = useState(false);
50 const [err, setErr] = useState<string | null>(null);
51 const [note, setNote] = useState<string | null>(null);
52
53 const loadUsers = useCallback(async (id: string) => {
54 if (!id) return;
55 setErr(null);
56 const res = await fetch(`/api/v1/projects/${id}/auth/users?limit=50`, {
57 credentials: 'include',
58 });
59 if (!res.ok) {
60 setErr(`load failed (${res.status})`);
61 return;
62 }
63 const body = (await res.json()) as { items?: RedactedUser[] };
64 const list = body.items ?? [];
65 setUsers(list);
66 setUserId((prev) => (prev && list.some((u) => u.id === prev) ? prev : (list[0]?.id ?? '')));
67 }, []);
68
69 const loadDetail = useCallback(async (pid: string, uid: string) => {
70 if (!pid || !uid) {
71 setDevices([]);
72 setSessions([]);
73 return;
74 }
75 setBusy(true);
76 setErr(null);
77 try {
78 // Devices stay on project-local table; sessions prefer briven-engine be_sessions.
79 const [dRes, engineSess, legacySess] = await Promise.all([
80 fetch(`/api/v1/projects/${pid}/auth/users/${uid}/devices`, { credentials: 'include' }),
81 fetch(
82 `/api/v1/auth-core/session/list?userId=${encodeURIComponent(uid)}&projectId=${encodeURIComponent(pid)}`,
83 { credentials: 'include' },
84 ),
85 fetch(`/api/v1/projects/${pid}/auth/users/${uid}/sessions`, { credentials: 'include' }),
86 ]);
87 if (dRes.ok) {
88 const b = (await dRes.json()) as { items?: DeviceRow[] };
89 setDevices(b.items ?? []);
90 } else setDevices([]);
91 if (engineSess.ok) {
92 const b = (await engineSess.json()) as { handles?: string[] };
93 const handles = b.handles ?? [];
94 setSessions(
95 handles.map((h) => ({
96 id: h,
97 createdAt: '',
98 expiresAt: null,
99 hint: 'briven-engine session',
100 })),
101 );
102 } else if (legacySess.ok) {
103 const b = (await legacySess.json()) as { items?: SessionRow[] };
104 setSessions(b.items ?? []);
105 } else setSessions([]);
106 } finally {
107 setBusy(false);
108 }
109 }, []);
110
111 useEffect(() => {
112 if (projectId) void loadUsers(projectId);
113 }, [projectId, loadUsers]);
114
115 useEffect(() => {
116 if (projectId && userId) void loadDetail(projectId, userId);
117 }, [projectId, userId, loadDetail]);
118
119 async function revokeSession(sessionId: string): Promise<void> {
120 if (!projectId || !userId) return;
121 setNote(null);
122 setErr(null);
123 // Prefer engine revoke (be_sessions handle); fall back to legacy project sessions.
124 let res = await fetch(`/api/v1/auth-core/session/revoke`, {
125 method: 'POST',
126 credentials: 'include',
127 headers: { 'content-type': 'application/json' },
128 body: JSON.stringify({ sessionHandle: sessionId, projectId }),
129 });
130 if (!res.ok) {
131 res = await fetch(
132 `/api/v1/projects/${projectId}/auth/users/${userId}/sessions/${sessionId}/revoke`,
133 { method: 'POST', credentials: 'include' },
134 );
135 }
136 if (!res.ok) {
137 const body = (await res.json().catch(() => ({}))) as { message?: string };
138 setErr(body.message ?? `revoke failed (${res.status})`);
139 return;
140 }
141 setNote('session revoked');
142 await loadDetail(projectId, userId);
143 }
144
145 if (enabled.length === 0) {
146 return (
147 <p className="font-mono text-xs text-[var(--color-text-muted)]">
148 enable Auth on a project first.
149 </p>
150 );
151 }
152
153 return (
154 <div className="flex max-w-2xl flex-col gap-4">
155 <label className="flex flex-col gap-1 font-mono text-xs">
156 <span className="text-[var(--color-text-muted)]">project</span>
157 <select
158 value={projectId}
159 onChange={(e) => setProjectId(e.target.value)}
160 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
161 style={{ borderColor: 'var(--auth-accent-border)' }}
162 >
163 {enabled.map((p) => (
164 <option key={p.id} value={p.id}>
165 {p.name}
166 </option>
167 ))}
168 </select>
169 </label>
170
171 {users.length === 0 ? (
172 <p className="font-mono text-xs text-[var(--color-text-muted)]">
173 no users yet — when someone signs in, devices appear here.
174 </p>
175 ) : (
176 <label className="flex flex-col gap-1 font-mono text-xs">
177 <span className="text-[var(--color-text-muted)]">user</span>
178 <select
179 value={userId}
180 onChange={(e) => setUserId(e.target.value)}
181 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
182 style={{ borderColor: 'var(--auth-accent-border)' }}
183 >
184 {users.map((u) => (
185 <option key={u.id} value={u.id}>
186 {u.nameInitial ? `${u.nameInitial} · ` : ''}@{u.emailDomainHint ?? '?'} ·{' '}
187 {u.id.slice(0, 10)}…
188 </option>
189 ))}
190 </select>
191 </label>
192 )}
193
194 {busy ? (
195 <p className="font-mono text-xs text-[var(--color-text-muted)]">loading…</p>
196 ) : userId ? (
197 <>
198 <section className="flex flex-col gap-2">
199 <h3 className="font-mono text-xs uppercase tracking-widest text-[var(--color-text-muted)]">
200 known devices ({devices.length})
201 </h3>
202 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
203 first time a browser signs in, we remember a fingerprint and email the
204 user. no raw IP stored.
205 </p>
206 {devices.length === 0 ? (
207 <p className="font-mono text-xs text-[var(--color-text-muted)]">none yet</p>
208 ) : (
209 <ul className="flex flex-col gap-1.5">
210 {devices.map((d) => (
211 <li
212 key={d.id}
213 className="rounded-md border px-3 py-2 font-mono text-xs"
214 style={{ borderColor: 'var(--auth-accent-border)' }}
215 >
216 <span className="text-[var(--color-text)]">{d.hint}</span>
217 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
218 first {shortTime(d.createdAt)} · last {shortTime(d.updatedAt)}
219 </span>
220 </li>
221 ))}
222 </ul>
223 )}
224 </section>
225
226 <section className="flex flex-col gap-2">
227 <h3 className="font-mono text-xs uppercase tracking-widest text-[var(--color-text-muted)]">
228 live sessions ({sessions.length})
229 </h3>
230 {sessions.length === 0 ? (
231 <p className="font-mono text-xs text-[var(--color-text-muted)]">none live</p>
232 ) : (
233 <ul className="flex flex-col gap-1.5">
234 {sessions.map((s) => (
235 <li
236 key={s.id}
237 className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
238 style={{ borderColor: 'var(--auth-accent-border)' }}
239 >
240 <span>
241 <span className="text-[var(--color-text)]">{s.hint || 'session'}</span>
242 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
243 since {shortTime(s.createdAt)}
244 </span>
245 </span>
246 <button
247 type="button"
248 onClick={() => void revokeSession(s.id)}
249 className="text-[10px] underline text-[var(--color-text-muted)]"
250 >
251 revoke
252 </button>
253 </li>
254 ))}
255 </ul>
256 )}
257 </section>
258
259 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
260 linked Google/GitHub accounts:{' '}
261 <Link
262 href="/dashboard/auth/users"
263 className="underline"
264 style={{ color: 'var(--auth-accent)' }}
265 >
266 open users → details
267 </Link>
268 </p>
269 </>
270 ) : null}
271
272 {note ? (
273 <p className="font-mono text-xs" style={{ color: 'var(--auth-accent)' }}>
274 {note}
275 </p>
276 ) : null}
277 {err ? <p className="font-mono text-xs text-[var(--color-error)]">{err}</p> : null}
278 </div>
279 );
280}