mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{!props.hideCancel && (
|
||||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||||
<X size={14} className="btn-icon" />
|
<X size={14} className="btn-icon" />
|
||||||
{props.cancelText || t('txt_no')}
|
{props.cancelText || t('txt_no')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: '请输入主密码',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user