mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: implement ActiveSessions component and JWT session tracking to manage user device access
This commit is contained in:
@@ -44,7 +44,7 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import { ActiveSessions, setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -404,8 +404,9 @@ export function ProfilePage() {
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="lg">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.security')}</Title>
|
||||
@@ -502,8 +503,11 @@ export function ProfilePage() {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<ActiveSessions />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Preferences ---- */}
|
||||
|
||||
@@ -454,6 +454,37 @@ export const am: Translations = {
|
||||
dark: "ሌሊት",
|
||||
system: "ሲስተም",
|
||||
},
|
||||
sessions: {
|
||||
title: 'ንቁ የመግቢያ ክፍለ ጊዜዎች',
|
||||
hint: 'በአሁኑ ሰዓት ወደ መለያዎ የገቡ መሣሪያዎች። የማያውቁትን ይሰርዙ።',
|
||||
columns: {
|
||||
device: 'የአይ ፒ አድራሻ',
|
||||
signedIn: 'የገባበት ጊዜ',
|
||||
expires: 'የሚያበቃበት',
|
||||
status: 'ሁኔታ',
|
||||
actions: 'እርምጃዎች',
|
||||
},
|
||||
select: 'ይምረጡ',
|
||||
selectAll: 'ሁሉንም ክፍለ ጊዜዎች ይምረጡ',
|
||||
selectRow: 'ከ {{device}} የመጣውን ክፍለ ጊዜ ይምረጡ',
|
||||
thisDevice: 'ይህ መሣሪያ',
|
||||
revoke: 'ሰርዝ',
|
||||
cannotRevokeCurrent: 'ይህ አሁን እየተጠቀሙበት ያለው ክፍለ ጊዜ ነው።',
|
||||
revokeSelected_one: 'የተመረጠውን {{count}} ሰርዝ',
|
||||
revokeSelected_other: 'የተመረጡትን {{count}} ሰርዝ',
|
||||
signOutOthers: 'ከሌሎች ቦታዎች ሁሉ ውጣ',
|
||||
empty: 'ንቁ ክፍለ ጊዜ የለም።',
|
||||
confirm: {
|
||||
title: 'ክፍለ ጊዜ ሰርዝ',
|
||||
one: 'ከ {{device}} የመጣው ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_one: '{{count}} ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_other: '{{count}} ክፍለ ጊዜዎች ወዲያውኑ ይወጣሉ።',
|
||||
others: 'ሌሎቹ ክፍለ ጊዜዎች በሙሉ ወዲያውኑ ይወጣሉ።',
|
||||
unknownDevice: 'ይህ አሁን እየተጠቀሙበት ያለውን መሣሪያ ሊያካትት ይችላል።',
|
||||
},
|
||||
revoked_one: '{{count}} ክፍለ ጊዜ ተሰርዟል',
|
||||
revoked_other: '{{count}} ክፍለ ጊዜዎች ተሰርዘዋል',
|
||||
},
|
||||
twoStep: {
|
||||
title: "ባለሁለት ደረጃ ማረጋገጫ",
|
||||
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
|
||||
|
||||
@@ -452,6 +452,37 @@ export const en = {
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Active sessions',
|
||||
hint: 'Devices currently signed in to your account. Revoke any you do not recognise.',
|
||||
columns: {
|
||||
device: 'IP address',
|
||||
signedIn: 'Signed in',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
select: 'Select',
|
||||
selectAll: 'Select all sessions',
|
||||
selectRow: 'Select session from {{device}}',
|
||||
thisDevice: 'This device',
|
||||
revoke: 'Revoke',
|
||||
cannotRevokeCurrent: 'This is the session you are using now.',
|
||||
revokeSelected_one: 'Revoke {{count}} selected',
|
||||
revokeSelected_other: 'Revoke {{count}} selected',
|
||||
signOutOthers: 'Sign out everywhere else',
|
||||
empty: 'No active sessions.',
|
||||
confirm: {
|
||||
title: 'Revoke session',
|
||||
one: 'The session from {{device}} will be signed out immediately.',
|
||||
selected_one: '{{count}} session will be signed out immediately.',
|
||||
selected_other: '{{count}} sessions will be signed out immediately.',
|
||||
others: 'Every other session will be signed out immediately.',
|
||||
unknownDevice: 'This may include the device you are using now.',
|
||||
},
|
||||
revoked_one: '{{count}} session revoked',
|
||||
revoked_other: '{{count}} sessions revoked',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'Two-step verification',
|
||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||
|
||||
@@ -51,7 +51,7 @@ import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||
import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -788,8 +788,9 @@ export function ProfilePage() {
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="lg">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.security')}</Title>
|
||||
@@ -886,8 +887,11 @@ export function ProfilePage() {
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<ActiveSessions />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Preferences ---- */}
|
||||
|
||||
@@ -352,6 +352,37 @@ export const am: Translations = {
|
||||
dark: 'ጨለማ',
|
||||
system: 'ሲስተም',
|
||||
},
|
||||
sessions: {
|
||||
title: 'ንቁ የመግቢያ ክፍለ ጊዜዎች',
|
||||
hint: 'በአሁኑ ሰዓት ወደ መለያዎ የገቡ መሣሪያዎች። የማያውቁትን ይሰርዙ።',
|
||||
columns: {
|
||||
device: 'የአይ ፒ አድራሻ',
|
||||
signedIn: 'የገባበት ጊዜ',
|
||||
expires: 'የሚያበቃበት',
|
||||
status: 'ሁኔታ',
|
||||
actions: 'እርምጃዎች',
|
||||
},
|
||||
select: 'ይምረጡ',
|
||||
selectAll: 'ሁሉንም ክፍለ ጊዜዎች ይምረጡ',
|
||||
selectRow: 'ከ {{device}} የመጣውን ክፍለ ጊዜ ይምረጡ',
|
||||
thisDevice: 'ይህ መሣሪያ',
|
||||
revoke: 'ሰርዝ',
|
||||
cannotRevokeCurrent: 'ይህ አሁን እየተጠቀሙበት ያለው ክፍለ ጊዜ ነው።',
|
||||
revokeSelected_one: 'የተመረጠውን {{count}} ሰርዝ',
|
||||
revokeSelected_other: 'የተመረጡትን {{count}} ሰርዝ',
|
||||
signOutOthers: 'ከሌሎች ቦታዎች ሁሉ ውጣ',
|
||||
empty: 'ንቁ ክፍለ ጊዜ የለም።',
|
||||
confirm: {
|
||||
title: 'ክፍለ ጊዜ ሰርዝ',
|
||||
one: 'ከ {{device}} የመጣው ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_one: '{{count}} ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
|
||||
selected_other: '{{count}} ክፍለ ጊዜዎች ወዲያውኑ ይወጣሉ።',
|
||||
others: 'ሌሎቹ ክፍለ ጊዜዎች በሙሉ ወዲያውኑ ይወጣሉ።',
|
||||
unknownDevice: 'ይህ አሁን እየተጠቀሙበት ያለውን መሣሪያ ሊያካትት ይችላል።',
|
||||
},
|
||||
revoked_one: '{{count}} ክፍለ ጊዜ ተሰርዟል',
|
||||
revoked_other: '{{count}} ክፍለ ጊዜዎች ተሰርዘዋል',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
|
||||
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
|
||||
|
||||
@@ -351,6 +351,37 @@ export const en = {
|
||||
dark: 'Dark',
|
||||
system: 'System',
|
||||
},
|
||||
sessions: {
|
||||
title: 'Active sessions',
|
||||
hint: 'Devices currently signed in to your account. Revoke any you do not recognise.',
|
||||
columns: {
|
||||
device: 'IP address',
|
||||
signedIn: 'Signed in',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
select: 'Select',
|
||||
selectAll: 'Select all sessions',
|
||||
selectRow: 'Select session from {{device}}',
|
||||
thisDevice: 'This device',
|
||||
revoke: 'Revoke',
|
||||
cannotRevokeCurrent: 'This is the session you are using now.',
|
||||
revokeSelected_one: 'Revoke {{count}} selected',
|
||||
revokeSelected_other: 'Revoke {{count}} selected',
|
||||
signOutOthers: 'Sign out everywhere else',
|
||||
empty: 'No active sessions.',
|
||||
confirm: {
|
||||
title: 'Revoke session',
|
||||
one: 'The session from {{device}} will be signed out immediately.',
|
||||
selected_one: '{{count}} session will be signed out immediately.',
|
||||
selected_other: '{{count}} sessions will be signed out immediately.',
|
||||
others: 'Every other session will be signed out immediately.',
|
||||
unknownDevice: 'This may include the device you are using now.',
|
||||
},
|
||||
revoked_one: '{{count}} session revoked',
|
||||
revoked_other: '{{count}} sessions revoked',
|
||||
},
|
||||
twoStep: {
|
||||
title: 'Two-step verification',
|
||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||
|
||||
@@ -26,7 +26,7 @@ const queryApi = baseApi.injectEndpoints({
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
export const { useApiQueryQuery, useLazyApiQueryQuery, useApiMutationMutation } = queryApi;
|
||||
|
||||
export function useApiQuery<TData = unknown>(
|
||||
args: ApiQueryArgs,
|
||||
@@ -37,6 +37,17 @@ export function useApiQuery<TData = unknown>(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same endpoint as `useApiQuery`, fetched on demand instead of on render — for
|
||||
* the case where the arguments are only known at click time.
|
||||
*/
|
||||
export function useApiLazyQuery<TData = unknown>(): [
|
||||
(args: ApiQueryArgs) => { unwrap: () => Promise<TData> },
|
||||
] {
|
||||
const [trigger] = useLazyApiQueryQuery();
|
||||
return [trigger as unknown as (args: ApiQueryArgs) => { unwrap: () => Promise<TData> }];
|
||||
}
|
||||
|
||||
type UseApiMutationResult<TData> = {
|
||||
data: TData | undefined;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -28,6 +28,10 @@ export {
|
||||
export { usePermissions } from "./lib/hooks/usePermissions";
|
||||
export { useAuthToken } from "./lib/hooks/useAuthToken";
|
||||
export { useTwoFactor } from "./lib/hooks/useTwoFactor";
|
||||
export { useSessions } from "./lib/hooks/useSessions";
|
||||
export type { MySession } from "./lib/hooks/useSessions";
|
||||
export { ActiveSessions } from "./lib/components/ActiveSessions";
|
||||
export { currentSessionId } from "./lib/utils/jwt";
|
||||
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
||||
export { RequirePermission } from "./lib/components/RequirePermission";
|
||||
export {
|
||||
|
||||
124
libs/auth/src/lib/components/ActiveSessions/columns.tsx
Normal file
124
libs/auth/src/lib/components/ActiveSessions/columns.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ActionIcon, Badge, Checkbox, Group, Text, Tooltip } from '@mantine/core';
|
||||
import { IconLogout } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { MySession } from '../../hooks/useSessions';
|
||||
|
||||
interface Opts {
|
||||
t: TFunction;
|
||||
sessions: MySession[];
|
||||
selected: string[];
|
||||
setSelected: Dispatch<SetStateAction<string[]>>;
|
||||
/** Undefined when the token carries no session claim — then no row is "this device". */
|
||||
currentId?: string;
|
||||
showDate: (value: string) => string;
|
||||
onRevoke: (session: MySession) => void;
|
||||
}
|
||||
|
||||
export function sessionColumns({
|
||||
t,
|
||||
sessions,
|
||||
selected,
|
||||
setSelected,
|
||||
currentId,
|
||||
showDate,
|
||||
onRevoke,
|
||||
}: Opts): AdvancedColumn<MySession>[] {
|
||||
// The current session is never selectable, so "all" means "all the others".
|
||||
const selectable = sessions.filter((s) => s.id !== currentId);
|
||||
const allSelected = selectable.length > 0 && selectable.every((s) => selected.includes(s.id));
|
||||
|
||||
return [
|
||||
{
|
||||
header: (
|
||||
<Checkbox
|
||||
aria-label={t('profile.sessions.selectAll')}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
disabled={selectable.length === 0}
|
||||
onChange={() => setSelected(allSelected ? [] : selectable.map((s) => s.id))}
|
||||
/>
|
||||
),
|
||||
label: t('profile.sessions.select'),
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const isCurrent = row.original.id === currentId;
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={t('profile.sessions.selectRow', { device: row.original.device })}
|
||||
checked={selected.includes(row.original.id)}
|
||||
disabled={isCurrent}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
setSelected((prev) =>
|
||||
checked
|
||||
? [...prev, row.original.id]
|
||||
: prev.filter((id) => id !== row.original.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('profile.sessions.columns.device'),
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.device || '—'}
|
||||
</Text>
|
||||
{row.original.id === currentId && (
|
||||
<Badge variant="light" color="emaTeal" size="sm">
|
||||
{t('profile.sessions.thisDevice')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('profile.sessions.columns.signedIn'),
|
||||
cell: ({ row }) => <Text size="sm">{showDate(row.original.createdAt)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('profile.sessions.columns.expires'),
|
||||
cell: ({ row }) => <Text size="sm">{showDate(row.original.expiryTime)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('profile.sessions.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" size="sm" color={row.original.status === 'ACTIVE' ? 'green' : 'gray'}>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('profile.sessions.columns.actions'),
|
||||
size: 70,
|
||||
align: 'center',
|
||||
cell: ({ row }) => {
|
||||
const isCurrent = row.original.id === currentId;
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
isCurrent ? t('profile.sessions.cannotRevokeCurrent') : t('profile.sessions.revoke')
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={isCurrent}
|
||||
aria-label={t('profile.sessions.revoke')}
|
||||
onClick={() => onRevoke(row.original)}
|
||||
>
|
||||
<IconLogout size={14} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
153
libs/auth/src/lib/components/ActiveSessions/index.tsx
Normal file
153
libs/auth/src/lib/components/ActiveSessions/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconLogout } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdvancedTable, ConfirmModal, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useSessions, type MySession } from '../../hooks/useSessions';
|
||||
import { useAuthToken } from '../../hooks/useAuthToken';
|
||||
import { currentSessionId } from '../../utils/jwt';
|
||||
import { sessionColumns } from './columns';
|
||||
|
||||
/** What the one confirm dialog is currently asking about. */
|
||||
type Pending =
|
||||
| { kind: 'one'; ids: string[]; device: string }
|
||||
| { kind: 'selected'; ids: string[] }
|
||||
| { kind: 'others' };
|
||||
|
||||
/**
|
||||
* Where the signed-in user is logged in, and how to end those sessions.
|
||||
*
|
||||
* Renders as its own card so it can sit OUTSIDE the change-password <form> on
|
||||
* the Security tab — a bare <button> inside that form would submit it.
|
||||
*/
|
||||
export function ActiveSessions() {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const showDate = useDateDisplayer();
|
||||
const token = useAuthToken();
|
||||
const currentId = useMemo(() => currentSessionId(token), [token]);
|
||||
|
||||
const { pageIndex, setPageIndex, pageSize, setPageSize, skip, take } = useServerTable({
|
||||
pageSize: 5,
|
||||
});
|
||||
const { sessions, total, isFetching, refetch, revoke, isRevoking, allSessionIds } = useSessions({
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
sessionColumns({
|
||||
t,
|
||||
sessions,
|
||||
selected,
|
||||
setSelected,
|
||||
currentId,
|
||||
showDate,
|
||||
onRevoke: (s: MySession) => setPending({ kind: 'one', ids: [s.id], device: s.device }),
|
||||
}),
|
||||
[t, sessions, selected, currentId, showDate],
|
||||
);
|
||||
|
||||
const confirmMessage = () => {
|
||||
if (!pending) return '';
|
||||
const base =
|
||||
pending.kind === 'one'
|
||||
? t('profile.sessions.confirm.one', { device: pending.device })
|
||||
: pending.kind === 'selected'
|
||||
? t('profile.sessions.confirm.selected', { count: pending.ids.length })
|
||||
: t('profile.sessions.confirm.others');
|
||||
// Without a session claim on the token there is no way to spare this
|
||||
// device, so say so rather than implying the current login survives.
|
||||
return currentId ? base : `${base} ${t('profile.sessions.confirm.unknownDevice')}`;
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (!pending) return;
|
||||
try {
|
||||
const ids =
|
||||
pending.kind === 'others'
|
||||
? (await allSessionIds()).filter((id) => id !== currentId)
|
||||
: pending.ids;
|
||||
await revoke(ids);
|
||||
notify.success(t('profile.sessions.revoked', { count: ids.length }));
|
||||
setSelected([]);
|
||||
setPending(null);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
// "Sign out everywhere else" is only meaningful once a second session exists.
|
||||
const hasOthers = total > (currentId ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Title order={5}>{t('profile.sessions.title')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('profile.sessions.hint')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{selected.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => setPending({ kind: 'selected', ids: selected })}
|
||||
>
|
||||
{t('profile.sessions.revokeSelected', { count: selected.length })}
|
||||
</Button>
|
||||
)}
|
||||
{hasOthers && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={() => setPending({ kind: 'others' })}
|
||||
>
|
||||
{t('profile.sessions.signOutOthers')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<AdvancedTable<MySession>
|
||||
tableName="active-sessions"
|
||||
columns={columns}
|
||||
data={sessions}
|
||||
itemCount={total}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
pageSizeOptions={[5, 10, 20]}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('profile.sessions.empty')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<ConfirmModal
|
||||
opened={pending !== null}
|
||||
onClose={() => setPending(null)}
|
||||
onConfirm={onConfirm}
|
||||
loading={isRevoking}
|
||||
title={t('profile.sessions.confirm.title')}
|
||||
message={confirmMessage()}
|
||||
confirmLabel={t('profile.sessions.revoke')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
65
libs/auth/src/lib/hooks/useSessions.ts
Normal file
65
libs/auth/src/lib/hooks/useSessions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useApiLazyQuery, useApiMutation, useApiQuery } from '@ema-platform/api';
|
||||
|
||||
export interface MySession {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
/** IP address the session was created from — IAM sends no user agent. */
|
||||
device: string;
|
||||
expiryTime: string;
|
||||
refreshCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** `/sessions/my-sessions` answers with a tuple, not the usual `{items, count}`. */
|
||||
type SessionsResponse = [MySession[], number];
|
||||
|
||||
const SESSIONS_URL = '/sessions/my-sessions';
|
||||
const ORDER_BY = 'CreatedAt:DESC';
|
||||
|
||||
function unwrapList(data: unknown): SessionsResponse {
|
||||
if (!Array.isArray(data)) return [[], 0];
|
||||
const [items, total] = data as Partial<SessionsResponse>;
|
||||
return [items ?? [], total ?? 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's login sessions, and the two ways to end them.
|
||||
*
|
||||
* Uses the generic query/mutation endpoints rather than its own slice, so
|
||||
* freshness comes from `refetch()` rather than cache tags — the same shape as
|
||||
* `useTwoFactor`.
|
||||
*/
|
||||
export function useSessions({ skip, take }: { skip: number; take: number }) {
|
||||
const { data, isFetching, refetch } = useApiQuery<SessionsResponse>({
|
||||
url: SESSIONS_URL,
|
||||
params: { skip, take, orderBy: ORDER_BY },
|
||||
});
|
||||
const [fetchAll] = useApiLazyQuery<SessionsResponse>();
|
||||
const [send, { isLoading: isRevoking }] = useApiMutation();
|
||||
|
||||
const [sessions, total] = unwrapList(data);
|
||||
|
||||
/** Every session id the user has, not just the ones on the current page. */
|
||||
const allSessionIds = async (): Promise<string[]> => {
|
||||
// `total` is one page stale at worst; ask for a page big enough to cover it
|
||||
// growing between render and click.
|
||||
const result = await fetchAll({
|
||||
url: SESSIONS_URL,
|
||||
params: { skip: 0, take: Math.max(total, sessions.length) + 20, orderBy: ORDER_BY },
|
||||
}).unwrap();
|
||||
return unwrapList(result)[0].map((s) => s.id);
|
||||
};
|
||||
|
||||
const revoke = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
await send(
|
||||
ids.length === 1
|
||||
? { url: `/sessions/revoke/${ids[0]}`, method: 'DELETE' }
|
||||
: { url: '/sessions/bulk-revoke', method: 'POST', body: { sessionIds: ids } },
|
||||
).unwrap();
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return { sessions, total, isFetching, refetch, revoke, isRevoking, allSessionIds };
|
||||
}
|
||||
18
libs/auth/src/lib/utils/jwt.ts
Normal file
18
libs/auth/src/lib/utils/jwt.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Session id from the access token, when it carries one.
|
||||
*
|
||||
* `/sessions/my-sessions` returns no "this is you" flag, so the only way to
|
||||
* stop the user revoking the session they are sitting in is to read the id off
|
||||
* the token. Undefined is a normal answer — an opaque token just means no
|
||||
* "This device" badge and a confirm dialog that warns instead.
|
||||
*/
|
||||
export function currentSessionId(token?: string): string | undefined {
|
||||
const payload = token?.split('.')[1];
|
||||
if (!payload) return undefined;
|
||||
try {
|
||||
const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return claims.sessionId ?? claims.sid ?? claims.jti;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user