Files
loyaly-merchant/src/features/settings/components/ActiveSessionsPanel.tsx
2026-09-25 16:29:41 +05:30

132 lines
4.1 KiB
TypeScript

'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {useToast} from '@astryxdesign/core/Toast';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useResource} from '@/shared/hooks/useResource';
import {securityRepository} from '@/features/settings/repositories/securityRepository';
import type {DeviceSession} from '@/features/settings/services/mapSession';
/**
* Every device signed in as the current person, from GET /api/auth/sessions.
*
* Shared by the merchant Security settings and the platform console's
* Settings: `/api/auth/*` is scoped to whoever is signed in, not to a tenant,
* so it answers for a platform admin exactly as it does for a merchant. One
* component so the two consoles cannot drift on what "sign out" means.
*
* Nothing optimistic: after a revoke the list is re-read, because the platform
* decides what is still live.
*/
export function ActiveSessionsPanel() {
const toast = useToast();
const sessions = useResource(securityRepository.sessions());
const handleRevokeSession = async (id: string) => {
const res = await securityRepository.revoke(id);
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
return;
}
sessions.refetch();
toast({body: 'That device has been signed out.'});
};
const handleRevokeAllOther = async () => {
const res = await securityRepository.revokeOthers();
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
return;
}
sessions.refetch();
toast({body: 'Every other device has been signed out.'});
};
const columns: TableColumn<DeviceSession & Record<string, unknown>>[] = [
{
key: 'device',
header: 'Device',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? <Badge variant="success" label="This browser" /> : null}
</HStack>
),
},
{
key: 'lastUsedAt',
header: 'Last used',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{new Date(row.lastUsedAt).toLocaleString()}
</Text>
),
},
{
key: 'actions',
header: 'Action',
align: 'center',
width: pixel(120),
// The current session CAN be revoked upstream, but doing it from this
// table signs you out mid-page with no warning. Signing out is what the
// account menu is for.
renderCell: (row) =>
row.isCurrent ? null : (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Sign out"
onClick={() => void handleRevokeSession(row.id)}
/>
</HStack>
),
},
];
return (
<PanelCard
title="Active sessions"
subtitle="Every device signed in as you."
resource={sessions}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="security"
title="No other devices"
description="Only this browser is signed in."
/>
}
actions={
<Button
variant="secondary"
size="sm"
label="Sign out everywhere else"
onClick={() => void handleRevokeAllOther()}
/>
}
>
{(rows) => (
<ResponsiveTable
columns={columns}
data={rows as (DeviceSession & Record<string, unknown>)[]}
idKey="id"
primaryKey="device"
/>
)}
</PanelCard>
);
}