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
+1
View File
@@ -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,
+2
View File
@@ -95,6 +95,7 @@ export interface AppMainRoutesProps {
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
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}
+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 { t } from '@/lib/i18n';
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
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<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 (
<div className="stack">
<section className="card">
<>
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button>
</div>
</div>
</section>
</section>
<section className="card">
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr key={device.identifier}>
<td data-label={t('txt_device')}>
<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>
</td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</span>
</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')}>
{device.trusted ? (
<div className="trusted-cell">
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')}
{t('txt_untrust')}
</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" />
{t('txt_remove_device_2')}
{t('txt_delete')}
</button>
</div>
</td>
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)}
</tbody>
</table>
</section>
</div>
</section>
</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,
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'));
}
})();
},
});
+15
View File
@@ -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<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> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
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_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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<string, string> = {
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<string, string> = {
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: '用户',
+4
View File
@@ -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;