From 5f2f38da3e987ce68e864ae0c47778d82f30d64e Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 11 Sep 2026 15:38:41 +0800 Subject: [PATCH] feat: revoke other login sessions --- backend/main.py | 12 ++++++++++++ frontend/src/MvpPanel.vue | 10 +++++++++- frontend/src/style.css | 2 +- frontend/src/style.test.ts | 5 +++++ tests/test_app.py | 24 ++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 3ac7409..3dcf81b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -272,6 +272,18 @@ async def list_sessions( return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows] +@app.delete("/api/v1/sessions/others", status_code=204) +async def revoke_other_sessions( + token: str = Depends(session_token), + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + current_hash = hash_token(token) + await db.execute(delete(Session).where(Session.user_id == user.id, Session.token_hash != current_hash)) + await db.commit() + return Response(status_code=204) + + @app.delete("/api/v1/sessions/{session_id}", status_code=204) async def revoke_session( session_id: UUID, diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index 227ba83..369f0a9 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -511,6 +511,14 @@ async function loadSettings() { async function revoke(id: string) { await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') }) } +async function revokeOtherSessions() { + if (!confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')) return + await safe(async () => { + await request('/sessions/others', { method: 'DELETE' }) + await loadSettings() + emit('notice', '其他设备的登录会话已全部撤销') + }) +} function downloadBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } @@ -669,7 +677,7 @@ onBeforeUnmount(() => {

数据导出与恢复

导出完整 CSV 数据,或从 CSV / JSON 备份恢复。

修改密码

修改后当前设备保持登录,其他设备会自动退出。

-

登录会话

{{ s.current ? '当前设备' : '其他设备' }}{{ formatUserAgent(s.user_agent) }}

没有可显示的会话。

+

登录会话

可单独撤销设备,也可以一次撤销除当前设备外的全部会话。

{{ s.current ? '当前设备' : '其他设备' }}{{ formatUserAgent(s.user_agent) }}

没有可显示的会话。

最近活动

{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}
diff --git a/frontend/src/style.css b/frontend/src/style.css index cd1f12a..472c0d3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -31,7 +31,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto .habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}.habit-check{margin-left:-4px}.habit-row.done .habit-check .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.habit-detail-mask{position:fixed;z-index:85;inset:0;background:rgba(45,38,31,.36);display:grid;place-items:end center;padding:20px}.habit-detail-sheet{width:min(500px,100%);background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.habit-detail-sheet header{display:flex;align-items:center;justify-content:space-between;gap:10px}.habit-detail-sheet header small{color:var(--muted);font-size:11px}.habit-detail-sheet header h3{margin:2px 0 0;font-size:19px}.habit-detail-sheet header button{width:44px;height:44px;border:0;background:transparent;display:grid;place-items:center}.habit-detail-progress{display:flex;justify-content:space-between;align-items:center;padding:14px 0;border-block:1px solid var(--line);font-size:12px;color:var(--muted)}.habit-detail-progress strong{font-size:14px;color:#3c372f}.habit-detail-sheet footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}.habit-detail-sheet .habit-delete-button{font-weight:750} .empty-panel{text-align:center;color:var(--muted);display:grid;place-items:center;gap:10px}.today-empty-panel{min-height:130px}.empty-action{margin-top:4px;color:#655d52}.empty-action svg{width:15px;height:15px} .field-error{display:block;color:var(--danger);font-size:12px;line-height:1.45;overflow-wrap:anywhere}.habit-state-note{color:var(--muted);font-size:11px}.habit-weekdays{min-width:0;border:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.habit-weekdays legend{width:100%;font-size:12px;font-weight:650}.habit-weekdays label{min-height:44px;display:flex;align-items:center;gap:4px}.habit-compose-sheet input,.habit-compose-sheet select{max-width:100%}.task-check:disabled{cursor:not-allowed;opacity:.55} -.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>h2{margin:0;font-size:1.17em}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700} +.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>h2{margin:0;font-size:1.17em}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.session-card-actions{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px}.session-card-actions p{min-width:0}.session-revoke-all{min-height:44px;flex:0 0 auto}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700} @media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}} @media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}} @media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 24eea7d..d89eae8 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -129,6 +129,11 @@ describe('settings sessions and audit activity', () => { expect(mvpPanel).toContain(':datetime="s.last_seen_at ?? s.created_at"') expect(mvpPanel).toContain(':aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`"') expect(mvpPanel).toContain('class="danger-text session-revoke"') + expect(mvpPanel).toContain('class="session-card-actions"') + expect(mvpPanel).toContain('撤销其他所有会话') + expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })") + expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')") + expect(css).toContain('.session-card-actions{') }) it('does not expose the raw user agent in session DOM attributes', () => { diff --git a/tests/test_app.py b/tests/test_app.py index 4a87a5c..5e4871e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -174,6 +174,30 @@ def test_logout_revokes_current_session(client): assert client.get("/api/v1/me").status_code == 401 +def test_revoke_other_sessions_keeps_current_session(client): + password = "correct horse battery staple" + client.post("/api/v1/setup/initialize", json={"username": "owner", "password": password}) + other = type(client)(client.app) + third = type(client)(client.app) + try: + assert other.post("/api/v1/auth/login", json={"username": "owner", "password": password}).status_code == 200 + assert third.post("/api/v1/auth/login", json={"username": "owner", "password": password}).status_code == 200 + assert len(client.get("/api/v1/sessions").json()) == 3 + + assert client.delete("/api/v1/sessions/others").status_code == 204 + + remaining = client.get("/api/v1/sessions") + assert remaining.status_code == 200 + assert len(remaining.json()) == 1 + assert remaining.json()[0]["current"] is True + assert client.get("/api/v1/me").status_code == 200 + assert other.get("/api/v1/me").status_code == 401 + assert third.get("/api/v1/me").status_code == 401 + finally: + other.close() + third.close() + + def test_change_password_checks_current_password_and_revokes_other_sessions(client): old_password = "correct horse battery staple" new_password = "new correct horse battery staple"