mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user