diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 3d1da10..20ffa9b 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -153,12 +153,15 @@ CREATE TABLE IF NOT EXISTS devices ( encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, + device_note TEXT, + last_seen_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); +CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( token TEXT PRIMARY KEY, diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 95ef5de..77e5269 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick { } } +function parseDeviceName(value: unknown): string { + return String(value || '').trim().slice(0, 128); +} + // GET /api/devices/knowndevice // Compatible with Bitwarden/Vaultwarden behavior: // - X-Request-Email: base64url(email) without padding @@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use encryptedPublicKey: null, encryptedPrivateKey: null, devicePendingAuthRequest: null, + deviceNote: null, + lastSeenAt: null, createdAt: '', updatedAt: '', }; data.push({ ...buildDeviceResponse(placeholderDevice), isTrusted: true, + hasStoredDevice: false, online: onlineSet.has(row.deviceIdentifier), trusted: true, trustedTokenCount: row.tokenCount, @@ -269,6 +289,29 @@ export async function handleDeleteDevice( return jsonResponse({ success: deleted }); } +// PUT /api/devices/:deviceIdentifier/name +export async function handleUpdateDeviceName( + request: Request, + env: Env, + userId: string, + deviceIdentifier: string +): Promise { + const normalized = String(deviceIdentifier || '').trim(); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const body = await readJsonBody(request); + const name = parseDeviceName(body?.name); + if (!name) return errorResponse('Device name is required', 400); + + const storage = new StorageService(env.DB); + const updated = await storage.updateDeviceName(userId, normalized, name); + if (!updated) return errorResponse('Device not found', 404); + + const device = await storage.getDevice(userId, normalized); + if (!device) return errorResponse('Device not found', 404); + return jsonResponse(buildDeviceResponse(device)); +} + // DELETE /api/devices export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise { void request; diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 271535b..ceb7be1 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -450,6 +450,9 @@ export async function handleToken(request: Request, env: Env): Promise ); const { accessToken, user, device } = result; + if (device?.identifier) { + await storage.touchDeviceLastSeen(user.id, device.identifier); + } const newRefreshToken = await auth.generateRefreshToken(user.id, device); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); diff --git a/src/router-devices.ts b/src/router-devices.ts index e4d7ea6..dafb1ca 100644 --- a/src/router-devices.ts +++ b/src/router-devices.ts @@ -13,6 +13,7 @@ import { handleRevokeTrustedDevice, handleDeleteAllDevices, handleDeleteDevice, + handleUpdateDeviceName, handleUpdateDeviceToken, handleUpdateDeviceWebPushAuth, handleClearDeviceToken, @@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute( return handleDeleteDevice(request, env, userId, deviceIdentifier); } + const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); + if (updateDeviceNameMatch && method === 'PUT') { + const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); + return handleUpdateDeviceName(request, env, userId, deviceIdentifier); + } + const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); if (identifierMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(identifierMatch[1]); diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index 522a055..a37c1b7 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device { userId: row.user_id, deviceIdentifier: row.device_identifier, name: row.name, + deviceNote: row.device_note ?? null, type: row.type, sessionStamp: row.session_stamp || '', encryptedUserKey: row.encrypted_user_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null, + lastSeenAt: row.last_seen_at ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -33,31 +35,62 @@ export async function upsertDevice( } ): Promise { const now = new Date().toISOString(); - const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; + const existingDevice = await getDeviceById(userId, deviceIdentifier); + const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; + const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); await db .prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_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, ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + + 'last_seen_at=excluded.last_seen_at, ' + 'updated_at=excluded.updated_at' ) .bind( userId, deviceIdentifier, - name, + effectiveName, type, effectiveSessionStamp, keys?.encryptedUserKey ?? null, keys?.encryptedPublicKey ?? null, keys?.encryptedPrivateKey ?? null, + existingDevice?.deviceNote ?? null, + now, now, now ) .run(); } +export async function updateDeviceName( + db: D1Database, + userId: string, + deviceIdentifier: string, + name: string +): Promise { + const result = await db + .prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?') + .bind(String(name || '').trim(), userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function touchDeviceLastSeen( + db: D1Database, + userId: string, + deviceIdentifier: string +): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?') + .bind(now, userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + export async function updateDeviceKeys( db: D1Database, userId: string, @@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail( export async function getDevicesByUserId(db: D1Database, userId: string): Promise { const res = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + - 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + + 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' ) .bind(userId) .all(); @@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const row = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' ) .bind(userId, deviceIdentifier) diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index b29e9b1..6a73b42 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -73,7 +73,7 @@ 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, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_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)', @@ -84,6 +84,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned_at TEXT', + 'ALTER TABLE devices ADD COLUMN device_note TEXT', + 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', + 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', '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, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index da6a12c..ec32f82 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -92,7 +92,9 @@ import { isKnownDevice as getKnownStoredDevice, isKnownDeviceByEmail as getKnownStoredDeviceByEmail, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, + touchDeviceLastSeen as touchStoredDeviceLastSeen, upsertDevice as saveStoredDevice, + updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, } from './storage-device-repo'; import { @@ -106,7 +108,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-03-30.1'; +const STORAGE_SCHEMA_VERSION = '2026-04-18.1'; // D1-backed storage. // Contract: @@ -550,6 +552,14 @@ export class StorageService { return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); } + async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise { + return updateStoredDeviceName(this.db, userId, deviceIdentifier, name); + } + + async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise { + return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); + } + async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise { return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); } diff --git a/src/types/index.ts b/src/types/index.ts index b017d3b..f188431 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -189,12 +189,14 @@ export interface Device { userId: string; deviceIdentifier: string; name: string; + deviceNote: string | null; type: number; sessionStamp: string; encryptedUserKey: string | null; encryptedPublicKey: string | null; encryptedPrivateKey: string | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null; + lastSeenAt: string | null; createdAt: string; updatedAt: string; } @@ -208,10 +210,14 @@ export interface DeviceResponse { id: string; userId?: string | null; name: string; + systemName?: string | null; + deviceNote?: string | null; identifier: string; type: number; creationDate: string; revisionDate: string; + lastSeenAt?: string | null; + hasStoredDevice?: boolean; isTrusted: boolean; encryptedUserKey: string | null; encryptedPublicKey: string | null; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0e83f62..f93670d 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1196,6 +1196,7 @@ export default function App() { onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, + onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRemoveDevice: accountSecurityActions.openRemoveDevice, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index f16edba..de8b18f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -95,6 +95,7 @@ export interface AppMainRoutesProps { onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; onRefreshAuthorizedDevices: () => Promise; + onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; onRevokeAllDeviceTrust: () => void; @@ -281,6 +282,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { devices={props.authorizedDevices} loading={props.authorizedDevicesLoading} onRefresh={() => void props.onRefreshAuthorizedDevices()} + onRenameDevice={props.onRenameAuthorizedDevice} onRevokeTrust={props.onRevokeDeviceTrust} onRemoveDevice={props.onRemoveDevice} onRevokeAll={props.onRevokeAllDeviceTrust} diff --git a/webapp/src/components/SecurityDevicesPage.tsx b/webapp/src/components/SecurityDevicesPage.tsx index 8e0bdb6..5a04521 100644 --- a/webapp/src/components/SecurityDevicesPage.tsx +++ b/webapp/src/components/SecurityDevicesPage.tsx @@ -1,4 +1,6 @@ -import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; +import { useState } from 'preact/hooks'; +import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; +import ConfirmDialog from '@/components/ConfirmDialog'; import type { AuthorizedDevice } from '@/lib/types'; import { t } from '@/lib/i18n'; @@ -6,6 +8,7 @@ interface SecurityDevicesPageProps { devices: AuthorizedDevice[]; loading: boolean; onRefresh: () => void; + onRenameDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; onRevokeAll: () => void; @@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string { } export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { + const [editingDevice, setEditingDevice] = useState(null); + const [deviceNote, setDeviceNote] = useState(''); + const [savingNote, setSavingNote] = useState(false); + + async function handleSaveDeviceNote(): Promise { + if (!editingDevice || savingNote) return; + setSavingNote(true); + try { + await props.onRenameDevice(editingDevice, deviceNote); + setEditingDevice(null); + setDeviceNote(''); + } finally { + setSavingNote(false); + } + } + return ( -
-
+ <> +
+

{t('txt_device_management')}

@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
-
+
-
+

{t('txt_authorized_devices')}

@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { @@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { - + @@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { )}
{device.name || t('txt_unknown_device')}
+ {!!device.deviceNote && !!device.systemName && device.systemName !== device.name && ( +
{device.systemName}
+ )}
{device.identifier}
{mapDeviceTypeName(device.type)} {formatDateTime(device.creationDate)}{formatDateTime(device.revisionDate)}{formatDateTime(device.lastSeenAt || device.revisionDate)} {device.trusted ? (
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { onClick={() => props.onRevokeTrust(device)} > - {t('txt_revoke_trust')} + {t('txt_untrust')} - +
-
-
+ + + + void handleSaveDeviceNote()} + onCancel={() => { + if (savingNote) return; + setEditingDevice(null); + setDeviceNote(''); + }} + > + + + ); } diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 688c241..6b46ca4 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -9,6 +9,7 @@ import { revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, setTotp, + updateAuthorizedDeviceName, updateProfile, } from '@/lib/api/auth'; import { t } from '@/lib/i18n'; @@ -151,6 +152,21 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct await refetchAuthorizedDevices(); }, + async renameAuthorizedDevice(device: AuthorizedDevice, name: string) { + const normalized = String(name || '').trim(); + if (!normalized) { + onNotify('error', t('txt_device_note_required')); + return; + } + try { + await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized); + await refetchAuthorizedDevices(); + onNotify('success', t('txt_device_note_updated')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed')); + } + }, + openRevokeDeviceTrust(device: AuthorizedDevice) { onSetConfirm({ title: t('txt_revoke_device_authorization'), @@ -159,9 +175,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct onConfirm: () => { onSetConfirm(null); void (async () => { - await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); - await refetchAuthorizedDevices(); - onNotify('success', t('txt_device_authorization_revoked')); + try { + await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); + await refetchAuthorizedDevices(); + onNotify('success', t('txt_device_authorization_revoked')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed')); + } })(); }, }); @@ -175,14 +195,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct onConfirm: () => { onSetConfirm(null); void (async () => { - await deleteAuthorizedDevice(authedFetch, device.identifier); - if (device.identifier === getCurrentDeviceIdentifier()) { + try { + await deleteAuthorizedDevice(authedFetch, device.identifier); + if (device.identifier === getCurrentDeviceIdentifier()) { + onNotify('success', t('txt_device_removed')); + onLogoutNow(); + return; + } + await refetchAuthorizedDevices(); onNotify('success', t('txt_device_removed')); - onLogoutNow(); - return; + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed')); } - await refetchAuthorizedDevices(); - onNotify('success', t('txt_device_removed')); })(); }, }); @@ -196,9 +220,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct onConfirm: () => { onSetConfirm(null); void (async () => { - await revokeAllAuthorizedDeviceTrust(authedFetch); - await refetchAuthorizedDevices(); - onNotify('success', t('txt_all_device_authorizations_revoked')); + try { + await revokeAllAuthorizedDeviceTrust(authedFetch); + await refetchAuthorizedDevices(); + onNotify('success', t('txt_all_device_authorizations_revoked')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed')); + } })(); }, }); @@ -212,9 +240,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct onConfirm: () => { onSetConfirm(null); void (async () => { - await deleteAllAuthorizedDevices(authedFetch); - onNotify('success', t('txt_all_devices_removed')); - onLogoutNow(); + try { + await deleteAllAuthorizedDevices(authedFetch); + onNotify('success', t('txt_all_devices_removed')); + onLogoutNow(); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed')); + } })(); }, }); diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index a122c4a..2f5e24c 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -575,6 +575,21 @@ export async function deleteAuthorizedDevice( if (!resp.ok) throw new Error(t('txt_remove_device_failed')); } +export async function updateAuthorizedDeviceName( + authedFetch: AuthedFetch, + deviceIdentifier: string, + name: string +): Promise { + const normalized = String(name || '').trim(); + if (!normalized) throw new Error(t('txt_device_note_required')); + const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: normalized }), + }); + if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); +} + export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/devices', { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 34f427a..a330d71 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -387,6 +387,9 @@ const messages: Record> = { txt_device: "Device", txt_device_authorization_revoked: "Device trust revoked", txt_device_management: "Device Management", + txt_device_note: "Device Note", + txt_device_note_required: "Device name is required", + txt_device_note_updated: "Device name updated", txt_device_removed: "Device removed", txt_load_devices_failed: "Failed to load devices", txt_disable_this_send: "Disable this send", @@ -550,6 +553,7 @@ const messages: Record> = { txt_not_trusted: "Not trusted", txt_note: "Note", txt_notes: "Notes", + txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.", txt_number: "Number", txt_open: "Open", txt_opera_browser: "Opera Browser", @@ -618,6 +622,8 @@ const messages: Record> = { txt_revoke_device_trust_failed: "Failed to revoke device trust", txt_revoke_all_device_trust_failed: "Failed to revoke all device trust", txt_revoke_trust: "Revoke Trust", + txt_untrust: "Untrust", + txt_update_device_note_failed: "Update device note failed", txt_role: "Role", txt_save: "Save", txt_save_profile: "Save Profile", @@ -1067,6 +1073,7 @@ const zhCNOverrides: Record = { txt_additional_options: '附加选项', txt_custom_fields: '自定义字段', txt_notes: '备注', + txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。', txt_item_history: '项目历史', txt_last_edited_value: '最后编辑:{value}', txt_created_value: '创建于:{value}', @@ -1113,12 +1120,17 @@ const zhCNOverrides: Record = { txt_view_recovery_code: '查看恢复代码', txt_copy_code: '复制代码', txt_device_management: '设备管理', + txt_device_note: '备注', + txt_device_note_required: '设备名称不能为空', + txt_device_note_updated: '设备名称已更新', txt_authorized_devices: '已授权设备', txt_device: '设备', txt_last_seen: '最后在线', txt_trusted_until: '信任至', txt_revoke_trust: '撤销信任', + txt_untrust: '不信任', txt_remove_device_2: '移除设备', + txt_update_device_note_failed: '更新设备备注失败', txt_not_trusted: '未信任', txt_unknown_device: '未知设备', txt_users: '用户', diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index b660fba..8e0889f 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -338,10 +338,14 @@ export interface AdminInvite { export interface AuthorizedDevice { id: string; name: string; + systemName?: string | null; + deviceNote?: string | null; identifier: string; type: number; creationDate: string | null; revisionDate: string | null; + lastSeenAt?: string | null; + hasStoredDevice?: boolean; online: boolean; trusted: boolean; trustedTokenCount: number;