SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
6.8 KB · 164 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { LogOut, MonitorSmartphone } from "lucide-react";4import { authClient, useSession } from "@/lib/auth-client";5import { recordAuditFromClient } from "@/actions/account";6import { Button } from "@/components/ui/button";7import { Badge } from "@/components/ui/badge";8import { Alert } from "@/components/ui/alert";9import { Skeleton } from "@/components/ui/skeleton";10import { EmptyState } from "@/components/ui/empty-state";11import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";12import { formatDate, timeAgo, truncate } from "@/lib/format";1314interface SessionRow {15  id: string;16  token: string;17  ipAddress?: string | null;18  userAgent?: string | null;19  createdAt: Date | string;20  expiresAt: Date | string;21}2223/** Turn a UA string into something a human can recognise, without a UA-parser dependency. */24function describeAgent(ua: string | null | undefined): string {25  if (!ua) return "Unknown device";26  const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Chrome\//.test(ua) ? "Chrome" : /Firefox\//.test(ua) ? "Firefox" : /Safari\//.test(ua) ? "Safari" : /curl|python|node|okhttp/i.test(ua) ? "API client" : "Browser";27  const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "";28  return os ? `${browser} · ${os}` : browser;29}3031export function ActiveSessions() {32  const { data: current } = useSession();33  const [sessions, setSessions] = React.useState<SessionRow[] | null>(null);34  const [error, setError] = React.useState<string | null>(null);35  const [busyToken, setBusyToken] = React.useState<string | null>(null);36  const [busyAll, setBusyAll] = React.useState(false);3738  const [reloadKey, setReloadKey] = React.useState(0);39  const load = () => setReloadKey((k) => k + 1);4041  React.useEffect(() => {42    let cancelled = false;43    authClient.listSessions().then(({ data, error }) => {44      if (cancelled) return;45      if (error) {46        setError(error.message ?? "Could not load sessions.");47        setSessions([]);48        return;49      }50      const rows = ((data ?? []) as SessionRow[]).slice().sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());51      setSessions(rows);52    });53    return () => {54      cancelled = true;55    };56  }, [reloadKey]);5758  const currentToken = current?.session?.token;5960  async function revoke(token: string) {61    setError(null);62    setBusyToken(token);63    const { error } = await authClient.revokeSession({ token });64    if (error) setError(error.message ?? "Could not revoke the session.");65    else await recordAuditFromClient("session.revoked", { scope: "one" }).catch(() => {});66    setBusyToken(null);67    load();68  }6970  async function revokeOthers() {71    setError(null);72    setBusyAll(true);73    const { error } = await authClient.revokeOtherSessions();74    if (error) setError(error.message ?? "Could not sign out other devices.");75    else await recordAuditFromClient("session.revoked", { scope: "others" }).catch(() => {});76    setBusyAll(false);77    load();78  }7980  const others = sessions?.filter((s) => s.token !== currentToken).length ?? 0;8182  return (83    <div className="grid gap-3">84      {error ? <Alert variant="danger">{error}</Alert> : null}85      <div className="flex items-center justify-between gap-3">86        <p className="text-[13px] text-fg-muted">87          {sessions ? (88            <>89              <span className="font-mono tabular text-fg">{sessions.length}</span> active {sessions.length === 1 ? "session" : "sessions"}90              {others > 0 ? <> · {others} on other devices</> : null}91            </>92          ) : (93            "Loading sessions…"94          )}95        </p>96        <Button variant="outline" size="sm" onClick={revokeOthers} loading={busyAll} disabled={!sessions || others === 0}>97          <LogOut /> Sign out other devices98        </Button>99      </div>100      {sessions === null ? (101        <div className="grid gap-2 rounded-lg border border-border p-4">102          <Skeleton className="h-4 w-2/3" />103          <Skeleton className="h-4 w-1/2" />104          <Skeleton className="h-4 w-3/5" />105        </div>106      ) : sessions.length === 0 ? (107        <EmptyState compact icon={MonitorSmartphone} title="No sessions found" description="Sign in again to see your devices here." />108      ) : (109        <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">110          <Table>111            <TableHeader>112              <TableRow className="hover:bg-transparent">113                <TableHead>Device</TableHead>114                <TableHead>IP address</TableHead>115                <TableHead>Signed in</TableHead>116                <TableHead>Expires</TableHead>117                <TableHead className="text-right">118                  <span className="sr-only">Actions</span>119                </TableHead>120              </TableRow>121            </TableHeader>122            <TableBody>123              {sessions.map((s) => {124                const isCurrent = s.token === currentToken;125                return (126                  <TableRow key={s.id}>127                    <TableCell>128                      <div className="flex flex-col gap-0.5">129                        <span className="flex items-center gap-2 font-medium">130                          {describeAgent(s.userAgent)}131                          {isCurrent ? (132                            <Badge variant="accent" dot>133                              This device134                            </Badge>135                          ) : null}136                        </span>137                        <span className="font-mono text-[11px] text-fg-subtle" title={s.userAgent ?? undefined}>138                          {s.userAgent ? truncate(s.userAgent, 64) : "—"}139                        </span>140                      </div>141                    </TableCell>142                    <TableCell className="font-mono text-[12.5px] tabular">{s.ipAddress || "—"}</TableCell>143                    <TableCell className="whitespace-nowrap tabular" title={formatDate(s.createdAt)}>144                      {timeAgo(s.createdAt)}145                    </TableCell>146                    <TableCell className="whitespace-nowrap tabular text-fg-muted">{formatDate(s.expiresAt, { timeStyle: undefined })}</TableCell>147                    <TableCell className="text-right">148                      {!isCurrent ? (149                        <Button variant="ghost" size="xs" onClick={() => revoke(s.token)} loading={busyToken === s.token}>150                          Revoke151                        </Button>152                      ) : null}153                    </TableCell>154                  </TableRow>155                );156              })}157            </TableBody>158          </Table>159        </div>160      )}161    </div>162  );163}164