mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
5
Commits
fb376797d2
...
b731a014f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b731a014f1 | ||
|
|
e25ec159bb | ||
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 |
@@ -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);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const scratch = process.env.SCRATCH || '.';
|
||||||
|
const cases = [
|
||||||
|
'http://127.0.0.1',
|
||||||
|
'http://169.254.169.254',
|
||||||
|
'http://[::1]',
|
||||||
|
'http://[0:0:0:0:0:0:0:1]',
|
||||||
|
'http://[::2]',
|
||||||
|
'http://[::]',
|
||||||
|
'http://[fe80::1]',
|
||||||
|
'http://[fc00::1]',
|
||||||
|
'https://example.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const url of cases) {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
|
||||||
|
out.push({ url, allowed: true, normalized });
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
|
||||||
|
fs.writeFileSync(path, JSON.stringify(out, null, 2));
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
|
||||||
|
// Security expectation: IPv6 loopback must NOT be allowed.
|
||||||
|
const loopback = out.find((row) => row.url === 'http://[::1]');
|
||||||
|
if (loopback?.allowed) {
|
||||||
|
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
|
||||||
|
process.exitCode = 2;
|
||||||
|
} else {
|
||||||
|
console.log('IPv6 loopback rejected as expected');
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.7.3';
|
export const APP_VERSION = '1.7.4';
|
||||||
|
|||||||
+18
-6
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
+47
-14
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
|
|||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireMasterPasswordHash(
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
masterPasswordHash: unknown
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const normalized = String(masterPasswordHash || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||||
|
if (!valid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
return body && typeof body === 'object' && !Array.isArray(body)
|
||||||
|
? body as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function randomHex(bytes: number): string {
|
function randomHex(bytes: number): string {
|
||||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||||
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let body: { expiresInHours?: number } = {};
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
body = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInHours = Number.isFinite(body.expiresInHours)
|
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
|
||||||
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||||
: 24 * 7;
|
: 24 * 7;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const deleted = await storage.deleteInvite(code);
|
const deleted = await storage.deleteInvite(code);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.searchParams.get('scope') === 'invalid') {
|
if (url.searchParams.get('scope') === 'invalid') {
|
||||||
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { status?: string };
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
return errorResponse('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||||
if (!nextStatus) {
|
if (!nextStatus) {
|
||||||
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
|
|||||||
actorUser: User,
|
actorUser: User,
|
||||||
targetUserId: string
|
targetUserId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
|
||||||
if (!isAdmin(actorUser)) {
|
if (!isAdmin(actorUser)) {
|
||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
|
|||||||
return errorResponse('You cannot delete yourself', 400);
|
return errorResponse('You cannot delete yourself', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const target = await storage.getUserById(targetUserId);
|
const target = await storage.getUserById(targetUserId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
+16
-1
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
|
|||||||
|
|
||||||
// DELETE /api/devices
|
// DELETE /api/devices
|
||||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let masterPasswordHash = '';
|
||||||
|
try {
|
||||||
|
const body = await request.json() as { masterPasswordHash?: string };
|
||||||
|
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
|
||||||
|
} catch {
|
||||||
|
masterPasswordHash = '';
|
||||||
|
}
|
||||||
|
if (!masterPasswordHash) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!passwordValid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
storage.deleteRefreshTokensByUserId(userId),
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
|||||||
+138
-99
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
|
||||||
|
* Needed so compressed forms like "::1" are not misclassified by a naive
|
||||||
|
* "first non-empty hextet" check (which would read "1" and miss loopback).
|
||||||
|
*/
|
||||||
|
function expandIpv6Address(hostname: string): string[] | null {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (!normalized.includes(':')) return null;
|
||||||
|
if (normalized.includes('.')) {
|
||||||
|
// IPv4-embedded forms are handled separately by the caller.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((normalized.match(/::/g) || []).length > 1) return null;
|
||||||
|
|
||||||
|
const sides = normalized.split('::');
|
||||||
|
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
|
||||||
|
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
|
||||||
|
if (left.length + right.length > 8) return null;
|
||||||
|
if (sides.length === 1 && left.length !== 8) return null;
|
||||||
|
|
||||||
|
const missing = 8 - left.length - right.length;
|
||||||
|
if (sides.length > 1 && missing < 0) return null;
|
||||||
|
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
|
||||||
|
const parts = [...left, ...middle, ...right];
|
||||||
|
if (parts.length !== 8) return null;
|
||||||
|
|
||||||
|
const hextets: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
||||||
|
hextets.push(part.padStart(4, '0'));
|
||||||
|
}
|
||||||
|
return hextets;
|
||||||
|
}
|
||||||
|
|
||||||
function isBlockedIpv6Address(hostname: string): boolean {
|
function isBlockedIpv6Address(hostname: string): boolean {
|
||||||
if (!hostname.includes(':')) return false;
|
if (!hostname.includes(':')) return false;
|
||||||
const normalized = hostname.toLowerCase();
|
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
||||||
|
// IPv4-mapped dotted form: ::ffff:127.0.0.1
|
||||||
|
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
||||||
if (mappedIpv4) {
|
if (mappedIpv4) {
|
||||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||||
return !octets || isBlockedIpv4Address(octets);
|
return !octets || isBlockedIpv4Address(octets);
|
||||||
}
|
}
|
||||||
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
|
|
||||||
const firstHextet = Number.parseInt(firstHextetText, 16);
|
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
|
||||||
|
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||||
|
if (mappedHex) {
|
||||||
|
const hi = Number.parseInt(mappedHex[1], 16);
|
||||||
|
const lo = Number.parseInt(mappedHex[2], 16);
|
||||||
|
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
|
||||||
|
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
||||||
|
return isBlockedIpv4Address(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hextets = expandIpv6Address(normalized);
|
||||||
|
if (!hextets) return true;
|
||||||
|
const firstHextet = Number.parseInt(hextets[0], 16);
|
||||||
if (!Number.isFinite(firstHextet)) return true;
|
if (!Number.isFinite(firstHextet)) return true;
|
||||||
|
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
|
||||||
return (
|
return (
|
||||||
firstHextet === 0 ||
|
firstHextet === 0 ||
|
||||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||||
(firstHextet & 0xff00) === 0xff00 ||
|
(firstHextet & 0xff00) === 0xff00 ||
|
||||||
normalized.startsWith('2001:db8:')
|
hextets.join(':').startsWith('2001:0db8:')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-2
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -1856,6 +1895,8 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const adminActions = useAdminActions({
|
const adminActions = useAdminActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
|
email: String(profile?.email || session?.email || ''),
|
||||||
|
defaultKdfIterations,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
onSetConfirm: setConfirm,
|
onSetConfirm: setConfirm,
|
||||||
refetchUsers: usersQuery.refetch,
|
refetchUsers: usersQuery.refetch,
|
||||||
@@ -2219,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}
|
||||||
@@ -2259,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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export interface AppConfirmState {
|
|||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
hideCancel?: boolean;
|
hideCancel?: boolean;
|
||||||
onConfirm: () => void;
|
/** When true, dialog shows a master-password field and passes it to onConfirm. */
|
||||||
|
requireMasterPassword?: boolean;
|
||||||
|
onConfirm: (masterPassword?: string) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
|
|||||||
|
|
||||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const availableProviders = useMemo(
|
const availableProviders = useMemo(
|
||||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||||
[props.pendingTotpAvailableProviders]
|
[props.pendingTotpAvailableProviders]
|
||||||
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||||
|
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMethodChooserOpen(false);
|
setMethodChooserOpen(false);
|
||||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmPassword('');
|
||||||
|
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={props.confirm?.confirmText}
|
confirmText={props.confirm?.confirmText}
|
||||||
cancelText={props.confirm?.cancelText}
|
cancelText={props.confirm?.cancelText}
|
||||||
hideCancel={props.confirm?.hideCancel}
|
hideCancel={props.confirm?.hideCancel}
|
||||||
onConfirm={() => props.confirm?.onConfirm()}
|
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
|
||||||
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
|
onConfirm={() => {
|
||||||
|
if (requireMasterPassword && !confirmPassword.trim()) return;
|
||||||
|
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
|
||||||
|
setConfirmPassword('');
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmPassword('');
|
||||||
|
(props.confirm?.onCancel || props.onCancelConfirm)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requireMasterPassword && (
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.pendingTotpOpen}
|
open={props.pendingTotpOpen}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
openRemoveAllDevices() {
|
openRemoveAllDevices() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_remove_all_devices'),
|
title: t('txt_remove_all_devices'),
|
||||||
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllAuthorizedDevices(authedFetch);
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalizedPassword = String(masterPassword || '');
|
||||||
|
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||||
|
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
|
||||||
onNotify('success', t('txt_all_devices_removed'));
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
onLogoutNow();
|
onLogoutNow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
|
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
|
||||||
|
import { deriveLoginHash } from '@/lib/api/auth';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
|||||||
|
|
||||||
interface UseAdminActionsOptions {
|
interface UseAdminActionsOptions {
|
||||||
authedFetch: AuthedFetch;
|
authedFetch: AuthedFetch;
|
||||||
|
email: string;
|
||||||
|
defaultKdfIterations: number;
|
||||||
onNotify: Notify;
|
onNotify: Notify;
|
||||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||||
refetchUsers: () => Promise<unknown>;
|
refetchUsers: () => Promise<unknown>;
|
||||||
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function useAdminActions(options: UseAdminActionsOptions) {
|
export default function useAdminActions(options: UseAdminActionsOptions) {
|
||||||
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options;
|
const {
|
||||||
|
authedFetch,
|
||||||
|
email,
|
||||||
|
defaultKdfIterations,
|
||||||
|
onNotify,
|
||||||
|
onSetConfirm,
|
||||||
|
refetchUsers,
|
||||||
|
refetchInvites,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
|
||||||
|
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||||
|
const normalizedPassword = String(masterPassword || '');
|
||||||
|
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
|
||||||
|
return derived.hash;
|
||||||
|
}
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createInvite(hours: number) {
|
async createInvite(hours: number) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_create_timed_invite'),
|
||||||
|
message: t('txt_enter_master_password_to_continue'),
|
||||||
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await createInvite(authedFetch, hours);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await createInvite(authedFetch, hours, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_created'));
|
onNotify('success', t('txt_invite_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||||
|
const nextStatus = status === 'active' ? 'banned' : 'active';
|
||||||
|
onSetConfirm({
|
||||||
|
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
|
||||||
|
message: t('txt_enter_master_password_to_continue'),
|
||||||
|
danger: nextStatus === 'banned',
|
||||||
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await setUserStatus(authedFetch, userId, nextStatus, hash);
|
||||||
await refetchUsers();
|
await refetchUsers();
|
||||||
onNotify('success', t('txt_user_status_updated'));
|
onNotify('success', t('txt_user_status_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteInvite(code: string) {
|
async deleteInvite(code: string) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_invite'),
|
title: t('txt_delete_invite'),
|
||||||
message: t('txt_delete_invite_confirm_message'),
|
message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteInvite(authedFetch, code);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteInvite(authedFetch, code, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_deleted'));
|
onNotify('success', t('txt_invite_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteInvalidInvites() {
|
async deleteInvalidInvites() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_invalid_invites'),
|
title: t('txt_delete_invalid_invites'),
|
||||||
message: t('txt_delete_invalid_invites_confirm_message'),
|
message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteInvalidInvites(authedFetch);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteInvalidInvites(authedFetch, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invalid_invites_deleted'));
|
onNotify('success', t('txt_invalid_invites_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteAllInvites() {
|
async deleteAllInvites() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_all_invites'),
|
title: t('txt_delete_all_invites'),
|
||||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllInvites(authedFetch);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteAllInvites(authedFetch, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_all_invites_deleted'));
|
onNotify('success', t('txt_all_invites_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteUser(userId: string) {
|
async deleteUser(userId: string) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_user'),
|
title: t('txt_delete_user'),
|
||||||
message: t('txt_delete_this_user_and_all_user_data'),
|
message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
|
||||||
danger: true,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteUser(authedFetch, userId);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteUser(authedFetch, userId, hash);
|
||||||
await refetchUsers();
|
await refetchUsers();
|
||||||
onNotify('success', t('txt_user_deleted'));
|
onNotify('success', t('txt_user_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
[authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-12
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> {
|
export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/admin/invites', {
|
const resp = await authedFetch('/api/admin/invites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ expiresInHours: hours }),
|
body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Create invite failed');
|
if (!resp.ok) throw new Error('Create invite failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
|
export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error('Delete invite failed');
|
if (!resp.ok) throw new Error('Delete invite failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
|
const resp = await authedFetch('/api/admin/invites?scope=invalid', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error('Delete invalid invites failed');
|
if (!resp.ok) throw new Error('Delete invalid invites failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
|
const resp = await authedFetch('/api/admin/invites', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error('Delete all invites failed');
|
if (!resp.ok) throw new Error('Delete all invites failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setUserStatus(
|
export async function setUserStatus(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
userId: string,
|
userId: string,
|
||||||
status: 'active' | 'banned'
|
status: 'active' | 'banned',
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Update user status failed');
|
if (!resp.ok) throw new Error('Update user status failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> {
|
export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error('Delete user failed');
|
if (!resp.ok) throw new Error('Delete user failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}` } : {}),
|
||||||
@@ -1140,8 +1149,12 @@ export async function updateAuthorizedDeviceName(
|
|||||||
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
const resp = await authedFetch('/api/devices', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Очистить поиск",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "清空搜索",
|
||||||
|
|||||||
@@ -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": "清空搜索",
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 10;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user