From 26857413860cefcb498650488e77fea6117c2282 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 12 May 2026 18:01:04 +0800 Subject: [PATCH] feat: add permanent trust functionality for devices with corresponding API and UI updates --- src/handlers/devices.ts | 25 +++++++++++++++++++ src/router-devices.ts | 7 ++++++ src/services/storage-device-repo.ts | 15 +++++++++++ src/services/storage.ts | 5 ++++ webapp/src/App.tsx | 1 + webapp/src/components/AppMainRoutes.tsx | 2 ++ webapp/src/components/SecurityDevicesPage.tsx | 20 +++++++++++++-- webapp/src/hooks/useAccountSecurityActions.ts | 21 ++++++++++++++++ webapp/src/lib/api/auth.ts | 8 ++++++ webapp/src/lib/demo.ts | 8 ++++++ webapp/src/lib/i18n/locales/en.ts | 6 +++++ webapp/src/lib/i18n/locales/es.ts | 6 +++++ webapp/src/lib/i18n/locales/ru.ts | 6 +++++ webapp/src/lib/i18n/locales/zh-CN.ts | 6 +++++ webapp/src/lib/i18n/locales/zh-TW.ts | 6 +++++ 15 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index b11406e..e62f950 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -6,6 +6,8 @@ import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; import { generateUUID } from '../utils/uuid'; +const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59); + function normalizeIdentifier(value: string | null | undefined): string { return String(value || '').trim(); } @@ -268,6 +270,29 @@ export async function handleRevokeTrustedDevice( return jsonResponse({ success: true, removed }); } +// POST /api/devices/authorized/:deviceIdentifier/permanent +// Upgrades an existing active 2FA remember-token record to permanent trust. +export async function handleTrustDevicePermanently( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + void request; + const normalized = String(deviceIdentifier || '').trim(); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS); + if (!updated) return errorResponse('Device is not currently trusted', 409); + + return jsonResponse({ + success: true, + updated, + trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(), + }); +} + // DELETE /api/devices/:deviceIdentifier export async function handleDeleteDevice( request: Request, diff --git a/src/router-devices.ts b/src/router-devices.ts index dafb1ca..c6730c1 100644 --- a/src/router-devices.ts +++ b/src/router-devices.ts @@ -11,6 +11,7 @@ import { handleDeactivateDevice, handleRevokeAllTrustedDevices, handleRevokeTrustedDevice, + handleTrustDevicePermanently, handleDeleteAllDevices, handleDeleteDevice, handleUpdateDeviceName, @@ -44,6 +45,12 @@ export async function handleAuthenticatedDeviceRoute( return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); } + const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); + if (permanentAuthorizedDeviceMatch && method === 'POST') { + const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); + return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); + } + const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); if (deleteDeviceMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index a37c1b7..1a93cfd 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -233,6 +233,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI return Number(result.meta.changes ?? 0); } +export async function updateTrustedTwoFactorTokensExpiryByDevice( + db: D1Database, + userId: string, + deviceIdentifier: string, + expiresAtMs: number +): Promise { + const now = Date.now(); + await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run(); + const result = await db + .prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?') + .bind(expiresAtMs, userId, deviceIdentifier, now) + .run(); + return Number(result.meta.changes ?? 0); +} + export async function saveTrustedTwoFactorDeviceToken( db: D1Database, trustedTokenKey: TrustedTokenKeyFn, diff --git a/src/services/storage.ts b/src/services/storage.ts index 0a8cbde..51a2ec6 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -96,6 +96,7 @@ import { upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, + updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, } from './storage-device-repo'; import { ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, @@ -614,6 +615,10 @@ export class StorageService { return deleteStoredTrustedTokensByUserId(this.db, userId); } + async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise { + return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs); + } + // --- Trusted 2FA remember tokens (device-bound) --- async saveTrustedTwoFactorDeviceToken( diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 07d9b2f..c125d74 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1517,6 +1517,7 @@ export default function App() { onSaveDomainRules: handleSaveDomainRules, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, + onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently, onRemoveDevice: accountSecurityActions.openRemoveDevice, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 4fa84f9..ac7a7b6 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -116,6 +116,7 @@ export interface AppMainRoutesProps { onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; + onTrustDevicePermanently: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; onRevokeAllDeviceTrust: () => void; onRemoveAllDevices: () => void; @@ -322,6 +323,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onRefresh={() => void props.onRefreshAuthorizedDevices()} onRenameDevice={props.onRenameAuthorizedDevice} onRevokeTrust={props.onRevokeDeviceTrust} + onTrustPermanently={props.onTrustDevicePermanently} onRemoveDevice={props.onRemoveDevice} onRevokeAll={props.onRevokeAllDeviceTrust} onRemoveAll={props.onRemoveAllDevices} diff --git a/webapp/src/components/SecurityDevicesPage.tsx b/webapp/src/components/SecurityDevicesPage.tsx index fd62fed..f6b1199 100644 --- a/webapp/src/components/SecurityDevicesPage.tsx +++ b/webapp/src/components/SecurityDevicesPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'preact/hooks'; -import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; +import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import ConfirmDialog from '@/components/ConfirmDialog'; import LoadingState from '@/components/LoadingState'; import type { AuthorizedDevice } from '@/lib/types'; @@ -12,6 +12,7 @@ interface SecurityDevicesPageProps { onRefresh: () => void; onRenameDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeTrust: (device: AuthorizedDevice) => void; + onTrustPermanently: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; onRevokeAll: () => void; onRemoveAll: () => void; @@ -24,6 +25,12 @@ function formatDateTime(value: string | null | undefined): string { return date.toLocaleString(); } +function isPermanentTrust(value: string | null | undefined): boolean { + if (!value) return false; + const date = new Date(value); + return !Number.isNaN(date.getTime()) && date.getUTCFullYear() >= 2099; +} + function mapDeviceTypeName(type: number): string { switch (type) { case 0: return t('txt_android'); @@ -135,7 +142,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { {device.trusted ? (
- {formatDateTime(device.trustedUntil)} + {isPermanentTrust(device.trustedUntil) ? t('txt_permanent_trust') : formatDateTime(device.trustedUntil)}
) : ( {t('txt_not_trusted')} @@ -152,6 +159,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { {t('txt_untrust')} +