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:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,7 +44,24 @@ 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">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -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>
|
||||||
@@ -137,5 +177,33 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
try {
|
||||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
onNotify('success', t('txt_device_authorization_revoked'));
|
onNotify('success', t('txt_device_authorization_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -175,6 +195,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try {
|
||||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||||
onNotify('success', t('txt_device_removed'));
|
onNotify('success', t('txt_device_removed'));
|
||||||
@@ -183,6 +204,9 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
}
|
}
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
onNotify('success', t('txt_device_removed'));
|
onNotify('success', t('txt_device_removed'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -196,9 +220,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try {
|
||||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
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 () => {
|
||||||
|
try {
|
||||||
await deleteAllAuthorizedDevices(authedFetch);
|
await deleteAllAuthorizedDevices(authedFetch);
|
||||||
onNotify('success', t('txt_all_devices_removed'));
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
onLogoutNow();
|
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'));
|
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'));
|
||||||
|
|||||||
@@ -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: '用户',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user