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"