Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 |
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
|
api_key TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
// clientSecret length
|
||||||
|
// clientSecret 长度
|
||||||
|
clientSecretLength: 30,
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
// Max failed login attempts before temporary lock.
|
// Max failed login attempts before temporary lock.
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
verifyDevices: true,
|
verifyDevices: true,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
|
apiKey: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -751,3 +752,68 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/api-key
|
||||||
|
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
return apiKey(request, env, userId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/rotate-api-key
|
||||||
|
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
return apiKey(request, env, userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, string | undefined>;
|
||||||
|
try {
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
body = await request.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
||||||
|
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
|
if (rotate || user.apiKey === null) {
|
||||||
|
// Upstream apikeys are 30-character random alphanumeric strings
|
||||||
|
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||||
|
if (rotate) {
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
apiKey: user.apiKey,
|
||||||
|
revisionDate: user.updatedAt,
|
||||||
|
object: 'apiKey',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
||||||
|
function randomStringAlphanum(length: number): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[array[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ function parseCookieValue(request: Request, name: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function constantTimeEquals(a: string, b: string): boolean {
|
||||||
|
const encA = new TextEncoder().encode(a);
|
||||||
|
const encB = new TextEncoder().encode(b);
|
||||||
|
if (encA.length !== encB.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < encA.length; i++) {
|
||||||
|
diff |= encA[i] ^ encB[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
const isHttps = new URL(request.url).protocol === 'https:';
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -361,6 +373,98 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
|
|
||||||
|
} else if (grantType === 'client_credentials') {
|
||||||
|
// Login with client credentials
|
||||||
|
const clientId = body.client_id;
|
||||||
|
const clientSecret = body.client_secret;
|
||||||
|
const scope = body.scope;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
|
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
||||||
|
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||||
|
if (!parmValid) {
|
||||||
|
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
|
if (!loginCheck.allowed) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = clientId.slice(5);
|
||||||
|
const user = await storage.getUserById(uid);
|
||||||
|
if (!user) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist device only after successful client credential verification.
|
||||||
|
const deviceSession =
|
||||||
|
deviceInfo.deviceIdentifier
|
||||||
|
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||||
|
: null;
|
||||||
|
if (deviceSession) {
|
||||||
|
await storage.upsertDevice(
|
||||||
|
user.id,
|
||||||
|
deviceSession.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
deviceSession.sessionStamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login - clear failed attempts
|
||||||
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
|
const response: TokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
|
Key: user.key,
|
||||||
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: accountKeys,
|
||||||
|
accountKeys: accountKeys,
|
||||||
|
Kdf: user.kdfType,
|
||||||
|
KdfIterations: user.kdfIterations,
|
||||||
|
KdfMemory: user.kdfMemory,
|
||||||
|
KdfParallelism: user.kdfParallelism,
|
||||||
|
ForcePasswordReset: false,
|
||||||
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
|
scope: 'api offline_access',
|
||||||
|
unofficialServer: true,
|
||||||
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
if (!sendAccessLimit.allowed) {
|
if (!sendAccessLimit.allowed) {
|
||||||
@@ -553,3 +657,16 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
? withWebRefreshCookie(request, baseResponse, null)
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||||
|
if (scope !== 'api') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientId.startsWith('user.')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!clientSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetApiKey,
|
||||||
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
import {
|
import {
|
||||||
handleGetCiphers,
|
handleGetCiphers,
|
||||||
@@ -119,6 +121,14 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleSetVerifyDevices(request, env, userId);
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||||
|
return handleGetApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||||
|
return handleRotateApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
return handleSync(request, env, userId);
|
return handleSync(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNwIconSvg(): string {
|
function getDefaultWebsiteIconSvg(): string {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNwFavicon(): Response {
|
function handleNwFavicon(): Response {
|
||||||
return new Response(getNwIconSvg(), {
|
return new Response(getDefaultWebsiteIconSvg(), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
@@ -66,6 +66,15 @@ function handleNwFavicon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMissingWebsiteIcon(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
function buildIconServiceBase(origin: string): string {
|
||||||
return `${origin}/icons`;
|
return `${origin}/icons`;
|
||||||
}
|
}
|
||||||
@@ -127,9 +136,9 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
const encodedHost = encodeURIComponent(normalizedHost);
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
@@ -172,9 +181,9 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleNwFavicon();
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
} catch {
|
} catch {
|
||||||
return handleNwFavicon();
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +230,8 @@ export async function handlePublicRoute(
|
|||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
return handleWebsiteIcon(iconMatch[1]);
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
|
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ export async function buildBackupArchive(
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
|
|||||||
@@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('users'),
|
tableName('users'),
|
||||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
|
||||||
payload.users || []
|
payload.users || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
|||||||
const USER_SELECT_COLUMNS =
|
const USER_SELECT_COLUMNS =
|
||||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||||
'totp_secret, totp_recovery_code, created_at, updated_at';
|
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||||
|
|
||||||
function mapUserRow(row: any): User {
|
function mapUserRow(row: any): User {
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
|
|||||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||||
totpSecret: row.totp_secret ?? null,
|
totpSecret: row.totp_secret ?? null,
|
||||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
|
apiKey: row.api_key ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
|||||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||||
);
|
);
|
||||||
await safeBind(
|
await safeBind(
|
||||||
stmt,
|
stmt,
|
||||||
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
|||||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||||
);
|
);
|
||||||
const result = await safeBind(
|
const result = await safeBind(
|
||||||
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ import {
|
|||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-04-18.1';
|
const STORAGE_SCHEMA_VERSION = '2026-04-22';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface User {
|
|||||||
verifyDevices?: boolean;
|
verifyDevices?: boolean;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpRecoveryCode: string | null;
|
totpRecoveryCode: string | null;
|
||||||
|
apiKey: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg width="862" height="101" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="#006DF4"/>
|
||||||
|
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="#006DF4"/>
|
||||||
|
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="#006DF4"/>
|
||||||
|
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="#006DF4"/>
|
||||||
|
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="#006DF4"/>
|
||||||
|
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="#006DF4"/>
|
||||||
|
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="#006DF4"/>
|
||||||
|
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="#006DF4"/>
|
||||||
|
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="#006DF4"/>
|
||||||
|
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="#006DF4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1203,6 +1203,8 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||||
<span className="brand-name">NodeWarden</span>
|
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" />
|
||||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface AppMainRoutesProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
@@ -225,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
|
onGetApiKey={props.onGetApiKey}
|
||||||
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface SettingsPageProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
|
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||||
|
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.totpEnabled) {
|
if (!props.totpEnabled) {
|
||||||
@@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadApiKey(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = await props.onGetApiKey(apiKeyMasterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRotateApiKey(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = await props.onRotateApiKey(apiKeyMasterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||||
|
} catch (error) {
|
||||||
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
if (!value) return t('txt_dash');
|
if (!value) return t('txt_dash');
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -235,8 +262,105 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-subcard">
|
||||||
|
<h3>{t('txt_api_key')}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={apiKeyMasterPassword}
|
||||||
|
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
|
||||||
|
<KeyRound size={14} className="btn-icon" />
|
||||||
|
{t('txt_view_api_key')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setRotateApiKeyConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_rotate_api_key')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={apiKeyDialogOpen}
|
||||||
|
title={t('txt_api_key')}
|
||||||
|
message={t('txt_api_key_dialog_intro')}
|
||||||
|
hideCancel
|
||||||
|
confirmText={t('txt_close')}
|
||||||
|
onConfirm={() => setApiKeyDialogOpen(false)}
|
||||||
|
onCancel={() => setApiKeyDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
|
||||||
|
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
|
||||||
|
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||||
|
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
|
||||||
|
<KeyRound size={15} />
|
||||||
|
<span>{t('txt_oauth_client_credentials')}</span>
|
||||||
|
</div>
|
||||||
|
{([
|
||||||
|
[t('txt_client_id'), `user.${props.profile.id}`],
|
||||||
|
[t('txt_client_secret'), apiKey],
|
||||||
|
[t('txt_scope'), 'api'],
|
||||||
|
[t('txt_grant_type'), 'client_credentials'],
|
||||||
|
] as [string, string][]).map(([label, value]) => (
|
||||||
|
<label key={label} className="field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
|
||||||
|
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
{t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rotateApiKeyConfirmOpen}
|
||||||
|
title={t('txt_rotate_api_key')}
|
||||||
|
message={t('txt_rotate_api_key_confirm')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => {
|
||||||
|
setRotateApiKeyConfirmOpen(false);
|
||||||
|
void doRotateApiKey();
|
||||||
|
}}
|
||||||
|
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
<div className="standalone-brand standalone-brand-outside">
|
<div className="standalone-brand standalone-brand-outside">
|
||||||
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||||
<div>
|
<div>
|
||||||
<div className="standalone-brand-title">NodeWarden</div>
|
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
const uri = firstCipherUri(cipher);
|
const uri = firstCipherUri(cipher);
|
||||||
const host = hostFromUri(uri);
|
const host = hostFromUri(uri);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(host ? failedIconHosts.has(host) : false);
|
||||||
|
}, [host]);
|
||||||
|
|
||||||
if (host && !errored) {
|
if (host && !errored) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -185,11 +185,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{!!props.filteredCiphers.length && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.visibleCiphers.map((cipher, index) => (
|
{props.visibleCiphers.map((cipher) => (
|
||||||
<div
|
<div
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.row-check')) return;
|
if (target.closest('.row-check')) return;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
@@ -37,7 +37,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
|||||||
|
|
||||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||||
export const VAULT_LIST_OVERSCAN = 10;
|
export const VAULT_LIST_OVERSCAN = 10;
|
||||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
@@ -161,7 +161,7 @@ export function hostFromUri(uri: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function websiteIconUrl(host: string): string {
|
export function websiteIconUrl(host: string): string {
|
||||||
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
@@ -433,6 +433,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
const uri = firstCipherUri(cipher);
|
const uri = firstCipherUri(cipher);
|
||||||
const host = hostFromUri(uri);
|
const host = hostFromUri(uri);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(host ? failedIconHosts.has(host) : false);
|
||||||
|
}, [host]);
|
||||||
|
|
||||||
if (host && !errored) {
|
if (host && !errored) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
|
getApiKey,
|
||||||
getTotpRecoveryCode,
|
getTotpRecoveryCode,
|
||||||
|
rotateApiKey,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
setTotp,
|
setTotp,
|
||||||
@@ -148,6 +150,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
return code;
|
return code;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getApiKey(masterPassword: string): Promise<string> {
|
||||||
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalized = String(masterPassword || '');
|
||||||
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||||
|
const key = await getApiKey(authedFetch, derived.hash);
|
||||||
|
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
|
async rotateApiKey(masterPassword: string): Promise<string> {
|
||||||
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalized = String(masterPassword || '');
|
||||||
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||||
|
const key = await rotateApiKey(authedFetch, derived.hash);
|
||||||
|
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
|
||||||
async refreshAuthorizedDevices() {
|
async refreshAuthorizedDevices() {
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -594,3 +594,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom
|
|||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||||
|
const resp = await authedFetch('/api/accounts/api-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
|
return String(body.apiKey || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||||
|
const resp = await authedFetch('/api/accounts/rotate-api-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||||
|
return String(body.apiKey || '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<V
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const resp = await authedFetch('/api/sync');
|
const resp = await authedFetch('/api/sync', {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error('Failed to load vault');
|
if (!resp.ok) throw new Error('Failed to load vault');
|
||||||
const body = await parseJson<VaultSyncResponse>(resp);
|
const body = await parseJson<VaultSyncResponse>(resp);
|
||||||
return body || {};
|
return body || {};
|
||||||
|
|||||||
@@ -601,6 +601,21 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_recovery_code_copied: "Recovery code copied",
|
txt_recovery_code_copied: "Recovery code copied",
|
||||||
txt_recovery_code_is_empty: "Recovery code is empty",
|
txt_recovery_code_is_empty: "Recovery code is empty",
|
||||||
txt_recovery_code_loaded: "Recovery code loaded",
|
txt_recovery_code_loaded: "Recovery code loaded",
|
||||||
|
txt_api_key: "API Key",
|
||||||
|
txt_view_api_key: "View API Key",
|
||||||
|
txt_rotate_api_key: "Rotate API Key",
|
||||||
|
txt_api_key_copied: "API key copied",
|
||||||
|
txt_api_key_loaded: "API key loaded",
|
||||||
|
txt_api_key_rotated: "API key rotated",
|
||||||
|
txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.",
|
||||||
|
txt_api_key_is_empty: "API key is empty",
|
||||||
|
txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.",
|
||||||
|
txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.",
|
||||||
|
txt_oauth_client_credentials: "OAuth 2.0 Client Credentials",
|
||||||
|
txt_client_id: "client_id",
|
||||||
|
txt_client_secret: "client_secret",
|
||||||
|
txt_scope: "scope",
|
||||||
|
txt_grant_type: "grant_type",
|
||||||
txt_refresh: "Refresh",
|
txt_refresh: "Refresh",
|
||||||
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
|
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
|
||||||
txt_regenerate: "Regenerate",
|
txt_regenerate: "Regenerate",
|
||||||
@@ -1363,6 +1378,21 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_recovery_code_copied: '恢复代码已复制',
|
txt_recovery_code_copied: '恢复代码已复制',
|
||||||
txt_recovery_code_is_empty: '恢复代码为空',
|
txt_recovery_code_is_empty: '恢复代码为空',
|
||||||
txt_recovery_code_loaded: '恢复代码已加载',
|
txt_recovery_code_loaded: '恢复代码已加载',
|
||||||
|
txt_api_key: 'API 密钥',
|
||||||
|
txt_view_api_key: '查看 API 密钥',
|
||||||
|
txt_rotate_api_key: '轮换 API 密钥',
|
||||||
|
txt_api_key_copied: 'API 密钥已复制',
|
||||||
|
txt_api_key_loaded: 'API 密钥已加载',
|
||||||
|
txt_api_key_rotated: 'API 密钥已轮换',
|
||||||
|
txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。',
|
||||||
|
txt_api_key_is_empty: 'API 密钥为空',
|
||||||
|
txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。',
|
||||||
|
txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。',
|
||||||
|
txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据',
|
||||||
|
txt_client_id: 'client_id',
|
||||||
|
txt_client_secret: 'client_secret',
|
||||||
|
txt_scope: 'scope',
|
||||||
|
txt_grant_type: 'grant_type',
|
||||||
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
|
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
|
||||||
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
|
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
|
||||||
txt_remove_device: '移除设备',
|
txt_remove_device: '移除设备',
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
.loading-screen {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
animation: fade-in-up var(--dur-panel) var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-send-page {
|
||||||
|
min-height: 80vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
transform-origin: 50% 24%;
|
||||||
|
animation: surface-enter 520ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-shell {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
animation: fade-in-up 420ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-outside {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-wordmark {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
width: clamp(200px, 30vw, 360px);
|
||||||
|
max-width: 100%;
|
||||||
|
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-title {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 31px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-muted {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-warning-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #b45309;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-warning-box {
|
||||||
|
border: 1px solid #f1d8a5;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fffaf0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-warning-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #92400e;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-warning-copy {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-warning-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-inline-link {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-inline-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-secret-fields {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-secret-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-secret-row > span {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-generator {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-generator-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jwt-copy-hint {
|
||||||
|
color: #15803d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-footer {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-footer a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-version {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-accent);
|
||||||
|
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-medium) var(--ease-smooth),
|
||||||
|
color var(--dur-medium) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dialog-open {
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
:root[data-theme='dark'] body,
|
||||||
|
:root[data-theme='dark'] #root,
|
||||||
|
:root[data-theme='dark'] .app-page,
|
||||||
|
:root[data-theme='dark'] .auth-page {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-shell,
|
||||||
|
:root[data-theme='dark'] .auth-card,
|
||||||
|
:root[data-theme='dark'] .dialog,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-box,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
|
:root[data-theme='dark'] .list-panel,
|
||||||
|
:root[data-theme='dark'] .card,
|
||||||
|
:root[data-theme='dark'] .sidebar-block,
|
||||||
|
:root[data-theme='dark'] .empty {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .topbar,
|
||||||
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
:root[data-theme='dark'] .sort-menu,
|
||||||
|
:root[data-theme='dark'] .create-menu,
|
||||||
|
:root[data-theme='dark'] .dialog-card,
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
||||||
|
:root[data-theme='dark'] .mobile-detail-sheet {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-card.warning {
|
||||||
|
border-color: rgba(248, 113, 113, 0.36);
|
||||||
|
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
|
||||||
|
box-shadow:
|
||||||
|
0 36px 90px rgba(5, 5, 5, 0.56),
|
||||||
|
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-mask.warning {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-warning-badge {
|
||||||
|
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
|
||||||
|
color: #fda4af;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(0, 0, 0, 0.32),
|
||||||
|
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-warning-kicker,
|
||||||
|
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-message.warning {
|
||||||
|
border-color: rgba(248, 113, 113, 0.18);
|
||||||
|
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
|
||||||
|
color: #fecdd3;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .sidebar,
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
||||||
|
background: var(--panel-muted);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .auth-card {
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .brand,
|
||||||
|
:root[data-theme='dark'] .mobile-page-title,
|
||||||
|
:root[data-theme='dark'] .detail-title,
|
||||||
|
:root[data-theme='dark'] .dialog-title,
|
||||||
|
:root[data-theme='dark'] .standalone-title,
|
||||||
|
:root[data-theme='dark'] .kv-main strong,
|
||||||
|
:root[data-theme='dark'] .list-title,
|
||||||
|
:root[data-theme='dark'] .sidebar-title,
|
||||||
|
:root[data-theme='dark'] h1,
|
||||||
|
:root[data-theme='dark'] h2,
|
||||||
|
:root[data-theme='dark'] h3,
|
||||||
|
:root[data-theme='dark'] h4 {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .standalone-brand-wordmark,
|
||||||
|
:root[data-theme='dark'] .brand-wordmark {
|
||||||
|
text-shadow: 0 16px 28px rgba(2, 6, 23, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .muted,
|
||||||
|
:root[data-theme='dark'] .detail-sub,
|
||||||
|
:root[data-theme='dark'] .field-help,
|
||||||
|
:root[data-theme='dark'] .list-sub,
|
||||||
|
:root[data-theme='dark'] .kv-label,
|
||||||
|
:root[data-theme='dark'] .standalone-muted,
|
||||||
|
:root[data-theme='dark'] .standalone-footer,
|
||||||
|
:root[data-theme='dark'] .backup-inline-note,
|
||||||
|
:root[data-theme='dark'] .backup-browser-empty,
|
||||||
|
:root[data-theme='dark'] .or,
|
||||||
|
:root[data-theme='dark'] .mobile-tab,
|
||||||
|
:root[data-theme='dark'] .side-link,
|
||||||
|
:root[data-theme='dark'] .user-chip,
|
||||||
|
:root[data-theme='dark'] .list-count {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .user-chip {
|
||||||
|
background: rgba(17, 34, 56, 0.94);
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: 0 12px 24px rgba(1, 7, 18, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .side-link:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-tab:hover {
|
||||||
|
background: rgba(132, 182, 255, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .side-link.active,
|
||||||
|
:root[data-theme='dark'] .mobile-tab.active,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item.active,
|
||||||
|
:root[data-theme='dark'] .list-item.active {
|
||||||
|
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.08));
|
||||||
|
border-color: rgba(132, 182, 255, 0.28);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input,
|
||||||
|
:root[data-theme='dark'] .textarea,
|
||||||
|
:root[data-theme='dark'] select.input,
|
||||||
|
:root[data-theme='dark'] .dialog input,
|
||||||
|
:root[data-theme='dark'] .dialog textarea,
|
||||||
|
:root[data-theme='dark'] .dialog select {
|
||||||
|
background: rgba(13, 24, 40, 0.94);
|
||||||
|
border-color: rgba(103, 136, 186, 0.36);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input::placeholder,
|
||||||
|
:root[data-theme='dark'] .textarea::placeholder,
|
||||||
|
:root[data-theme='dark'] input::placeholder,
|
||||||
|
:root[data-theme='dark'] textarea::placeholder {
|
||||||
|
color: #7488a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input:focus,
|
||||||
|
:root[data-theme='dark'] .textarea:focus,
|
||||||
|
:root[data-theme='dark'] .search-input:focus,
|
||||||
|
:root[data-theme='dark'] .dialog input:focus,
|
||||||
|
:root[data-theme='dark'] .dialog textarea:focus,
|
||||||
|
:root[data-theme='dark'] .dialog select:focus {
|
||||||
|
border-color: rgba(132, 182, 255, 0.54);
|
||||||
|
background-color: rgba(16, 30, 49, 0.98);
|
||||||
|
box-shadow: 0 0 0 4px rgba(132, 182, 255, 0.12), 0 10px 22px rgba(5, 13, 28, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .input-readonly {
|
||||||
|
background: #0f1b2d;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input:disabled,
|
||||||
|
:root[data-theme='dark'] .btn:disabled {
|
||||||
|
background: #132033;
|
||||||
|
border-color: #22334c;
|
||||||
|
color: #70829d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-secondary {
|
||||||
|
background: linear-gradient(180deg, rgba(22, 41, 66, 0.98), rgba(16, 31, 52, 0.98));
|
||||||
|
border-color: rgba(132, 182, 255, 0.22);
|
||||||
|
color: #a9cdff;
|
||||||
|
box-shadow: 0 12px 22px rgba(1, 7, 18, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-secondary:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(26, 49, 79, 0.98), rgba(19, 37, 61, 0.98));
|
||||||
|
border-color: rgba(132, 182, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-danger {
|
||||||
|
background: linear-gradient(180deg, rgba(45, 23, 33, 0.98), rgba(35, 18, 28, 0.98));
|
||||||
|
border-color: rgba(255, 139, 168, 0.38);
|
||||||
|
color: #ff9bb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-danger:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(56, 27, 40, 0.98), rgba(41, 19, 31, 0.98));
|
||||||
|
border-color: rgba(255, 171, 192, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-primary {
|
||||||
|
background: linear-gradient(135deg, #79acff, #57c2ff 76%);
|
||||||
|
border-color: rgba(176, 214, 255, 0.22);
|
||||||
|
color: #061120;
|
||||||
|
box-shadow: 0 18px 32px rgba(10, 26, 52, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #90bcff, #6accff 76%);
|
||||||
|
box-shadow: 0 22px 36px rgba(10, 26, 52, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toolbar.actions,
|
||||||
|
:root[data-theme='dark'] .list-head,
|
||||||
|
:root[data-theme='dark'] .mobile-panel-head,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-header,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .detail-actions,
|
||||||
|
:root[data-theme='dark'] .topbar,
|
||||||
|
:root[data-theme='dark'] .app-side,
|
||||||
|
:root[data-theme='dark'] .kv-row,
|
||||||
|
:root[data-theme='dark'] .attachment-row,
|
||||||
|
:root[data-theme='dark'] .backup-browser-row {
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .input,
|
||||||
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .list-item,
|
||||||
|
:root[data-theme='dark'] .sidebar-block {
|
||||||
|
background: rgba(15, 28, 45, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .sidebar,
|
||||||
|
:root[data-theme='dark'] .content,
|
||||||
|
:root[data-theme='dark'] .list-col,
|
||||||
|
:root[data-theme='dark'] .detail-col {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .mobile-sidebar-mask,
|
||||||
|
:root[data-theme='dark'] .dialog-mask {
|
||||||
|
background: var(--overlay-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast {
|
||||||
|
background: linear-gradient(180deg, rgba(19, 34, 54, 0.98), rgba(14, 26, 42, 0.98));
|
||||||
|
border-color: #263a57;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.success {
|
||||||
|
background: #0f2a1f;
|
||||||
|
border-color: #1f5b44;
|
||||||
|
color: #9be2bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.error {
|
||||||
|
background: #2a1720;
|
||||||
|
border-color: #6c2b41;
|
||||||
|
color: #ffb1c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .toast.warning {
|
||||||
|
background: #2d2413;
|
||||||
|
border-color: #7b6230;
|
||||||
|
color: #f7d48b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .jwt-warning-head,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-label,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-copy,
|
||||||
|
:root[data-theme='dark'] .jwt-warning-list {
|
||||||
|
color: #f4d48a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch-input:focus + .theme-switch-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(132, 182, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .list-head .search-input,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-card,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-link,
|
||||||
|
:root[data-theme='dark'] .table tr,
|
||||||
|
:root[data-theme='dark'] .settings-subcard,
|
||||||
|
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||||
|
:root[data-theme='dark'] .backup-detail-panel,
|
||||||
|
:root[data-theme='dark'] .dialog-card,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path,
|
||||||
|
:root[data-theme='dark'] .backup-browser-list,
|
||||||
|
:root[data-theme='dark'] .create-menu,
|
||||||
|
:root[data-theme='dark'] .create-menu-item,
|
||||||
|
:root[data-theme='dark'] .sort-menu,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-card,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-dav-item,
|
||||||
|
:root[data-theme='dark'] .backup-destination-item,
|
||||||
|
:root[data-theme='dark'] .totp-code-row,
|
||||||
|
:root[data-theme='dark'] .list-item {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(18, 32, 52, 0.92), rgba(14, 26, 42, 0.92));
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item:hover,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item:hover,
|
||||||
|
:root[data-theme='dark'] .create-menu-item:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-link:hover,
|
||||||
|
:root[data-theme='dark'] .backup-destination-item:hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(24, 44, 70, 0.96), rgba(16, 31, 51, 0.96));
|
||||||
|
border-color: rgba(118, 150, 197, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item.active {
|
||||||
|
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
|
||||||
|
border-color: rgba(122, 176, 255, 0.34);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(200, 225, 255, 0.06), 0 12px 24px rgba(5, 13, 28, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .list-item::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(132, 182, 255, 0.08), transparent 24%, transparent 76%, rgba(56, 189, 248, 0.08)),
|
||||||
|
radial-gradient(circle at 18px 50%, rgba(255, 255, 255, 0.06), transparent 44%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-destination-item.active,
|
||||||
|
:root[data-theme='dark'] .backup-interval-preset.active,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-link.active,
|
||||||
|
:root[data-theme='dark'] .tree-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
|
||||||
|
border-color: rgba(132, 182, 255, 0.34);
|
||||||
|
color: #f4f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch-slider {
|
||||||
|
background: linear-gradient(180deg, #1d3659, #142845);
|
||||||
|
border-color: rgba(120, 152, 198, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch-slider::before {
|
||||||
|
background: linear-gradient(180deg, #f8fbff, #dce9ff);
|
||||||
|
box-shadow: 0 3px 10px rgba(2, 8, 20, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch .moon svg {
|
||||||
|
fill: #8db6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .theme-switch .sun svg {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-code-name,
|
||||||
|
:root[data-theme='dark'] .backup-destination-name,
|
||||||
|
:root[data-theme='dark'] .backup-browser-entry,
|
||||||
|
:root[data-theme='dark'] .mobile-settings-link,
|
||||||
|
:root[data-theme='dark'] .backup-browser-path strong,
|
||||||
|
:root[data-theme='dark'] .backup-option-label,
|
||||||
|
:root[data-theme='dark'] .sort-menu-item,
|
||||||
|
:root[data-theme='dark'] .create-menu-item,
|
||||||
|
:root[data-theme='dark'] .tree-btn,
|
||||||
|
:root[data-theme='dark'] .folder-add-btn,
|
||||||
|
:root[data-theme='dark'] .list-icon-fallback,
|
||||||
|
:root[data-theme='dark'] .totp-code-main strong,
|
||||||
|
:root[data-theme='dark'] .totp-timer-value {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-code-username,
|
||||||
|
:root[data-theme='dark'] .backup-destination-meta,
|
||||||
|
:root[data-theme='dark'] .backup-browser-meta,
|
||||||
|
:root[data-theme='dark'] .table td::before,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-step,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
||||||
|
:root[data-theme='dark'] .backup-inline-suffix,
|
||||||
|
:root[data-theme='dark'] .folder-delete-btn,
|
||||||
|
:root[data-theme='dark'] .folder-add-btn:hover,
|
||||||
|
:root[data-theme='dark'] .tree-label,
|
||||||
|
:root[data-theme='dark'] .list-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .import-export-panel p,
|
||||||
|
:root[data-theme='dark'] .dialog-message,
|
||||||
|
:root[data-theme='dark'] .local-error,
|
||||||
|
:root[data-theme='dark'] .status-ok {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-destination-type {
|
||||||
|
background: #1d3048;
|
||||||
|
color: #c9d8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger {
|
||||||
|
border-color: #38618f;
|
||||||
|
background: #173150;
|
||||||
|
color: #9ec5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .backup-help-trigger:focus-visible {
|
||||||
|
border-color: #5f92d7;
|
||||||
|
background: #20426a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-bubble {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-help-bubble::before {
|
||||||
|
background: var(--panel);
|
||||||
|
border-left-color: var(--line);
|
||||||
|
border-top-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .table td {
|
||||||
|
border-bottom-color: #203047;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .local-error {
|
||||||
|
color: #ff9bb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .status-ok {
|
||||||
|
color: #9be2bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-qr {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-qr svg,
|
||||||
|
:root[data-theme='dark'] .totp-qr img {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
.muted {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid rgba(74, 103, 150, 0.42);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.input {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
padding-right: 42px;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
||||||
|
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 18px) calc(50% - 3px),
|
||||||
|
calc(100% - 12px) calc(50% - 3px);
|
||||||
|
background-size: 6px 6px, 6px 6px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input {
|
||||||
|
height: auto;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input::file-selector-button {
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #3f5b9e;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #1f4ea0;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='file'].input::file-selector-button:hover {
|
||||||
|
background: #dfeaff;
|
||||||
|
border-color: #2f5fd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 110px;
|
||||||
|
height: auto;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: rgba(43, 102, 217, 0.6);
|
||||||
|
background-color: #fbfdff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-readonly {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-wrap .input {
|
||||||
|
padding-right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #275ac2;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 9px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #334155;
|
||||||
|
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover,
|
||||||
|
.eye-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
transform: translateY(-1px) scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.user-chip,
|
||||||
|
.side-link,
|
||||||
|
.mobile-tab {
|
||||||
|
--mag-x: 0px;
|
||||||
|
--mag-y: 0px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn::before,
|
||||||
|
.user-chip::before,
|
||||||
|
.side-link::before,
|
||||||
|
.mobile-tab::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: var(--mx, 50%);
|
||||||
|
top: var(--my, 50%);
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.08) 42%, transparent 72%);
|
||||||
|
transform: translate(-50%, -50%) scale(0.68);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease-smooth),
|
||||||
|
transform var(--dur-medium) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn:hover::before,
|
||||||
|
.user-chip:hover::before,
|
||||||
|
.side-link:hover::before,
|
||||||
|
.mobile-tab:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.full {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #3b82f6 72%);
|
||||||
|
border-color: rgba(15, 63, 152, 0.32);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #1d4ed8, #3377f0 72%);
|
||||||
|
border-color: rgba(15, 63, 152, 0.38);
|
||||||
|
box-shadow: 0 18px 34px rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: rgba(37, 99, 235, 0.22);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
box-shadow: 0 8px 18px rgba(13, 31, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f4f8ff;
|
||||||
|
border-color: rgba(37, 99, 235, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-color: rgba(217, 45, 87, 0.28);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: rgba(255, 241, 242, 0.96);
|
||||||
|
border-color: rgba(217, 45, 87, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.or {
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-support-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin: -2px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link-btn:disabled {
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
@keyframes toast-life {
|
||||||
|
from {
|
||||||
|
transform: scaleX(1);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 16px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shell-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 18px, 0) scale(0.992);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes surface-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 20px, 0) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menu-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 10px, 0) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 18px, 0) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(18px, 0, 0) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stagger-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 18px, 0) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 10px, 0) scale(0.972);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes route-stage-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 14px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 1200;
|
||||||
|
padding: 20px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
transform-origin: 50% 30%;
|
||||||
|
animation: dialog-in 240ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-mask.warning {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
|
||||||
|
box-shadow:
|
||||||
|
0 36px 90px rgba(69, 10, 10, 0.28),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-badge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
|
||||||
|
color: #dc2626;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(220, 38, 38, 0.18),
|
||||||
|
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-kicker {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-mask.closing {
|
||||||
|
animation: fade-out 220ms var(--ease-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card.closing {
|
||||||
|
animation: dialog-out 220ms var(--ease-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card .field {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message {
|
||||||
|
color: #475467;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning .dialog-title {
|
||||||
|
color: #7f1d1d;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message.warning {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.16);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
|
||||||
|
color: #7a2832;
|
||||||
|
line-height: 1.65;
|
||||||
|
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-extra {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-dialog {
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-close:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table th,
|
||||||
|
.import-summary-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table th {
|
||||||
|
text-align: left;
|
||||||
|
color: #475467;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table td:last-child,
|
||||||
|
.import-summary-table th:last-child {
|
||||||
|
text-align: right;
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-summary-failed-list li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-twofactor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subcard {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subcard h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1400;
|
||||||
|
width: min(420px, calc(100vw - 20px));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #bbdfc6;
|
||||||
|
background: #dff4e5;
|
||||||
|
color: #0f5132;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
animation: toast-in 240ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.error {
|
||||||
|
border-color: #f2b8c1;
|
||||||
|
background: #fde7eb;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item.warning {
|
||||||
|
border-color: #f2b8c1;
|
||||||
|
background: #fde7eb;
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-text {
|
||||||
|
font-weight: 700;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
color: inherit;
|
||||||
|
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(15, 23, 42, 0.2);
|
||||||
|
animation: toast-life 4.5s linear forwards;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 1ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled),
|
||||||
|
.btn:active:not(:disabled),
|
||||||
|
.side-link:hover,
|
||||||
|
.tree-btn:hover,
|
||||||
|
.list-item:hover,
|
||||||
|
.list-item.active,
|
||||||
|
.search-input:focus,
|
||||||
|
.input:focus,
|
||||||
|
.password-toggle:hover,
|
||||||
|
.eye-btn:hover,
|
||||||
|
.auth-link-btn:hover,
|
||||||
|
.sort-menu-item:hover,
|
||||||
|
.create-menu-item:hover,
|
||||||
|
.toast-close:hover,
|
||||||
|
.mobile-sidebar-close:hover {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
@media (max-width: 1180px) {
|
||||||
|
.app-page {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: calc(100vh - 16px);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-side {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #d9e0ea;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
align-self: start;
|
||||||
|
height: fit-content;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-side > .side-link {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
.totp-grid,
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-copy-btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-panels,
|
||||||
|
.backup-browser-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-twofactor-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.auth-page {
|
||||||
|
padding: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-outside {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-brand-logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.full {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-support-row {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
--mobile-topbar-height: 58px;
|
||||||
|
--mobile-tabbar-height: 70px;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: var(--mobile-topbar-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-title {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .user-chip,
|
||||||
|
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
|
||||||
|
.topbar-actions > .theme-switch-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-toggle,
|
||||||
|
.mobile-lock-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-toggle .btn-icon,
|
||||||
|
.mobile-lock-btn .btn-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn .theme-switch {
|
||||||
|
transform: scale(0.8);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: var(--mobile-tabbar-height);
|
||||||
|
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: rgba(248, 251, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition:
|
||||||
|
transform 220ms var(--ease-out-soft),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab:hover {
|
||||||
|
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab.active {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16), rgba(59, 130, 246, 0.08));
|
||||||
|
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
top: calc(var(--mobile-topbar-height) + 10px);
|
||||||
|
bottom: auto;
|
||||||
|
max-height: calc(100dvh - 145px);
|
||||||
|
z-index: 55;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d8dee8;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate3d(0, 10px, 0) scale(0.98);
|
||||||
|
transition:
|
||||||
|
opacity 220ms var(--ease-smooth),
|
||||||
|
transform 240ms var(--ease-out-soft),
|
||||||
|
visibility 220ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-close {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid #d7dde6;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition:
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-close:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .sidebar-block {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .tree-btn {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .folder-row {
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .folder-row .tree-btn {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .sidebar-title,
|
||||||
|
.mobile-sidebar-sheet .sidebar-title-row {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .tree-btn {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .tree-btn.active {
|
||||||
|
background: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-sheet .folder-delete-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-col {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-count {
|
||||||
|
grid-column: auto;
|
||||||
|
width: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .search-input-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .search-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-icon-btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: unset;
|
||||||
|
gap: var(--actions-gap);
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: var(--actions-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.actions .btn.small {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fab-wrap {
|
||||||
|
position: fixed;
|
||||||
|
right: 14px;
|
||||||
|
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||||
|
z-index: 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fab-trigger {
|
||||||
|
width: 36px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0;
|
||||||
|
gap: 0;
|
||||||
|
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fab-trigger .btn-icon {
|
||||||
|
margin: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-fab-wrap .create-menu {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-panel {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid.mobile-panel-detail .sidebar,
|
||||||
|
.vault-grid.mobile-panel-detail .list-col,
|
||||||
|
.vault-grid.mobile-panel-edit .sidebar,
|
||||||
|
.vault-grid.mobile-panel-edit .list-col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
|
||||||
|
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||||
|
z-index: 35;
|
||||||
|
overflow: auto;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 0 18px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate3d(0, 18px, 0);
|
||||||
|
transition:
|
||||||
|
opacity 220ms var(--ease-smooth),
|
||||||
|
transform 260ms var(--ease-out-soft),
|
||||||
|
visibility 220ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-panel-back {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-detail-sheet > .detail-switch-stage,
|
||||||
|
.mobile-detail-sheet > .card,
|
||||||
|
.mobile-detail-sheet > .empty {
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-col .card,
|
||||||
|
.import-export-panel,
|
||||||
|
.settings-subcard {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions .actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions .actions .btn,
|
||||||
|
.detail-delete-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-row {
|
||||||
|
grid-template-columns: minmax(64px, 80px) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-line {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-actions {
|
||||||
|
width: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-actions .btn.small {
|
||||||
|
width: 34px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-actions .btn.small .btn-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-panels,
|
||||||
|
.settings-twofactor-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-panel .actions .btn,
|
||||||
|
.settings-subcard .actions .btn,
|
||||||
|
.section-head .actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-grid {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-qr {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-qr svg,
|
||||||
|
.totp-qr img {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-card {
|
||||||
|
min-height: calc(100dvh - 170px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-subhead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-back {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-links {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #dbe2ec;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-link.active {
|
||||||
|
background: #e8f0ff;
|
||||||
|
border-color: #b9cff6;
|
||||||
|
color: #175ddc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-settings-logout {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack,
|
||||||
|
.import-export-page,
|
||||||
|
.totp-codes-page,
|
||||||
|
.detail-col {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-create-group {
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.small {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table,
|
||||||
|
.table tbody,
|
||||||
|
.table tr,
|
||||||
|
.table td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
border-bottom: 1px solid #edf1f6;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td::before {
|
||||||
|
display: block;
|
||||||
|
content: attr(data-label);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-mask {
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: calc(100dvh - 10px);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-strip {
|
||||||
|
margin: -18px -16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
height: 46px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.backup-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-operations-sidebar,
|
||||||
|
.backup-destination-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.backup-interval-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-row,
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-destination-top {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-add-chooser {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-name-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-option-field {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-help-bubble {
|
||||||
|
left: 0;
|
||||||
|
transform: translate(0, -4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-help-bubble::before {
|
||||||
|
left: 16px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-help-wrap:hover .backup-help-bubble,
|
||||||
|
.backup-help-wrap:focus-within .backup-help-bubble,
|
||||||
|
.backup-help-wrap.open .backup-help-bubble {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
.app-page {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: shell-enter 560ms var(--ease-out-strong) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 58px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
color: #0f172a;
|
||||||
|
background: rgba(244, 248, 255, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 18px;
|
||||||
|
transition: background-color var(--dur-fast) var(--ease-smooth), border-color var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-wordmark {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
width: clamp(210px, 20vw, 290px);
|
||||||
|
max-width: 100%;
|
||||||
|
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-page-title {
|
||||||
|
display: none;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(58vw, 240px);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
|
||||||
|
transition: transform var(--dur-medium) var(--ease-out-soft), filter var(--dur-medium) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-lock-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-theme-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(180deg, #dceaff, #c8dcff);
|
||||||
|
border: 1px solid #9dbbec;
|
||||||
|
transition:
|
||||||
|
background var(--dur-medium) var(--ease-out-soft),
|
||||||
|
border-color var(--dur-medium) var(--ease-smooth),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-slider::before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 26px;
|
||||||
|
width: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #edf4ff);
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
|
||||||
|
transition:
|
||||||
|
transform var(--dur-medium) var(--ease-out-strong),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
background var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch .sun svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 32px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.95;
|
||||||
|
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch .moon svg {
|
||||||
|
fill: #5b86d6;
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 7px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
opacity: 0.88;
|
||||||
|
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:checked + .theme-switch-slider {
|
||||||
|
background: linear-gradient(180deg, #173150, #122742);
|
||||||
|
border-color: #35527a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:focus + .theme-switch-slider {
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch-input:checked + .theme-switch-slider::before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch:hover .theme-switch-slider {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch:hover .sun svg,
|
||||||
|
.theme-switch:hover .moon svg {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn {
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transform: translate3d(var(--mag-x), var(--mag-y), 0);
|
||||||
|
transition-duration: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions .btn:hover:not(:disabled) {
|
||||||
|
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 2px), 0) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
background: rgba(249, 251, 255, 0.92);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 10px 18px rgba(13, 31, 68, 0.05);
|
||||||
|
transform: translate3d(var(--mag-x), var(--mag-y), 0);
|
||||||
|
transition:
|
||||||
|
transform 220ms var(--ease-out-soft),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip:hover {
|
||||||
|
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
|
||||||
|
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-side {
|
||||||
|
border-right: 1px solid var(--line-soft);
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
transform var(--dur-fast) var(--ease-out-soft),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(128, 152, 192, 0.18);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translate3d(calc(var(--mag-x) + 3px), var(--mag-y), 0);
|
||||||
|
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active {
|
||||||
|
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(59, 130, 246, 0.08));
|
||||||
|
border-color: rgba(37, 99, 235, 0.28);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.64), 0 10px 18px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-stage {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 901px) {
|
||||||
|
.route-stage {
|
||||||
|
animation: route-stage-in 240ms var(--ease-out-soft) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.36);
|
||||||
|
z-index: 54;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 220ms var(--ease-smooth),
|
||||||
|
visibility 220ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-mask.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sidebar-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
:root {
|
||||||
|
--bg-accent: #e7edf8;
|
||||||
|
--panel: #f9fbff;
|
||||||
|
--panel-soft: #f2f6fd;
|
||||||
|
--panel-muted: #e8eff9;
|
||||||
|
--line: rgba(128, 152, 192, 0.32);
|
||||||
|
--line-soft: rgba(143, 167, 206, 0.18);
|
||||||
|
--text: #0b1730;
|
||||||
|
--muted: #60708b;
|
||||||
|
--muted-strong: #334765;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--primary-strong: #0f3f98;
|
||||||
|
--danger: #d92d57;
|
||||||
|
--overlay-strong: rgba(15, 23, 42, 0.56);
|
||||||
|
--shadow-sm: 0 10px 22px rgba(13, 31, 68, 0.045);
|
||||||
|
--shadow-md: 0 22px 48px rgba(13, 31, 68, 0.08);
|
||||||
|
--shadow-lg: 0 28px 76px rgba(13, 31, 68, 0.11);
|
||||||
|
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
|
||||||
|
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--dur-fast: 180ms;
|
||||||
|
--dur-medium: 240ms;
|
||||||
|
--dur-panel: 280ms;
|
||||||
|
--actions-gap: clamp(0px, calc((100vw - 520px) * 1), 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg-accent: #06111d;
|
||||||
|
--panel: #0d192b;
|
||||||
|
--panel-soft: #112136;
|
||||||
|
--panel-muted: #0a1626;
|
||||||
|
--line: rgba(108, 141, 190, 0.28);
|
||||||
|
--line-soft: rgba(120, 152, 198, 0.16);
|
||||||
|
--text: #edf4ff;
|
||||||
|
--muted: #8fa6c6;
|
||||||
|
--muted-strong: #c3d5ef;
|
||||||
|
--primary: #84b6ff;
|
||||||
|
--primary-hover: #a6ccff;
|
||||||
|
--primary-strong: #f3f8ff;
|
||||||
|
--danger: #ff8ba8;
|
||||||
|
--overlay-strong: rgba(2, 8, 20, 0.84);
|
||||||
|
--shadow-sm: 0 14px 28px rgba(1, 7, 18, 0.24);
|
||||||
|
--shadow-md: 0 24px 52px rgba(1, 7, 18, 0.36);
|
||||||
|
--shadow-lg: 0 34px 88px rgba(1, 7, 18, 0.46);
|
||||||
|
}
|
||||||