feat: add device note and last seen tracking to devices, enhance device management features

This commit is contained in:
shuaiplus
2026-04-18 01:43:21 +08:00
parent f7cbdaf730
commit 7ebd12fa07
15 changed files with 280 additions and 38 deletions
+3
View File
@@ -153,12 +153,15 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier), PRIMARY KEY (user_id, device_identifier),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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_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 ( CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
+47 -4
View File
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
} }
function buildDeviceResponse(device: Device): DeviceResponse { function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
UserId: device.userId, UserId: device.userId,
userId: device.userId, userId: device.userId,
Name: device.name, Name: displayName,
name: device.name, name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt, creationDate: device.createdAt,
RevisionDate: device.updatedAt, RevisionDate: device.updatedAt,
revisionDate: device.updatedAt, revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device), IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device), isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey, EncryptedUserKey: device.encryptedUserKey,
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
Name: device.name, Name: String(device.deviceNote || '').trim() || device.name,
name: device.name, name: String(device.deviceNote || '').trim() || device.name,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
} }
} }
function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
data.push({ data.push({
...buildDeviceResponse(placeholderDevice), ...buildDeviceResponse(placeholderDevice),
isTrusted: true, isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier), online: onlineSet.has(row.deviceIdentifier),
trusted: true, trusted: true,
trustedTokenCount: row.tokenCount, trustedTokenCount: row.tokenCount,
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
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 // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
+3
View File
@@ -450,6 +450,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device); const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
+7
View File
@@ -13,6 +13,7 @@ import {
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth, handleUpdateDeviceWebPushAuth,
handleClearDeviceToken, handleClearDeviceToken,
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); 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); const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
+39 -6
View File
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
userId: row.user_id, userId: row.user_id,
deviceIdentifier: row.device_identifier, deviceIdentifier: row.device_identifier,
name: row.name, name: row.name,
deviceNote: row.device_note ?? null,
type: row.type, type: row.type,
sessionStamp: row.session_stamp || '', sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -33,31 +35,62 @@ export async function upsertDevice(
} }
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); 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 await db
.prepare( .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, ' + '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_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
.bind( .bind(
userId, userId,
deviceIdentifier, deviceIdentifier,
name, effectiveName,
type, type,
effectiveSessionStamp, effectiveSessionStamp,
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now, now,
now now
) )
.run(); .run();
} }
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<boolean> {
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<boolean> {
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( export async function updateDeviceKeys(
db: D1Database, db: D1Database,
userId: string, userId: string,
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .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 = ? ORDER BY updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
.all<any>(); .all<any>();
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .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' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
+4 -1
View File
@@ -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 INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + '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, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', '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 encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', '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 (' + '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, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
+11 -1
View File
@@ -92,7 +92,9 @@ import {
isKnownDevice as getKnownStoredDevice, isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
@@ -106,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; 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. // D1-backed storage.
// Contract: // Contract:
@@ -550,6 +552,14 @@ export class StorageService {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
} }
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
}
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
+6
View File
@@ -189,12 +189,14 @@ export interface Device {
userId: string; userId: string;
deviceIdentifier: string; deviceIdentifier: string;
name: string; name: string;
deviceNote: string | null;
type: number; type: number;
sessionStamp: string; sessionStamp: string;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -208,10 +210,14 @@ export interface DeviceResponse {
id: string; id: string;
userId?: string | null; userId?: string | null;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean; isTrusted: boolean;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
+1
View File
@@ -1196,6 +1196,7 @@ export default function App() {
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
+2
View File
@@ -95,6 +95,7 @@ export interface AppMainRoutesProps {
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
@@ -281,6 +282,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
devices={props.authorizedDevices} devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
+79 -11
View File
@@ -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 type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
@@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string {
} }
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return;
setSavingNote(true);
try {
await props.onRenameDevice(editingDevice, deviceNote);
setEditingDevice(null);
setDeviceNote('');
} finally {
setSavingNote(false);
}
}
return ( return (
<div className="stack"> <>
<section className="card"> <div className="stack">
<section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section className="card"> <section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3> <h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table"> <table className="table">
<thead> <thead>
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr key={device.identifier}> <tr key={device.identifier}>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
<div className="muted-inline">{device.systemName}</div>
)}
<div className="muted-inline">{device.identifier}</div> <div className="muted-inline">{device.identifier}</div>
</td> </td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td> <td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</span> </span>
</td> </td>
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td> <td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td> <td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
<td data-label={t('txt_trusted_until')}> <td data-label={t('txt_trusted_until')}>
{device.trusted ? ( {device.trusted ? (
<div className="trusted-cell"> <div className="trusted-cell">
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)} onClick={() => props.onRevokeTrust(device)}
> >
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')} {t('txt_untrust')}
</button> </button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}> <button
type="button"
className="btn btn-secondary small"
disabled={device.hasStoredDevice === false}
onClick={() => {
setEditingDevice(device);
setDeviceNote(device.deviceNote || device.name || '');
}}
>
<Pencil size={14} className="btn-icon" />
{t('txt_device_note')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={device.hasStoredDevice === false}
onClick={() => props.onRemoveDevice(device)}
>
<Trash2 size={14} className="btn-icon" /> <Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')} {t('txt_delete')}
</button> </button>
</div> </div>
</td> </td>
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)} )}
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
<ConfirmDialog
open={!!editingDevice}
title={t('txt_device_note')}
message={t('txt_replace_device_name_with_note')}
confirmText={t('txt_save')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={savingNote}
cancelDisabled={savingNote}
onConfirm={() => void handleSaveDeviceNote()}
onCancel={() => {
if (savingNote) return;
setEditingDevice(null);
setDeviceNote('');
}}
>
<label className="field">
<span>{t('txt_device_note')}</span>
<input
className="input"
maxLength={128}
value={deviceNote}
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
</>
); );
} }
+47 -15
View File
@@ -9,6 +9,7 @@ import {
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
setTotp, setTotp,
updateAuthorizedDeviceName,
updateProfile, updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -151,6 +152,21 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
await refetchAuthorizedDevices(); 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) { openRevokeDeviceTrust(device: AuthorizedDevice) {
onSetConfirm({ onSetConfirm({
title: t('txt_revoke_device_authorization'), title: t('txt_revoke_device_authorization'),
@@ -159,9 +175,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); try {
await refetchAuthorizedDevices(); await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
onNotify('success', t('txt_device_authorization_revoked')); 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: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAuthorizedDevice(authedFetch, device.identifier); try {
if (device.identifier === getCurrentDeviceIdentifier()) { 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')); onNotify('success', t('txt_device_removed'));
onLogoutNow(); } catch (error) {
return; 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: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAllAuthorizedDeviceTrust(authedFetch); try {
await refetchAuthorizedDevices(); await revokeAllAuthorizedDeviceTrust(authedFetch);
onNotify('success', t('txt_all_device_authorizations_revoked')); 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: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAllAuthorizedDevices(authedFetch); try {
onNotify('success', t('txt_all_devices_removed')); await deleteAllAuthorizedDevices(authedFetch);
onLogoutNow(); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
}
})(); })();
}, },
}); });
+15
View File
@@ -575,6 +575,21 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed')); if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
} }
export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch,
deviceIdentifier: string,
name: string
): Promise<void> {
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<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
+12
View File
@@ -387,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_device: "Device", txt_device: "Device",
txt_device_authorization_revoked: "Device trust revoked", txt_device_authorization_revoked: "Device trust revoked",
txt_device_management: "Device Management", 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_device_removed: "Device removed",
txt_load_devices_failed: "Failed to load devices", txt_load_devices_failed: "Failed to load devices",
txt_disable_this_send: "Disable this send", txt_disable_this_send: "Disable this send",
@@ -550,6 +553,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_not_trusted: "Not trusted", txt_not_trusted: "Not trusted",
txt_note: "Note", txt_note: "Note",
txt_notes: "Notes", 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_number: "Number",
txt_open: "Open", txt_open: "Open",
txt_opera_browser: "Opera Browser", txt_opera_browser: "Opera Browser",
@@ -618,6 +622,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_revoke_device_trust_failed: "Failed to revoke device trust", txt_revoke_device_trust_failed: "Failed to revoke device trust",
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust", txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
txt_revoke_trust: "Revoke Trust", txt_revoke_trust: "Revoke Trust",
txt_untrust: "Untrust",
txt_update_device_note_failed: "Update device note failed",
txt_role: "Role", txt_role: "Role",
txt_save: "Save", txt_save: "Save",
txt_save_profile: "Save Profile", txt_save_profile: "Save Profile",
@@ -1067,6 +1073,7 @@ const zhCNOverrides: Record<string, string> = {
txt_additional_options: '附加选项', txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段', txt_custom_fields: '自定义字段',
txt_notes: '备注', txt_notes: '备注',
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
txt_item_history: '项目历史', txt_item_history: '项目历史',
txt_last_edited_value: '最后编辑:{value}', txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}', txt_created_value: '创建于:{value}',
@@ -1113,12 +1120,17 @@ const zhCNOverrides: Record<string, string> = {
txt_view_recovery_code: '查看恢复代码', txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码', txt_copy_code: '复制代码',
txt_device_management: '设备管理', txt_device_management: '设备管理',
txt_device_note: '备注',
txt_device_note_required: '设备名称不能为空',
txt_device_note_updated: '设备名称已更新',
txt_authorized_devices: '已授权设备', txt_authorized_devices: '已授权设备',
txt_device: '设备', txt_device: '设备',
txt_last_seen: '最后在线', txt_last_seen: '最后在线',
txt_trusted_until: '信任至', txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任', txt_revoke_trust: '撤销信任',
txt_untrust: '不信任',
txt_remove_device_2: '移除设备', txt_remove_device_2: '移除设备',
txt_update_device_note_failed: '更新设备备注失败',
txt_not_trusted: '未信任', txt_not_trusted: '未信任',
txt_unknown_device: '未知设备', txt_unknown_device: '未知设备',
txt_users: '用户', txt_users: '用户',
+4
View File
@@ -338,10 +338,14 @@ export interface AdminInvite {
export interface AuthorizedDevice { export interface AuthorizedDevice {
id: string; id: string;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string | null; creationDate: string | null;
revisionDate: string | null; revisionDate: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
online: boolean; online: boolean;
trusted: boolean; trusted: boolean;
trustedTokenCount: number; trustedTokenCount: number;