diff --git a/apps/app/src/components/sessions.tsx b/apps/app/src/components/sessions.tsx new file mode 100644 index 00000000..a657424c --- /dev/null +++ b/apps/app/src/components/sessions.tsx @@ -0,0 +1,367 @@ +// Device sessions (ADR-0028 section 4, issue #89): the signed-in user's own sessions, and an owner's org-scoped revoke +// for a member. The server enforces who may see and revoke what; these screens only explain it. +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useState, type ReactNode } from 'react'; +import { client, orpc, queryClient } from '../lib/api.ts'; +import { accessToken, auth } from '../lib/auth.ts'; +import { formatDateTime } from '../lib/format.ts'; +import { + describeUserAgent, + otherActiveSessionIds, + sessionErrorMessage, + sessionIdFromToken, + shortSessionId, + sortSessions, + type SessionRow, +} from '../lib/sessions.ts'; +import { Badge, Button, Card, ErrorText, Input, Spinner } from './ui.tsx'; + +// Signs this tab out; auth-js tells the other tabs, and main.tsx sends every tab to /login. +function signOutHere(): void { + void auth().signOut({ scope: 'local' }); +} + +function useCurrentSessionId() { + return useQuery({ queryKey: ['crove', 'current-session'], queryFn: async () => sessionIdFromToken(await accessToken()), staleTime: 60_000 }); +} + +function ConfirmBox({ title, children, actions }: { title: string; children?: ReactNode; actions: ReactNode }) { + return ( +
+

{title}

+ {children} +
{actions}
+
+ ); +} + +function SessionLine({ session, label, current, action }: { session: SessionRow; label: string; current: boolean; action?: ReactNode }) { + return ( +
  • +
    +

    + {label} + {current && Phiên hiện tại} + {session.status === 'revoked' && Đã thu hồi} +

    +

    + {session.status === 'revoked' && session.revokedAt + ? `Thu hồi lúc ${formatDateTime(session.revokedAt)}` + : `Hoạt động gần nhất ${formatDateTime(session.lastSeenAt)}`} + {` · đăng nhập từ ${formatDateTime(session.firstSeenAt)}`} +

    +
    + {action &&
    {action}
    } +
  • + ); +} + +function RevokedList({ sessions, labelOf }: { sessions: SessionRow[]; labelOf: (s: SessionRow) => string }) { + if (!sessions.length) return null; + return ( +
    + Đã thu hồi ({sessions.length}) + +
    + ); +} + +type MyConfirm = { kind: 'one'; sessionId: string } | { kind: 'others' } | { kind: 'all' } | null; + +// "Thiết bị đang đăng nhập" for the signed-in user. Revoking is global (every org) and the server also signs the +// session out in Supabase Auth. Revoking the current session, or every session, signs this browser out. +export function MySessions({ userId }: { userId: string }) { + const sessions = useQuery(orpc.sessions.list.queryOptions({ input: { userId } })); + const current = useCurrentSessionId(); + const [confirm, setConfirm] = useState(null); + const currentId = current.data ?? null; + + const done = (revoked: string[]) => { + setConfirm(null); + if (currentId && revoked.includes(currentId)) { + signOutHere(); + return; + } + void queryClient.invalidateQueries({ queryKey: orpc.sessions.key() }); + }; + // Signing out this session ends here even if the server had already revoked it and lists nothing. + const revokeOne = useMutation( + orpc.sessions.revoke.mutationOptions({ + onSuccess: (result, input) => done(input.sessionId && input.sessionId === currentId ? [currentId] : result.revoked), + }), + ); + const revokeAll = useMutation( + orpc.sessions.revoke.mutationOptions({ + onSuccess: () => { + // "Everywhere" includes this browser, whatever the answer lists. + setConfirm(null); + signOutHere(); + }, + }), + ); + const revokeOthers = useMutation({ + mutationFn: async (ids: string[]) => { + const results = await Promise.all(ids.map((sessionId) => client.sessions.revoke({ userId, sessionId }))); + return results.flatMap((r) => r.revoked); + }, + onSuccess: done, + onSettled: () => void queryClient.invalidateQueries({ queryKey: orpc.sessions.key() }), + }); + const busy = revokeOne.isPending || revokeAll.isPending || revokeOthers.isPending; + const error = revokeOne.error ?? revokeAll.error ?? revokeOthers.error; + + const thisDevice = describeUserAgent(typeof navigator === 'undefined' ? null : navigator.userAgent); + const labelOf = (s: SessionRow) => + s.sessionId === currentId ? `Thiết bị này${thisDevice ? ` (${thisDevice})` : ''}` : `Phiên đăng nhập ${shortSessionId(s.sessionId)}`; + const sorted = sortSessions(sessions.data ?? [], currentId); + const active = sorted.filter((s) => s.status === 'active'); + const revoked = sorted.filter((s) => s.status === 'revoked'); + const others = otherActiveSessionIds(sorted, currentId); + + const ask = (next: MyConfirm) => { + revokeOne.reset(); + revokeAll.reset(); + revokeOthers.reset(); + setConfirm(next); + }; + const cancel = ( + + ); + + return ( + +

    Thiết bị đang đăng nhập

    +

    + Các phiên đăng nhập Crove đã thấy từ tài khoản của bạn. Thu hồi một phiên sẽ đăng xuất thiết bị đó khỏi mọi tổ chức ngay từ yêu cầu tiếp + theo. +

    + {sessions.isLoading || current.isLoading ? ( +
    + +
    + ) : sessions.error ? ( +
    + {sessionErrorMessage(sessions.error, 'self')} +
    + ) : ( + <> + + + + {confirm?.kind === 'one' && ( + + + {cancel} + + } + > +

    + {confirm.sessionId === currentId + ? 'Bạn sẽ được đưa về trang đăng nhập, ở mọi tab đang mở.' + : 'Thiết bị đó sẽ bị đăng xuất khỏi mọi tổ chức và phải đăng nhập lại.'} +

    +
    + )} + {confirm?.kind === 'others' && ( + + + {cancel} + + } + > +

    Thiết bị này vẫn đăng nhập; mọi thiết bị khác phải đăng nhập lại.

    +
    + )} + {confirm?.kind === 'all' && ( + + + {cancel} + + } + > +

    + Mọi phiên đăng nhập của bạn, kể cả thiết bị này, bị thu hồi ngay. Hãy dùng khi nghi ngờ tài khoản bị lộ, rồi đổi mật khẩu. +

    +
    + )} + + {!confirm && ( +
    + {others.length > 0 && ( + + )} + +
    + )} + {error && ( +
    + {sessionErrorMessage(error, 'self')} +
    + )} + + )} +
    + ); +} + +type MemberTarget = { sessionId: string } | 'all' | null; + +function MemberSessionsPanel({ userId, name, onClose }: { userId: string; name: string; onClose: () => void }) { + const sessions = useQuery(orpc.sessions.list.queryOptions({ input: { userId } })); + const [target, setTarget] = useState(null); + const [reason, setReason] = useState(''); + const revoke = useMutation( + orpc.sessions.revoke.mutationOptions({ + onSuccess: () => { + setTarget(null); + setReason(''); + }, + onSettled: () => void queryClient.invalidateQueries({ queryKey: orpc.sessions.list.key({ input: { userId } }) }), + }), + ); + const sorted = sortSessions(sessions.data ?? [], null); + const active = sorted.filter((s) => s.status === 'active'); + const revoked = sorted.filter((s) => s.status === 'revoked'); + const labelOf = (s: SessionRow) => `Phiên đăng nhập ${shortSessionId(s.sessionId)}`; + const ask = (next: MemberTarget) => { + revoke.reset(); + setTarget(next); + }; + const submit = () => { + if (!target) return; + const trimmed = reason.trim(); + const base = { userId, ...(trimmed ? { reason: trimmed } : {}) }; + revoke.mutate(target === 'all' ? { ...base, all: true } : { ...base, sessionId: target.sessionId }); + }; + + return ( +
    +
    +

    + Thu hồi phiên chỉ chặn {name} truy cập tổ chức này. Họ vẫn dùng được các tổ chức khác và không bị + đăng xuất khỏi tài khoản. Muốn cắt hẳn quyền truy cập, hãy xóa họ khỏi tổ chức. +

    + +
    + {sessions.isLoading ? ( +
    + +
    + ) : sessions.error ? ( +
    + {sessionErrorMessage(sessions.error, 'member')} +
    + ) : ( + <> +
      + {active.map((s) => ( + ask({ sessionId: s.sessionId })}> + Thu hồi + + } + /> + ))} + {!active.length &&
    • Không có phiên nào đang hoạt động trong tổ chức này.
    • } +
    + + {target ? ( + + + + + } + > +

    Từ yêu cầu tiếp theo, phiên bị thu hồi không vào được tổ chức này nữa. Thao tác được ghi vào nhật ký.

    + +
    + ) : ( + active.length > 0 && ( +
    + +
    + ) + )} + {revoke.error && ( +
    + {sessionErrorMessage(revoke.error, 'member')} +
    + )} + + )} +
    + ); +} + +// Owner only (the server answers 403 to everyone else, admins included): a member's sessions seen in this org. +export function MemberSessions({ userId, name }: { userId: string; name: string }) { + const [open, setOpen] = useState(false); + if (!open) { + return ( + + ); + } + return setOpen(false)} />; +} + diff --git a/apps/app/src/lib/sessions.ts b/apps/app/src/lib/sessions.ts new file mode 100644 index 00000000..2d9664bf --- /dev/null +++ b/apps/app/src/lib/sessions.ts @@ -0,0 +1,90 @@ +// Pure helpers for the device sessions screens (ADR-0028 section 4). No browser or API imports, so they are unit tested. + +export interface SessionRow { + sessionId: string; + status: 'active' | 'revoked'; + firstSeenAt: string; + lastSeenAt: string; + revokedAt: string | null; +} + +// The Supabase Auth session id carried by an access token (`session_id` claim). Reading the claim is display only: +// the server verifies every token and decides what may be revoked. +export function sessionIdFromToken(token: string | null | undefined): string | null { + const payload = token?.split('.')[1]; + if (!payload) return null; + try { + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='); + const json = new TextDecoder().decode(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))); + const claims = JSON.parse(json) as { session_id?: unknown }; + return typeof claims.session_id === 'string' && claims.session_id ? claims.session_id : null; + } catch { + return null; + } +} + +// The current session first, then active sessions by last activity, then revoked ones by revocation time. +export function sortSessions(sessions: readonly T[], currentId: string | null): T[] { + const rank = (s: T) => (s.sessionId === currentId ? 0 : s.status === 'active' ? 1 : 2); + const time = (s: T) => Date.parse(s.status === 'revoked' ? (s.revokedAt ?? s.lastSeenAt) : s.lastSeenAt) || 0; + return [...sessions].sort((a, b) => rank(a) - rank(b) || time(b) - time(a)); +} + +export function otherActiveSessionIds(sessions: readonly SessionRow[], currentId: string | null): string[] { + return sessions.filter((s) => s.status === 'active' && s.sessionId !== currentId).map((s) => s.sessionId); +} + +// A short, stable label for a session the API knows only by id. +export function shortSessionId(sessionId: string): string { + return sessionId.replace(/-/g, '').slice(-6).toUpperCase(); +} + +// "Chrome trên Windows" for this browser. The API does not record user agents, so only the current device gets one. +export function describeUserAgent(userAgent: string | null | undefined): string | null { + if (!userAgent) return null; + const browser = /Edg\//.test(userAgent) + ? 'Edge' + : /OPR\/|Opera/.test(userAgent) + ? 'Opera' + : /Firefox\//.test(userAgent) + ? 'Firefox' + : /Chrome\/|CriOS\//.test(userAgent) + ? 'Chrome' + : /Safari\//.test(userAgent) + ? 'Safari' + : null; + const os = /Windows/.test(userAgent) + ? 'Windows' + : /Android/.test(userAgent) + ? 'Android' + : /iPhone|iPad|iPod/.test(userAgent) + ? 'iOS' + : /Mac OS X|Macintosh/.test(userAgent) + ? 'macOS' + : /Linux/.test(userAgent) + ? 'Linux' + : null; + if (browser && os) return `${browser} trên ${os}`; + return browser ?? os; +} + +export type SessionScope = 'self' | 'member'; + +// Clear messages for the answers this screen can get. STEP_UP_REQUIRED is the R3 answer (ADR-0028 section 5); a +// session revoke is R1 today, but a deployment may tighten it, so the screen explains it instead of a raw code. +export function sessionErrorMessage(error: unknown, scope: SessionScope): string { + const e = (error ?? {}) as { status?: unknown; code?: unknown; message?: unknown }; + if (e.code === 'STEP_UP_REQUIRED') { + return 'Thao tác này cần xác thực hai lớp trong 5 phút gần nhất. Hãy xác minh lại mã hai lớp rồi thử lại.'; + } + if (e.code === 'NOT_A_MEMBER' || e.code === 'USER_DISABLED') { + return typeof e.message === 'string' && e.message ? e.message : 'Không có quyền thực hiện'; + } + if (e.status === 403 || e.code === 'FORBIDDEN') { + return scope === 'member' + ? 'Chỉ chủ sở hữu tổ chức mới xem và thu hồi được phiên đăng nhập của thành viên. Quản trị viên không có quyền này.' + : 'Bạn không có quyền xem hoặc thu hồi các phiên đăng nhập này.'; + } + if (typeof e.message === 'string' && e.message) return e.message; + return 'Đã có lỗi xảy ra'; +} diff --git a/apps/app/src/routes/settings.tsx b/apps/app/src/routes/settings.tsx index 9cf24769..0249a3f0 100644 --- a/apps/app/src/routes/settings.tsx +++ b/apps/app/src/routes/settings.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { useState, type FormEvent } from 'react'; +import { MemberSessions, MySessions } from '../components/sessions.tsx'; import { Badge, Button, Card, ErrorText, Input, PageHeader, Spinner } from '../components/ui.tsx'; import { errorMessage, orpc, queryClient } from '../lib/api.ts'; import { formatDateTime } from '../lib/format.ts'; @@ -296,13 +297,16 @@ export function SettingsPage() {

    Thành viên

      {org.data?.members.map((m) => ( -
    • - {m.displayName ?? m.email} - {isOwner && me.data && m.userId !== me.data.userId ? ( - - ) : ( - {ROLE_LABEL[m.role]} - )} +
    • +
      + {m.displayName ?? m.email} + {isOwner && me.data && m.userId !== me.data.userId ? ( + + ) : ( + {ROLE_LABEL[m.role]} + )} +
      + {isOwner && me.data && m.userId !== me.data.userId && }
    • ))}
    @@ -313,6 +317,7 @@ export function SettingsPage() { )} + {me.data && } ); diff --git a/apps/app/test/sessions.test.ts b/apps/app/test/sessions.test.ts new file mode 100644 index 00000000..602b7c09 --- /dev/null +++ b/apps/app/test/sessions.test.ts @@ -0,0 +1,114 @@ +// Helpers behind the device sessions screens (issue #89). +import { describe, expect, it } from 'vitest'; +import { + describeUserAgent, + otherActiveSessionIds, + sessionErrorMessage, + sessionIdFromToken, + shortSessionId, + sortSessions, + type SessionRow, +} from '../src/lib/sessions.ts'; + +function token(claims: Record): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`; +} + +const CURRENT = '11111111-1111-4111-8111-111111111111'; +const OTHER = '22222222-2222-4222-8222-222222222222'; +const OLD = '33333333-3333-4333-8333-333333333333'; + +function row(sessionId: string, status: SessionRow['status'], lastSeenAt: string, revokedAt: string | null = null): SessionRow { + return { sessionId, status, firstSeenAt: '2026-10-01T00:00:00.000Z', lastSeenAt, revokedAt }; +} + +describe('sessionIdFromToken', () => { + it('reads the session_id claim', () => { + expect(sessionIdFromToken(token({ sub: 'u', session_id: CURRENT }))).toBe(CURRENT); + }); + + it('decodes base64url payloads with non-ASCII claims', () => { + expect(sessionIdFromToken(token({ name: 'Nguyễn Văn Ả?>~', session_id: OTHER }))).toBe(OTHER); + }); + + it('returns null for missing, empty or malformed tokens', () => { + expect(sessionIdFromToken(null)).toBeNull(); + expect(sessionIdFromToken('')).toBeNull(); + expect(sessionIdFromToken('not-a-jwt')).toBeNull(); + expect(sessionIdFromToken('a.%%%.c')).toBeNull(); + expect(sessionIdFromToken(token({ sub: 'u' }))).toBeNull(); + expect(sessionIdFromToken(token({ session_id: 42 }))).toBeNull(); + }); +}); + +describe('sortSessions and otherActiveSessionIds', () => { + const sessions = [ + row(OLD, 'revoked', '2026-10-10T00:00:00.000Z', '2026-10-11T00:00:00.000Z'), + row(OTHER, 'active', '2026-10-14T00:00:00.000Z'), + row(CURRENT, 'active', '2026-10-12T00:00:00.000Z'), + ]; + + it('puts the current session first, then active, then revoked', () => { + expect(sortSessions(sessions, CURRENT).map((s) => s.sessionId)).toEqual([CURRENT, OTHER, OLD]); + expect(sortSessions(sessions, null).map((s) => s.sessionId)).toEqual([OTHER, CURRENT, OLD]); + }); + + it('does not mutate its input', () => { + const copy = [...sessions]; + sortSessions(sessions, CURRENT); + expect(sessions).toEqual(copy); + }); + + it('lists the other active sessions only', () => { + expect(otherActiveSessionIds(sessions, CURRENT)).toEqual([OTHER]); + expect(otherActiveSessionIds(sessions, null)).toEqual([OTHER, CURRENT]); + }); +}); + +describe('labels', () => { + it('shortens a session id', () => { + expect(shortSessionId(OTHER)).toBe('222222'); + }); + + it('describes common browsers', () => { + expect( + describeUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'), + ).toBe('Chrome trên Windows'); + expect( + describeUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', + ), + ).toBe('Edge trên Windows'); + expect( + describeUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1'), + ).toBe('Safari trên iOS'); + expect(describeUserAgent('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')).toBe('Firefox trên Linux'); + expect(describeUserAgent('curl/8.0')).toBeNull(); + expect(describeUserAgent(null)).toBeNull(); + }); +}); + +describe('sessionErrorMessage', () => { + it('explains STEP_UP_REQUIRED', () => { + expect(sessionErrorMessage({ status: 403, code: 'STEP_UP_REQUIRED', message: 'x' }, 'self')).toContain('xác thực hai lớp'); + }); + + it('explains a 403 by scope', () => { + const forbidden = { status: 403, code: 'FORBIDDEN', message: 'Không có quyền thực hiện' }; + expect(sessionErrorMessage(forbidden, 'member')).toContain('Chỉ chủ sở hữu'); + expect(sessionErrorMessage(forbidden, 'member')).toContain('Quản trị viên không có quyền này'); + expect(sessionErrorMessage(forbidden, 'self')).toContain('Bạn không có quyền'); + }); + + it('keeps the server message for membership and disabled accounts', () => { + expect(sessionErrorMessage({ status: 403, code: 'NOT_A_MEMBER', message: 'Bạn không thuộc tổ chức này' }, 'member')).toBe( + 'Bạn không thuộc tổ chức này', + ); + }); + + it('falls back to the message, then a generic text', () => { + expect(sessionErrorMessage({ status: 503, code: 'OVERLOADED', message: 'Hệ thống đang quá tải' }, 'self')).toBe('Hệ thống đang quá tải'); + expect(sessionErrorMessage(null, 'self')).toBe('Đã có lỗi xảy ra'); + }); +});