diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index eea33c3..a901d2e 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -2,6 +2,7 @@ import { Env } from '../types'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; +import { generateUUID } from '../utils/uuid'; // GET /api/devices/knowndevice // Compatible with Bitwarden/Vaultwarden behavior: @@ -133,10 +134,29 @@ export async function handleDeleteDevice( const storage = new StorageService(env.DB); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); + await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); return jsonResponse({ success: deleted }); } +// DELETE /api/devices +export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ + storage.deleteTrustedTwoFactorTokensByUserId(userId), + storage.deleteRefreshTokensByUserId(userId), + storage.deleteDevicesByUserId(userId), + ]); + user.securityStamp = generateUUID(); + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); +} + // PUT /api/devices/identifier/{deviceIdentifier}/token // Bitwarden mobile reports push token updates to this endpoint. // NodeWarden does not implement push notifications, so accept and no-op. diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index c7f57ad..4eb6711 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -8,6 +8,7 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; +import { generateUUID } from '../utils/uuid'; import { issueSendAccessToken } from './sends'; import { buildAccountKeys, @@ -227,15 +228,25 @@ export async function handleToken(request: Request, env: Env): Promise } // Persist device only after successful password + (optional) 2FA verification. - if (deviceInfo.deviceIdentifier) { - await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType); + const deviceSession = + deviceInfo.deviceIdentifier + ? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() } + : null; + if (deviceSession) { + await storage.upsertDevice( + user.id, + deviceSession.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + deviceSession.sessionStamp + ); } // Successful login - clear failed attempts await rateLimit.clearLoginAttempts(loginIdentifier); - const accessToken = await auth.generateAccessToken(user); - const refreshToken = await auth.generateRefreshToken(user.id); + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const response: TokenResponse = { access_token: accessToken, @@ -346,8 +357,8 @@ export async function handleToken(request: Request, env: Env): Promise Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs ); - const { accessToken, user } = result; - const newRefreshToken = await auth.generateRefreshToken(user.id); + const { accessToken, user, device } = result; + const newRefreshToken = await auth.generateRefreshToken(user.id, device); const response: TokenResponse = { access_token: accessToken, diff --git a/src/router.ts b/src/router.ts index 1b6fbc0..ee92bdd 100644 --- a/src/router.ts +++ b/src/router.ts @@ -75,6 +75,7 @@ import { handleGetDevices, handleRevokeAllTrustedDevices, handleRevokeTrustedDevice, + handleDeleteAllDevices, handleDeleteDevice, handleUpdateDeviceToken } from './handlers/devices'; @@ -750,8 +751,9 @@ export async function handleRequest(request: Request, env: Env): Promise { + async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise { return createJWT( { sub: user.id, email: user.email, name: user.name, sstamp: user.securityStamp, + ...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}), }, this.env.JWT_SECRET ); } // Generate refresh token - async generateRefreshToken(userId: string): Promise { + async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise { const token = createRefreshToken(); - await this.storage.saveRefreshToken(token, userId); + await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); return token; } @@ -100,22 +101,44 @@ export class AuthService { return null; // Token was issued before password change } + if (payload.did) { + const device = await this.storage.getDevice(user.id, payload.did); + if (!device) return null; + if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null; + } + return payload; } // Refresh access token - async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> { - const userId = await this.storage.getRefreshTokenUserId(refreshToken); - if (!userId) return null; + async refreshAccessToken( + refreshToken: string + ): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> { + const record = await this.storage.getRefreshTokenRecord(refreshToken); + if (!record?.userId) return null; - const user = await this.storage.getUserById(userId); + const user = await this.storage.getUserById(record.userId); if (!user) return null; if (user.status !== 'active') { await this.storage.deleteRefreshToken(refreshToken); return null; } - const accessToken = await this.generateAccessToken(user); - return { accessToken, user }; + let device: { identifier: string; sessionStamp: string } | null = null; + if (record.deviceIdentifier) { + const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); + if (!boundDevice) { + await this.storage.deleteRefreshToken(refreshToken); + return null; + } + if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) { + await this.storage.deleteRefreshToken(refreshToken); + return null; + } + device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; + } + + const accessToken = await this.generateAccessToken(user, device); + return { accessToken, user, device }; } } diff --git a/src/services/storage.ts b/src/services/storage.ts index 01842a7..2320d45 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,4 @@ -import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary } from '../types'; +import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; import { LIMITS } from '../config/limits'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -52,9 +52,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE sends ADD COLUMN emails TEXT', 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + - 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', + 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', + 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'CREATE TABLE IF NOT EXISTS invites (' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + @@ -70,11 +72,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE TABLE IF NOT EXISTS devices (' + - 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' + + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'PRIMARY KEY (user_id, device_identifier), ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)', + 'ALTER TABLE devices ADD COLUMN session_stamp TEXT', + 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + @@ -749,40 +754,57 @@ export class StorageService { // --- Refresh tokens --- - async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { + async saveRefreshToken( + token: string, + userId: string, + expiresAtMs?: number, + deviceIdentifier?: string | null, + deviceSessionStamp?: string | null + ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); await this.maybeCleanupExpiredRefreshTokens(Date.now()); const tokenKey = await this.refreshTokenKey(token); await this.db.prepare( - 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + - 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' ) - .bind(tokenKey, userId, expiresAt) + .bind(tokenKey, userId, expiresAt, deviceIdentifier ?? null, deviceSessionStamp ?? null) .run(); } - async getRefreshTokenUserId(token: string): Promise { + async getRefreshTokenRecord(token: string): Promise { const now = Date.now(); await this.maybeCleanupExpiredRefreshTokens(now); const tokenKey = await this.refreshTokenKey(token); - let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') + let row = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .bind(tokenKey) - .first<{ user_id: string; expires_at: number }>(); + .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); if (!row) { - const legacyRow = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') + const legacyRow = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .bind(token) - .first<{ user_id: string; expires_at: number }>(); + .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); if (legacyRow) { if (legacyRow.expires_at && legacyRow.expires_at < now) { await this.deleteRefreshToken(token); return null; } - await this.saveRefreshToken(token, legacyRow.user_id, legacyRow.expires_at); + await this.saveRefreshToken( + token, + legacyRow.user_id, + legacyRow.expires_at, + legacyRow.device_identifier ?? null, + legacyRow.device_session_stamp ?? null + ); await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); - return legacyRow.user_id; + return { + userId: legacyRow.user_id, + expiresAt: legacyRow.expires_at, + deviceIdentifier: legacyRow.device_identifier ?? null, + deviceSessionStamp: legacyRow.device_session_stamp ?? null, + }; } } @@ -791,7 +813,17 @@ export class StorageService { await this.deleteRefreshToken(token); return null; } - return row.user_id; + return { + userId: row.user_id, + expiresAt: row.expires_at, + deviceIdentifier: row.device_identifier ?? null, + deviceSessionStamp: row.device_session_stamp ?? null, + }; + } + + async getRefreshTokenUserId(token: string): Promise { + const record = await this.getRefreshTokenRecord(token); + return record?.userId ?? null; } async deleteRefreshToken(token: string): Promise { @@ -915,8 +947,17 @@ export class StorageService { return (res.results || []).map(row => this.mapSendRow(row)); } - async deleteRefreshTokensByUserId(userId: string): Promise { - await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); + async deleteRefreshTokensByUserId(userId: string): Promise { + const result = await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); + return Number(result.meta.changes ?? 0); + } + + async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise { + const result = await this.db + .prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?') + .bind(userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0); } // Keep a short overlap window for rotated refresh token to reduce @@ -946,13 +987,14 @@ export class StorageService { // --- Devices --- - async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise { + async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise { const now = new Date().toISOString(); + const effectiveSessionStamp = String(sessionStamp || '').trim() || (await this.getDevice(userId, deviceIdentifier))?.sessionStamp || ''; await this.db.prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at' ) - .bind(userId, deviceIdentifier, name, type, now, now) + .bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now) .run(); } @@ -973,7 +1015,7 @@ export class StorageService { async getDevicesByUserId(userId: string): Promise { const res = await this.db .prepare( - 'SELECT user_id, device_identifier, name, type, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' ) .bind(userId) @@ -983,11 +1025,32 @@ export class StorageService { deviceIdentifier: row.device_identifier, name: row.name, type: row.type, + sessionStamp: row.session_stamp || '', createdAt: row.created_at, updatedAt: row.updated_at, })); } + async getDevice(userId: string, deviceIdentifier: string): Promise { + const row = await this.db + .prepare( + 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' + ) + .bind(userId, deviceIdentifier) + .first(); + if (!row) return null; + return { + userId: row.user_id, + deviceIdentifier: row.device_identifier, + name: row.name, + type: row.type, + sessionStamp: row.session_stamp || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + async deleteDevice(userId: string, deviceIdentifier: string): Promise { const result = await this.db .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') @@ -996,6 +1059,14 @@ export class StorageService { return Number(result.meta.changes ?? 0) > 0; } + async deleteDevicesByUserId(userId: string): Promise { + const result = await this.db + .prepare('DELETE FROM devices WHERE user_id = ?') + .bind(userId) + .run(); + return Number(result.meta.changes ?? 0); + } + async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise { const now = Date.now(); await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run(); diff --git a/src/types/index.ts b/src/types/index.ts index b521fb4..71dd79d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -183,10 +183,18 @@ export interface Device { deviceIdentifier: string; name: string; type: number; + sessionStamp: string; createdAt: string; updatedAt: string; } +export interface RefreshTokenRecord { + userId: string; + expiresAt: number; + deviceIdentifier: string | null; + deviceSessionStamp: string | null; +} + export interface TrustedDeviceTokenSummary { deviceIdentifier: string; expiresAt: number; @@ -257,6 +265,8 @@ export interface JWTPayload { email_verified: boolean; // required by mobile client amr: string[]; // authentication methods reference - required by mobile client sstamp: string; // security stamp - invalidates token when user changes password + did?: string; // device identifier - invalidates per-device sessions + dstamp?: string; // device session stamp iat: number; exp: number; iss: string; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 18208ee..59c3389 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -43,6 +43,7 @@ import { getPreloginKdfConfig, getProfile, getAuthorizedDevices, + getCurrentDeviceIdentifier, getSetupStatus, getSends, getTotpStatus, @@ -60,6 +61,7 @@ import { saveSession, setTotp, setUserStatus, + deleteAllAuthorizedDevices, deleteAuthorizedDevice, uploadCipherAttachment, updateCipher, @@ -969,10 +971,21 @@ export default function App() { async function removeDeviceAction(device: AuthorizedDevice) { await deleteAuthorizedDevice(authedFetch, device.identifier); + if (device.identifier === getCurrentDeviceIdentifier()) { + pushToast('success', t('txt_device_removed')); + logoutNow(); + return; + } await authorizedDevicesQuery.refetch(); pushToast('success', t('txt_device_removed')); } + async function removeAllDevicesAction() { + await deleteAllAuthorizedDevices(authedFetch); + pushToast('success', t('txt_all_devices_removed')); + logoutNow(); + } + async function createVaultItem(draft: VaultDraft, attachments: File[] = []) { if (!session) return; try { @@ -2004,7 +2017,7 @@ export default function App() { onRemoveDevice={(device) => { setConfirm({ title: t('txt_remove_device'), - message: t('txt_remove_device_name_and_clear_its_2fa_trust', { name: device.name }), + message: t('txt_remove_device_and_sign_out_name', { name: device.name }), danger: true, onConfirm: () => { setConfirm(null); @@ -2023,6 +2036,17 @@ export default function App() { }, }); }} + onRemoveAll={() => { + setConfirm({ + title: t('txt_remove_all_devices'), + message: t('txt_remove_all_devices_and_sign_out_all_sessions'), + danger: true, + onConfirm: () => { + setConfirm(null); + void removeAllDevicesAction(); + }, + }); + }} /> diff --git a/webapp/src/components/SecurityDevicesPage.tsx b/webapp/src/components/SecurityDevicesPage.tsx index 7c76b33..f01e8c7 100644 --- a/webapp/src/components/SecurityDevicesPage.tsx +++ b/webapp/src/components/SecurityDevicesPage.tsx @@ -9,6 +9,7 @@ interface SecurityDevicesPageProps { onRevokeTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; onRevokeAll: () => void; + onRemoveAll: () => void; } function formatDateTime(value: string | null | undefined): string { @@ -47,7 +48,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {

{t('txt_device_management')}

- {t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')} + {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
@@ -59,6 +60,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { {t('txt_revoke_all_trusted')} +
diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 8981455..db51db7 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -119,6 +119,10 @@ function getOrCreateDeviceIdentifier(): string { return next; } +export function getCurrentDeviceIdentifier(): string { + return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); +} + function guessDeviceName(): string { const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase(); const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim(); @@ -772,6 +776,13 @@ export async function deleteAuthorizedDevice( if (!resp.ok) throw new Error('Failed to remove device'); } +export async function deleteAllAuthorizedDevices( + authedFetch: (input: string, init?: RequestInit) => Promise +): Promise { + const resp = await authedFetch('/api/devices', { method: 'DELETE' }); + if (!resp.ok) throw new Error('Failed to remove all devices'); +} + export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { const resp = await authedFetch('/api/admin/users'); if (!resp.ok) throw new Error('Failed to load users'); diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 28c37c4..1a75688 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -232,6 +232,7 @@ const messages: Record> = { txt_login_success: "Login success", txt_macos_desktop: "macOS Desktop", txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: "Manage authorized devices and 30-day TOTP trusted sessions.", + txt_manage_device_sessions_and_30_day_totp_trusted_sessions: "Manage device sessions and 30-day TOTP trusted sessions.", txt_master_password: "Master Password", txt_master_password_changed_please_login_again: "Master password changed. Please login again.", txt_master_password_is_required: "Master password is required", @@ -301,7 +302,11 @@ const messages: Record> = { txt_ignore: "Ignore", txt_remove_device: "Remove device", txt_remove_device_2: "Remove Device", + txt_remove_all_devices: "Remove all devices", + txt_remove_all_devices_and_clear_all_2fa_trust: "Remove all devices and clear all 2FA trust?", + txt_remove_all_devices_and_sign_out_all_sessions: "Remove all devices, clear all trust, and sign out every device?", txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?", + txt_remove_device_and_sign_out_name: "Remove device \"{name}\", clear its trust, and sign it out?", txt_reveal: "Reveal", txt_revoke: "Revoke", txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?", @@ -384,6 +389,7 @@ const messages: Record> = { txt_unlock_vault: "Unlock Vault", txt_unignore: "Unignore", txt_unlocked: "Unlocked", + txt_all_devices_removed: "All devices removed", txt_update_item_failed: "Update item failed", txt_update_send_failed: "Update send failed", txt_use_recovery_code: "Use Recovery Code", @@ -610,6 +616,7 @@ const zhCNOverrides: Record = { txt_copy_secret: '复制密钥', txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: '这是一次性恢复代码,使用后将自动生成新的恢复代码。', txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: '管理已授权设备和 30 天 TOTP 受信会话。', + txt_manage_device_sessions_and_30_day_totp_trusted_sessions: '管理设备会话和 30 天 TOTP 受信状态。', txt_role: '角色', txt_status: '状态', txt_actions: '操作', @@ -619,6 +626,10 @@ const zhCNOverrides: Record = { txt_revoke_30_day_totp_trust_from_all_devices: '确认撤销所有设备的 30 天 TOTP 信任吗?', txt_revoke_30_day_totp_trust_for_name: '确认撤销“{name}”的 30 天 TOTP 信任吗?', txt_remove_device_name_and_clear_its_2fa_trust: '确认移除设备“{name}”并清除其 2FA 信任吗?', + txt_remove_all_devices: '移除所有设备', + txt_remove_all_devices_and_clear_all_2fa_trust: '确认移除所有设备并清除全部 2FA 信任吗?', + txt_remove_all_devices_and_sign_out_all_sessions: '确认移除所有设备、清除全部信任,并让所有设备重新登录吗?', + txt_remove_device_and_sign_out_name: '确认移除设备“{name}”、清除其信任,并让它重新登录吗?', txt_role_admin: '管理员', txt_role_user: '用户', txt_status_active: '正常', @@ -766,6 +777,7 @@ const zhCNOverrides: Record = { txt_unlock_failed: '解锁失败', txt_unlock_failed_master_password_is_incorrect: '解锁失败,主密码不正确。', txt_unlocked: '已解锁', + txt_all_devices_removed: '已移除所有设备', txt_update_item_failed: '更新项目失败', txt_update_send_failed: '更新发送失败', txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',