feat: add permanent trust functionality for devices with corresponding API and UI updates

This commit is contained in:
shuaiplus
2026-05-12 18:01:04 +08:00
parent 83a1fc2376
commit 2685741386
15 changed files with 140 additions and 2 deletions
+25
View File
@@ -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<Response> {
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,
+7
View File
@@ -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]);
+15
View File
@@ -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<number> {
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,
+5
View File
@@ -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<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken(
+1
View File
@@ -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,
+2
View File
@@ -116,6 +116,7 @@ export interface AppMainRoutesProps {
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
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}
+18 -2
View File
@@ -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<void>;
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 ? (
<div className="trusted-cell">
<Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span>
<span>{isPermanentTrust(device.trustedUntil) ? t('txt_permanent_trust') : formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">{t('txt_not_trusted')}</span>
@@ -152,6 +159,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<ShieldOff size={14} className="btn-icon" />
{t('txt_untrust')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted || !device.trustedUntil || isPermanentTrust(device.trustedUntil)}
onClick={() => props.onTrustPermanently(device)}
>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_trust_permanently')}
</button>
<button
type="button"
className="btn btn-secondary small"
@@ -11,6 +11,7 @@ import {
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
setTotp,
trustAuthorizedDevicePermanently,
updateAuthorizedDeviceName,
updateProfile,
} from '@/lib/api/auth';
@@ -208,6 +209,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
});
},
openTrustDevicePermanently(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_trust_device_permanently'),
message: t('txt_trust_device_permanently_for_name', { name: device.name }),
danger: false,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await trustAuthorizedDevicePermanently(authedFetch, device.identifier);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_trusted_permanently'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_trust_device_permanently_failed'));
}
})();
},
});
},
openRemoveDevice(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_remove_device'),
+8
View File
@@ -667,6 +667,14 @@ export async function revokeAuthorizedDeviceTrust(
if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed'));
}
export async function trustAuthorizedDevicePermanently(
authedFetch: AuthedFetch,
deviceIdentifier: string
): Promise<void> {
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}/permanent`, { method: 'POST' });
if (!resp.ok) throw new Error(t('txt_trust_device_permanently_failed'));
}
export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed'));
+8
View File
@@ -1083,6 +1083,14 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
)));
notify('success', t('txt_device_authorization_revoked'));
},
onTrustDevicePermanently: (device) => {
state.setAuthorizedDevices((prev) => prev.map((item) => (
item.identifier === device.identifier && item.trusted
? { ...item, trustedUntil: '2099-12-31T23:59:59.000Z', revisionDate: new Date().toISOString() }
: item
)));
notify('success', t('txt_device_trusted_permanently'));
},
onRemoveDevice: (device) => {
state.setAuthorizedDevices((prev) => prev.filter((item) => item.identifier !== device.identifier));
notify('success', t('txt_device_removed'));
+6
View File
@@ -690,6 +690,12 @@ const en: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Failed to revoke all device trust",
"txt_revoke_trust": "Revoke Trust",
"txt_untrust": "Untrust",
"txt_trust_permanently": "Trust permanently",
"txt_trust_device_permanently": "Trust device permanently",
"txt_trust_device_permanently_for_name": "Upgrade \"{name}\" from 30-day trust to permanent trust?",
"txt_trust_device_permanently_failed": "Failed to trust device permanently",
"txt_device_trusted_permanently": "Device trusted permanently",
"txt_permanent_trust": "Permanent trust",
"txt_update_device_note_failed": "Update device note failed",
"txt_role": "Role",
"txt_save": "Save",
+6
View File
@@ -690,6 +690,12 @@ const es: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Error al revocar la confianza de todos los dispositivos",
"txt_revoke_trust": "Revocar confianza",
"txt_untrust": "Quitar confianza",
"txt_trust_permanently": "Confiar permanentemente",
"txt_trust_device_permanently": "Confiar permanentemente en el dispositivo",
"txt_trust_device_permanently_for_name": "¿Actualizar \"{name}\" de confianza de 30 días a confianza permanente?",
"txt_trust_device_permanently_failed": "Error al confiar permanentemente en el dispositivo",
"txt_device_trusted_permanently": "Dispositivo confiado permanentemente",
"txt_permanent_trust": "Confianza permanente",
"txt_update_device_note_failed": "Error al actualizar la nota del dispositivo",
"txt_role": "Rol",
"txt_save": "Guardar",
+6
View File
@@ -690,6 +690,12 @@ const ru: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "Не удалось отозвать все доверие устройств.",
"txt_revoke_trust": "Отозвать доверие",
"txt_untrust": "Не доверять",
"txt_trust_permanently": "Доверять постоянно",
"txt_trust_device_permanently": "Постоянно доверять устройству",
"txt_trust_device_permanently_for_name": "Повысить доверие к «{name}» с 30 дней до постоянного?",
"txt_trust_device_permanently_failed": "Не удалось постоянно доверять устройству.",
"txt_device_trusted_permanently": "Устройство постоянно доверено",
"txt_permanent_trust": "Постоянное доверие",
"txt_update_device_note_failed": "Не удалось обновить примечание об устройстве.",
"txt_role": "Роль",
"txt_save": "Сохранить",
+6
View File
@@ -690,6 +690,12 @@ const zhCN: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "撤销所有设备信任失败",
"txt_revoke_trust": "撤销信任",
"txt_untrust": "不信任",
"txt_trust_permanently": "永久信任",
"txt_trust_device_permanently": "永久信任设备",
"txt_trust_device_permanently_for_name": "确认把“{name}”从 30 天信任升级为永久信任吗?",
"txt_trust_device_permanently_failed": "永久信任设备失败",
"txt_device_trusted_permanently": "设备已永久信任",
"txt_permanent_trust": "永久信任",
"txt_update_device_note_failed": "更新设备备注失败",
"txt_role": "角色",
"txt_save": "保存",
+6
View File
@@ -690,6 +690,12 @@ const zhTW: Record<string, string> = {
"txt_revoke_all_device_trust_failed": "撤銷所有設備信任失敗",
"txt_revoke_trust": "撤銷信任",
"txt_untrust": "不信任",
"txt_trust_permanently": "永久信任",
"txt_trust_device_permanently": "永久信任設備",
"txt_trust_device_permanently_for_name": "確認把“{name}”從 30 天信任升級為永久信任嗎?",
"txt_trust_device_permanently_failed": "永久信任設備失敗",
"txt_device_trusted_permanently": "設備已永久信任",
"txt_permanent_trust": "永久信任",
"txt_update_device_note_failed": "更新設備備註失敗",
"txt_role": "角色",
"txt_save": "保存",