sessions-client.tsx255 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 const [dRes, sRes] = await Promise.all([
79 fetch(`/api/v1/projects/${pid}/auth/users/${uid}/devices`, { credentials: 'include' }),
80 fetch(`/api/v1/projects/${pid}/auth/users/${uid}/sessions`, { credentials: 'include' }),
81 ]);
82 if (dRes.ok) {
83 const b = (await dRes.json()) as { items?: DeviceRow[] };
84 setDevices(b.items ?? []);
85 } else setDevices([]);
86 if (sRes.ok) {
87 const b = (await sRes.json()) as { items?: SessionRow[] };
88 setSessions(b.items ?? []);
89 } else setSessions([]);
90 } finally {
91 setBusy(false);
92 }
93 }, []);
94
95 useEffect(() => {
96 if (projectId) void loadUsers(projectId);
97 }, [projectId, loadUsers]);
98
99 useEffect(() => {
100 if (projectId && userId) void loadDetail(projectId, userId);
101 }, [projectId, userId, loadDetail]);
102
103 async function revokeSession(sessionId: string): Promise<void> {
104 if (!projectId || !userId) return;
105 setNote(null);
106 setErr(null);
107 const res = await fetch(
108 `/api/v1/projects/${projectId}/auth/users/${userId}/sessions/${sessionId}/revoke`,
109 { method: 'POST', credentials: 'include' },
110 );
111 if (!res.ok) {
112 const body = (await res.json().catch(() => ({}))) as { message?: string };
113 setErr(body.message ?? `revoke failed (${res.status})`);
114 return;
115 }
116 setNote('session revoked');
117 await loadDetail(projectId, userId);
118 }
119
120 if (enabled.length === 0) {
121 return (
122 <p className="font-mono text-xs text-[var(--color-text-muted)]">
123 enable Auth on a project first.
124 </p>
125 );
126 }
127
128 return (
129 <div className="flex max-w-2xl flex-col gap-4">
130 <label className="flex flex-col gap-1 font-mono text-xs">
131 <span className="text-[var(--color-text-muted)]">project</span>
132 <select
133 value={projectId}
134 onChange={(e) => setProjectId(e.target.value)}
135 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
136 style={{ borderColor: 'var(--auth-accent-border)' }}
137 >
138 {enabled.map((p) => (
139 <option key={p.id} value={p.id}>
140 {p.name}
141 </option>
142 ))}
143 </select>
144 </label>
145
146 {users.length === 0 ? (
147 <p className="font-mono text-xs text-[var(--color-text-muted)]">
148 no users yet — when someone signs in, devices appear here.
149 </p>
150 ) : (
151 <label className="flex flex-col gap-1 font-mono text-xs">
152 <span className="text-[var(--color-text-muted)]">user</span>
153 <select
154 value={userId}
155 onChange={(e) => setUserId(e.target.value)}
156 className="rounded-md border bg-[var(--color-surface)] px-3 py-2"
157 style={{ borderColor: 'var(--auth-accent-border)' }}
158 >
159 {users.map((u) => (
160 <option key={u.id} value={u.id}>
161 {u.nameInitial ? `${u.nameInitial} · ` : ''}@{u.emailDomainHint ?? '?'} ·{' '}
162 {u.id.slice(0, 10)}…
163 </option>
164 ))}
165 </select>
166 </label>
167 )}
168
169 {busy ? (
170 <p className="font-mono text-xs text-[var(--color-text-muted)]">loading…</p>
171 ) : userId ? (
172 <>
173 <section className="flex flex-col gap-2">
174 <h3 className="font-mono text-xs uppercase tracking-widest text-[var(--color-text-muted)]">
175 known devices ({devices.length})
176 </h3>
177 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
178 first time a browser signs in, we remember a fingerprint and email the
179 user. no raw IP stored.
180 </p>
181 {devices.length === 0 ? (
182 <p className="font-mono text-xs text-[var(--color-text-muted)]">none yet</p>
183 ) : (
184 <ul className="flex flex-col gap-1.5">
185 {devices.map((d) => (
186 <li
187 key={d.id}
188 className="rounded-md border px-3 py-2 font-mono text-xs"
189 style={{ borderColor: 'var(--auth-accent-border)' }}
190 >
191 <span className="text-[var(--color-text)]">{d.hint}</span>
192 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
193 first {shortTime(d.createdAt)} · last {shortTime(d.updatedAt)}
194 </span>
195 </li>
196 ))}
197 </ul>
198 )}
199 </section>
200
201 <section className="flex flex-col gap-2">
202 <h3 className="font-mono text-xs uppercase tracking-widest text-[var(--color-text-muted)]">
203 live sessions ({sessions.length})
204 </h3>
205 {sessions.length === 0 ? (
206 <p className="font-mono text-xs text-[var(--color-text-muted)]">none live</p>
207 ) : (
208 <ul className="flex flex-col gap-1.5">
209 {sessions.map((s) => (
210 <li
211 key={s.id}
212 className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 font-mono text-xs"
213 style={{ borderColor: 'var(--auth-accent-border)' }}
214 >
215 <span>
216 <span className="text-[var(--color-text)]">{s.hint || 'session'}</span>
217 <span className="mt-0.5 block text-[10px] text-[var(--color-text-muted)]">
218 since {shortTime(s.createdAt)}
219 </span>
220 </span>
221 <button
222 type="button"
223 onClick={() => void revokeSession(s.id)}
224 className="text-[10px] underline text-[var(--color-text-muted)]"
225 >
226 revoke
227 </button>
228 </li>
229 ))}
230 </ul>
231 )}
232 </section>
233
234 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
235 linked Google/GitHub accounts:{' '}
236 <Link
237 href="/dashboard/auth/users"
238 className="underline"
239 style={{ color: 'var(--auth-accent)' }}
240 >
241 open users → details
242 </Link>
243 </p>
244 </>
245 ) : null}
246
247 {note ? (
248 <p className="font-mono text-xs" style={{ color: 'var(--auth-accent)' }}>
249 {note}
250 </p>
251 ) : null}
252 {err ? <p className="font-mono text-xs text-[var(--color-error)]">{err}</p> : null}
253 </div>
254 );
255}