Files
resolutionflow/frontend/src/components/account/RevokeSessionsModal.tsx
Michael Chihlas c7cd711859 feat: AccountSecuritySettingsPage + active-users list + toast + login banner
Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).

Backend additions:
- accounts/me/security GET response gains active_users: list of
  {user_id, name, email, last_login_at} for users in this account
  with at least one un-revoked refresh token. Joined query on
  refresh_tokens + users, distinct, ordered by last_login desc.
  Drives the Active Sessions section.

Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
  auth store, returns warning ('none'|'soon'|'now') + reason
  ('idle'|'absolute') so consumers can pick the right UX for the
  closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
  fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
  button hits authApi.refresh() and updates the store on success.
  Absolute case: info-cyan tone, [Sign in now] link to /login (no
  recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
  the two bulk-revoke scopes. Title, body, and confirm-label vary by
  scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
  Header (Shield icon), intro, Policy card with Strict/Standard/Custom
  radios + always-visible-disabled Custom inputs (idle/absolute
  minutes) with inline validation, Save button + emerald success ping,
  info note about 'applies at next login'. Active sessions card with
  count-aware copy, list of {name, email, last-login-ago} rows
  (caller tagged '(you)'), two buttons — 'except me' hidden when
  count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
  owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
  ?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.

Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).

Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:07:14 -04:00

90 lines
2.5 KiB
TypeScript

import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
interface RevokeSessionsModalProps {
isOpen: boolean
onClose: () => void
onConfirm: () => Promise<void>
scope: 'all' | 'others'
activeUserCount: number
}
/**
* Confirmation modal for bulk session revocation. Two scopes:
*
* - "others" — revokes other users' sessions, caller stays signed in.
* - "all" — revokes everyone including the caller; the parent handles
* the post-revoke auto-redirect to /login (see plan §4.8 D4).
*/
export function RevokeSessionsModal({
isOpen,
onClose,
onConfirm,
scope,
activeUserCount,
}: RevokeSessionsModalProps) {
const [busy, setBusy] = useState(false)
const isAll = scope === 'all'
const otherCount = isAll ? activeUserCount : Math.max(activeUserCount - 1, 0)
const title = isAll ? 'Sign out everyone?' : 'Sign out other users?'
const body = isAll
? `This signs out all ${activeUserCount} active users including yourself. Everyone will need to sign in again.`
: `This signs out the ${otherCount} other active users in your account. They'll need to sign in again. You stay signed in.`
const confirmLabel = isAll
? 'Sign out everyone'
: otherCount === 1
? 'Sign out 1 user'
: `Sign out ${otherCount} users`
const handleConfirm = async () => {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
}
}
return (
<Modal
isOpen={isOpen}
onClose={busy ? () => undefined : onClose}
title={title}
size="sm"
footer={
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-border text-foreground hover:bg-card-hover',
'disabled:opacity-50',
)}
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={busy}
className={cn(
'rounded-md border px-4 py-2 text-sm font-medium',
'border-danger/40 bg-danger/10 text-danger hover:bg-danger/15',
'disabled:opacity-50',
)}
>
{busy ? 'Working…' : confirmLabel}
</button>
</div>
}
>
<p className="text-sm text-foreground">{body}</p>
</Modal>
)
}