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>
This commit is contained in:
@@ -23,6 +23,7 @@ from app.models.account import Account
|
|||||||
from app.models.refresh_token import RefreshToken
|
from app.models.refresh_token import RefreshToken
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.account_security import (
|
from app.schemas.account_security import (
|
||||||
|
ActiveUser,
|
||||||
RevokeSessionsRequest,
|
RevokeSessionsRequest,
|
||||||
RevokeSessionsResponse,
|
RevokeSessionsResponse,
|
||||||
SessionPolicyResponse,
|
SessionPolicyResponse,
|
||||||
@@ -32,7 +33,9 @@ from app.schemas.account_security import (
|
|||||||
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
|
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
|
||||||
|
|
||||||
|
|
||||||
def _policy_response(account: Account) -> SessionPolicyResponse:
|
def _policy_response(
|
||||||
|
account: Account, active_users: list[ActiveUser]
|
||||||
|
) -> SessionPolicyResponse:
|
||||||
eff_idle, eff_abs = resolve_session_policy(account)
|
eff_idle, eff_abs = resolve_session_policy(account)
|
||||||
return SessionPolicyResponse(
|
return SessionPolicyResponse(
|
||||||
idle_minutes=account.session_idle_minutes,
|
idle_minutes=account.session_idle_minutes,
|
||||||
@@ -43,6 +46,7 @@ def _policy_response(account: Account) -> SessionPolicyResponse:
|
|||||||
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
|
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
|
||||||
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
|
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
|
||||||
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
|
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
|
||||||
|
active_users=active_users,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -52,13 +56,33 @@ async def _load_account(db: AsyncSession, account_id) -> Account:
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
|
||||||
|
"""Return distinct users in this account who currently hold an
|
||||||
|
un-revoked refresh token. See plan §4.7."""
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(User.id, User.name, User.email, User.last_login)
|
||||||
|
.join(RefreshToken, RefreshToken.user_id == User.id)
|
||||||
|
.where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
|
||||||
|
.distinct()
|
||||||
|
.order_by(User.last_login.desc().nulls_last())
|
||||||
|
)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SessionPolicyResponse)
|
@router.get("", response_model=SessionPolicyResponse)
|
||||||
async def get_session_policy(
|
async def get_session_policy(
|
||||||
current_user: Annotated[User, Depends(require_account_owner)],
|
current_user: Annotated[User, Depends(require_account_owner)],
|
||||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||||
):
|
):
|
||||||
account = await _load_account(db, current_user.account_id)
|
account = await _load_account(db, current_user.account_id)
|
||||||
return _policy_response(account)
|
active_users = await _load_active_users(db, current_user.account_id)
|
||||||
|
return _policy_response(account, active_users)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("", response_model=SessionPolicyResponse)
|
@router.patch("", response_model=SessionPolicyResponse)
|
||||||
@@ -139,7 +163,8 @@ async def update_session_policy(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(account)
|
await db.refresh(account)
|
||||||
return _policy_response(account)
|
active_users = await _load_active_users(db, account.id)
|
||||||
|
return _policy_response(account, active_users)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
|
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
|
||||||
|
|||||||
@@ -2,11 +2,28 @@
|
|||||||
|
|
||||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
|
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveUser(BaseModel):
|
||||||
|
"""One row in the active-users list on GET /accounts/me/security.
|
||||||
|
|
||||||
|
Rendered as 'name (email) · logged in 2d ago' on the Account Security
|
||||||
|
page. `last_login_at` reflects the last successful sign-in, not the last
|
||||||
|
refresh-token use — that requires the deferred refresh_tokens.last_used_at
|
||||||
|
follow-up (see plan §9).
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id: UUID
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
last_login_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class SessionPolicyResponse(BaseModel):
|
class SessionPolicyResponse(BaseModel):
|
||||||
"""GET /accounts/me/security — the policy in effect for this account.
|
"""GET /accounts/me/security — the policy in effect for this account.
|
||||||
|
|
||||||
@@ -32,6 +49,10 @@ class SessionPolicyResponse(BaseModel):
|
|||||||
absolute_minutes_min: int
|
absolute_minutes_min: int
|
||||||
absolute_minutes_max: int
|
absolute_minutes_max: int
|
||||||
|
|
||||||
|
# Active sessions in this account — users with at least one un-revoked
|
||||||
|
# refresh token. Drives the Active Sessions section in the UI.
|
||||||
|
active_users: list[ActiveUser] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class SessionPolicyUpdateRequest(BaseModel):
|
class SessionPolicyUpdateRequest(BaseModel):
|
||||||
"""PATCH /accounts/me/security — set or clear the per-account override.
|
"""PATCH /accounts/me/security — set or clear the per-account override.
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class TestSessionPolicyEndpoint:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_returns_defaults_and_bounds(
|
async def test_get_returns_defaults_and_bounds(
|
||||||
self, client: AsyncClient, auth_headers: dict
|
self, client: AsyncClient, auth_headers: dict, test_user: dict
|
||||||
):
|
):
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/v1/accounts/me/security", headers=auth_headers
|
"/api/v1/accounts/me/security", headers=auth_headers
|
||||||
@@ -221,6 +221,19 @@ class TestSessionPolicyEndpoint:
|
|||||||
assert body["absolute_minutes_min"] == 60
|
assert body["absolute_minutes_min"] == 60
|
||||||
assert body["absolute_minutes_max"] == 129600
|
assert body["absolute_minutes_max"] == 129600
|
||||||
|
|
||||||
|
# active_users reflects users with un-revoked refresh tokens.
|
||||||
|
# auth_headers logged the owner in once, so they should appear.
|
||||||
|
assert isinstance(body["active_users"], list)
|
||||||
|
assert len(body["active_users"]) >= 1
|
||||||
|
emails = [u["email"] for u in body["active_users"]]
|
||||||
|
assert test_user["email"] in emails
|
||||||
|
# Schema check on one row.
|
||||||
|
first = body["active_users"][0]
|
||||||
|
assert "user_id" in first
|
||||||
|
assert "name" in first
|
||||||
|
assert "email" in first
|
||||||
|
assert "last_login_at" in first
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_persists_override_and_returns_new_state(
|
async def test_patch_persists_override_and_returns_new_state(
|
||||||
self, client: AsyncClient, auth_headers: dict
|
self, client: AsyncClient, auth_headers: dict
|
||||||
|
|||||||
@@ -172,12 +172,19 @@ Each of the four token-issuing endpoints (login, login/json, both OAuth callback
|
|||||||
New endpoint module: `backend/app/api/endpoints/account_security.py`
|
New endpoint module: `backend/app/api/endpoints/account_security.py`
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /accounts/me/security → returns {idle_minutes, absolute_minutes, effective_idle_minutes, effective_absolute_minutes, system_min/max bounds}
|
GET /accounts/me/security → returns {
|
||||||
|
idle_minutes, absolute_minutes,
|
||||||
|
effective_idle_minutes, effective_absolute_minutes,
|
||||||
|
system_min/max bounds,
|
||||||
|
active_users: [{user_id, name, email, last_login_at}, ...]
|
||||||
|
}
|
||||||
PATCH /accounts/me/security → owner only; validates bounds + invariant; writes account row
|
PATCH /accounts/me/security → owner only; validates bounds + invariant; writes account row
|
||||||
```
|
```
|
||||||
|
|
||||||
`require_account_owner` from `app/api/deps.py:189` enforces ownership. Returns the *effective* values (after defaults applied) so the frontend doesn't have to know about NULL semantics.
|
`require_account_owner` from `app/api/deps.py:189` enforces ownership. Returns the *effective* values (after defaults applied) so the frontend doesn't have to know about NULL semantics.
|
||||||
|
|
||||||
|
**`active_users` field** (added during plan-design-review pass on 2026-05-13): the GET response includes a list of users with at least one un-revoked refresh token in this account. Query: `SELECT DISTINCT u.id, u.email, u.name, u.last_login FROM users u JOIN refresh_tokens rt ON rt.user_id = u.id WHERE u.account_id = :acct AND rt.revoked_at IS NULL`. The frontend uses this to render the "Active sessions" section with names + relative last-login timestamps (see §4.8) rather than a faceless count. Caveat: `last_login` updates only at login, not on refresh — so the relative timestamp is honest about "when they signed in," not "last touched the app." Per-refresh activity needs the deferred `refresh_tokens.last_used_at` follow-up (§9).
|
||||||
|
|
||||||
### 4.8 Frontend changes
|
### 4.8 Frontend changes
|
||||||
|
|
||||||
**Response-field naming (single scheme, used everywhere):**
|
**Response-field naming (single scheme, used everywhere):**
|
||||||
@@ -197,6 +204,12 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh
|
|||||||
- "soon" fires at T-5min on whichever window comes first.
|
- "soon" fires at T-5min on whichever window comes first.
|
||||||
- Pairs with a top-of-app `<SessionExpiryToast />` mounted in `AppLayout.tsx`.
|
- Pairs with a top-of-app `<SessionExpiryToast />` mounted in `AppLayout.tsx`.
|
||||||
|
|
||||||
|
**SessionExpiryToast — differentiated by `reason`** (locked during plan-design-review):
|
||||||
|
- **`reason === "idle"`** (idle window is closer): warning-amber tone. Copy: *"Your session times out in 5 minutes."* Action button: `[Stay signed in]` → triggers a manual `/auth/refresh` call (resets the idle window). On success, toast dismisses + the store updates `idleExpiresAt`. On failure (e.g. absolute cap is also nearby and the refresh hits `session_expired_absolute`), fall through to the standard 401-handling redirect.
|
||||||
|
- **`reason === "absolute"`** (absolute window is closer): info-cyan tone (matching the `?reason=session_expired` banner). Copy: *"Your session ends at HH:MM for security. You'll need to sign in again."* No action button — nothing the user can do extends an absolute cap. Optional secondary action: `[Sign in now]` link to `/login` for users who want to re-auth proactively.
|
||||||
|
- Toast does not auto-dismiss (persists until acted on or window expires).
|
||||||
|
- Re-fires only after a successful `/auth/refresh` extends the idle window past T-5min and we cross back into "soon" later. Does not nag.
|
||||||
|
|
||||||
**Modified:** `frontend/src/api/client.ts` interceptor
|
**Modified:** `frontend/src/api/client.ts` interceptor
|
||||||
- On 401 with `detail="session_expired_absolute"` **or** `detail="session_expired_idle"`: **skip the refresh attempt**, flush tokens, redirect to `/login?reason=session_expired`. (Both surfaces go through the same banner — users don't need to distinguish the two.)
|
- On 401 with `detail="session_expired_absolute"` **or** `detail="session_expired_idle"`: **skip the refresh attempt**, flush tokens, redirect to `/login?reason=session_expired`. (Both surfaces go through the same banner — users don't need to distinguish the two.)
|
||||||
- On 401 with `detail="invalid_refresh_token"` or any other detail: current behavior (drop to `/login` without the reason banner).
|
- On 401 with `detail="invalid_refresh_token"` or any other detail: current behavior (drop to `/login` without the reason banner).
|
||||||
@@ -211,14 +224,44 @@ ISO strings (not Unix ints) for consistency with the rest of the API surface, wh
|
|||||||
- The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to the spread so OAuth-issued sessions get the same expiry metadata as password logins.
|
- The `setTokens({...})` call at `OAuthCallbackPage.tsx:102` currently passes `{access_token, refresh_token, token_type}` from the `OAuthCallbackResponse`. Add `idle_expires_at` and `absolute_expires_at` to the spread so OAuth-issued sessions get the same expiry metadata as password logins.
|
||||||
|
|
||||||
**New page:** `frontend/src/pages/account/AccountSecuritySettingsPage.tsx`
|
**New page:** `frontend/src/pages/account/AccountSecuritySettingsPage.tsx`
|
||||||
- Lives under existing `/account` routing with `requireRoleOwner` style guard.
|
- Lives under existing `/account` routing with `requireRoleOwner` style guard. Card lives in `AccountSettingsPage.tsx` grid alongside Branding / Chat Retention; **hidden entirely for non-owners** (matches existing role-conditional rendering at `AccountSettingsPage.tsx:597-651`).
|
||||||
- Two preset tiers — **Strict (3d/14d)** and **Standard (7d/30d)** — plus a **Custom** tier with two numeric inputs (idle/absolute in days).
|
- Page shell matches `ChatRetentionSettingsPage.tsx`: `max-w-2xl mx-auto py-8 px-6`, header row with Lucide icon + Bricolage 22px page title, `card-flat rounded-2xl p-6 space-y-6` body.
|
||||||
- Hint copy showing the system min/max from the GET response.
|
- **Vertical order (top → bottom):**
|
||||||
- Save → PATCH → toast.
|
1. Page header (Lucide `Shield` icon + "Session Security")
|
||||||
- Below the form, an info line: *"Policy changes apply to new logins. Existing sessions continue under the policy in effect at their login time. To force-logout existing sessions, use the actions below."*
|
2. One-line intro paragraph (`text-muted-foreground`): *"Control how long sessions can last before users must sign in again."*
|
||||||
- A separate "**Active sessions**" section with two actions (see §4.11):
|
3. **Session policy** card: three radios (Strict / Standard / Custom) with effective minute values visible per option ("Strict — 3d idle, 14d absolute"), then two numeric inputs (Idle minutes, Absolute minutes). **Inputs are always visible; disabled when a preset is selected.** Below inputs: hint text showing the system min/max from the GET response. Save button (primary) + inline `text-emerald-400 "Settings saved"` success ping for 3s after save (matching `ChatRetentionSettingsPage.tsx:112-114`).
|
||||||
- **Sign out everyone except me** (secondary button) — revokes other users' sessions in this account, leaves the caller signed in.
|
4. Info line directly below Save: *"New policy applies the next time each person signs in. Use **Active sessions** below to force it immediately."* (`text-muted-foreground`, bold on "Active sessions" — anchor link or just visual emphasis).
|
||||||
- **Sign out everyone, including me** (destructive-style button) — revokes all sessions for the account; the caller is immediately redirected to `/login`. Confirmation modal required.
|
5. Visual divider (1px `border-default`).
|
||||||
|
6. **Active sessions** section (see below for details).
|
||||||
|
- **Initial GET loading state:** centered `Loader2 animate-spin` page-body, matching `ChatRetentionSettingsPage.tsx:46-51`.
|
||||||
|
- **Inline validation** on Custom inputs: debounced 300ms; red border (`border-danger`) + small error text below field; Save button disabled when any field is invalid. Server-side 422 from PATCH surfaces via the existing axios interceptor toast.
|
||||||
|
|
||||||
|
**Active sessions section (within the same page):**
|
||||||
|
- GET response includes `active_users: [{user_id, name, email, last_login_at}, ...]` — backend addition; see §4.7.
|
||||||
|
- Section header: "Active sessions"
|
||||||
|
- Subhead: "N people are signed in to this account." (singular: "Only you are signed in.")
|
||||||
|
- Active-users list: one row per active user — `name (email) · logged in 2d ago` (relative time from `last_login_at`). Caller's own row marked with a small "(you)" tag.
|
||||||
|
- Buttons below the list — count-aware:
|
||||||
|
- **count > 1:** Two ghost buttons side-by-side — `[Sign out everyone except me]` and `[Sign me out and everyone else]` (the latter uses `text-danger` color to telegraph the self-impact).
|
||||||
|
- **count = 1 (solo owner):** Hide the "except me" button (it would revoke 0 — confusing). Show only `[Sign me out everywhere]` (still useful — signs the owner out from their other devices).
|
||||||
|
|
||||||
|
**Bulk-revoke confirmation modal** (via `components/common/Modal.tsx`):
|
||||||
|
- **scope=others:** title *"Sign out other users?"* · body *"This signs out the N other active users in your account. They'll need to sign in again. You stay signed in."* · buttons `[Cancel]` (ghost) + `[Sign out N users]` (`text-danger`).
|
||||||
|
- **scope=all:** title *"Sign out everyone?"* · body *"This signs out all N active users including yourself. Everyone will need to sign in again."* · buttons `[Cancel]` (ghost) + `[Sign out everyone]` (`text-danger`).
|
||||||
|
- After success: modal closes, `toast.success("Signed out N sessions")`. For scope=all: 1.5s delay → `useAuthStore.getState().logout()` + `window.location = '/login'` (no banner — they just did this, they know why they're here).
|
||||||
|
|
||||||
|
**Modified:** `AccountSettingsPage.tsx`
|
||||||
|
- Add a "Session Security" link card to the existing grid (owner-only visibility, alongside Branding / Chat Retention). Lucide `Shield` icon.
|
||||||
|
|
||||||
|
**New login page banner:** when `?reason=session_expired` is present, show a small info-tone banner **above the email/password form**:
|
||||||
|
- Background: `info-dim` (cyan-dim, `rgba(103,232,249,0.10)` dark / `rgba(8,145,178,0.07)` light per DESIGN-SYSTEM.md)
|
||||||
|
- Text color: `info` text token
|
||||||
|
- Border: `1px solid info-dim`
|
||||||
|
- Padding: 12px 16px, `radius-sm` (5px)
|
||||||
|
- Icon: Lucide `Info` (16px, info color, left-aligned)
|
||||||
|
- Copy: *"You were signed out for security. Sign back in to continue."*
|
||||||
|
- Not dismissable — disappears naturally when the user submits the form (the query string clears on navigate).
|
||||||
|
- Note: this is the first cyan info-tone banner in the app; sets the precedent we'll reuse for future neutral system messages.
|
||||||
|
|
||||||
**Modified:** `AccountSettingsPage.tsx`
|
**Modified:** `AccountSettingsPage.tsx`
|
||||||
- Add a "Session Security" link card to the existing grid (owner-only visibility).
|
- Add a "Session Security" link card to the existing grid (owner-only visibility).
|
||||||
@@ -412,7 +455,7 @@ Follow-up issues to file after this plan is approved (not blocking this PR):
|
|||||||
5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH).
|
5. `feat(api): add GET/PATCH /accounts/me/security endpoint` (router, schemas, owner gate, bounds + partial-override invariant validation, audit logging on PATCH).
|
||||||
6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17–#22).
|
6. `feat(api): add POST /accounts/me/security/revoke-sessions` (bulk-revoke endpoint with `scope=all|others`, single-UPDATE implementation, audit logging, tests #17–#22).
|
||||||
7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`).
|
7. `feat(ui): handle session_expired_{idle,absolute} in axios interceptor + authStore` (new fields persisted, legacy-state migration, redirect to `/login?reason=session_expired`).
|
||||||
8. `feat(ui): add AccountSecuritySettingsPage + AppLayout toast + login banner` (Strict/Standard/Custom presets, Active Sessions section with two revoke buttons + confirmation modal, `useAuthSessionExpiry`, expiry-soon toast, `?reason=session_expired` banner).
|
8. `feat: AccountSecuritySettingsPage + active-users list + toasts + login banner` (Strict/Standard/Custom presets with always-visible-disabled Custom inputs, count-aware Active Sessions section with name/email/last-login rows, differentiated SessionExpiryToast for idle-vs-absolute, cyan info-tone login banner, scope=all auto-redirect-after-toast UX. Includes a small backend addition: `active_users` field on `GET /accounts/me/security` — see §4.7).
|
||||||
9. `docs: add decision entry + update CURRENT-STATE auth surface` (`.ai/DECISIONS.md`, `CURRENT-STATE.md`).
|
9. `docs: add decision entry + update CURRENT-STATE auth surface` (`.ai/DECISIONS.md`, `CURRENT-STATE.md`).
|
||||||
|
|
||||||
Each commit independently passes `pytest --override-ini="addopts="` and `npm run build`. The two backend behavior gates (#2 and #4) ship behind no flag — they're the point of the work — but they're sequenced so any rollback is a single commit.
|
Each commit independently passes `pytest --override-ini="addopts="` and `npm run build`. The two backend behavior gates (#2 and #4) ship behind no flag — they're the point of the work — but they're sequenced so any rollback is a single commit.
|
||||||
@@ -433,3 +476,18 @@ Each commit independently passes `pytest --override-ini="addopts="` and `npm run
|
|||||||
- [ ] Final approval on commit sequence in §10.
|
- [ ] Final approval on commit sequence in §10.
|
||||||
- [ ] No conflict with Phase O cutover sequencing (this can ship before OR after EIN/Stripe lands; independent path).
|
- [ ] No conflict with Phase O cutover sequencing (this can ship before OR after EIN/Stripe lands; independent path).
|
||||||
- [ ] File the kill-all-sessions follow-up issue per §9 before implementation begins, so the Account Security page can link to it (or leave the support-contact copy in place).
|
- [ ] File the kill-all-sessions follow-up issue per §9 before implementation begins, so the Account Security page can link to it (or leave the support-contact copy in place).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GSTACK REVIEW REPORT
|
||||||
|
|
||||||
|
| Review | Trigger | Why | Runs | Status | Findings |
|
||||||
|
|--------|---------|-----|------|--------|----------|
|
||||||
|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
|
||||||
|
| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | not run |
|
||||||
|
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 0 | — | not run (the plan itself was eng-reviewed inline across 7 commits — backend complete & green) |
|
||||||
|
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (PLAN) | score: 4/10 → 9/10, 7 decisions added |
|
||||||
|
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
|
||||||
|
|
||||||
|
**UNRESOLVED:** 0 design decisions; 3 plan-level checklist items remain (system bounds, commit sequence, Phase O sequencing — none block design).
|
||||||
|
**VERDICT:** DESIGN CLEARED — page layout, state coverage, post-revoke flow, toast logic, login banner tone, and form copy all locked. Commit 8 has a complete spec.
|
||||||
|
|||||||
49
frontend/src/api/accountSecurity.ts
Normal file
49
frontend/src/api/accountSecurity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export interface ActiveUser {
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
last_login_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPolicyResponse {
|
||||||
|
idle_minutes: number | null
|
||||||
|
absolute_minutes: number | null
|
||||||
|
effective_idle_minutes: number
|
||||||
|
effective_absolute_minutes: number
|
||||||
|
idle_minutes_min: number
|
||||||
|
idle_minutes_max: number
|
||||||
|
absolute_minutes_min: number
|
||||||
|
absolute_minutes_max: number
|
||||||
|
active_users: ActiveUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPolicyUpdateRequest {
|
||||||
|
idle_minutes: number | null
|
||||||
|
absolute_minutes: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeSessionsResponse {
|
||||||
|
revoked_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountSecurityApi = {
|
||||||
|
async get(): Promise<SessionPolicyResponse> {
|
||||||
|
const response = await apiClient.get<SessionPolicyResponse>('/accounts/me/security')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(body: SessionPolicyUpdateRequest): Promise<SessionPolicyResponse> {
|
||||||
|
const response = await apiClient.patch<SessionPolicyResponse>('/accounts/me/security', body)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeSessions(scope: 'all' | 'others'): Promise<RevokeSessionsResponse> {
|
||||||
|
const response = await apiClient.post<RevokeSessionsResponse>(
|
||||||
|
'/accounts/me/security/revoke-sessions',
|
||||||
|
{ scope },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
89
frontend/src/components/account/RevokeSessionsModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
125
frontend/src/components/common/SessionExpiryToast.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { AlertCircle, Info, X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthSessionExpiry } from '@/hooks/useAuthSessionExpiry'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-app notice that fires when the session is within 5 minutes of
|
||||||
|
* idle OR absolute expiry. Behavior differs by which window is closer
|
||||||
|
* (per docs/plans/2026-05-13-session-expiration-policy.md §4.8):
|
||||||
|
*
|
||||||
|
* - Idle: warning-amber tone, "Stay signed in" button hits /auth/refresh.
|
||||||
|
* - Absolute: info-cyan tone, no action — re-auth is required.
|
||||||
|
*
|
||||||
|
* Persists until the user dismisses, refreshes, or the window expires.
|
||||||
|
*/
|
||||||
|
export function SessionExpiryToast() {
|
||||||
|
const { warning, reason, idleExpiresAt, absoluteExpiresAt } = useAuthSessionExpiry()
|
||||||
|
const setTokens = useAuthStore((s) => s.setTokens)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
|
||||||
|
if (warning !== 'soon' || dismissed) return null
|
||||||
|
|
||||||
|
const handleStay = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const refreshed = await authApi.refresh()
|
||||||
|
localStorage.setItem('access_token', refreshed.access_token)
|
||||||
|
localStorage.setItem('refresh_token', refreshed.refresh_token)
|
||||||
|
setTokens(refreshed)
|
||||||
|
setDismissed(true)
|
||||||
|
} catch {
|
||||||
|
// The axios interceptor handles the redirect on session_expired_*;
|
||||||
|
// if we land here, something else went wrong — just close the toast.
|
||||||
|
setDismissed(true)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignInNow = () => navigate('/login')
|
||||||
|
|
||||||
|
// ── Format the deadline for the absolute case ──
|
||||||
|
const deadline = reason === 'idle' ? idleExpiresAt : absoluteExpiresAt
|
||||||
|
const deadlineLabel = deadline
|
||||||
|
? deadline.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const isIdle = reason === 'idle'
|
||||||
|
const Icon = isIdle ? AlertCircle : Info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
'fixed top-4 right-4 z-50 max-w-md rounded-lg border p-4 shadow-lg',
|
||||||
|
'flex items-start gap-3',
|
||||||
|
isIdle
|
||||||
|
? 'bg-warning-dim border-warning/30 text-warning'
|
||||||
|
: 'bg-info-dim border-info/30 text-info',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{isIdle
|
||||||
|
? 'Your session times out in 5 minutes.'
|
||||||
|
: `Your session ends at ${deadlineLabel} for security.`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs opacity-90">
|
||||||
|
{isIdle
|
||||||
|
? 'Click to stay signed in.'
|
||||||
|
: "You'll need to sign in again."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
{isIdle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleStay}
|
||||||
|
disabled={busy}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||||
|
'bg-warning/20 text-warning border border-warning/40 hover:bg-warning/30',
|
||||||
|
'disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{busy ? 'Refreshing…' : 'Stay signed in'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSignInNow}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-xs font-medium',
|
||||||
|
'bg-info/20 text-info border border-info/40 hover:bg-info/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Sign in now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="text-xs opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="shrink-0 opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { EmailVerificationBanner } from './EmailVerificationBanner'
|
|||||||
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
|
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
|
||||||
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
||||||
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
||||||
|
import { SessionExpiryToast } from '@/components/common/SessionExpiryToast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -69,6 +70,7 @@ export function AppLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SessionExpiryToast />
|
||||||
<div
|
<div
|
||||||
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
||||||
data-testid="app-shell"
|
data-testid="app-shell"
|
||||||
|
|||||||
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal file
66
frontend/src/hooks/useAuthSessionExpiry.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
|
||||||
|
const SOON_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
export type ExpiryWarning = 'none' | 'soon' | 'now'
|
||||||
|
export type ExpiryReason = 'idle' | 'absolute'
|
||||||
|
|
||||||
|
interface ExpiryState {
|
||||||
|
idleExpiresAt: Date | null
|
||||||
|
absoluteExpiresAt: Date | null
|
||||||
|
warning: ExpiryWarning
|
||||||
|
/**
|
||||||
|
* Which window is the closer (and therefore the active) deadline. Used by
|
||||||
|
* SessionExpiryToast to pick the right copy + action button: idle gets
|
||||||
|
* "Stay signed in" (calls /auth/refresh); absolute is informational only.
|
||||||
|
*/
|
||||||
|
reason: ExpiryReason | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeState(token: ReturnType<typeof useAuthStore.getState>['token']): ExpiryState {
|
||||||
|
const idleStr = token?.idle_expires_at
|
||||||
|
const absStr = token?.absolute_expires_at
|
||||||
|
if (!idleStr || !absStr) {
|
||||||
|
return { idleExpiresAt: null, absoluteExpiresAt: null, warning: 'none', reason: null }
|
||||||
|
}
|
||||||
|
const idle = new Date(idleStr)
|
||||||
|
const abs = new Date(absStr)
|
||||||
|
const now = Date.now()
|
||||||
|
const idleMs = idle.getTime() - now
|
||||||
|
const absMs = abs.getTime() - now
|
||||||
|
|
||||||
|
// Closer window wins.
|
||||||
|
const reason: ExpiryReason = idleMs <= absMs ? 'idle' : 'absolute'
|
||||||
|
const closestMs = Math.min(idleMs, absMs)
|
||||||
|
|
||||||
|
let warning: ExpiryWarning = 'none'
|
||||||
|
if (closestMs <= 0) warning = 'now'
|
||||||
|
else if (closestMs <= SOON_MS) warning = 'soon'
|
||||||
|
|
||||||
|
return { idleExpiresAt: idle, absoluteExpiresAt: abs, warning, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track how close the active session is to its idle/absolute deadline.
|
||||||
|
*
|
||||||
|
* Returns `warning: "soon"` within 5 min of whichever window comes first,
|
||||||
|
* and `reason: "idle" | "absolute"` so callers can choose the right UX
|
||||||
|
* (idle is recoverable via /auth/refresh; absolute is not). Re-evaluates
|
||||||
|
* every 30 seconds while authenticated; cheap (single Date subtraction).
|
||||||
|
*
|
||||||
|
* See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
|
||||||
|
*/
|
||||||
|
export function useAuthSessionExpiry(): ExpiryState {
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
|
const [state, setState] = useState<ExpiryState>(() => computeState(token))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(computeState(token))
|
||||||
|
if (!token?.idle_expires_at || !token?.absolute_expires_at) return
|
||||||
|
const interval = window.setInterval(() => setState(computeState(token)), 30_000)
|
||||||
|
return () => window.clearInterval(interval)
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Plug,
|
Plug,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
|
Shield,
|
||||||
UserCog,
|
UserCog,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -632,6 +633,12 @@ export function AccountSettingsPage() {
|
|||||||
title="Chat retention"
|
title="Chat retention"
|
||||||
description="Conversation retention and assistant data lifecycle"
|
description="Conversation retention and assistant data lifecycle"
|
||||||
/>
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/security"
|
||||||
|
icon={<Shield className="h-4 w-4" />}
|
||||||
|
title="Session security"
|
||||||
|
description="Session-expiration policy and active sessions"
|
||||||
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
to="/account/categories"
|
to="/account/categories"
|
||||||
icon={<FolderTree className="h-4 w-4" />}
|
icon={<FolderTree className="h-4 w-4" />}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||||
@@ -17,6 +18,11 @@ export function LoginPage() {
|
|||||||
|
|
||||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
|
||||||
|
|
||||||
|
// When the user lands here after the session-policy axios interceptor
|
||||||
|
// forcibly logged them out, show a calm info-tone banner above the form.
|
||||||
|
// See docs/plans/2026-05-13-session-expiration-policy.md §4.8.
|
||||||
|
const showSessionExpiredBanner = new URLSearchParams(location.search).get('reason') === 'session_expired'
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLocalError('')
|
setLocalError('')
|
||||||
@@ -60,6 +66,15 @@ export function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSessionExpiredBanner && (
|
||||||
|
<div className="rounded-md border border-info/30 bg-info-dim px-4 py-3 flex items-start gap-2">
|
||||||
|
<Info size={16} className="text-info mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm text-info">
|
||||||
|
You were signed out for security. Sign back in to continue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
|
<form onSubmit={handleSubmit} className="mt-8 space-y-6" data-testid="login-form">
|
||||||
<div className="card-flat p-6 space-y-4">
|
<div className="card-flat p-6 space-y-4">
|
||||||
{(error || localError) && (
|
{(error || localError) && (
|
||||||
|
|||||||
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal file
353
frontend/src/pages/account/AccountSecuritySettingsPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Loader2, Save, Shield } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { accountSecurityApi, type SessionPolicyResponse } from '@/api/accountSecurity'
|
||||||
|
import { RevokeSessionsModal } from '@/components/account/RevokeSessionsModal'
|
||||||
|
|
||||||
|
type Preset = 'strict' | 'standard' | 'custom'
|
||||||
|
|
||||||
|
const PRESETS: Record<Exclude<Preset, 'custom'>, { idle: number; absolute: number; label: string; sub: string }> = {
|
||||||
|
strict: { idle: 4320, absolute: 20160, label: 'Strict', sub: '3 days idle · 14 days absolute' },
|
||||||
|
standard: { idle: 10080, absolute: 43200, label: 'Standard', sub: '7 days idle · 30 days absolute' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPreset(idle: number, absolute: number): Preset {
|
||||||
|
if (idle === PRESETS.strict.idle && absolute === PRESETS.strict.absolute) return 'strict'
|
||||||
|
if (idle === PRESETS.standard.idle && absolute === PRESETS.standard.absolute) return 'standard'
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeFromNow(iso: string | null): string {
|
||||||
|
if (!iso) return 'unknown'
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.round(diffMs / 60_000)
|
||||||
|
if (m < 1) return 'just now'
|
||||||
|
if (m < 60) return `${m}m ago`
|
||||||
|
const h = Math.round(m / 60)
|
||||||
|
if (h < 24) return `${h}h ago`
|
||||||
|
const d = Math.round(h / 24)
|
||||||
|
if (d < 30) return `${d}d ago`
|
||||||
|
return new Date(iso).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountSecuritySettingsPage() {
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id) ?? null
|
||||||
|
|
||||||
|
const [data, setData] = useState<SessionPolicyResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [preset, setPreset] = useState<Preset>('strict')
|
||||||
|
const [idleMin, setIdleMin] = useState<string>('')
|
||||||
|
const [absMin, setAbsMin] = useState<string>('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [modalScope, setModalScope] = useState<'all' | 'others' | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await accountSecurityApi.get()
|
||||||
|
setData(res)
|
||||||
|
const eff = res
|
||||||
|
const detected = detectPreset(eff.effective_idle_minutes, eff.effective_absolute_minutes)
|
||||||
|
setPreset(detected)
|
||||||
|
setIdleMin(String(eff.effective_idle_minutes))
|
||||||
|
setAbsMin(String(eff.effective_absolute_minutes))
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not load security settings')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customDisabled = preset !== 'custom'
|
||||||
|
|
||||||
|
// Sync the inputs to the chosen preset so the visible values track the radio.
|
||||||
|
const handlePresetChange = (next: Preset) => {
|
||||||
|
setPreset(next)
|
||||||
|
if (next === 'strict') {
|
||||||
|
setIdleMin(String(PRESETS.strict.idle))
|
||||||
|
setAbsMin(String(PRESETS.strict.absolute))
|
||||||
|
} else if (next === 'standard') {
|
||||||
|
setIdleMin(String(PRESETS.standard.idle))
|
||||||
|
setAbsMin(String(PRESETS.standard.absolute))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = useMemo(() => {
|
||||||
|
if (!data) return { idleErr: null, absErr: null, ok: false }
|
||||||
|
const idle = parseInt(idleMin, 10)
|
||||||
|
const abs = parseInt(absMin, 10)
|
||||||
|
let idleErr: string | null = null
|
||||||
|
let absErr: string | null = null
|
||||||
|
if (!Number.isFinite(idle)) idleErr = 'Required'
|
||||||
|
else if (idle < data.idle_minutes_min || idle > data.idle_minutes_max) {
|
||||||
|
idleErr = `Between ${data.idle_minutes_min} and ${data.idle_minutes_max}`
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(abs)) absErr = 'Required'
|
||||||
|
else if (abs < data.absolute_minutes_min || abs > data.absolute_minutes_max) {
|
||||||
|
absErr = `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max}`
|
||||||
|
}
|
||||||
|
if (!idleErr && !absErr && idle > abs) {
|
||||||
|
idleErr = 'Idle cannot exceed absolute'
|
||||||
|
}
|
||||||
|
return { idleErr, absErr, ok: !idleErr && !absErr }
|
||||||
|
}, [data, idleMin, absMin])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validation.ok || !data) return
|
||||||
|
setSaving(true)
|
||||||
|
setSuccess(false)
|
||||||
|
try {
|
||||||
|
const body =
|
||||||
|
preset === 'custom'
|
||||||
|
? { idle_minutes: parseInt(idleMin, 10), absolute_minutes: parseInt(absMin, 10) }
|
||||||
|
: preset === 'strict'
|
||||||
|
? { idle_minutes: PRESETS.strict.idle, absolute_minutes: PRESETS.strict.absolute }
|
||||||
|
: { idle_minutes: PRESETS.standard.idle, absolute_minutes: PRESETS.standard.absolute }
|
||||||
|
const updated = await accountSecurityApi.update(body)
|
||||||
|
setData(updated)
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => setSuccess(false), 3000)
|
||||||
|
} catch {
|
||||||
|
// Global axios interceptor surfaces 422 via toast.
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevokeConfirm = async () => {
|
||||||
|
if (!modalScope) return
|
||||||
|
const scope = modalScope
|
||||||
|
try {
|
||||||
|
const res = await accountSecurityApi.revokeSessions(scope)
|
||||||
|
toast.success(`Signed out ${res.revoked_count} sessions`)
|
||||||
|
setModalScope(null)
|
||||||
|
if (scope === 'all') {
|
||||||
|
// Per plan §4.8 D4: small delay so the user sees the toast,
|
||||||
|
// then clear local state and redirect to /login.
|
||||||
|
setTimeout(() => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
useAuthStore.getState().logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
}, 1500)
|
||||||
|
} else {
|
||||||
|
// scope=others: reload to reflect the new (shorter) active-users list.
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// global handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="animate-spin text-primary" size={24} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeUserCount = data.active_users.length
|
||||||
|
const solo = activeUserCount <= 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 px-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Shield size={20} className="text-primary" />
|
||||||
|
<h1 className="text-xl font-heading font-bold text-foreground">Session Security</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Control how long sessions can last before users must sign in again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── Policy card ────────────────────────────────────────────────── */}
|
||||||
|
<div className="card-flat rounded-2xl p-6 space-y-5">
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="text-sm font-medium text-foreground mb-1">Policy</legend>
|
||||||
|
|
||||||
|
{(['strict', 'standard', 'custom'] as const).map((p) => {
|
||||||
|
const isSelected = preset === p
|
||||||
|
const labels = p === 'custom'
|
||||||
|
? { label: 'Custom', sub: 'Set your own idle and absolute windows below' }
|
||||||
|
: PRESETS[p]
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={p}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary/40 bg-primary/[0.06]'
|
||||||
|
: 'border-border hover:bg-card-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="preset"
|
||||||
|
value={p}
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handlePresetChange(p)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">{labels.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{labels.sub}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* Custom inputs — always visible, disabled outside Custom */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||||
|
Idle minutes
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={idleMin}
|
||||||
|
onChange={(e) => setIdleMin(e.target.value)}
|
||||||
|
disabled={customDisabled}
|
||||||
|
min={data.idle_minutes_min}
|
||||||
|
max={data.idle_minutes_max}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5',
|
||||||
|
'focus:outline-hidden focus:border-primary/30',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
validation.idleErr && !customDisabled && 'border-danger/50',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
validation.idleErr && !customDisabled ? undefined : 'var(--color-border-default)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
{validation.idleErr && !customDisabled
|
||||||
|
? validation.idleErr
|
||||||
|
: `Between ${data.idle_minutes_min} and ${data.idle_minutes_max} min`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="font-sans text-xs uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||||
|
Absolute minutes
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={absMin}
|
||||||
|
onChange={(e) => setAbsMin(e.target.value)}
|
||||||
|
disabled={customDisabled}
|
||||||
|
min={data.absolute_minutes_min}
|
||||||
|
max={data.absolute_minutes_max}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5',
|
||||||
|
'focus:outline-hidden focus:border-primary/30',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
validation.absErr && !customDisabled && 'border-danger/50',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
validation.absErr && !customDisabled ? undefined : 'var(--color-border-default)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
{validation.absErr && !customDisabled
|
||||||
|
? validation.absErr
|
||||||
|
: `Between ${data.absolute_minutes_min} and ${data.absolute_minutes_max} min`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !validation.ok}
|
||||||
|
className="bg-primary text-white font-semibold text-sm rounded-lg px-5 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-40 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
Save Policy
|
||||||
|
</button>
|
||||||
|
{success && <span className="text-sm text-emerald-400">Settings saved</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground border-t border-border pt-3">
|
||||||
|
New policy applies the next time each person signs in. Use{' '}
|
||||||
|
<span className="font-medium text-foreground">Active sessions</span> below to force it
|
||||||
|
immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Active sessions card ───────────────────────────────────────── */}
|
||||||
|
<div className="card-flat rounded-2xl p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-foreground">Active sessions</h2>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{solo
|
||||||
|
? 'Only you are signed in to this account.'
|
||||||
|
: `${activeUserCount} people are signed in to this account.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.active_users.map((u) => {
|
||||||
|
const isMe = u.user_id === currentUserId
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={u.user_id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<span className="truncate">{u.name}</span>
|
||||||
|
{isMe && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
(you)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{u.email} · last signed in {relativeFromNow(u.last_login_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{!solo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalScope('others')}
|
||||||
|
className="rounded-md border border-border bg-transparent px-3 py-1.5 text-sm font-medium text-foreground hover:bg-card-hover"
|
||||||
|
>
|
||||||
|
Sign out everyone except me
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalScope('all')}
|
||||||
|
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-1.5 text-sm font-medium text-danger hover:bg-danger/15"
|
||||||
|
>
|
||||||
|
{solo ? 'Sign me out everywhere' : 'Sign me out and everyone else'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RevokeSessionsModal
|
||||||
|
isOpen={modalScope !== null}
|
||||||
|
scope={modalScope ?? 'others'}
|
||||||
|
activeUserCount={activeUserCount}
|
||||||
|
onClose={() => setModalScope(null)}
|
||||||
|
onConfirm={handleRevokeConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ const ProfileSettingsPage = lazyWithRetry(() => import('@/pages/account/ProfileS
|
|||||||
const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage'))
|
const TeamCategoriesPage = lazyWithRetry(() => import('@/pages/account/TeamCategoriesPage'))
|
||||||
const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage'))
|
const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsPage'))
|
||||||
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||||
|
const AccountSecuritySettingsPage = lazyWithRetry(() => import('@/pages/account/AccountSecuritySettingsPage'))
|
||||||
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
||||||
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
||||||
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
|
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
|
||||||
@@ -341,6 +342,14 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'security',
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute requiredRole="owner">
|
||||||
|
{page(AccountSecuritySettingsPage)}
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: 'target-lists', element: page(TargetListsPage) },
|
{ path: 'target-lists', element: page(TargetListsPage) },
|
||||||
{
|
{
|
||||||
path: 'integrations',
|
path: 'integrations',
|
||||||
|
|||||||
Reference in New Issue
Block a user