feat(07.1): session revocation on privilege change — CR-01/CR-02/CR-03

- revoke_all_refresh_tokens: add skip_token_hash optional param (exclude
  current session while revoking others)
- change_password, enable_totp, disable_totp: call revoke with skip hash
  derived from refresh cookie; return sessions_revoked in response and
  write to audit log metadata_
- 3 new tests: test_{change_password,enable_totp,disable_totp}_revokes_other_sessions
  — all PASSED; 373 total passing, 0 regressions
- Frontend toasts: SettingsAccountTab + TotpEnrollment show
  "Other sessions have been terminated." when sessions_revoked > 0
- Companion fixes: rate_limiting get_client_ip refactor, deps/auth.py
  request.state.current_user, locustfile refresh-token task removal
- Version bump: 0.1.0 → 0.1.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-05 12:47:16 +02:00
co-authored by Claude Sonnet 4.6
parent 8d060a5da4
commit c38c6b1c01
14 changed files with 360 additions and 34 deletions
+8 -7
View File
@@ -2,9 +2,9 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: Redo and Optimize LLM Integration
current_phase: 7.1 (planned — ready to execute)
status: ready_to_execute
last_updated: "2026-06-05T12:00:00.000Z"
current_phase: 7.1 (complete)
status: complete
last_updated: "2026-06-05T15:00:00.000Z"
progress:
total_phases: 7
completed_phases: 3
@@ -17,7 +17,7 @@ progress:
**Project:** DocuVault
**Status:** v1.0 Milestone COMPLETE — All 7 phases done
**Current Phase:** 7.1 (not started — inserted)
**Current Phase:** 7.1 (complete)
**Last Updated:** 2026-06-05
## Phase Status
@@ -33,12 +33,12 @@ progress:
| 6.1 | Close v1.0 audit gaps: SHARE-02/STORE-06/ADMIN-06 | ✓ Complete (2/2 plans) |
| 6.2 | Close v1 sharing + cloud-delete + CSV export gaps | ✓ Complete (5/5 plans, UAT passed, security gate passed) |
| 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) |
| 7.1 | Security: session revocation on privilege change (CR-01..03) | 📋 Planned (2 plans, 2 waves) — ready to execute |
| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) |
## Current Position
Phase: 7 (redo-and-optimize-llm-integration) — COMPLETE
Phase: 7.1 (security-session-revocation-on-privilege-change) — PLANNED (2 plans, 2 waves)
Phase: 7.1 (security-session-revocation-on-privilege-change) — COMPLETE (2/2 plans)
**Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
## Performance Metrics
@@ -204,6 +204,7 @@ _Updated at each phase transition._
| Last session | 2026-05-31 — Phase 6.2 planned: 4 plans (3 waves); SHARE-03/SHARE-05 (Plan 02), cloud-delete (Plan 03), ADMIN-06 audit enrichment + CSV + daily exports (Plan 04); verification passed (0 blockers, 2 cosmetic warnings fixed) |
| Last session | 2026-06-03 — Phase 7 planned: 5 plans (5 waves); system_settings DB + HKDF (Plan 01), ProviderConfig + GenericOpenAIProvider + singleton fix (Plan 02), Anthropic output_config + classifier wiring (Plan 03), Celery retry harness + re-queue endpoint (Plan 04), admin AI panel + DocumentCard badge (Plan 05); verification passed (4 blockers fixed in revision round, 0 remaining) |
| Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE |
| Next action | Phase 7.1 planned — run `/gsd:execute-phase 7.1` to execute 2 plans (Wave 1: backend, Wave 2: tests + frontend toast) |
| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 |
| Next action | All phases and sub-phases complete. Ready for next milestone. |
| Pending decisions | None |
| Resume file | None |
@@ -0,0 +1,26 @@
# Plan 07.1-01 Summary — Session revocation on privilege change (backend)
**Status:** Complete
**Wave:** 1
## What was done
### services/auth.py
- Extended `revoke_all_refresh_tokens` signature: added `skip_token_hash: Optional[str] = None`
- When `skip_token_hash` is set, the WHERE clause excludes that token (`RefreshToken.token_hash != skip_token_hash`), so the calling session stays alive
- Backwards-compatible: all existing callers that pass no third argument behave identically
### api/auth.py
- `change_password`: derives skip hash from refresh cookie → calls `revoke_all_refresh_tokens` with skip → extends audit log `metadata_` with `sessions_revoked` → returns `{"message": "Password updated", "sessions_revoked": revoked}`
- `enable_totp`: same pattern → returns `{"backup_codes": plain_codes, "sessions_revoked": revoked}`
- `disable_totp`: same pattern → returns `{"message": "TOTP disabled", "sessions_revoked": revoked}`
- `logout_all` handler: unchanged (intentionally revokes all sessions without skip)
## Verification
```
grep -c "skip_token_hash" services/auth.py → 4
grep -c "sessions_revoked" api/auth.py → 7
grep -c "skip_token_hash" api/auth.py → 6
python3 -c "import api.auth; import services.auth" → exits 0
```
@@ -0,0 +1,34 @@
# Plan 07.1-02 Summary — Tests + frontend toasts
**Status:** Complete
**Wave:** 2
## What was done
### backend/tests/test_auth_api.py
- Added `RefreshToken` to imports from `db.models`
- Appended 3 new `@pytest.mark.asyncio` tests:
- `test_change_password_revokes_other_sessions`: inserts a second RefreshToken, calls change-password, asserts `sessions_revoked >= 1` and the row is revoked
- `test_enable_totp_revokes_other_sessions`: same pattern for totp/enable (mocks `verify_totp` and `store_backup_codes`)
- `test_disable_totp_revokes_other_sessions`: same pattern for DELETE /api/auth/totp
### frontend/src/components/settings/SettingsAccountTab.vue
- Added `sessionRevokedToast = ref(false)`
- Added a fixed top-right toast (same visual pattern as SettingsView OAuth toast)
- `changePassword()`: captures API response, shows toast for 5s when `sessions_revoked > 0`
- `disableTotp()`: captures API response, shows toast for 5s when `sessions_revoked > 0`
### frontend/src/components/auth/TotpEnrollment.vue
- Added `sessionRevokedToast = ref(false)`
- Added an inline (non-fixed) alert block at the top of the component template
- `confirmEnrollment()`: checks `data.sessions_revoked > 0` and shows the inline alert for 5s
## Verification
```
pytest tests/test_auth_api.py -k "revokes_other_sessions" -v → 3 PASSED
pytest -q --ignore=tests/test_extractor.py → 373 passed, 0 failed
npm run build (frontend) → exits 0
grep -c "sessions_revoked" SettingsAccountTab.vue → 2
grep -c "sessions_revoked" TotpEnrollment.vue → 1
```
+81 -2
View File
@@ -4,7 +4,7 @@
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
**Current state:** Brownfield — single-user app is functional. Active milestone: migrating to multi-user, adding auth, PostgreSQL + MinIO, and cloud storage.
**Current state:** v0.1 alpha — not production-ready. Multi-user SaaS with full auth, PostgreSQL + MinIO, cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV), folder/share/quota management, structured observability, and a refactored AI provider layer backed by a `system_settings` DB table. All 7 phases complete as of 2026-06-05. The app is functional for local/self-hosted use but has not been hardened or audited for public internet deployment.
## Stack
@@ -116,7 +116,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts
/gsd:progress — check status and advance workflow
```
### Current phase: Not started — run `/gsd:discuss-phase 1` to begin
### Current state: v0.1 alpha — all 7 foundation phases complete (2026-06-05)
All phases are marked complete in `.planning/ROADMAP.md`. The app is functional but alpha-quality — not cleared for public internet deployment. The next milestone has not been started.
## Development Setup
@@ -134,6 +136,83 @@ cd frontend && npm run dev
cd backend && pytest -v
```
---
## Documentation Protocol (Non-Negotiable)
Every major development step — completing a plan, fixing a significant bug, or shipping a new feature — must end with documentation and a commit. "Major step" means any plan execution (`/gsd:execute-phase`) or standalone feature work that changes user-facing behaviour, the API surface, environment variables, or the architectural rules.
### After completing each plan or phase
1. **Update `CLAUDE.md`** (this file):
- Update the "Current state" line in the GSD Workflow section to reflect what was just completed.
- Update the shared module map if new shared helpers were introduced.
- Update the code standards if new non-negotiable rules were established.
2. **Update `README.md`** if any of the following changed:
- New user-facing features (add to the Features section).
- New or changed environment variables (update the env var table).
- New cloud storage backend, AI provider, or API endpoint group.
- Changed service URLs, port numbers, or startup procedure.
- Changed version number (`backend/main.py` and `frontend/package.json` are the sources of truth).
3. **Version bump rule**: increment the patch segment of the version in `backend/main.py` and `frontend/package.json` after every plan that ships user-facing changes (e.g. `0.1.0``0.1.1`). Increment the minor segment after completing a full phase (e.g. `0.1.0``0.2.0`). Do not jump to `1.0.0` until the project owner explicitly signs off on a production-ready release.
---
## Git Protocol (Non-Negotiable)
After every major development step, save the work to the repository with an atomic commit and push. Do not accumulate multiple plan's changes into one commit — each plan gets its own commit.
### Commit sequence
```bash
# 1. Stage all changed files (be explicit — avoid -A when sensitive files may exist)
git add backend/ frontend/ CLAUDE.md README.md RUNBOOK.md SECURITY.md docker-compose.yml
# 2. Commit with a descriptive message following this format:
# <type>(<scope>): <short summary>
# where type = feat | fix | docs | refactor | test | chore | security
git commit -m "feat(phase-N): short description of what was shipped"
# 3. Push to the remote repository immediately
git push origin main
```
### Commit types
| Type | When to use |
|------|-------------|
| `feat` | New user-facing feature or endpoint |
| `fix` | Bug fix (root-cause, ≤50 lines) |
| `docs` | CLAUDE.md, README.md, RUNBOOK.md, or SECURITY.md only |
| `refactor` | Internal restructuring with no behaviour change |
| `test` | Adding or fixing tests only |
| `chore` | Dependencies, CI, build config |
| `security` | Security hardening, CVE fixes, auth changes |
### When to commit
| Event | Action |
|-------|--------|
| Plan execution complete (`/gsd:execute-phase`) | Commit + push immediately after tests pass |
| Documentation update (CLAUDE.md / README.md) | Commit + push in the same operation as the code it documents |
| Bug fix during a phase | Commit the fix separately before continuing the plan |
| Security gate findings resolved | Commit + push before marking the phase complete |
| Version bump | Part of the plan's commit — not a separate commit |
### Commit message examples
```bash
git commit -m "feat(07-ai): GenericOpenAIProvider + Anthropic json_schema + Celery retry backoff"
git commit -m "fix(quota): atomic decrement on document delete — regression test added"
git commit -m "docs(readme): add cloud storage backend table + alpha status warning"
git commit -m "security(headers): add X-Correlation-ID + Referrer-Policy to SecurityHeadersMiddleware"
git commit -m "chore(deps): bump PyMuPDF to 1.26.7 — resolves read-only filesystem issue"
```
---
## Testing Protocol (Non-Negotiable)
Every feature, function, and bug fix requires tests. No phase or plan may advance until all tests pass.
+20 -3
View File
@@ -489,6 +489,10 @@ async def change_password(
_ip = get_client_ip(request)
user = await session.get(User, current_user.id)
user.password_hash = auth_service.hash_password(body.new_password)
# Revoke other sessions; keep current one alive via skip_token_hash (CR-01)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: password changed event (flush within same transaction before commit)
await write_audit_log(
session,
@@ -497,10 +501,11 @@ async def change_password(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"message": "Password updated"}
return {"message": "Password updated", "sessions_revoked": revoked}
# ── Request models for new endpoints ─────────────────────────────────────────
@@ -575,6 +580,11 @@ async def enable_totp(
plain_codes = auth_service.generate_backup_codes(10)
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
# Revoke other sessions; keep current one alive via skip_token_hash (CR-02)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: TOTP enrolled event
_ip = get_client_ip(request)
await write_audit_log(
@@ -584,10 +594,11 @@ async def enable_totp(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"backup_codes": plain_codes}
return {"backup_codes": plain_codes, "sessions_revoked": revoked}
# ── DELETE /api/auth/totp ─────────────────────────────────────────────────────
@@ -610,6 +621,11 @@ async def disable_totp(
# Delete all backup codes for this user (including unused ones)
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
# Revoke other sessions; keep current one alive via skip_token_hash (CR-03)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: TOTP revoked event
await write_audit_log(
session,
@@ -618,10 +634,11 @@ async def disable_totp(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"message": "TOTP disabled"}
return {"message": "TOTP disabled", "sessions_revoked": revoked}
# ── POST /api/auth/password-reset ─────────────────────────────────────────────
+6 -1
View File
@@ -22,7 +22,7 @@ Usage in route handlers:
"""
import uuid
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,6 +36,7 @@ security = HTTPBearer()
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db),
) -> User:
@@ -72,6 +73,10 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Set on request.state so the per-account rate-limiter key_func (_account_key)
# can read it. The dependency resolves before @account_limiter.limit() calls
# key_func, so this must live here — not in the handler body (too late).
request.state.current_user = user
return user
+5 -8
View File
@@ -114,6 +114,10 @@ class DocuVaultUser(HttpUser):
wait_time = between(0.5, 2.0)
access_token: str = ""
# NOTE: /api/auth/refresh requires an httpOnly cookie that Locust cannot
# obtain without a full browser session. Removed from the task mix to avoid
# guaranteed 401s that inflate the fail_ratio and mask real regressions.
def on_start(self) -> None:
global _TOKEN_IDX
with _TOKEN_LOCK:
@@ -128,7 +132,7 @@ class DocuVaultUser(HttpUser):
def _auth_headers(self) -> dict:
return {"Authorization": f"Bearer {self.access_token}"}
@task(5)
@task(6)
def list_documents(self) -> None:
self.client.get("/api/documents/", headers=self._auth_headers(), name="GET /api/documents/")
@@ -159,13 +163,6 @@ class DocuVaultUser(HttpUser):
name="POST /api/documents/upload",
)
@task(1)
def refresh_token(self) -> None:
self.client.post(
"/api/auth/refresh",
headers=self._auth_headers(),
name="POST /api/auth/refresh",
)
@events.quitting.add_listener
+1 -1
View File
@@ -188,7 +188,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="1.0.0", lifespan=lifespan)
app = FastAPI(title="Document Scanner API", version="0.1.1", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
+9 -5
View File
@@ -216,17 +216,21 @@ async def rotate_refresh_token(
async def revoke_all_refresh_tokens(
session: AsyncSession, user_id: uuid.UUID
session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None
) -> int:
"""Mark all active refresh tokens for user_id as revoked.
Returns the count of revoked tokens (supports sign-out-all-devices).
skip_token_hash: if set, the token with this hash is excluded (keep current session alive).
"""
conditions = [
RefreshToken.user_id == user_id,
RefreshToken.revoked.is_(False),
]
if skip_token_hash is not None:
conditions.append(RefreshToken.token_hash != skip_token_hash)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked.is_(False),
)
select(RefreshToken).where(*conditions)
)
rows = result.scalars().all()
count = 0
+4 -3
View File
@@ -4,14 +4,15 @@ from __future__ import annotations
from fastapi import Request
from slowapi import Limiter
from deps.utils import get_client_ip
def _account_key(request: Request) -> str:
user = getattr(request.state, "current_user", None)
if user is not None:
return str(user.id)
if request.client:
return request.client.host
return "anonymous"
ip = get_client_ip(request)
return ip if ip is not None else "anonymous"
account_limiter = Limiter(key_func=_account_key)
+105 -1
View File
@@ -23,7 +23,7 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import BackupCode, Quota, User
from db.models import BackupCode, Quota, RefreshToken, User
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -496,3 +496,107 @@ async def test_patch_preferences_requires_auth(async_client):
json={"pdf_open_mode": "in_app"},
)
assert resp.status_code == 401
# ── Tests — sessions_revoked (CR-01, CR-02, CR-03) ───────────────────────────
@pytest.mark.asyncio
async def test_change_password_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""change_password revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="cpr1", email="cpr1@example.com")
login_resp = await _login(authed_client, email="cpr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "cpr1@example.com"))
user = result.scalar_one()
# Insert a second session token (the "other device") directly in the DB
await auth_service.create_refresh_token(db_session, user.id)
with patch("services.auth.check_hibp", return_value=False):
resp = await authed_client.post(
"/api/auth/change-password",
json={"current_password": "ValidPass12!", "new_password": "NewStrong99!@"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
@pytest.mark.asyncio
async def test_enable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""enable_totp revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="etr1", email="etr1@example.com")
login_resp = await _login(authed_client, email="etr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "etr1@example.com"))
user = result.scalar_one()
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
# Insert a second session token (the "other device")
await auth_service.create_refresh_token(db_session, user.id)
with patch("services.auth.verify_totp", return_value=True):
with patch("services.auth.store_backup_codes", return_value=None):
resp = await authed_client.post(
"/api/auth/totp/enable",
json={"code": "123456"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
@pytest.mark.asyncio
async def test_disable_totp_revokes_other_sessions(authed_client, db_session: AsyncSession):
"""disable_totp revokes other sessions and returns sessions_revoked >= 1."""
from services import auth as auth_service
await _register(authed_client, handle="dtr1", email="dtr1@example.com")
login_resp = await _login(authed_client, email="dtr1@example.com")
token = login_resp.json()["access_token"]
result = await db_session.execute(select(User).where(User.email == "dtr1@example.com"))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = "JBSWY3DPEHPK3PXP"
await db_session.commit()
# Insert a second session token (the "other device")
await auth_service.create_refresh_token(db_session, user.id)
resp = await authed_client.delete(
"/api/auth/totp",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["sessions_revoked"] >= 1
result2 = await db_session.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id)
)
rows = result2.scalars().all()
assert any(r.revoked for r in rows), "Expected at least one revoked RefreshToken row"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "document-scanner-frontend",
"version": "1.0.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -1,6 +1,27 @@
<template>
<div class="space-y-4">
<!-- Sessions-revoked inline alert -->
<div
v-if="sessionRevokedToast"
class="flex items-center gap-3 bg-white border border-green-200 rounded-xl px-5 py-4"
>
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="flex-1 text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
<button
@click="sessionRevokedToast = false"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Step: setup initial prompt to begin enrollment -->
<template v-if="step === 'setup'">
<div class="space-y-3">
@@ -125,6 +146,7 @@ const error = ref(null)
const loading = ref(false)
const verified = ref(false)
const secretCopied = ref(false)
const sessionRevokedToast = ref(false)
async function startSetup() {
loading.value = true
@@ -149,6 +171,10 @@ async function confirmEnrollment() {
const data = await api.totpEnable(verifyCode.value)
backupCodes.value = data.backup_codes
verified.value = true
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
// Brief success flash before transitioning to backup codes screen
setTimeout(() => {
step.value = 'backup-codes'
@@ -1,6 +1,29 @@
<template>
<div class="space-y-6">
<!-- Sessions-revoked toast (fixed top-right, auto-dismisses after 5s) -->
<div
v-if="sessionRevokedToast"
class="fixed top-4 right-4 z-50 flex items-center gap-3 bg-white border border-green-200 rounded-xl shadow-lg px-5 py-4 max-w-sm"
>
<svg class="w-5 h-5 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900">Other sessions have been terminated.</p>
</div>
<button
@click="sessionRevokedToast = false"
aria-label="Dismiss notification"
class="text-gray-400 hover:text-gray-600 shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 1. Account information -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
@@ -185,19 +208,24 @@ const newPassword = ref('')
const changingPassword = ref(false)
const passwordError = ref(null)
const passwordSuccess = ref(null)
const sessionRevokedToast = ref(false)
async function changePassword() {
changingPassword.value = true
passwordError.value = null
passwordSuccess.value = null
try {
await api.changePassword({
const data = await api.changePassword({
current_password: currentPassword.value,
new_password: newPassword.value,
})
passwordSuccess.value = 'Password updated.'
currentPassword.value = ''
newPassword.value = ''
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
} catch (e) {
const msg = e.message || ''
if (msg.toLowerCase().includes('current') || msg.toLowerCase().includes('incorrect')) {
@@ -226,11 +254,15 @@ function onTotpEnrolled() {
async function disableTotp() {
totpError.value = null
try {
await api.totpDisable()
const data = await api.totpDisable()
if (authStore.user) {
authStore.user.totp_enabled = false
}
confirmDisable2fa.value = false
if (data.sessions_revoked > 0) {
sessionRevokedToast.value = true
setTimeout(() => { sessionRevokedToast.value = false }, 5000)
}
} catch (e) {
totpError.value = e.message
}