feat: implement ActiveSessions component and JWT session tracking to manage user device access

This commit is contained in:
estifanos
2026-08-19 11:00:20 +00:00
parent ff89b9af5e
commit ec28f6456d
12 changed files with 518 additions and 11 deletions

View File

@@ -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 ---- */}

View File

@@ -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: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",

View File

@@ -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.',

View File

@@ -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 ---- */}

View File

@@ -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: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',

View File

@@ -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.',

View File

@@ -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;

View File

@@ -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 {

View 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>
);
},
},
];
}

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

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

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