mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add device note and last seen tracking to devices, enhance device management features
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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: '用户',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user