260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
import { Fragment, useState } from "react";
|
|
import { Form, useNavigation } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FeatherAlertTriangle, FeatherLogOut, FeatherMonitor, FeatherShieldOff, FeatherSmartphone } from "@subframe/core";
|
|
import type { SessionListVM } from "@toolctrl/view-models";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
|
|
|
|
// D-P2 — Settings > Sicherheit nach Davids Subframe-"Session Management"-Design: Header (Titel + "Überall
|
|
// abmelden") + Geräte-Card (Icon + Label/Standort + "Dieses Gerät"-Badge / "Abmelden") + Confirm-Overlay
|
|
// (Revoke / Logout-All). Praesentational: VM + csrf werden injiziert, Aktionen laufen ueber die bestehende
|
|
// security-Action (intents revoke / revoke-all / forget-device / forget-all-devices). Die Trusted-Devices-
|
|
// Sektion hat kein eigenes David-Design -> konsistent in derselben Card-Optik re-skinned.
|
|
|
|
type TrustedDeviceVM = { id: string; label: string | null; approxLocation: string | null };
|
|
|
|
type ConfirmState =
|
|
| { kind: "revoke-all" }
|
|
| { kind: "revoke"; sessionId: string; label: string }
|
|
| { kind: "forget-all-devices" }
|
|
| { kind: "forget-device"; deviceId: string; label: string };
|
|
|
|
function isMobileDevice(label?: string | null): boolean {
|
|
if (!label) return false;
|
|
return /iphone|android|mobile|ipad|tablet/i.test(label);
|
|
}
|
|
|
|
export default function SecuritySessionsScreen({
|
|
vm,
|
|
trustedDevices,
|
|
csrf,
|
|
}: {
|
|
vm: SessionListVM;
|
|
trustedDevices: TrustedDeviceVM[];
|
|
csrf: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const navigation = useNavigation();
|
|
const submitting = navigation.state !== "idle";
|
|
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
|
|
|
|
const ready = vm.status === "ready";
|
|
const devices = ready ? vm.data.devices : [];
|
|
const onlyThisDevice = ready && devices.length <= 1 && !vm.data.canRevokeOthers;
|
|
|
|
return (
|
|
<div className="relative flex w-full flex-col items-center" data-ui-id="ui.settings.security.page">
|
|
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
|
|
{/* Header */}
|
|
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
|
|
<div className="flex flex-col items-start gap-2">
|
|
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
|
|
{t("settings.security.title")}
|
|
</span>
|
|
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
|
|
{t("settings.security.sessions.subtitle")}
|
|
</span>
|
|
</div>
|
|
{ready && vm.data.canRevokeOthers ? (
|
|
<Button
|
|
variant="secondary"
|
|
icon={<FeatherLogOut />}
|
|
className="mobile:w-full"
|
|
onClick={() => setConfirm({ kind: "revoke-all" })}
|
|
data-ui-id="ui.settings.security.revokeAll"
|
|
>
|
|
{t("settings.security.sessions.revokeAll")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Aktive Sitzungen */}
|
|
<section
|
|
className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm"
|
|
data-ui-id="ui.settings.security.sessions"
|
|
aria-label={t("settings.security.sessions.heading")}
|
|
>
|
|
{!ready ? (
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
|
{t("settings.security.sessions.empty")}
|
|
</span>
|
|
) : (
|
|
devices.map((device, index) => {
|
|
const Icon = isMobileDevice(device.deviceLabel) ? FeatherSmartphone : FeatherMonitor;
|
|
const label = device.deviceLabel ?? t(device.deviceLabelFallbackKey ?? "settings.security.sessions.unknownDevice");
|
|
const meta = [device.approxLocation, t(`settings.security.sessions.lastSeen.${device.lastSeenBucket}`, { count: device.lastSeenCount ?? 0 })]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
return (
|
|
<Fragment key={device.id}>
|
|
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
|
|
<div
|
|
className="flex w-full items-center gap-4 py-4"
|
|
data-ui-id="ui.settings.security.sessionRow"
|
|
data-current={device.isCurrent}
|
|
>
|
|
<Icon className="text-heading- font-heading- text-icon-neutral-secondary" />
|
|
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
|
|
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{label}</span>
|
|
{meta ? (
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{meta}</span>
|
|
) : null}
|
|
</div>
|
|
{device.isCurrent ? (
|
|
<BadgeFeedback farbe="subtle-positive" size="small">
|
|
{t("settings.security.sessions.thisDevice")}
|
|
</BadgeFeedback>
|
|
) : device.canRevoke ? (
|
|
<Button
|
|
variant="secondary"
|
|
size="small"
|
|
onClick={() => setConfirm({ kind: "revoke", sessionId: device.id, label })}
|
|
data-ui-id="ui.settings.security.revoke"
|
|
>
|
|
{t("settings.security.sessions.revoke")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</section>
|
|
|
|
{onlyThisDevice ? (
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
|
{t("settings.security.sessions.onlyThisDevice")}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* Vertrauenswürdige Geräte (kein eigenes David-Design -> konsistente Card-Optik) */}
|
|
{trustedDevices.length > 0 ? (
|
|
<section
|
|
className="flex w-full flex-col items-start gap-4"
|
|
data-ui-id="ui.settings.security.trustedDevices"
|
|
aria-label={t("settings.security.trustedDevices.heading")}
|
|
>
|
|
<div className="flex w-full items-center justify-between gap-4">
|
|
<div className="flex flex-col items-start gap-1">
|
|
<span className="text-heading- font-heading- text-text-neutral-primary">
|
|
{t("settings.security.trustedDevices.heading")}
|
|
</span>
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
|
{t("settings.security.trustedDevices.description")}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="tertiary"
|
|
size="small"
|
|
icon={<FeatherShieldOff />}
|
|
onClick={() => setConfirm({ kind: "forget-all-devices" })}
|
|
data-ui-id="ui.settings.security.forgetAllDevices"
|
|
>
|
|
{t("settings.security.trustedDevices.forgetAll")}
|
|
</Button>
|
|
</div>
|
|
<div className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
|
|
{trustedDevices.map((device, index) => (
|
|
<Fragment key={device.id}>
|
|
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
|
|
<div className="flex w-full items-center gap-4 py-4" data-ui-id="ui.settings.security.trustedDeviceRow">
|
|
<FeatherShieldOff className="text-heading- font-heading- text-icon-neutral-secondary" />
|
|
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
|
|
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">
|
|
{device.label ?? t("settings.security.trustedDevices.unknownDevice")}
|
|
</span>
|
|
{device.approxLocation ? (
|
|
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
|
{device.approxLocation}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
variant="tertiary"
|
|
size="small"
|
|
onClick={() => setConfirm({ kind: "forget-device", deviceId: device.id, label: device.label ?? t("settings.security.trustedDevices.unknownDevice") })}
|
|
data-ui-id="ui.settings.security.forgetDevice"
|
|
>
|
|
{t("settings.security.trustedDevices.forget")}
|
|
</Button>
|
|
</div>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
|
|
{confirm ? <ConfirmOverlay confirm={confirm} csrf={csrf} submitting={submitting} onClose={() => setConfirm(null)} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConfirmOverlay({
|
|
confirm,
|
|
csrf,
|
|
submitting,
|
|
onClose,
|
|
}: {
|
|
confirm: ConfirmState;
|
|
csrf: string;
|
|
submitting: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const copy = {
|
|
"revoke-all": {
|
|
title: t("settings.security.sessions.confirmRevokeAll.title"),
|
|
description: t("settings.security.sessions.confirmRevokeAll.description"),
|
|
confirm: t("settings.security.sessions.confirmRevokeAll.confirm"),
|
|
},
|
|
revoke: {
|
|
title: t("settings.security.sessions.confirmRevoke.title"),
|
|
description: t("settings.security.sessions.confirmRevoke.description", { device: "label" in confirm ? confirm.label : "" }),
|
|
confirm: t("settings.security.sessions.confirmRevoke.confirm"),
|
|
},
|
|
"forget-all-devices": {
|
|
title: t("settings.security.trustedDevices.confirmForgetAll.title"),
|
|
description: t("settings.security.trustedDevices.confirmForgetAll.description"),
|
|
confirm: t("settings.security.trustedDevices.forgetAll"),
|
|
},
|
|
"forget-device": {
|
|
title: t("settings.security.trustedDevices.confirmForget.title"),
|
|
description: t("settings.security.trustedDevices.confirmForget.description", { device: "label" in confirm ? confirm.label : "" }),
|
|
confirm: t("settings.security.trustedDevices.forget"),
|
|
},
|
|
}[confirm.kind];
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4"
|
|
data-ui-id="ui.settings.security.confirm"
|
|
>
|
|
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
|
|
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
|
|
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
|
|
</div>
|
|
<div className="flex w-full flex-col items-start gap-2">
|
|
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">{copy.title}</span>
|
|
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{copy.description}</span>
|
|
</div>
|
|
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
|
|
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.security.confirm.cancel">
|
|
{t("shared.cancel")}
|
|
</Button>
|
|
<Form method="post" className="contents">
|
|
<input type="hidden" name="csrf" value={csrf} readOnly />
|
|
<input type="hidden" name="intent" value={confirm.kind} readOnly />
|
|
{confirm.kind === "revoke" ? <input type="hidden" name="sessionId" value={confirm.sessionId} readOnly /> : null}
|
|
{confirm.kind === "forget-device" ? <input type="hidden" name="deviceId" value={confirm.deviceId} readOnly /> : null}
|
|
<Button variant="destructive" type="submit" loading={submitting} data-ui-id="ui.settings.security.confirm.submit">
|
|
{copy.confirm}
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|