Skip to content
File

Blob: src/client/pages/account/sessions.tsx

typescript219 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { LogOut, Monitor } from "lucide-react";
3import { useState } from "react";
4import { useNavigate } from "react-router";
5 
6import { BackLink } from "@/client/components/ui/back-link";
7import { Card } from "@/client/components/ui/card";
8import { ConfirmButton } from "@/client/components/ui/confirm-button";
9import { PageError } from "@/client/components/ui/page-error";
10import { PageHeader } from "@/client/components/ui/page-header";
11import { PanelRow } from "@/client/components/ui/row";
12import { Skeleton } from "@/client/components/ui/skeleton";
13import { useRequireSession } from "@/client/hooks/use-auth-guard";
14import { authClient } from "@/client/lib/auth-client";
15import { formatDateTime } from "@/client/lib/format";
16import { queryKeys } from "@/client/lib/query";
17 
18interface SessionRow {
19 id: string;
20 token: string;
21 ipAddress?: string | null;
22 userAgent?: string | null;
23 createdAt: string | Date;
24 expiresAt: string | Date;
25}
26 
27const SessionRowsSkeleton = ({ count }: { count: number }) => (
28 <div className="flex flex-col gap-3" aria-busy="true">
29 {Array.from({ length: count }, (_, i) => (
30 <div
31 key={i}
32 className="flex items-start justify-between rounded-xl border border-zinc-800/60 bg-zinc-900/40 px-4 py-3"
33 >
34 <div className="flex min-w-0 items-start gap-3">
35 <Skeleton className="mt-0.5 h-4 w-4 shrink-0 rounded-md" />
36 <div className="min-w-0 space-y-2">
37 <Skeleton className="h-4 w-48" />
38 <Skeleton className="h-3 w-60" />
39 </div>
40 </div>
41 <Skeleton className="h-9 w-20 rounded-lg" />
42 </div>
43 ))}
44 </div>
45);
46 
47const AccountSessionsPageSkeleton = () => (
48 <div className="mx-auto max-w-3xl" aria-busy="true">
49 <Skeleton className="mb-6 h-4 w-24" />
50 <div className="mb-8">
51 <Skeleton className="mb-3 h-8 w-32" />
52 <Skeleton className="h-4 w-full max-w-lg" />
53 </div>
54 <div className="rounded-2xl border border-zinc-800/60 bg-zinc-900/40 p-5 sm:p-6">
55 <div className="mb-4 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
56 <Skeleton className="h-5 w-36" />
57 </div>
58 <SessionRowsSkeleton count={3} />
59 </div>
60 </div>
61);
62 
63// Heuristic device label from the User-Agent string. We don't need
64// perfect parsing — just a recognizable hint so the user can spot
65// their own device in a list of three. Falls back to the raw UA.
66const summariseUserAgent = (ua: string | null | undefined): string => {
67 if (!ua) return "Unknown device";
68 const isMobile = /Mobile|Android|iPhone|iPad/i.test(ua);
69 const browser = /Firefox/i.test(ua)
70 ? "Firefox"
71 : /Edg/i.test(ua)
72 ? "Edge"
73 : /Chrome/i.test(ua)
74 ? "Chrome"
75 : /Safari/i.test(ua)
76 ? "Safari"
77 : "Browser";
78 const os = /Windows/i.test(ua)
79 ? "Windows"
80 : /Mac OS X/i.test(ua)
81 ? "macOS"
82 : /Android/i.test(ua)
83 ? "Android"
84 : /iPhone|iPad|iOS/i.test(ua)
85 ? "iOS"
86 : /Linux/i.test(ua)
87 ? "Linux"
88 : "device";
89 return `${browser} on ${os}${isMobile ? " (mobile)" : ""}`;
90};
91 
92export const AccountSessionsPage = () => {
93 const navigate = useNavigate();
94 const session = useRequireSession("/account/sessions");
95 const queryClient = useQueryClient();
96 const [error, setError] = useState<string | null>(null);
97 const [pending, setPending] = useState<string | null>(null);
98 
99 const userId = session.data?.user.id ?? null;
100 const sessionsQueryKey = queryKeys.accountSessions(userId);
101 const sessionsQuery = useQuery({
102 queryKey: sessionsQueryKey,
103 queryFn: async () => {
104 const res = await authClient.listSessions();
105 if (res.error) throw new Error(res.error.message ?? "Could not load sessions.");
106 return (res.data ?? []) as unknown as SessionRow[];
107 },
108 enabled: Boolean(userId),
109 });
110 const sessions = sessionsQuery.data ?? null;
111 
112 const handleRevoke = async (row: SessionRow) => {
113 setError(null);
114 setPending(row.id);
115 try {
116 const res = await authClient.revokeSession({ token: row.token });
117 if (res.error) {
118 setError(res.error.message ?? "Could not revoke session.");
119 return;
120 }
121 // Revoking the current session signs us out — bounce to landing.
122 if (row.token === session.data?.session.token) {
123 await session.refetch();
124 navigate("/");
125 return;
126 }
127 await queryClient.invalidateQueries({ queryKey: sessionsQueryKey, exact: true });
128 } catch (e) {
129 setError(e instanceof Error ? e.message : "Failed to revoke session.");
130 } finally {
131 setPending(null);
132 }
133 };
134 
135 const handleRevokeOthers = async () => {
136 setError(null);
137 setPending("others");
138 try {
139 const res = await authClient.revokeOtherSessions();
140 if (res.error) {
141 setError(res.error.message ?? "Could not revoke other sessions.");
142 return;
143 }
144 await queryClient.invalidateQueries({ queryKey: sessionsQueryKey, exact: true });
145 } catch (e) {
146 setError(e instanceof Error ? e.message : "Failed to revoke other sessions.");
147 } finally {
148 setPending(null);
149 }
150 };
151 
152 if (session.isPending || !session.data) {
153 return <AccountSessionsPageSkeleton />;
154 }
155 
156 const currentToken = session.data.session.token;
157 const otherCount = sessions?.filter((s) => s.token !== currentToken).length ?? 0;
158 
159 return (
160 <div className="mx-auto max-w-3xl">
161 <BackLink to="/account">Account</BackLink>
162 <PageHeader
163 title="Sessions"
164 description="Each row is a browser or device currently signed in. Revoke any you don't recognise."
165 />
166 <PageError message={error} onDismiss={() => setError(null)} />
167 <Card>
168 <div className="mb-4 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
169 <h2 className="text-lg font-semibold text-zinc-100">Active sessions</h2>
170 {sessions && otherCount > 0 ? (
171 <ConfirmButton variant="ghost" size="sm" loading={pending === "others"} onConfirm={handleRevokeOthers}>
172 <LogOut className="h-4 w-4" aria-hidden="true" />
173 Sign out everywhere else
174 </ConfirmButton>
175 ) : null}
176 </div>
177 {sessions === null ? (
178 <SessionRowsSkeleton count={3} />
179 ) : (
180 <div className="flex flex-col gap-3">
181 {sessions.map((row) => {
182 const isCurrent = row.token === currentToken;
183 return (
184 <PanelRow key={row.id} className="flex items-start justify-between">
185 <div className="flex min-w-0 items-start gap-3">
186 <Monitor className="mt-0.5 h-4 w-4 shrink-0 text-zinc-500" aria-hidden="true" />
187 <div className="min-w-0">
188 <p className="text-sm font-medium text-zinc-100">
189 {summariseUserAgent(row.userAgent)}
190 {isCurrent ? (
191 <span className="ml-2 inline-flex items-center rounded-md bg-zinc-700/50 px-1.5 py-0.5 text-[0.7rem] font-medium uppercase tracking-wider text-zinc-300">
192 this device
193 </span>
194 ) : null}
195 </p>
196 <p className="mt-0.5 text-xs text-zinc-500">
197 {row.ipAddress ?? "ip unknown"} · started {formatDateTime(row.createdAt)}
198 </p>
199 </div>
200 </div>
201 <ConfirmButton
202 variant="ghost"
203 size="sm"
204 loading={pending === row.id}
205 onConfirm={() => handleRevoke(row)}
206 >
207 Revoke
208 </ConfirmButton>
209 </PanelRow>
210 );
211 })}
212 {sessions.length === 0 ? <p className="text-sm text-zinc-500">No active sessions.</p> : null}
213 </div>
214 )}
215 </Card>
216 </div>
217 );
218};