feat: add master password hint functionality

- Updated user model to include masterPasswordHint.
- Modified sync handler to return masterPasswordHint.
- Implemented password hint retrieval in public API.
- Enhanced user profile management to allow updating of password hint.
- Added UI components for displaying and editing password hint.
- Updated localization files for new password hint strings.
- Improved rate limiting for sensitive public requests.
- Adjusted database schema to accommodate master password hint.
This commit is contained in:
shuaiplus
2026-03-19 00:38:56 +08:00
parent 8bc43b8f0c
commit facd0ea5f7
26 changed files with 460 additions and 26 deletions
+1
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
name TEXT, name TEXT,
master_password_hint TEXT,
master_password_hash TEXT NOT NULL, master_password_hash TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
private_key TEXT, private_key TEXT,
+6
View File
@@ -44,6 +44,12 @@
// Sensitive public/auth request budget per IP per minute. // Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。 // 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30, sensitivePublicRequestsPerMinute: 30,
// Password hint lookup budget per IP per minute.
// 密码提示查询接口每 IP 每分钟请求配额。
passwordHintRequestsPerMinute: 1,
// Password hint lookup budget per IP per hour.
// 密码提示查询接口每 IP 每小时请求配额。
passwordHintRequestsPerHour: 3,
// Register endpoint budget per IP per minute. // Register endpoint budget per IP per minute.
// 注册接口每 IP 每分钟请求配额。 // 注册接口每 IP 每分钟请求配额。
registerRequestsPerMinute: 5, registerRequestsPerMinute: 5,
+113 -1
View File
@@ -62,6 +62,11 @@ function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
} }
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
const normalized = String(input || '').trim();
return normalized ? normalized : null;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
@@ -80,7 +85,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
premium: true, premium: true,
premiumFromOrganization: false, premiumFromOrganization: false,
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: user.masterPasswordHint,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
@@ -125,6 +130,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
inviteCode?: string; inviteCode?: string;
masterPasswordHint?: string;
keys?: { keys?: {
publicKey?: string; publicKey?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
@@ -144,6 +150,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
const privateKey = body.keys?.encryptedPrivateKey; const privateKey = body.keys?.encryptedPrivateKey;
const publicKey = body.keys?.publicKey; const publicKey = body.keys?.publicKey;
const inviteCode = (body.inviteCode || '').trim(); const inviteCode = (body.inviteCode || '').trim();
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (!email || !masterPasswordHash || !key) { if (!email || !masterPasswordHash || !key) {
return errorResponse('Email, masterPasswordHash, and key are required', 400); return errorResponse('Email, masterPasswordHash, and key are required', 400);
@@ -160,6 +167,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (!looksLikeEncString(privateKey)) { if (!looksLikeEncString(privateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
} }
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism); const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); if (kdfErr) return errorResponse(kdfErr, 400);
@@ -172,6 +182,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
id: generateUUID(), id: generateUUID(),
email, email,
name: name || email, name: name || email,
masterPasswordHint,
masterPasswordHash: serverHash, masterPasswordHash: serverHash,
key, key,
privateKey, privateKey,
@@ -242,6 +253,80 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return jsonResponse({ success: true, role: user.role }, 200); return jsonResponse({ success: true, role: user.role }, 200);
} }
// POST /api/accounts/password-hint
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
let body: { email?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const email = String(body.email || '').trim().toLowerCase();
if (!email) {
return errorResponse('Email is required', 400);
}
const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute,
60
);
if (!minuteBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60
);
if (!hourlyBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
'X-RateLimit-Remaining': '0',
},
}
);
}
const user = await storage.getUser(email);
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
return jsonResponse({
object: 'passwordHint',
hasHint: !!hint,
masterPasswordHint: hint,
});
}
// GET /api/accounts/profile // GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
@@ -251,6 +336,33 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
return jsonResponse(toProfile(user, env)); return jsonResponse(toProfile(user, env));
} }
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
masterPasswordHint?: string | null;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
return jsonResponse(toProfile(user, env));
}
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+1 -1
View File
@@ -135,7 +135,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
premium: true, premium: true,
premiumFromOrganization: false, premiumFromOrganization: false,
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: user.masterPasswordHint,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
+9 -1
View File
@@ -32,6 +32,10 @@ function injectBootstrapIntoHtml(html: string, env: Env): string {
return `${script}${html}`; return `${script}${html}`;
} }
function responseStatusCannotHaveBody(status: number): boolean {
return status === 101 || status === 204 || status === 205 || status === 304;
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> { async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null; if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null; if (request.method !== 'GET' && request.method !== 'HEAD') return null;
@@ -40,7 +44,11 @@ async function maybeServeAsset(request: Request, env: Env): Promise<Response | n
const assetResponse = await env.ASSETS.fetch(request); const assetResponse = await env.ASSETS.fetch(request);
const contentType = String(assetResponse.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(assetResponse.headers.get('Content-Type') || '').toLowerCase();
if (request.method === 'GET' && contentType.includes('text/html')) { if (
request.method === 'GET' &&
contentType.includes('text/html') &&
!responseStatusCannotHaveBody(assetResponse.status)
) {
const html = await assetResponse.text(); const html = await assetResponse.text();
const injected = injectBootstrapIntoHtml(html, env); const injected = injectBootstrapIntoHtml(html, env);
return new Response(injected, { return new Response(injected, {
+2
View File
@@ -2,6 +2,7 @@ import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response'; import { errorResponse, jsonResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile,
handleSetKeys, handleSetKeys,
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
@@ -79,6 +80,7 @@ export async function handleAuthenticatedRoute(
if (path === '/api/accounts/profile') { if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId); if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
return errorResponse('Method not allowed', 405); return errorResponse('Method not allowed', 405);
} }
+13
View File
@@ -11,6 +11,7 @@ import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { import {
handleRegister, handleRegister,
handleGetPasswordHint,
handleRecoverTwoFactor, handleRecoverTwoFactor,
} from './handlers/accounts'; } from './handlers/accounts';
import { handlePublicDownloadAttachment } from './handlers/attachments'; import { handlePublicDownloadAttachment } from './handlers/attachments';
@@ -252,6 +253,18 @@ export async function handlePublicRoute(
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return handleGetPasswordHint(request, env);
}
if ((path === '/config' || path === '/api/config') && method === 'GET') { if ((path === '/config' || path === '/api/config') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
+1 -1
View File
@@ -370,7 +370,7 @@ export async function buildBackupArchive(env: Env, date: Date = new Date()): Pro
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([ const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
+1 -1
View File
@@ -270,7 +270,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db']): P
...buildInsertStatements( ...buildInsertStatements(
db, db,
'users', 'users',
['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
payload.users || [] payload.users || []
), ),
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true), ...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
+8
View File
@@ -184,6 +184,14 @@ export class RateLimitService {
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS); return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
} }
async consumeBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
}
} }
function parseIpv4Octets(input: string): number[] | null { function parseIpv4Octets(input: string): number[] | null {
+2 -1
View File
@@ -3,10 +3,11 @@
// Any new table/column/index must be added to both places together. // Any new table/column/index must be added to both places together.
const SCHEMA_STATEMENTS: readonly string[] = [ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS users (' + 'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
+11 -8
View File
@@ -7,6 +7,7 @@ function mapUserRow(row: any): User {
id: row.id, id: row.id,
email: row.email, email: row.email,
name: row.name, name: row.name,
masterPasswordHint: row.master_password_hint ?? null,
masterPasswordHash: row.master_password_hash, masterPasswordHash: row.master_password_hash,
key: row.key, key: row.key,
privateKey: row.private_key, privateKey: row.private_key,
@@ -28,7 +29,7 @@ function mapUserRow(row: any): User {
export async function getUser(db: D1Database, email: string): Promise<User | null> { export async function getUser(db: D1Database, email: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
) )
.bind(email.toLowerCase()) .bind(email.toLowerCase())
.first<any>(); .first<any>();
@@ -39,7 +40,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
export async function getUserById(db: D1Database, id: string): Promise<User | null> { export async function getUserById(db: D1Database, id: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
) )
.bind(id) .bind(id)
.first<any>(); .first<any>();
@@ -55,7 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
export async function getAllUsers(db: D1Database): Promise<User[]> { export async function getAllUsers(db: D1Database): Promise<User[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
) )
.all<any>(); .all<any>();
return (res.results || []).map((row) => mapUserRow(row)); return (res.results || []).map((row) => mapUserRow(row));
@@ -64,10 +65,10 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
); );
await safeBind( await safeBind(
@@ -75,6 +76,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.id, user.id,
email, email,
user.name, user.name,
user.masterPasswordHint,
user.masterPasswordHash, user.masterPasswordHash,
user.key, user.key,
user.privateKey, user.privateKey,
@@ -100,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -109,6 +111,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.id, user.id,
email, email,
user.name, user.name,
user.masterPasswordHint,
user.masterPasswordHash, user.masterPasswordHash,
user.key, user.key,
user.privateKey, user.privateKey,
+1 -1
View File
@@ -102,7 +102,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-18.1'; const STORAGE_SCHEMA_VERSION = '2026-03-19.1';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
+1
View File
@@ -35,6 +35,7 @@ export interface User {
id: string; id: string;
email: string; email: string;
name: string | null; name: string | null;
masterPasswordHint: string | null;
masterPasswordHash: string; masterPasswordHash: string;
key: string; key: string;
privateKey: string | null; privateKey: string | null;
+80 -4
View File
@@ -11,6 +11,7 @@ import {
createAuthedFetch, createAuthedFetch,
getAuthorizedDevices, getAuthorizedDevices,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getPasswordHint,
getTotpStatus, getTotpStatus,
saveSession, saveSession,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
@@ -78,8 +79,18 @@ export default function App() {
email: '', email: '',
password: '', password: '',
password2: '', password2: '',
passwordHint: '',
inviteCode: initialInviteCode, inviteCode: initialInviteCode,
}); });
const [loginHintState, setLoginHintState] = useState<{
email: string;
loading: boolean;
hint: string | null;
}>({
email: '',
loading: false,
hint: null,
});
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
@@ -131,6 +142,15 @@ export default function App() {
setRegisterValues((prev) => (prev.inviteCode === inviteCodeFromUrl ? prev : { ...prev, inviteCode: inviteCodeFromUrl })); setRegisterValues((prev) => (prev.inviteCode === inviteCodeFromUrl ? prev : { ...prev, inviteCode: inviteCodeFromUrl }));
}, [inviteCodeFromUrl]); }, [inviteCodeFromUrl]);
useEffect(() => {
const normalizedEmail = loginValues.email.trim().toLowerCase();
setLoginHintState((prev) => (
prev.email && prev.email !== normalizedEmail
? { email: '', loading: false, hint: null }
: prev
));
}, [loginValues.email]);
useEffect(() => { useEffect(() => {
if (!inviteCodeFromUrl) return; if (!inviteCodeFromUrl) return;
if (phase === 'locked' || phase === 'app') return; if (phase === 'locked' || phase === 'app') return;
@@ -200,7 +220,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
(async () => { (async () => {
const boot = await bootstrapAppSession(); const boot = await bootstrapAppSession(initialBootstrap);
if (!mounted) return; if (!mounted) return;
setDefaultKdfIterations(boot.defaultKdfIterations); setDefaultKdfIterations(boot.defaultKdfIterations);
setJwtWarning(boot.jwtWarning); setJwtWarning(boot.jwtWarning);
@@ -212,7 +232,7 @@ export default function App() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, []); }, [initialBootstrap]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
setSession(login.session); setSession(login.session);
@@ -322,6 +342,7 @@ export default function App() {
email: registerValues.email, email: registerValues.email,
name: registerValues.name, name: registerValues.name,
password: registerValues.password, password: registerValues.password,
masterPasswordHint: registerValues.passwordHint,
inviteCode: registerValues.inviteCode, inviteCode: registerValues.inviteCode,
fallbackIterations: defaultKdfIterations, fallbackIterations: defaultKdfIterations,
}); });
@@ -338,6 +359,56 @@ export default function App() {
} }
} }
function openPasswordHintDialog(hint: string | null) {
setConfirm({
title: t('txt_password_hint'),
message: hint || t('txt_password_hint_not_set'),
showIcon: false,
confirmText: t('txt_close'),
hideCancel: true,
onConfirm: () => setConfirm(null),
});
}
async function handleTogglePasswordHint() {
if (pendingAuthAction) return;
const email = loginValues.email.trim().toLowerCase();
if (!email) return;
if (loginHintState.email === email && !loginHintState.loading) {
openPasswordHintDialog(loginHintState.hint);
return;
}
setLoginHintState({
email,
loading: true,
hint: null,
});
try {
const result = await getPasswordHint(email);
openPasswordHintDialog(result.masterPasswordHint);
setLoginHintState({
email,
loading: false,
hint: result.masterPasswordHint,
});
} catch (error) {
setLoginHintState({
email: '',
loading: false,
hint: null,
});
pushToast('error', error instanceof Error ? error.message : t('txt_password_hint_load_failed'));
}
}
function handleShowLockedPasswordHint() {
if (pendingAuthAction) return;
openPasswordHintDialog(profile?.masterPasswordHint ?? null);
}
async function handleUnlock() { async function handleUnlock() {
if (pendingAuthAction) return; if (pendingAuthAction) return;
if (!session || !profile) return; if (!session || !profile) return;
@@ -804,6 +875,7 @@ export default function App() {
}, },
onLogoutNow: logoutNow, onLogoutNow: logoutNow,
onNotify: pushToast, onNotify: pushToast,
onProfileUpdated: setProfile,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch, refetchTotpStatus: totpStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
@@ -923,6 +995,7 @@ export default function App() {
uploadingSendFileName: vaultSendActions.uploadingSendFileName, uploadingSendFileName: vaultSendActions.uploadingSendFileName,
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token);
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
@@ -992,6 +1065,7 @@ export default function App() {
registerValues={registerValues} registerValues={registerValues}
unlockPassword={unlockPassword} unlockPassword={unlockPassword}
emailForLock={profile?.email || session?.email || ''} emailForLock={profile?.email || session?.email || ''}
loginHintLoading={loginHintState.loading}
onChangeLogin={setLoginValues} onChangeLogin={setLoginValues}
onChangeRegister={setRegisterValues} onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword} onChangeUnlock={setUnlockPassword}
@@ -1010,12 +1084,14 @@ export default function App() {
navigate('/register'); navigate('/register');
}} }}
onLogout={logoutNow} onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint}
/> />
<AppGlobalOverlays <AppGlobalOverlays
toasts={toasts} toasts={toasts}
onCloseToast={removeToast} onCloseToast={removeToast}
confirm={null} confirm={confirm}
onCancelConfirm={() => {}} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp} pendingTotpOpen={!!pendingTotp}
totpCode={totpCode} totpCode={totpCode}
rememberDevice={rememberDevice} rememberDevice={rememberDevice}
@@ -8,6 +8,9 @@ export interface AppConfirmState {
message: string; message: string;
danger?: boolean; danger?: boolean;
showIcon?: boolean; showIcon?: boolean;
confirmText?: string;
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void; onConfirm: () => void;
} }
@@ -40,6 +43,9 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
message={props.confirm?.message || ''} message={props.confirm?.message || ''}
danger={props.confirm?.danger} danger={props.confirm?.danger}
showIcon={props.confirm?.showIcon} showIcon={props.confirm?.showIcon}
confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm} onCancel={props.onCancelConfirm}
/> />
+2
View File
@@ -77,6 +77,7 @@ export interface AppMainRoutesProps {
uploadingSendFileName: string; uploadingSendFileName: string;
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
@@ -198,6 +199,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
+40
View File
@@ -13,6 +13,7 @@ interface RegisterValues {
email: string; email: string;
password: string; password: string;
password2: string; password2: string;
passwordHint: string;
inviteCode: string; inviteCode: string;
} }
@@ -24,6 +25,7 @@ interface AuthViewsProps {
registerValues: RegisterValues; registerValues: RegisterValues;
unlockPassword: string; unlockPassword: string;
emailForLock: string; emailForLock: string;
loginHintLoading: boolean;
onChangeLogin: (next: LoginValues) => void; onChangeLogin: (next: LoginValues) => void;
onChangeRegister: (next: RegisterValues) => void; onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void; onChangeUnlock: (password: string) => void;
@@ -33,6 +35,8 @@ interface AuthViewsProps {
onGotoLogin: () => void; onGotoLogin: () => void;
onGotoRegister: () => void; onGotoRegister: () => void;
onLogout: () => void; onLogout: () => void;
onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void;
} }
function PasswordField(props: { function PasswordField(props: {
@@ -87,6 +91,17 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="current-password" autoComplete="current-password"
onInput={props.onChangeUnlock} onInput={props.onChangeUnlock}
/> />
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy}
>
{t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
@@ -147,6 +162,18 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="new-password" autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })} onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/> />
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
className="input"
maxLength={120}
value={props.registerValues.passwordHint}
placeholder={t('txt_password_hint_register_placeholder')}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, passwordHint: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field"> <label className="field">
<span>{t('txt_invite_code_optional')}</span> <span>{t('txt_invite_code_optional')}</span>
<input <input
@@ -199,6 +226,19 @@ export default function AuthViews(props: AuthViewsProps) {
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })} onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus autoFocus
/> />
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onTogglePasswordHint}
disabled={loginBusy || !props.loginValues.email.trim()}
>
{props.loginHintLoading
? t('txt_loading_password_hint')
: t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}> <button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<LogIn size={16} className="btn-icon" /> <LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')} {loginBusy ? t('txt_logging_in') : t('txt_log_in')}
+7 -4
View File
@@ -10,6 +10,7 @@ interface ConfirmDialogProps {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
danger?: boolean; danger?: boolean;
hideCancel?: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
children?: ComponentChildren; children?: ComponentChildren;
@@ -37,10 +38,12 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<Check size={14} className="btn-icon" /> <Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}> {!props.hideCancel && (
<X size={14} className="btn-icon" /> <button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || t('txt_no')} <X size={14} className="btn-icon" />
</button> {props.cancelText || t('txt_no')}
</button>
)}
{props.afterActions} {props.afterActions}
</form> </form>
</div> </div>
+28
View File
@@ -9,6 +9,7 @@ interface SettingsPageProps {
profile: Profile; profile: Profile;
totpEnabled: boolean; totpEnabled: boolean;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
@@ -40,6 +41,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState(''); const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
@@ -54,6 +56,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setTotpLocked(true); setTotpLocked(true);
}, [props.totpEnabled]); }, [props.totpEnabled]);
useEffect(() => {
setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]);
const qrDataUrl = useMemo(() => { const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret)); qr.addData(buildOtpUri(props.profile.email, secret));
@@ -81,6 +87,28 @@ export default function SettingsPage(props: SettingsPageProps) {
return ( return (
<div className="stack"> <div className="stack">
<section className="card">
<h3>{t('txt_profile')}</h3>
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
className="input"
maxLength={120}
value={passwordHint}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
>
{t('txt_save_profile')}
</button>
</section>
<section className="card"> <section className="card">
<h3>{t('txt_change_master_password')}</h3> <h3>{t('txt_change_master_password')}</h3>
<label className="field"> <label className="field">
@@ -9,6 +9,7 @@ import {
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
setTotp, setTotp,
updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
@@ -25,6 +26,7 @@ interface UseAccountSecurityActionsOptions {
clearDisableTotpDialog: () => void; clearDisableTotpDialog: () => void;
onLogoutNow: () => void; onLogoutNow: () => void;
onNotify: Notify; onNotify: Notify;
onProfileUpdated: (profile: Profile) => void;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchTotpStatus: () => Promise<unknown>; refetchTotpStatus: () => Promise<unknown>;
refetchAuthorizedDevices: () => Promise<unknown>; refetchAuthorizedDevices: () => Promise<unknown>;
@@ -39,6 +41,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
clearDisableTotpDialog, clearDisableTotpDialog,
onLogoutNow, onLogoutNow,
onNotify, onNotify,
onProfileUpdated,
onSetConfirm, onSetConfirm,
refetchTotpStatus, refetchTotpStatus,
refetchAuthorizedDevices, refetchAuthorizedDevices,
@@ -85,6 +88,22 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
}); });
}, },
async savePasswordHint(masterPasswordHint: string) {
if (!profile) return;
const normalized = String(masterPasswordHint || '').trim();
if (normalized.length > 120) {
onNotify('error', t('txt_password_hint_too_long'));
return;
}
try {
const nextProfile = await updateProfile(authedFetch, { masterPasswordHint: normalized });
onProfileUpdated(nextProfile);
onNotify('success', t('txt_profile_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
}
},
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string) {
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
@@ -208,6 +227,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
disableTotpPassword, disableTotpPassword,
onLogoutNow, onLogoutNow,
onNotify, onNotify,
onProfileUpdated,
onSetConfirm, onSetConfirm,
profile, profile,
refetchAuthorizedDevices, refetchAuthorizedDevices,
+37 -1
View File
@@ -201,11 +201,12 @@ export async function registerAccount(args: {
email: string; email: string;
name: string; name: string;
password: string; password: string;
masterPasswordHint?: string;
inviteCode?: string; inviteCode?: string;
fallbackIterations: number; fallbackIterations: number;
}): Promise<{ ok: true } | { ok: false; message: string }> { }): Promise<{ ok: true } | { ok: false; message: string }> {
try { try {
const { email, name, password, inviteCode, fallbackIterations } = args; const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32); const encKey = await hkdfExpand(masterKey, 'enc', 32);
@@ -233,6 +234,7 @@ export async function registerAccount(args: {
body: JSON.stringify({ body: JSON.stringify({
email: email.toLowerCase(), email: email.toLowerCase(),
name, name,
masterPasswordHint: String(masterPasswordHint || '').trim() || undefined,
masterPasswordHash: bytesToBase64(masterHash), masterPasswordHash: bytesToBase64(masterHash),
key: encryptedVaultKey, key: encryptedVaultKey,
kdf: 0, kdf: 0,
@@ -255,6 +257,20 @@ export async function registerAccount(args: {
} }
} }
export async function getPasswordHint(email: string): Promise<{ masterPasswordHint: string | null }> {
const resp = await fetch('/api/accounts/password-hint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim().toLowerCase() }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to load password hint');
}
const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {};
return { masterPasswordHint: body.masterPasswordHint ?? null };
}
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) { export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> { return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const session = getSession(); const session = getSession();
@@ -294,6 +310,26 @@ export async function getProfile(authedFetch: AuthedFetch): Promise<Profile> {
return body; return body;
} }
export async function updateProfile(
authedFetch: AuthedFetch,
payload: { masterPasswordHint: string }
): Promise<Profile> {
const resp = await authedFetch('/api/accounts/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
masterPasswordHint: String(payload.masterPasswordHint || '').trim() || null,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Save profile failed');
}
const body = await parseJson<Profile>(resp);
if (!body) throw new Error('Invalid profile');
return body;
}
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> { export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
const encKey = await hkdfExpand(masterKey, 'enc', 32); const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32);
+3 -2
View File
@@ -149,8 +149,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
}; };
} }
export async function bootstrapAppSession(): Promise<BootstrapAppResult> { export async function bootstrapAppSession(initial: InitialAppBootstrapState = readInitialAppBootstrapState()): Promise<BootstrapAppResult> {
const initial = readInitialAppBootstrapState();
const defaultKdfIterations = initial.defaultKdfIterations; const defaultKdfIterations = initial.defaultKdfIterations;
const jwtWarning = initial.jwtWarning; const jwtWarning = initial.jwtWarning;
@@ -309,6 +308,7 @@ export async function performRegistration(args: {
email: string; email: string;
name: string; name: string;
password: string; password: string;
masterPasswordHint: string;
inviteCode: string; inviteCode: string;
fallbackIterations: number; fallbackIterations: number;
}) { }) {
@@ -316,6 +316,7 @@ export async function performRegistration(args: {
email: args.email.trim().toLowerCase(), email: args.email.trim().toLowerCase(),
name: args.name.trim(), name: args.name.trim(),
password: args.password, password: args.password,
masterPasswordHint: args.masterPasswordHint.trim(),
inviteCode: args.inviteCode.trim(), inviteCode: args.inviteCode.trim(),
fallbackIterations: args.fallbackIterations, fallbackIterations: args.fallbackIterations,
}); });
+26
View File
@@ -458,6 +458,19 @@ const messages: Record<Locale, Record<string, string>> = {
txt_password: "Password", txt_password: "Password",
txt_password_is_already_verified: "Password is already verified.", txt_password_is_already_verified: "Password is already verified.",
txt_passwords_do_not_match: "Passwords do not match", txt_passwords_do_not_match: "Passwords do not match",
txt_password_hint: "Password Hint",
txt_password_hint_optional: "Password Hint (optional)",
txt_password_hint_placeholder: "A clue only you would understand",
txt_password_hint_register_placeholder: "This hint can be shown directly on the web login page.",
txt_password_hint_register_help: "This hint can be shown directly on the web login page. Do not include your master password, recovery code, or anything that can reveal it outright.",
txt_password_hint_login_help: "Forgot the master password? Reveal the hint you saved during registration.",
txt_password_hint_login_note: "Only a hint is shown here. It should help you remember the password, not expose it.",
txt_show_password_hint: "Show Password Hint",
txt_hide_password_hint: "Hide Password Hint",
txt_loading_password_hint: "Loading hint...",
txt_password_hint_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint",
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
txt_phone: "Phone", txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password", txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password", txt_please_input_master_password: "Please input master password",
@@ -1095,6 +1108,19 @@ const zhCNOverrides: Record<string, string> = {
txt_opera_extension: 'Opera 扩展', txt_opera_extension: 'Opera 扩展',
txt_password_is_already_verified: '密码已验证', txt_password_is_already_verified: '密码已验证',
txt_passwords_do_not_match: '两次输入的密码不一致', txt_passwords_do_not_match: '两次输入的密码不一致',
txt_password_hint: '密码提示',
txt_password_hint_optional: '密码提示(可选)',
txt_password_hint_placeholder: '写一句只有你自己看得懂的线索',
txt_password_hint_register_placeholder: '这个提示可以在网页登录页直接显示。',
txt_password_hint_register_help: '这个提示可以在网页登录页直接显示。不要填写主密码、恢复代码,或任何能直接暴露密码的信息。',
txt_password_hint_login_help: '忘记主密码时,可以查看注册时保存的提示。',
txt_password_hint_login_note: '这里只会显示提示语,不会显示你的主密码本身。',
txt_show_password_hint: '查看密码提示',
txt_hide_password_hint: '隐藏密码提示',
txt_loading_password_hint: '正在加载提示...',
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_phone: '电话', txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码', txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码', txt_please_input_master_password: '请输入主密码',
+1
View File
@@ -13,6 +13,7 @@ export interface Profile {
email: string; email: string;
name: string; name: string;
key: string; key: string;
masterPasswordHint?: string | null;
privateKey?: string | null; privateKey?: string | null;
publicKey?: string | null; publicKey?: string | null;
role: 'admin' | 'user'; role: 'admin' | 'user';
+40
View File
@@ -402,6 +402,41 @@ input[type='file'].input::file-selector-button:hover {
color: #334155; color: #334155;
} }
.field-help {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: #667085;
}
.auth-support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: -2px 0 12px;
}
.auth-link-btn {
border: none;
background: transparent;
padding: 0;
color: #1d4ed8;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.auth-link-btn:hover {
text-decoration: underline;
}
.auth-link-btn:disabled {
color: #94a3b8;
cursor: not-allowed;
text-decoration: none;
}
.app-page { .app-page {
min-height: 100%; min-height: 100%;
padding: 20px; padding: 20px;
@@ -2417,6 +2452,11 @@ input[type='file'].input::file-selector-button:hover {
font-size: 18px; font-size: 18px;
} }
.auth-support-row {
align-items: center;
flex-direction: row;
}
.app-page { .app-page {
padding: 0; padding: 0;
background: #f5f7fb; background: #f5f7fb;