feat(security): list active sessions, sign out everywhere, and show security activity

Add the user-facing account-security surface in Settings:

- Active sessions (closes #64): GET /api/auth/me/sessions lists each browser
  login with a coarse device label, masked IP, resolved location, the sliding
  last-active stamp, and the current-session marker. DELETE the collection
  signs out everywhere else (keeps the current session, revokes other sessions
  and native refresh tokens, leaves API tokens alone); DELETE one revokes a
  single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
  fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
  the audit log filtered to sign-ins, second-factor changes, password changes,
  session revocations, exports, and deletions, with masked IPs.

OpenAPI regenerated for the new endpoints.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:43 +02:00
1 parent bed6588699
commit 9269043e91
14 files changed
+1379 -1

No files matched your search

@@ -39,6 +39,8 @@ import {
} from "./account-section-utils";
import { PasskeyListSection } from "./passkey-list-section";
import { AvatarSection } from "./avatar-section";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -589,6 +591,12 @@ export function AccountSection() {
</div>
</SettingsCard>
{/* v1.23 — active-session management (issue #64) + the security-activity
feed. Both sit in the account/security block under the credential
controls above. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
Erweitert. It is a maintenance / reset action, not a profile or
security control, so it sits beside Research Mode + the danger zone. */}
@@ -0,0 +1,125 @@
"use client";
/**
* v1.23 — Settings → account → "Security activity".
*
* A read-only feed of the user's recent account-security events (sign-ins, MFA
* changes, password changes, session revocations, exports, deletions) from the
* shared `GET /api/auth/me/security-activity` endpoint. Each row shows a
* readable action label, the resolved location, the masked IP, and the time.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
interface ActivityRow {
action: string;
createdAt: string;
location: string | null;
ipMasked: string | null;
carrier: string | null;
}
/** Map an audit action onto a stable, translatable label key. */
function actionLabelKey(action: string): string {
if (action === "auth.login.new_device")
return "settings.security.action.newDevice";
if (action === "auth.login.failed")
return "settings.security.action.loginFailed";
if (action.startsWith("auth.login") || action === "auth.register")
return "settings.security.action.login";
if (action.startsWith("auth.password"))
return "settings.security.action.passwordChange";
if (action.startsWith("auth.mfa")) return "settings.security.action.mfa";
if (action.startsWith("auth.session"))
return "settings.security.action.sessionRevoke";
if (action.startsWith("auth.token") || action.startsWith("auth.bearer"))
return "settings.security.action.token";
if (action === "user.account.delete")
return "settings.security.action.accountDelete";
if (action === "user.data.clear") return "settings.security.action.dataClear";
if (action.includes("export")) return "settings.security.action.export";
return "settings.security.action.other";
}
export function SecurityActivityCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
enabled: isAuthenticated,
});
const events = data?.events ?? [];
return (
<SettingsCard data-slot="settings-security-activity-card">
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.security.activityTitle")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<p className="text-muted-foreground text-xs">
{t("settings.security.activityDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.activityLoadError")}
</p>
)}
{!isLoading && !isError && events.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.activityEmpty")}
</p>
)}
{events.length > 0 && (
<ul className="divide-border divide-y">
{events.map((e, idx) => (
<li
key={`${e.action}-${e.createdAt}-${idx}`}
className="flex items-center justify-between gap-3 py-2"
data-slot="security-activity-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{t(actionLabelKey(e.action))}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
e.location ?? t("settings.security.unknownLocation"),
e.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<p className="text-muted-foreground shrink-0 text-xs">
{new Date(e.createdAt).toLocaleString()}
</p>
</li>
))}
</ul>
)}
</div>
</SettingsCard>
);
}
@@ -0,0 +1,183 @@
"use client";
/**
* v1.23 — Settings → account → "Active sessions" (issue #64).
*
* Lists the user's active web sessions (device label, masked IP, resolved
* location, last-active time, current-device marker) and offers a per-session
* "sign out" plus a "sign out everywhere else" that revokes every other
* session (and native device logins) while keeping the current one. All reads
* + writes route through the centralised query-key factory.
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
interface SessionRow {
id: string;
device: string;
ipMasked: string | null;
location: string | null;
lastActiveAt: string | null;
createdAt: string;
isCurrent: boolean;
}
export function SecuritySessionsCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
});
const revokeOne = useMutation({
mutationFn: (id: string) => apiDelete(`/api/auth/me/sessions/${id}`),
onSuccess: () => {
setStatus(null);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
onSuccess: (res) => {
setStatus(
t("settings.security.signOutEverywhereDone", {
count: res?.sessionsRevoked ?? 0,
}),
);
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
void queryClient.invalidateQueries({
queryKey: queryKeys.securityActivity(),
});
},
onError: () => setStatus(t("settings.security.revokeError")),
});
const sessions = data?.sessions ?? [];
const hasOthers = sessions.some((s) => !s.isCurrent);
return (
<SettingsCard data-slot="settings-security-sessions-card">
<SettingsCardHeader
icon={MonitorSmartphone}
title={t("settings.security.sessionsTitle")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.sessionsLoadError")}
</p>
)}
{!isLoading && !isError && sessions.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.sessionsEmpty")}
</p>
)}
{sessions.length > 0 && (
<ul className="divide-border divide-y">
{sessions.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 py-3"
data-slot="security-session-row"
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">
{s.device}
{s.isCurrent && (
<span className="text-success ml-2 text-xs font-normal">
{t("settings.security.currentSession")}
</span>
)}
</p>
<p className="text-muted-foreground truncate text-xs">
{[
s.location ?? t("settings.security.unknownLocation"),
s.ipMasked,
]
.filter(Boolean)
.join(" · ")}
</p>
{s.lastActiveAt && (
<p className="text-muted-foreground text-xs">
{t("settings.security.lastActive", {
time: new Date(s.lastActiveAt).toLocaleString(),
})}
</p>
)}
</div>
{!s.isCurrent && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9 shrink-0"
onClick={() => revokeOne.mutate(s.id)}
disabled={revokeOne.isPending}
>
{t("settings.security.revokeSession")}
</Button>
)}
</li>
))}
</ul>
)}
{hasOthers && (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => revokeOthers.mutate()}
disabled={revokeOthers.isPending}
>
{revokeOthers.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("settings.security.signOutEverywhere")}
</Button>
</div>
)}
{status && (
<p role="status" className="text-muted-foreground text-right text-sm">
{status}
</p>
)}
</div>
</SettingsCard>
);
}