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
+47 -4
View File
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
}
function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: device.name,
name: device.name,
Name: displayName,
name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: device.name,
name: device.name,
Name: String(device.deviceNote || '').trim() || 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,
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
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
return jsonResponse({ success: deleted });
}
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<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
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
+3
View File
@@ -450,6 +450,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
+7
View File
@@ -13,6 +13,7 @@ import {
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
+39 -6
View File
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
userId: row.user_id,
deviceIdentifier: row.device_identifier,
name: row.name,
deviceNote: row.device_note ?? null,
type: row.type,
sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -33,31 +35,62 @@ export async function upsertDevice(
}
): Promise<void> {
const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
name,
effectiveName,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now,
now
)
.run();
}
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<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(
db: D1Database,
userId: string,
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
)
.bind(userId)
.all<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> {
const row = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
)
.bind(userId, deviceIdentifier)
+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 TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -84,6 +84,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
+11 -1
View File
@@ -92,7 +92,9 @@ import {
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo';
import {
@@ -106,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-30.1';
const STORAGE_SCHEMA_VERSION = '2026-04-18.1';
// D1-backed storage.
// Contract:
@@ -550,6 +552,14 @@ export class StorageService {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<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> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
+6
View File
@@ -189,12 +189,14 @@ export interface Device {
userId: string;
deviceIdentifier: string;
name: string;
deviceNote: string | null;
type: number;
sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
@@ -208,10 +210,14 @@ export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;