Compare commits

...
3 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub 87273a0e81 chore(deps-dev): bump @cloudflare/workers-types
Bumps [@cloudflare/workers-types](https://github.com/cloudflare/workerd) from 4.20260630.1 to 5.20260712.1.
- [Release notes](https://github.com/cloudflare/workerd/releases)
- [Changelog](https://github.com/cloudflare/workerd/blob/main/RELEASE.md)
- [Commits](https://github.com/cloudflare/workerd/commits)

---
updated-dependencies:
- dependency-name: "@cloudflare/workers-types"
  dependency-version: 5.20260712.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 21:05:26 +00:00
shuaiplus b731a014f1 fix(auth): prevent unexpected session logout 2026-07-13 02:11:12 +08:00
shuaiplus e25ec159bb feat: enhance navigation layout and improve mobile settings UI 2026-07-12 23:16:01 +08:00
29 changed files with 605 additions and 379 deletions
+5
View File
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
device_identifier TEXT, device_identifier TEXT,
device_session_stamp TEXT, device_session_stamp TEXT,
security_stamp TEXT,
created_at INTEGER,
last_used_at INTEGER,
absolute_expires_at INTEGER,
client_type TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
+4 -4
View File
@@ -21,7 +21,7 @@
"wouter": "^3.10.0" "wouter": "^3.10.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1", "@cloudflare/workers-types": "^5.20260712.1",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
@@ -698,9 +698,9 @@
} }
}, },
"node_modules/@cloudflare/workers-types": { "node_modules/@cloudflare/workers-types": {
"version": "4.20260630.1", "version": "5.20260712.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260630.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260712.1.tgz",
"integrity": "sha512-yl+c9vwvko9UZ0frmtsHuwOh3BRHvNjLrfelAp5Akpqe1+Ho1UWekr3nmjJ1D64CH0Yb0K0oRMV4i7npOFzsog==", "integrity": "sha512-tkQ18Lz41EPXLzh4cSuIGQzztDVueuGfTcjlP4M7Qa0rfHpVBNItf3GRkd9O0JgiXs9csAwz/kVv7sjYXSoF2w==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0" "license": "MIT OR Apache-2.0"
}, },
+1 -1
View File
@@ -49,7 +49,7 @@
"ws": "8.21.0" "ws": "8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1", "@cloudflare/workers-types": "^5.20260712.1",
"@preact/preset-vite": "^2.10.5", "@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
"autoprefixer": "^10.5.2", "autoprefixer": "^10.5.2",
+18 -6
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds. // Access token lifetime in seconds.
// 访问令牌有效期(秒)。 // 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // Refresh sessions use a reusable opaque token with a sliding idle lifetime.
// 刷新令牌有效期(毫秒) // 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000, refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms). refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
refreshTokenOverlapGraceMs: 30 * 60 * 1000, // Hard upper bound for one login session, regardless of sliding refreshes.
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -62,6 +64,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Coarser IP budget; the per-session budget above remains the primary guard.
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
refreshTokenRequestsPerIpMinute: 300,
// Passwordless/auth-request creation budget per IP/email/device per minute. // Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5, authRequestRequestsPerMinute: 5,
@@ -159,3 +164,10 @@
cipherKeyEncryptionFeatureEnabled: true, cipherKeyEncryptionFeatureEnabled: true,
}, },
} as const; } as const;
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
const normalized = String(clientType || '').trim().toLowerCase();
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
}
+138 -99
View File
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
@@ -42,6 +42,10 @@ const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function identityJsonResponse(data: unknown, status: number = 200): Response {
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
}
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
return userSecret; return userSecret;
@@ -60,6 +64,33 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
if (shouldUseWebSession(request)) return 'web';
const clientId = String(body.client_id || '').trim().toLowerCase();
if (clientId === 'mobile') return 'mobile';
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
return clientId || 'other';
}
async function persistAndResolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
if (!candidate) return null;
await storage.upsertDevice(
userId,
candidate.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
candidate.sessionStamp
);
const persisted = await storage.getDevice(userId, candidate.identifier);
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string { function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
} }
@@ -163,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
headers.append( headers.append(
'Set-Cookie', 'Set-Cookie',
refreshToken refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) ? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
: buildClearedRefreshCookie(request) : buildClearedRefreshCookie(request)
); );
return new Response(response.body, { return new Response(response.body, {
@@ -173,30 +204,6 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -267,7 +274,7 @@ async function twoFactorRequiredResponse(
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
@@ -341,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request); const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) { if (!clientIdentifier && grantType !== 'refresh_token') {
return identityErrorResponse('Client IP is required', 'invalid_request', 403); await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'error',
targetType: 'tokenEndpoint',
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Authentication is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
} }
if (grantType === 'password') { if (grantType === 'password') {
@@ -359,7 +378,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -550,15 +569,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -569,7 +581,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -612,14 +624,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const token = String(body.token || '').trim(); const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -673,22 +685,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
@@ -734,7 +739,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -751,7 +756,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5); const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -805,15 +810,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
@@ -821,7 +819,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
@@ -863,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
@@ -880,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const sendId = String(body.send_id || body.sendId || '').trim(); const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) { if (!sendId) {
return jsonResponse( return identityJsonResponse(
{ {
error: 'invalid_request', error: 'invalid_request',
error_description: 'send_id is required', error_description: 'send_id is required',
@@ -905,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
clientIdentifier clientIdentifier || undefined
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
} }
return jsonResponse({ return identityJsonResponse({
access_token: result.token, access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
@@ -919,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true, unofficialServer: true,
}); });
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = String(body.refresh_token || '').trim() || ( const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request) shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE) ? parseCookieValue(request, WEB_REFRESH_COOKIE)
@@ -941,7 +926,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessTokenDetailed(refreshToken); const refreshTokenHash = await sha256Hex(refreshToken);
try {
const sessionLimit = await rateLimit.consumeBudget(
`refresh-session:${refreshTokenHash}`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
const ipLimit = clientIdentifier
? await rateLimit.consumeBudget(
`refresh-ip:${clientIdentifier}`,
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
)
: null;
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
if (rejected) {
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
return identityErrorResponse(
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
'temporarily_unavailable',
429,
{ 'Retry-After': String(retryAfter) }
);
}
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.rate_limit_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!clientIdentifier) {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'warn',
targetType: 'refreshToken',
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
}
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
try {
result = await auth.refreshAccessTokenDetailed(refreshToken);
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.temporarily_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!result.ok) { if (!result.ok) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null, actorUserId: result.userId ?? null,
@@ -963,18 +1013,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: invalidResponse; : invalidResponse;
} }
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) { if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier); await storage.touchDeviceLastSeen(user.id, device.identifier);
} }
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user); const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -982,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
@@ -1001,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
const baseResponse = jsonResponse(response); const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken) ? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse; : baseResponse;
} }
@@ -1036,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism)); return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
@@ -1044,12 +1086,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
try { try {
@@ -1060,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
body = await request.json(); body = await request.json();
} }
} catch { } catch {
return new Response(null, { status: 200 }); return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
} }
const token = String(body.token || '').trim() || ( const token = String(body.token || '').trim() || (
@@ -1072,7 +1108,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
await storage.deleteRefreshToken(token); await storage.deleteRefreshToken(token);
} }
const baseResponse = new Response(null, { status: 200 }); const baseResponse = new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
});
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null) ? withWebRefreshCookie(request, baseResponse, null)
: baseResponse; : baseResponse;
+41 -8
View File
@@ -1,5 +1,6 @@
import { Env, JWTPayload, User } from '../types'; import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage'; import { StorageService } from './storage';
// Server-side iterations for second-layer hashing. // Server-side iterations for second-layer hashing.
@@ -28,11 +29,12 @@ export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired' | 'token_not_found_or_expired'
| 'user_missing' | 'user_missing'
| 'user_inactive' | 'user_inactive'
| 'security_stamp_mismatch'
| 'device_missing' | 'device_missing'
| 'device_session_mismatch'; | 'device_session_mismatch';
export type RefreshAccessTokenResult = export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| { | {
ok: false; ok: false;
reason: RefreshAccessTokenFailureReason; reason: RefreshAccessTokenFailureReason;
@@ -190,9 +192,23 @@ export class AuthService {
} }
// Generate refresh token // Generate refresh token
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> { async generateRefreshToken(
user: User,
device?: { identifier: string; sessionStamp: string } | null,
clientType: string = 'other'
): Promise<string> {
const token = createRefreshToken(); const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null); const now = Date.now();
await this.storage.saveRefreshToken(
token,
user.id,
now + getRefreshTokenSlidingTtlMs(clientType),
device?.identifier ?? null,
device?.sessionStamp ?? null,
user.securityStamp,
clientType,
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
);
return token; return token;
} }
@@ -251,25 +267,42 @@ export class AuthService {
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; if (record.securityStamp && record.securityStamp !== user.securityStamp) {
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.securityStamp) {
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
} }
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) { if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (boundDevice.sessionStamp !== record.deviceSessionStamp) { if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier }; return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
if (!record.deviceSessionStamp) {
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp }; device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const now = Date.now();
const expiresAt = Math.min(
now + getRefreshTokenSlidingTtlMs(record.clientType),
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
if (!extended) {
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device }; return { ok: true, accessToken, user, device, expiresAt };
} }
async refreshAccessToken( async refreshAccessToken(
+4 -2
View File
@@ -45,7 +45,8 @@ export async function upsertDevice(
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
@@ -66,7 +67,8 @@ export async function upsertDevice(
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
now now,
''
) )
.run(); .run();
} }
+91 -34
View File
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
userId: string, userId: string,
expiresAtMs: number, expiresAtMs: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now()); await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const now = Date.now();
await db await db
.prepare( .prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
)
.bind(
tokenKey,
userId,
expiresAtMs,
deviceIdentifier ?? null,
deviceSessionStamp ?? null,
securityStamp ?? null,
now,
now,
absoluteExpiresAtMs ?? null,
clientType ?? null
) )
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run(); .run();
} }
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
const row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
security_stamp: string | null;
created_at: number | null;
last_used_at: number | null;
absolute_expires_at: number | null;
client_type: string | null;
}>();
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
return null; return null;
} }
@@ -50,9 +81,62 @@ export async function getRefreshTokenRecord(
expiresAt: row.expires_at, expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null, deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null, deviceSessionStamp: row.device_session_stamp ?? null,
securityStamp: row.security_stamp ?? null,
createdAt: row.created_at ?? null,
lastUsedAt: row.last_used_at ?? null,
absoluteExpiresAt: row.absolute_expires_at ?? null,
clientType: row.client_type ?? null,
}; };
} }
export async function extendRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
requestedExpiresAtMs: number,
nowMs: number
): Promise<boolean> {
const tokenKey = await refreshTokenKey(token);
const result = await db
.prepare(
'UPDATE refresh_tokens SET ' +
'expires_at = CASE ' +
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
'ELSE ? END, ' +
'last_used_at = ? ' +
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
)
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function bindRefreshTokenSecurityStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
securityStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
.bind(securityStamp, tokenKey, '')
.run();
}
export async function bindRefreshTokenDeviceStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
deviceSessionStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
.bind(deviceSessionStamp, tokenKey, '')
.run();
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> { export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
@@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
.run(); .run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+12 -1
View File
@@ -74,11 +74,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE sends ADD COLUMN emails TEXT', 'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
'CREATE TABLE IF NOT EXISTS invites (' + 'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -118,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+24 -10
View File
@@ -87,10 +87,12 @@ import {
saveSend as saveStoredSend, saveSend as saveStoredSend,
} from './storage-send-repo'; } from './storage-send-repo';
import { import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
deleteRefreshToken as deleteStoredRefreshToken, deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
getRefreshTokenRecord as findStoredRefreshTokenRecord, getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken, saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo'; } from './storage-refresh-token-repo';
@@ -162,7 +164,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa'; const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
@@ -632,9 +634,13 @@ export class StorageService {
userId: string, userId: string,
expiresAtMs?: number, expiresAtMs?: number,
deviceIdentifier?: string | null, deviceIdentifier?: string | null,
deviceSessionStamp?: string | null deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> { ): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); const now = Date.now();
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
await saveStoredRefreshToken( await saveStoredRefreshToken(
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
@@ -643,7 +649,10 @@ export class StorageService {
userId, userId,
expiresAt, expiresAt,
deviceIdentifier, deviceIdentifier,
deviceSessionStamp deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
); );
} }
@@ -717,11 +726,16 @@ export class StorageService {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
} }
// Keep a short overlap window for rotated refresh token to reduce async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
// multi-context refresh races (e.g. browser extension popup/background). return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
// Expiry is only tightened, never extended. }
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
}
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
} }
private async trustedTwoFactorTokenKey(token: string): Promise<string> { private async trustedTwoFactorTokenKey(token: string): Promise<string> {
+5
View File
@@ -402,6 +402,11 @@ export interface RefreshTokenRecord {
expiresAt: number; expiresAt: number;
deviceIdentifier: string | null; deviceIdentifier: string | null;
deviceSessionStamp: string | null; deviceSessionStamp: string | null;
securityStamp: string | null;
createdAt: number | null;
lastUsedAt: number | null;
absoluteExpiresAt: number | null;
clientType: string | null;
} }
export interface TrustedDeviceTokenSummary { export interface TrustedDeviceTokenSummary {
+8 -2
View File
@@ -144,7 +144,12 @@ export function unsupportedResponse(message: string = 'This feature is not suppo
} }
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(
message: string,
error: string = 'invalid_grant',
status: number = 400,
headers: Record<string, string> = {}
): Response {
return jsonResponse( return jsonResponse(
{ {
error: error, error: error,
@@ -154,7 +159,8 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
Object: 'error', Object: 'error',
}, },
}, },
status status,
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
); );
} }
+47 -2
View File
@@ -253,6 +253,8 @@ export default function App() {
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes()); const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction()); const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email); const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -269,6 +271,7 @@ export default function App() {
const [vaultDecryptError, setVaultDecryptError] = useState(''); const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false); const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const lockedSessionRetryAttemptRef = useRef(0);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
@@ -503,13 +506,15 @@ export default function App() {
if (phase !== 'locked' || !session) return; if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
let cancelled = false; let cancelled = false;
let retryTimerId: number | null = null;
void (async () => { void (async () => {
const result = await hydrateLockedSession(session, profile); const result = await hydrateLockedSession(session, profile);
if (cancelled) return; if (cancelled) return;
if (!result.session) { if (result.kind === 'expired') {
setSession(null); setSession(null);
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('login'); setPhase('login');
if (location !== '/login') navigate('/login'); if (location !== '/login') navigate('/login');
return; return;
@@ -518,11 +523,43 @@ export default function App() {
if (result.profile) { if (result.profile) {
setProfile(stripProfileSecrets(result.profile)); setProfile(stripProfileSecrets(result.profile));
} }
if (result.kind === 'transient') {
setUnlockPreparing(false);
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
lockedSessionRetryAttemptRef.current += 1;
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
retryTimerId = window.setTimeout(() => {
setLockedSessionRetryKey((value) => value + 1);
}, retryAfterMs);
return;
}
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
useEffect(() => {
if (!lockedSessionRefreshError || phase !== 'locked') return;
const retryNow = () => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRetryKey((value) => value + 1);
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') retryNow();
};
window.addEventListener('online', retryNow);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', retryNow);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [lockedSessionRefreshError, phase]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current = loginScopedBackupRepairAuthRef.current =
@@ -536,6 +573,7 @@ export default function App() {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null); setPendingTotpMode(null);
setPendingPasskeyPassword(null); setPendingPasskeyPassword(null);
@@ -884,6 +922,7 @@ export default function App() {
setPendingTotpMode(null); setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
@@ -2221,6 +2260,7 @@ export default function App() {
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined} unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
sessionRefreshError={lockedSessionRefreshError}
loginValues={loginValues} loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword} passkeyPassword={passkeyPassword}
@@ -2261,6 +2301,11 @@ export default function App() {
onLogout={logoutNow} onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()} onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint} onShowLockedPasswordHint={handleShowLockedPasswordHint}
onRetrySessionRefresh={() => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
setLockedSessionRetryKey((value) => value + 1);
}}
/> />
<AppGlobalOverlays <AppGlobalOverlays
toasts={toasts} toasts={toasts}
+57 -157
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact'; import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart'; const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode'; const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode { type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
if (typeof window === 'undefined') return 'flat'; type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try { try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY); const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved; if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch { } catch {
// Ignore local preference read failures. // Ignore local preference read failures.
} }
return 'flat'; return DEFAULT_EXPANDED_GROUPS;
} }
function isAdminProfile(profile: Profile | null): boolean { function isAdminProfile(profile: Profile | null): boolean {
@@ -55,58 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules'; const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive; const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
useEffect(() => { function toggleGroup(group: NavGroup): void {
const onPointerDown = (event: Event) => { setExpandedGroups((current) => {
if (!navLayoutPickerOpen) return; const next = { ...current, [group]: !current[group] };
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try { try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode); window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch { } catch {
// Ignore local preference write failures. // Ignore local preference write failures.
} }
} return next;
});
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
} }
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) { function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -127,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
} }
function renderNavGroup( function renderNavGroup(
group: keyof typeof expandedGroups, group: NavGroup,
title: string, title: string,
icon: ComponentChildren, icon: ComponentChildren,
active: boolean,
children: ComponentChildren children: ComponentChildren
) { ) {
const open = groupOpen(group, active); const open = expandedGroups[group];
return ( return (
<div className={`side-nav-group ${open ? 'open' : ''}`}> <div className={`side-nav-group ${open ? 'open' : ''}`}>
<button <button
type="button" type="button"
className={`side-group-trigger ${active ? 'active' : ''}`} className="side-group-trigger"
aria-expanded={open} aria-expanded={open}
onClick={() => toggleGroup(group)} onClick={() => toggleGroup(group)}
> >
@@ -155,82 +128,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
); );
} }
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
</>
);
const groupedNav = ( const groupedNav = (
<> <>
{renderNavGroup( {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup( {renderNavGroup(
'settings', 'tools',
t('txt_settings'), t('nav_group_tools'),
<SettingsIcon size={16} />, <Sparkles size={16} />,
settingsActive,
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))} {renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))} {renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
'management', 'settings',
t('nav_group_management'), t('txt_settings'),
<ShieldUser size={16} />, <SettingsIcon size={16} />,
managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</> </>
)} )}
</> </>
@@ -281,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main"> <div className="app-main">
<aside className="app-side"> <aside className="app-side">
<div className="side-nav-main"> <div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav} {groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
+26 -18
View File
@@ -210,9 +210,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return ( return (
<Switch> <Switch>
<Route path="/security/password-health"> <Route path="/security/password-health">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} /> <PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense> </Suspense>
</div>
</Route> </Route>
<Route path="/generator"> <Route path="/generator">
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
@@ -333,6 +343,19 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card settings-home-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('nav_group_tools')}</h3>
<div className="mobile-settings-links">
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
</div>
</div>
<div className="settings-home-section"> <div className="settings-home-section">
<h3>{t('txt_settings')}</h3> <h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
@@ -340,10 +363,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span> <span>{t('nav_account_settings')}</span>
</Link> </Link>
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
<Link href="/settings/security/device-management" className="mobile-settings-link"> <Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} /> <Shield size={18} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
@@ -354,25 +373,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
</div> </div>
</div> </div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && ( {isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_system_management')}</h3>
<div className="mobile-settings-links">
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
</Link> </Link>
)}
</div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link"> <Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} /> <ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
+15
View File
@@ -27,6 +27,7 @@ interface AuthViewsProps {
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null; pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
sessionRefreshError?: string;
loginValues: LoginValues; loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null; pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string; passkeyPassword: string;
@@ -50,6 +51,7 @@ interface AuthViewsProps {
onLogout: () => void; onLogout: () => void;
onTogglePasswordHint: () => void; onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void; onShowLockedPasswordHint: () => void;
onRetrySessionRefresh: () => void;
} }
function PasswordField(props: { function PasswordField(props: {
@@ -155,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
{props.sessionRefreshError ? (
<div className="offline-mode-notice" role="alert" aria-live="polite">
<AlertTriangle size={18} />
<div>
<strong>{props.sessionRefreshError}</strong>
<div>
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
{t('txt_refresh')}
</button>
</div>
</div>
</div>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
+14 -5
View File
@@ -42,6 +42,7 @@ interface RefreshFailure {
ok: false; ok: false;
transient: boolean; transient: boolean;
error: string; error: string;
retryAfterMs?: number;
} }
interface RefreshSuccess { interface RefreshSuccess {
@@ -333,8 +334,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
return json; return json;
} }
function isTransientRefreshStatus(status: number): boolean { function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
return status === 0 || status === 429 || status >= 500; return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
} }
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> { export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
@@ -346,6 +347,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
try { try {
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
@@ -354,15 +357,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
}); });
if (!resp.ok) { if (!resp.ok) {
const json = await parseJson<TokenError>(resp); const json = await parseJson<TokenError>(resp);
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
return { return {
ok: false, ok: false,
transient: isTransientRefreshStatus(resp.status), transient: !isPermanentRefreshFailure(resp.status, json?.error),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? { retryAfterMs: retryAfterSeconds * 1000 }
: {}),
}; };
} }
const json = await parseJson<TokenSuccess>(resp); const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) { if (!json?.access_token) {
return { ok: false, transient: false, error: t('txt_session_refresh_failed') }; return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
} }
return { ok: true, token: json }; return { ok: true, token: json };
} catch (error) { } catch (error) {
@@ -400,6 +407,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
} }
await fetch('/identity/connect/revocation', { await fetch('/identity/connect/revocation', {
method: 'POST', method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
+44 -9
View File
@@ -199,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
} }
} }
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> { type SessionRefreshOutcome =
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null; | { kind: 'success'; session: SessionState }
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
| { kind: 'expired' };
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
if (!session.refreshToken && session.authMode !== 'web-cookie') {
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
}
const exp = decodeJwtExp(session.accessToken); const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (session.accessToken && exp !== null && exp - nowSeconds > 60) { if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session; return { kind: 'success', session };
} }
const refreshed = await refreshAccessToken(session); const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) { if (!refreshed.ok) {
if (refreshed.transient) return session; if (refreshed.transient) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null; return {
kind: 'transient',
session,
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
retryAfterMs: refreshed.retryAfterMs,
};
}
return { kind: 'expired' };
} }
return { return {
kind: 'success',
session: {
...session, ...session,
accessToken: refreshed.token.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
},
}; };
} }
@@ -388,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
export async function hydrateLockedSession( export async function hydrateLockedSession(
session: SessionState, session: SessionState,
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<
| { kind: 'ready'; session: SessionState; profile: Profile | null }
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
| { kind: 'expired'; session: null; profile: null }
> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
if (hasOfflineUnlock && browserReportsOffline()) { if (hasOfflineUnlock && browserReportsOffline()) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
const refreshedSession = await maybeRefreshSession(session); const refreshOutcome = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (refreshOutcome.kind === 'expired') {
return { kind: 'expired', session: null, profile: null };
}
if (refreshOutcome.kind === 'transient') {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
kind: 'ready',
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
}; };
} }
return { session: null, profile: null }; return {
kind: 'transient',
session,
profile: fallbackProfile,
message: refreshOutcome.message,
retryAfterMs: refreshOutcome.retryAfterMs,
};
} }
const refreshedSession = refreshOutcome.session;
try { try {
const profile = await getProfile( const profile = await getProfile(
createAuthedFetch( createAuthedFetch(
@@ -415,11 +448,13 @@ export async function hydrateLockedSession(
) )
); );
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile, profile,
}; };
} catch { } catch {
return { return {
kind: 'ready',
session: refreshedSession, session: refreshedSession,
profile: fallbackProfile, profile: fallbackProfile,
}; };
+3
View File
@@ -12,6 +12,8 @@ const de: Record<string, string> = {
"nav_import_export": "Import und Export", "nav_import_export": "Import und Export",
"nav_group_data_backup": "Daten & Backup", "nav_group_data_backup": "Daten & Backup",
"nav_group_management": "Verwaltung", "nav_group_management": "Verwaltung",
"nav_group_tools": "Werkzeuge",
"nav_group_system_management": "Systemverwaltung",
"txt_settings_appearance": "Erscheinungsbild", "txt_settings_appearance": "Erscheinungsbild",
"txt_theme": "Design", "txt_theme": "Design",
"txt_use_system_theme": "Systemdesign verwenden", "txt_use_system_theme": "Systemdesign verwenden",
@@ -979,6 +981,7 @@ const de: Record<string, string> = {
"txt_save_profile_failed": "Fehler beim Speichern des Profils", "txt_save_profile_failed": "Fehler beim Speichern des Profils",
"txt_search_sends": "Sendungen suchen...", "txt_search_sends": "Sendungen suchen...",
"txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.", "txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.",
"txt_session_refresh_temporarily_unavailable": "Die Sitzung kann vorübergehend nicht geprüft werden. Die Anmeldung bleibt erhalten und wird erneut versucht.",
"txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...", "txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...",
"txt_search_items_count": "In {count} Einträgen suchen...", "txt_search_items_count": "In {count} Einträgen suchen...",
"txt_clear_search": "Suche löschen", "txt_clear_search": "Suche löschen",
+3
View File
@@ -35,6 +35,8 @@ const en: Record<string, string> = {
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
"nav_group_management": "Management", "nav_group_management": "Management",
"nav_group_tools": "Tools",
"nav_group_system_management": "System Management",
"txt_settings_appearance": "Appearance", "txt_settings_appearance": "Appearance",
"txt_theme": "Theme", "txt_theme": "Theme",
"txt_use_system_theme": "Use system theme", "txt_use_system_theme": "Use system theme",
@@ -1002,6 +1004,7 @@ const en: Record<string, string> = {
"txt_save_profile_failed": "Save profile failed", "txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...", "txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.", "txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
"txt_search_your_secure_vault": "Search your secure vault...", "txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...", "txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search", "txt_clear_search": "Clear search",
+3
View File
@@ -12,6 +12,8 @@ const es: Record<string, string> = {
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
"nav_group_management": "Gestión", "nav_group_management": "Gestión",
"nav_group_tools": "Herramientas",
"nav_group_system_management": "Administración del sistema",
"txt_settings_appearance": "Apariencia", "txt_settings_appearance": "Apariencia",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usar tema del sistema", "txt_use_system_theme": "Usar tema del sistema",
@@ -979,6 +981,7 @@ const es: Record<string, string> = {
"txt_save_profile_failed": "Error al guardar perfil", "txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...", "txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.", "txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...", "txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...", "txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda", "txt_clear_search": "Limpiar búsqueda",
+3
View File
@@ -12,6 +12,8 @@ const fi: Record<string, string> = {
"nav_import_export": "Tuonti ja Vienti", "nav_import_export": "Tuonti ja Vienti",
"nav_group_data_backup": "Data & Varmuuskopiointi", "nav_group_data_backup": "Data & Varmuuskopiointi",
"nav_group_management": "Hallinta", "nav_group_management": "Hallinta",
"nav_group_tools": "Työkalut",
"nav_group_system_management": "Järjestelmän hallinta",
"txt_settings_appearance": "Ulkoasu", "txt_settings_appearance": "Ulkoasu",
"txt_theme": "Teema", "txt_theme": "Teema",
"txt_use_system_theme": "Käytä järjestelmän teemaa", "txt_use_system_theme": "Käytä järjestelmän teemaa",
@@ -979,6 +981,7 @@ const fi: Record<string, string> = {
"txt_save_profile_failed": "Profiilin tallennus epäonnistui", "txt_save_profile_failed": "Profiilin tallennus epäonnistui",
"txt_search_sends": "Hae lähetyksiä...", "txt_search_sends": "Hae lähetyksiä...",
"txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.", "txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.",
"txt_session_refresh_temporarily_unavailable": "Istuntoa ei voida tarkistaa juuri nyt. Kirjautuminen säilytetään ja tarkistusta yritetään uudelleen.",
"txt_search_your_secure_vault": "Hae turvallisesta holvistasi...", "txt_search_your_secure_vault": "Hae turvallisesta holvistasi...",
"txt_search_items_count": "Hae {count} nimikkeen joukosta...", "txt_search_items_count": "Hae {count} nimikkeen joukosta...",
"txt_clear_search": "Tyhjennä haku", "txt_clear_search": "Tyhjennä haku",
+3
View File
@@ -12,6 +12,8 @@ const fr: Record<string, string> = {
"nav_import_export": "Importer & Exporter", "nav_import_export": "Importer & Exporter",
"nav_group_data_backup": "Données & Sauvegarde", "nav_group_data_backup": "Données & Sauvegarde",
"nav_group_management": "Gestion", "nav_group_management": "Gestion",
"nav_group_tools": "Outils",
"nav_group_system_management": "Administration système",
"txt_settings_appearance": "Apparence", "txt_settings_appearance": "Apparence",
"txt_theme": "Thème", "txt_theme": "Thème",
"txt_use_system_theme": "Utiliser le thème du système", "txt_use_system_theme": "Utiliser le thème du système",
@@ -979,6 +981,7 @@ const fr: Record<string, string> = {
"txt_save_profile_failed": "L'enregistrement du profil a échoué", "txt_save_profile_failed": "L'enregistrement du profil a échoué",
"txt_search_sends": "Rechercher des envois...", "txt_search_sends": "Rechercher des envois...",
"txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.", "txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.",
"txt_session_refresh_temporarily_unavailable": "La session ne peut pas être vérifiée temporairement. Votre connexion est conservée et une nouvelle tentative sera effectuée.",
"txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...", "txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...",
"txt_search_items_count": "Rechercher parmi {count} éléments...", "txt_search_items_count": "Rechercher parmi {count} éléments...",
"txt_clear_search": "Effacer la recherche", "txt_clear_search": "Effacer la recherche",
+3
View File
@@ -12,6 +12,8 @@ const it: Record<string, string> = {
"nav_import_export": "Importa ed Esporta", "nav_import_export": "Importa ed Esporta",
"nav_group_data_backup": "Dati e Backup", "nav_group_data_backup": "Dati e Backup",
"nav_group_management": "Gestione", "nav_group_management": "Gestione",
"nav_group_tools": "Strumenti",
"nav_group_system_management": "Gestione del sistema",
"txt_settings_appearance": "Aspetto", "txt_settings_appearance": "Aspetto",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Usa il tema del sistema", "txt_use_system_theme": "Usa il tema del sistema",
@@ -979,6 +981,7 @@ const it: Record<string, string> = {
"txt_save_profile_failed": "Salvataggio Profilo fallito", "txt_save_profile_failed": "Salvataggio Profilo fallito",
"txt_search_sends": "Cerca invii...", "txt_search_sends": "Cerca invii...",
"txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.", "txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.",
"txt_session_refresh_temporarily_unavailable": "La sessione non può essere verificata temporaneamente. L'accesso viene mantenuto e verrà effettuato un nuovo tentativo.",
"txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...", "txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...",
"txt_search_items_count": "Cerca in {count} elementi...", "txt_search_items_count": "Cerca in {count} elementi...",
"txt_clear_search": "Cancella Ricerca", "txt_clear_search": "Cancella Ricerca",
+3
View File
@@ -13,6 +13,8 @@ const ru: Record<string, string> = {
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
"nav_group_management": "Управление", "nav_group_management": "Управление",
"nav_group_tools": "Инструменты",
"nav_group_system_management": "Управление системой",
"txt_settings_appearance": "Внешний вид", "txt_settings_appearance": "Внешний вид",
"txt_theme": "Тема", "txt_theme": "Тема",
"txt_use_system_theme": "Использовать системную тему", "txt_use_system_theme": "Использовать системную тему",
@@ -979,6 +981,7 @@ const ru: Record<string, string> = {
"txt_save_profile_failed": "Сохранить профиль не удалось", "txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...", "txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.", "txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...", "txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...", "txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск", "txt_clear_search": "Очистить поиск",
+3
View File
@@ -12,6 +12,8 @@ const sv: Record<string, string> = {
"nav_import_export": "Importera och Exportera", "nav_import_export": "Importera och Exportera",
"nav_group_data_backup": "Data och Säkerhetskopiering", "nav_group_data_backup": "Data och Säkerhetskopiering",
"nav_group_management": "Hantering", "nav_group_management": "Hantering",
"nav_group_tools": "Verktyg",
"nav_group_system_management": "Systemadministration",
"txt_settings_appearance": "Utseende", "txt_settings_appearance": "Utseende",
"txt_theme": "Tema", "txt_theme": "Tema",
"txt_use_system_theme": "Använd systemtema", "txt_use_system_theme": "Använd systemtema",
@@ -979,6 +981,7 @@ const sv: Record<string, string> = {
"txt_save_profile_failed": "Misslyckades med att spara profil", "txt_save_profile_failed": "Misslyckades med att spara profil",
"txt_search_sends": "Sök sändningar...", "txt_search_sends": "Sök sändningar...",
"txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.", "txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.",
"txt_session_refresh_temporarily_unavailable": "Sessionen kan inte verifieras tillfälligt. Inloggningen bevaras och ett nytt försök görs.",
"txt_search_your_secure_vault": "Sök i ditt säkra valv...", "txt_search_your_secure_vault": "Sök i ditt säkra valv...",
"txt_search_items_count": "Sök bland {count} objekt...", "txt_search_items_count": "Sök bland {count} objekt...",
"txt_clear_search": "Rensa sökning", "txt_clear_search": "Rensa sökning",
+3
View File
@@ -15,6 +15,8 @@ const zhCN: Record<string, string> = {
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系统管理",
"txt_settings_appearance": "外观", "txt_settings_appearance": "外观",
"txt_theme": "主题", "txt_theme": "主题",
"txt_use_system_theme": "使用系统主题", "txt_use_system_theme": "使用系统主题",
@@ -982,6 +984,7 @@ const zhCN: Record<string, string> = {
"txt_save_profile_failed": "保存资料失败", "txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录", "txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
"txt_search_your_secure_vault": "搜索你的密码库...", "txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...", "txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
+3
View File
@@ -15,6 +15,8 @@ const zhTW: Record<string, string> = {
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
"nav_group_management": "管理", "nav_group_management": "管理",
"nav_group_tools": "工具",
"nav_group_system_management": "系統管理",
"txt_settings_appearance": "外觀", "txt_settings_appearance": "外觀",
"txt_theme": "主題", "txt_theme": "主題",
"txt_use_system_theme": "使用系統主題", "txt_use_system_theme": "使用系統主題",
@@ -982,6 +984,7 @@ const zhTW: Record<string, string> = {
"txt_save_profile_failed": "保存資料失敗", "txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入", "txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
"txt_search_your_secure_vault": "搜索你的密碼庫...", "txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...", "txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
+1 -1
View File
@@ -677,7 +677,7 @@ h4 {
} }
.card { .card {
margin-bottom: 0px; margin-bottom: 10;
padding: 14px; padding: 14px;
} }