diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index bfbf70610..51f3dd333 100644 --- a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -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 ---- */} - -
+ + +
{t('profile.security')} @@ -502,8 +503,11 @@ export function ProfilePage() { - - + + + + + {/* ---- Preferences ---- */} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 0d0f0dc58..7d279d800 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 6b151ea0f..bf33d0340 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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.', diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 054bd5420..e0cfad850 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -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 ---- */} - -
+ + +
{t('profile.security')} @@ -886,8 +887,11 @@ export function ProfilePage() { - - + + + + + {/* ---- Preferences ---- */} diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 4f62650c6..4baf91a77 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -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: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 6f5dd3f50..a8c3ba128 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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.', diff --git a/libs/api/src/lib/query-and-mutation/index.ts b/libs/api/src/lib/query-and-mutation/index.ts index 10b736e11..924115d6b 100644 --- a/libs/api/src/lib/query-and-mutation/index.ts +++ b/libs/api/src/lib/query-and-mutation/index.ts @@ -26,7 +26,7 @@ const queryApi = baseApi.injectEndpoints({ overrideExisting: false, }); -export const { useApiQueryQuery, useApiMutationMutation } = queryApi; +export const { useApiQueryQuery, useLazyApiQueryQuery, useApiMutationMutation } = queryApi; export function useApiQuery( args: ApiQueryArgs, @@ -37,6 +37,17 @@ export function useApiQuery( }; } +/** + * 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(): [ + (args: ApiQueryArgs) => { unwrap: () => Promise }, +] { + const [trigger] = useLazyApiQueryQuery(); + return [trigger as unknown as (args: ApiQueryArgs) => { unwrap: () => Promise }]; +} + type UseApiMutationResult = { data: TData | undefined; isLoading: boolean; diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 5e99e4520..7d8be994b 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -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 { diff --git a/libs/auth/src/lib/components/ActiveSessions/columns.tsx b/libs/auth/src/lib/components/ActiveSessions/columns.tsx new file mode 100644 index 000000000..11698b8e5 --- /dev/null +++ b/libs/auth/src/lib/components/ActiveSessions/columns.tsx @@ -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>; + /** 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[] { + // 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: ( + 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 ( + { + 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 }) => ( + + + {row.original.device || '—'} + + {row.original.id === currentId && ( + + {t('profile.sessions.thisDevice')} + + )} + + ), + }, + { + header: t('profile.sessions.columns.signedIn'), + cell: ({ row }) => {showDate(row.original.createdAt)}, + }, + { + header: t('profile.sessions.columns.expires'), + cell: ({ row }) => {showDate(row.original.expiryTime)}, + }, + { + header: t('profile.sessions.columns.status'), + cell: ({ row }) => ( + + {row.original.status} + + ), + }, + { + header: t('profile.sessions.columns.actions'), + size: 70, + align: 'center', + cell: ({ row }) => { + const isCurrent = row.original.id === currentId; + return ( + +
+ onRevoke(row.original)} + > + + +
+
+ ); + }, + }, + ]; +} diff --git a/libs/auth/src/lib/components/ActiveSessions/index.tsx b/libs/auth/src/lib/components/ActiveSessions/index.tsx new file mode 100644 index 000000000..701f924b7 --- /dev/null +++ b/libs/auth/src/lib/components/ActiveSessions/index.tsx @@ -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
on + * the Security tab — a bare + )} + {hasOthers && ( + + )} + + + + + 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')} + /> + + + setPending(null)} + onConfirm={onConfirm} + loading={isRevoking} + title={t('profile.sessions.confirm.title')} + message={confirmMessage()} + confirmLabel={t('profile.sessions.revoke')} + cancelLabel={t('common.cancel', 'Cancel')} + /> + + ); +} diff --git a/libs/auth/src/lib/hooks/useSessions.ts b/libs/auth/src/lib/hooks/useSessions.ts new file mode 100644 index 000000000..7b7596d0b --- /dev/null +++ b/libs/auth/src/lib/hooks/useSessions.ts @@ -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; + 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({ + url: SESSIONS_URL, + params: { skip, take, orderBy: ORDER_BY }, + }); + const [fetchAll] = useApiLazyQuery(); + 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 => { + // `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 }; +} diff --git a/libs/auth/src/lib/utils/jwt.ts b/libs/auth/src/lib/utils/jwt.ts new file mode 100644 index 000000000..f22842964 --- /dev/null +++ b/libs/auth/src/lib/utils/jwt.ts @@ -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; + } +}