Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/components/SecuritySessionsScreen.tsx
T

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>
);
}