13 Commits

Author SHA1 Message Date
shuaiplus 15ee922777 chore: update version to 1.4.6 in package.json, package-lock.json, and app-version.ts 2026-04-25 16:05:07 +08:00
shuaiplus 2ea0b2c14c feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments 2026-04-25 15:52:00 +08:00
shuaiplus 4ec1926888 fix: correct dialog-card width from 5000px to 500px for proper layout 2026-04-25 12:07:45 +08:00
shuaiplus 3995e01336 feat: enhance icon error handling and loading state management in TotpCodesPage and VaultListIcon components 2026-04-25 10:20:30 +08:00
shuaiplus 481536ba24 feat: update list icon opacity and z-index for improved loading behavior 2026-04-25 04:40:22 +08:00
shuaiplus db8b9263a1 feat: implement session timeout feature with customizable actions and update UI components 2026-04-25 03:49:15 +08:00
shuaiplus a1f7250e90 feat: update mobile layout query to 1180px and enhance icon loading experience 2026-04-25 03:19:06 +08:00
shuaiplus e4bc1b9bbe Refactor frontend styles toward Tailwind utilities and unified design system 2026-04-25 02:23:10 +08:00
shuaiplus 514889adfc feat: refactor TOTP code handling to improve state management and refresh logic 2026-04-25 01:48:20 +08:00
shuaiplus fccc85c4bb feat: enhance ConfirmDialog with focus management and accessibility improvements 2026-04-25 01:36:12 +08:00
shuaiplus acd59a7387 feat: add auto-lock feature with customizable timeout settings and update UI for security preferences 2026-04-24 15:27:46 +08:00
shuaiplus d40b0514fd Refactor styles to utilize Tailwind CSS utility classes for improved consistency and maintainability across forms, motion, shell, and vault components. Remove deprecated reduced-motion styles and consolidate motion-related animations. Update color tokens for better contrast and accessibility. Introduce a new Tailwind CSS configuration file. 2026-04-24 15:14:12 +08:00
shuaiplus 033d44808f chore: update version to 1.4.5 in package.json, package-lock.json, and app-version.ts 2026-04-24 00:51:27 +08:00
44 changed files with 3157 additions and 2332 deletions
+1
View File
@@ -42,3 +42,4 @@ tmp/
.tmp/
nodewarden.wiki/
AGENTS.md
+951 -15
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.4.4",
"version": "1.4.6",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -40,6 +40,9 @@
"@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.4';
export const APP_VERSION = '1.4.6';
+58
View File
@@ -279,6 +279,64 @@ export async function handleGetAttachment(
});
}
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
export async function handleUpdateAttachmentMetadata(
request: Request,
env: Env,
userId: string,
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
let body: { fileName?: string | null; key?: string | null };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
return errorResponse('No metadata fields supplied', 400);
}
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
const fileName = String(body.fileName || '').trim();
if (!fileName) return errorResponse('fileName is required', 400);
attachment.fileName = fileName;
}
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
const key = body.key == null ? null : String(body.key || '').trim();
attachment.key = key || null;
}
await storage.saveAttachment(attachment);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return jsonResponse({
object: 'attachment',
id: attachment.id,
fileName: attachment.fileName,
key: attachment.key,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
});
}
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
// Public download endpoint (uses token for auth instead of header)
export async function handlePublicDownloadAttachment(
+6
View File
@@ -60,6 +60,7 @@ import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleUpdateAttachmentMetadata,
handleDeleteAttachment,
} from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices';
@@ -201,6 +202,11 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
}
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
+6 -1
View File
@@ -126,7 +126,12 @@ function buildConfigResponse(origin: string) {
}
function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
let decoded: string;
try {
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
} catch {
return null;
}
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try {
const parsed = new URL(`https://${decoded}`);
+33
View File
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
canvas: 'var(--bg-accent)',
panel: 'var(--panel)',
'panel-soft': 'var(--panel-soft)',
'panel-muted': 'var(--panel-muted)',
line: 'var(--line)',
'line-soft': 'var(--line-soft)',
ink: 'var(--text)',
muted: 'var(--muted)',
'muted-strong': 'var(--muted-strong)',
brand: 'var(--primary)',
'brand-hover': 'var(--primary-hover)',
'brand-strong': 'var(--primary-strong)',
danger: 'var(--danger)',
},
boxShadow: {
soft: 'var(--shadow-sm)',
panel: 'var(--shadow-md)',
elevated: 'var(--shadow-lg)',
},
fontFamily: {
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
},
},
},
plugins: [],
};
+216 -66
View File
@@ -18,16 +18,18 @@ import {
revokeCurrentSession,
getTotpStatus,
saveSession,
stripProfileSecrets,
} from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { buildSendShareKey, getSends } from '@/lib/api/send';
import {
getCiphers,
getFolders,
repairCipherAttachmentMetadata,
updateFolder,
} from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
import {
buildPublicSendUrl,
deriveSendKeyParts,
@@ -82,48 +84,12 @@ const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type ThemePreference = 'system' | 'light' | 'dark';
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
type SessionTimeoutAction = 'lock' | 'logout';
function installMagneticUiFeedback() {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {};
const resetNode = (node: HTMLElement) => {
node.style.setProperty('--mag-x', '0px');
node.style.setProperty('--mag-y', '0px');
node.style.removeProperty('--mx');
node.style.removeProperty('--my');
};
const onPointerMove = (event: PointerEvent) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
const rect = node.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1);
const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1);
node.style.setProperty('--mx', `${localX}px`);
node.style.setProperty('--my', `${localY}px`);
node.style.setProperty('--mag-x', `${dx * 6}px`);
node.style.setProperty('--mag-y', `${dy * 4}px`);
};
const onPointerLeave = (event: Event) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
resetNode(node);
};
document.addEventListener('pointermove', onPointerMove, { passive: true });
document.addEventListener('pointerleave', onPointerLeave, true);
return () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerleave', onPointerLeave, true);
};
}
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system';
@@ -137,6 +103,18 @@ function resolveSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function readLockTimeoutMinutes(): LockTimeoutMinutes {
if (typeof window === 'undefined') return 15;
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY));
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
}
function readSessionTimeoutAction(): SessionTimeoutAction {
if (typeof window === 'undefined') return 'lock';
const value = String(window.localStorage.getItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY) || '').trim();
return value === 'logout' ? 'logout' : 'lock';
}
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
@@ -170,6 +148,7 @@ export default function App() {
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false);
@@ -180,10 +159,13 @@ export default function App() {
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false);
const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0);
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
@@ -245,7 +227,7 @@ export default function App() {
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(max-width: 900px)');
const media = window.matchMedia('(max-width: 1180px)');
const sync = () => setMobileLayout(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
@@ -287,12 +269,20 @@ export default function App() {
}, [profile]);
useEffect(() => {
if (phase === 'locked' && profile?.key && session) {
if (phase === 'locked' && session?.email) {
setUnlockPreparing(false);
}
}, [phase, profile, session]);
useEffect(() => installMagneticUiFeedback(), []);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
}, [lockTimeoutMinutes]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY, sessionTimeoutAction);
}, [sessionTimeoutAction]);
function handleToggleTheme() {
setThemePreference((prev) => {
@@ -307,6 +297,16 @@ export default function App() {
saveSession(next);
}
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
setLockTimeoutMinutesState(next);
pushToast('success', t('txt_session_timeout_updated'));
}
function setSessionTimeoutAction(next: SessionTimeoutAction) {
setSessionTimeoutActionState(next);
pushToast('success', t('txt_session_timeout_updated'));
}
const authedFetch = useMemo(
() =>
createAuthedFetch(
@@ -353,7 +353,7 @@ export default function App() {
setSession(boot.session);
setProfile(boot.profile);
setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
})();
return () => {
@@ -377,7 +377,7 @@ export default function App() {
}
setSession(result.session);
if (result.profile) {
setProfile(result.profile);
setProfile(stripProfileSecrets(result.profile));
}
})();
return () => {
@@ -385,17 +385,19 @@ export default function App() {
};
}, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) {
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
setSession(login.session);
setProfile(login.profile);
setUnlockPreparing(false);
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault');
}
pushToast('success', t('txt_login_success'));
pushToast('success', successMessage);
void (async () => {
try {
const hydratedProfile = await login.profilePromise;
@@ -422,6 +424,7 @@ export default function App() {
}
if (result.kind === 'totp') {
setPendingTotp(result.pendingTotp);
setPendingTotpMode('login');
setTotpCode('');
setRememberDevice(true);
return;
@@ -444,7 +447,7 @@ export default function App() {
setTotpSubmitting(true);
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login);
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally {
@@ -567,20 +570,26 @@ export default function App() {
async function handleUnlock() {
if (pendingAuthAction) return;
if (!session || !profile) return;
if (!session?.email) return;
if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password'));
return;
}
setPendingAuthAction('unlock');
try {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession);
setUnlockPassword('');
setUnlockPreparing(false);
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', t('txt_unlocked'));
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked'));
return;
}
if (result.kind === 'totp') {
setPendingTotp(result.pendingTotp);
setPendingTotpMode('unlock');
setTotpCode('');
setRememberDevice(true);
return;
}
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
} catch {
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
} finally {
@@ -588,17 +597,30 @@ export default function App() {
}
}
function handleLock() {
if (!session) return;
const nextSession = { ...session };
function lockCurrentSession() {
const currentSession = sessionRef.current;
if (!currentSession) return;
const nextSession = { ...currentSession };
delete nextSession.symEncKey;
delete nextSession.symMacKey;
setSession(nextSession);
setProfile((prev) => stripProfileSecrets(prev));
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setUnlockPassword('');
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setUnlockPreparing(false);
setPhase('locked');
navigate('/lock');
}
function handleLock() {
lockCurrentSession();
}
function logoutNow() {
void revokeCurrentSession(sessionRef.current);
setConfirm(null);
@@ -607,6 +629,7 @@ export default function App() {
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
setPendingTotpMode(null);
setPhase('login');
navigate('/login');
}
@@ -622,6 +645,62 @@ export default function App() {
});
}
useEffect(() => {
if (phase !== 'app' || lockTimeoutMinutes === 0) return;
if (typeof window === 'undefined') return;
let timerId: number | null = null;
let lastActivityAt = 0;
const timeoutMs = lockTimeoutMinutes * 60 * 1000;
const clearTimer = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
const runTimeoutAction = () => {
if (sessionTimeoutAction === 'logout') {
logoutNow();
return;
}
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) {
lockCurrentSession();
}
};
const scheduleTimeout = () => {
clearTimer();
timerId = window.setTimeout(() => {
runTimeoutAction();
}, timeoutMs);
};
const markActivity = () => {
const now = Date.now();
if (now - lastActivityAt < 1000) return;
lastActivityAt = now;
scheduleTimeout();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') markActivity();
};
scheduleTimeout();
window.addEventListener('pointerdown', markActivity, { passive: true });
window.addEventListener('keydown', markActivity);
window.addEventListener('scroll', markActivity, { passive: true });
window.addEventListener('touchstart', markActivity, { passive: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearTimer();
window.removeEventListener('pointerdown', markActivity);
window.removeEventListener('keydown', markActivity);
window.removeEventListener('scroll', markActivity);
window.removeEventListener('touchstart', markActivity);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [phase, lockTimeoutMinutes, sessionTimeoutAction]);
function renderPassiveOverlays() {
return (
<AppGlobalOverlays
@@ -725,6 +804,34 @@ export default function App() {
return value;
}
};
const sameBytes = (a: Uint8Array, b: Uint8Array) => {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
};
const decryptFieldWithSource = async (
value: string | null | undefined,
itemEnc: Uint8Array,
itemMac: Uint8Array
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
const raw = String(value || '').trim();
if (!raw) return { text: '', source: 'plain' };
try {
return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' };
} catch {
// 继续尝试旧 user key 数据。
}
if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) {
try {
return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
} catch {
// 保留原文。
}
}
return { text: raw, source: 'plain' };
};
const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({
@@ -830,10 +937,45 @@ export default function App() {
}
if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => ({
cipher.attachments.map(async (attachment) => {
const attachmentId = String(attachment?.id || '').trim();
const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac);
const metadata: { fileName?: string; key?: string | null } = {};
if (attachmentId && fileNameResult.source === 'user') {
metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac);
}
const attachmentKey = String(attachment?.key || '').trim();
if (
attachmentId &&
attachmentKey &&
looksLikeCipherString(attachmentKey) &&
(!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey))
) {
try {
await decryptBw(attachmentKey, itemEnc, itemMac);
} catch {
try {
const rawAttachmentKey = await decryptBw(attachmentKey, encKey, macKey);
if (rawAttachmentKey.length >= 64) {
metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac);
}
} catch {
// 文件下载时会继续尝试旧格式。
}
}
}
if (attachmentId && Object.keys(metadata).length > 0) {
void repairCipherAttachmentMetadata(authedFetch, cipher.id, attachmentId, metadata);
}
return {
...attachment,
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
}))
decFileName: fileNameResult.text,
};
})
);
}
return nextCipher;
@@ -1147,6 +1289,7 @@ export default function App() {
profile,
session,
mobileLayout,
mobileSidebarToggleKey,
importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1159,6 +1302,8 @@ export default function App() {
users: usersQuery.data || [],
invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled,
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
onNavigate: navigate,
@@ -1205,6 +1350,8 @@ export default function App() {
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
@@ -1267,7 +1414,7 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
unlockReady={!!profile?.key && !!session}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
registerValues={registerValues}
@@ -1309,12 +1456,14 @@ export default function App() {
onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setRememberDevice(true);
}}
onUseRecoveryCode={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');
@@ -1348,6 +1497,7 @@ export default function App() {
onLock={handleLock}
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps}
/>
@@ -21,6 +21,7 @@ interface AppAuthenticatedShellProps {
onLock: () => void;
onLogout: () => void;
onToggleTheme: () => void;
onToggleMobileSidebar: () => void;
mainRoutesProps: AppMainRoutesProps;
}
@@ -51,7 +52,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
onClick={props.onToggleMobileSidebar}
>
<FolderIcon size={16} className="btn-icon" />
</button>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="check-line" style={{ marginBottom: 0 }}>
<label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span>
</label>
+11
View File
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
profile: Profile | null;
session: SessionState | null;
mobileLayout: boolean;
mobileSidebarToggleKey: number;
importRoute: string;
settingsHomeRoute: string;
settingsAccountRoute: string;
@@ -45,6 +46,8 @@ export interface AppMainRoutesProps {
users: AdminUser[];
invites: AdminInvite[];
totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean;
onNavigate: (path: string) => void;
@@ -96,6 +99,8 @@ export interface AppMainRoutesProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
@@ -165,6 +170,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
onNotify={props.onNotify}
/>
</Suspense>
@@ -204,6 +210,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/>
</Suspense>
</Route>
@@ -222,6 +229,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage
profile={props.profile}
totpEnabled={props.totpEnabled}
lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction}
onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp}
@@ -229,6 +238,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify}
/>
</Suspense>
+100 -5
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n';
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
body.dataset.dialogCount = String(nextCount);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let dialogIdCounter = 0;
function getFocusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
});
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => {
if (!active) return;
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
const cardRef = useRef<HTMLFormElement | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`;
const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => {
if (props.open) {
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
useEffect(() => {
if (!props.open || typeof document === 'undefined') return;
const activeElement = document.activeElement;
restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
const frameId = window.requestAnimationFrame(() => {
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
const firstField = focusable.find((element) => (
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
));
const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true');
const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true');
const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card;
target.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frameId);
}, [props.open, props.danger]);
useEffect(() => {
if (props.open || present || typeof document === 'undefined') return;
const target = restoreFocusRef.current;
restoreFocusRef.current = null;
if (!target || !document.contains(target)) return;
target.focus({ preventScroll: true });
}, [props.open, present]);
useEffect(() => {
return () => {
const target = restoreFocusRef.current;
if (!target || typeof document === 'undefined' || !document.contains(target)) return;
target.focus({ preventScroll: true });
};
}, []);
function handleDialogKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
card.focus({ preventScroll: true });
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey) {
if (activeElement === first || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
last.focus({ preventScroll: true });
}
return;
}
if (activeElement === last || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
first.focus({ preventScroll: true });
}
}
if (!present || typeof document === 'undefined') return null;
return createPortal((
<div
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
}}
>
<form
ref={cardRef}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog"
aria-modal="true"
aria-label={props.title}
aria-labelledby={titleId}
aria-describedby={messageId}
tabIndex={-1}
onKeyDown={handleDialogKeyDown}
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled || closing) return;
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div>
</>
) : null}
<h3 className="dialog-title">{props.title}</h3>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
<h3 id={titleId} className="dialog-title">{props.title}</h3>
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children}
<button
type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled}
data-dialog-confirm="true"
>
{props.confirmText || t('txt_yes')}
</button>
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled}
data-dialog-cancel="true"
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
+78 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto';
@@ -11,29 +11,95 @@ interface PublicSendPageProps {
keyPart: string | null;
}
interface PublicSendFileData {
id: string;
fileName?: string | null;
sizeName?: string | null;
}
interface PublicSendData {
id: string;
type: 0 | 1;
decName?: string | null;
decText?: string | null;
decFileName?: string | null;
expirationDate?: string | null;
file?: PublicSendFileData | null;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function parsePublicSendData(value: unknown): PublicSendData | null {
const source = asRecord(value);
if (!source) return null;
const id = optionalString(source.id);
const rawType = Number(source.type);
if (!id || (rawType !== 0 && rawType !== 1)) return null;
const fileSource = asRecord(source.file);
const fileId = optionalString(fileSource?.id);
const file = fileSource && fileId
? {
id: fileId,
fileName: optionalString(fileSource.fileName),
sizeName: optionalString(fileSource.sizeName),
}
: null;
if (rawType === 1 && !file) return null;
return {
id,
type: rawType,
decName: optionalString(source.decName),
decText: optionalString(source.decText),
decFileName: optionalString(source.decFileName),
expirationDate: optionalString(source.expirationDate),
file,
};
}
export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true);
const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null);
const [sendData, setSendData] = useState<PublicSendData | null>(null);
const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
const loadAbortRef = useRef<AbortController | null>(null);
async function loadSend(pass?: string): Promise<void> {
loadAbortRef.current?.abort();
const controller = new AbortController();
const requestId = loadRequestRef.current + 1;
loadRequestRef.current = requestId;
loadAbortRef.current = controller;
setBusy(true);
setError('');
setLoading(true);
try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null);
return;
}
const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted);
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const parsed = parsePublicSendData(decrypted);
if (!parsed) throw new Error(t('txt_send_unavailable'));
setSendData(parsed);
setNeedPassword(false);
} catch (e) {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
@@ -43,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
}
setSendData(null);
} finally {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
setBusy(false);
setLoading(false);
}
@@ -86,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
useEffect(() => {
void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]);
return (
@@ -120,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
<h2 className="public-send-title">{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="card public-send-card">
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
<div className="card" style={{ marginTop: '10px' }}>
<div className="card public-send-card">
<div className="kv-line">
<span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
@@ -142,7 +212,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
<Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
</p>
)}
{!!error && <p className="local-error">{error}</p>}
@@ -66,8 +66,8 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
<h3 className="flush-title">{t('txt_device_management')}</h3>
<div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div>
</div>
@@ -89,7 +89,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
<tr>
@@ -169,7 +169,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td>
</tr>
)}
+9 -12
View File
@@ -14,12 +14,13 @@ interface SendsPageProps {
onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string;
sendUploadPercent: number | null;
mobileSidebarToggleKey: number;
onNotify: (type: 'success' | 'error', text: string) => void;
}
type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback);
@@ -107,12 +108,9 @@ export default function SendsPage(props: SendsPageProps) {
}, []);
useEffect(() => {
const onToggleSidebar = () => {
if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open);
};
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
}, [props.mobileSidebarToggleKey]);
useEffect(() => {
try {
@@ -325,8 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
{filteredSends.map((send, index) => (
<div
key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
@@ -405,7 +402,7 @@ export default function SendsPage(props: SendsPageProps) {
)}
{isEditing && draft && (
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
<div className="card stagger-item stagger-delay-0">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid">
@@ -505,12 +502,12 @@ export default function SendsPage(props: SendsPageProps) {
{!isEditing && selectedSend && (
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
<div className="card stagger-item stagger-delay-1">
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div>
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
<div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
@@ -533,7 +530,7 @@ export default function SendsPage(props: SendsPageProps) {
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
<div className="card stagger-item stagger-delay-3">
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
+172 -97
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps {
profile: Profile;
totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
@@ -16,9 +18,19 @@ interface SettingsPageProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void;
}
const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
{ value: 0, labelKey: 'txt_timeout_never' },
] as const;
function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = '';
@@ -39,21 +51,38 @@ function buildOtpUri(email: string, secret: string): string {
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
function clearLegacyTotpSetupSecrets(): void {
if (typeof window === 'undefined') return;
const prefix = 'nodewarden.totp.secret.';
const keys: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(prefix)) keys.push(key);
}
for (const key of keys) {
window.localStorage.removeItem(key);
}
}
export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [secret, setSecret] = useState(() => randomBase32Secret(32));
const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
useEffect(() => {
clearLegacyTotpSetupSecrets();
}, []);
useEffect(() => {
if (!props.totpEnabled) {
@@ -79,41 +108,58 @@ export default function SettingsPage(props: SettingsPageProps) {
async function enableTotp(): Promise<void> {
try {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
}
}
async function loadRecoveryCode(): Promise<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
setMasterPasswordPrompt(action);
setMasterPasswordPromptValue('');
}
function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
}
async function submitMasterPasswordPrompt(): Promise<void> {
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true);
try {
if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded'));
}
async function loadApiKey(): Promise<void> {
try {
const key = await props.onGetApiKey(apiKeyMasterPassword);
} else if (masterPasswordPrompt === 'apiKey') {
const key = await props.onGetApiKey(masterPassword);
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);
} else {
const key = await props.onRotateApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
}
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
} finally {
setMasterPasswordPromptSubmitting(false);
}
}
const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key')
: t('txt_view_api_key');
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
@@ -122,30 +168,44 @@ export default function SettingsPage(props: SettingsPageProps) {
}
return (
<div className="stack">
<section className="card">
<h3>{t('txt_profile')}</h3>
<div className="settings-modules-grid">
<section className="card settings-module">
<h3>{t('txt_session_timeout')}</h3>
<div className="session-timeout-fields">
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
<span>{t('txt_timeout_time')}</span>
<select
className="input"
maxLength={120}
value={passwordHint}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
value={String(props.lockTimeoutMinutes)}
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
>
{t('txt_save_profile')}
</button>
{LOCK_TIMEOUT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{t(option.labelKey)}
</option>
))}
</select>
</label>
<label className="field">
<span>{t('txt_timeout_action')}</span>
<select
className="input"
value={props.sessionTimeoutAction}
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
>
<option value="logout">{t('txt_timeout_action_logout')}</option>
<option value="lock">{t('txt_timeout_action_lock')}</option>
</select>
</label>
</div>
</section>
<section className="card">
<section className="card settings-module settings-module-placeholder">
<Lightbulb size={26} aria-hidden="true" />
<span>{t('txt_in_planning')}</span>
</section>
<section className="card settings-module">
<h3>{t('txt_change_master_password')}</h3>
<label className="field">
<span>{t('txt_current_password')}</span>
@@ -176,9 +236,29 @@ export default function SettingsPage(props: SettingsPageProps) {
</button>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<section className="card settings-module">
<h3>{t('txt_password_hint_optional')}</h3>
<label className="field">
<span>{t('txt_password_hint')}</span>
<input
className="input"
maxLength={120}
value={passwordHint}
placeholder={t('txt_password_hint_placeholder')}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
/>
<div className="field-help">{t('txt_password_hint_register_help')}</div>
</label>
<button
type="button"
className="btn btn-secondary"
onClick={() => void props.onSavePasswordHint(passwordHint)}
>
{t('txt_save_profile')}
</button>
</section>
<section className="card settings-module">
<h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid">
@@ -223,24 +303,20 @@ export default function SettingsPage(props: SettingsPageProps) {
<ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')}
</button>
</div>
</section>
<div className="settings-subcard">
<h3>{t('txt_recovery_code')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
<section className="card settings-module">
<h3>{t('txt_recovery_code_and_api_key')}</h3>
<div className="sensitive-actions-grid">
<div className="sensitive-action">
<div>
<h4>{t('txt_recovery_code')}</h4>
<p className="muted-inline settings-field-note">
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')}
</button>
@@ -257,25 +333,19 @@ export default function SettingsPage(props: SettingsPageProps) {
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
<div className="recovery-code-card">
<div className="recovery-code-value">{recoveryCode}</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="sensitive-action">
<div>
<h4>{t('txt_api_key')}</h4>
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
<KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')}
</button>
@@ -291,6 +361,28 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
</div>
</section>
<ConfirmDialog
open={masterPasswordPrompt !== null}
title={masterPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
cancelDisabled={masterPasswordPromptSubmitting}
onConfirm={() => void submitMasterPasswordPrompt()}
onCancel={closeMasterPasswordPrompt}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={masterPasswordPromptValue}
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={apiKeyDialogOpen}
title={t('txt_api_key')}
@@ -300,30 +392,13 @@ export default function SettingsPage(props: SettingsPageProps) {
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 className="api-key-warning-panel">
<div className="api-key-warning-title">{t('txt_warning')}</div>
<div className="api-key-warning-body">{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 }}>
<div className="api-key-credentials-panel">
<div className="api-key-credentials-title">
<KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span>
</div>
@@ -335,7 +410,7 @@ export default function SettingsPage(props: SettingsPageProps) {
] 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 }}>
<div className="api-key-credential-row">
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button
type="button"
@@ -357,7 +432,7 @@ export default function SettingsPage(props: SettingsPageProps) {
danger
onConfirm={() => {
setRotateApiKeyConfirmOpen(false);
void doRotateApiKey();
openMasterPasswordPrompt('rotateApiKey');
}}
onCancel={() => setRotateApiKeyConfirmOpen(false)}
/>
+53 -13
View File
@@ -35,6 +35,14 @@ const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>();
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
@@ -65,23 +73,41 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img
className="list-icon"
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
</span>
);
}
return (
@@ -168,7 +194,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
@@ -251,26 +278,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
useEffect(() => {
if (!totpItems.length) {
setTotpMap({});
setTotpCodes({});
return;
}
let stopped = false;
let activeRun = 0;
let timer = 0;
const tick = async () => {
let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all(
totpItems.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next] as const;
return [cipher.id, next?.code || null] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
if (!stopped) setTotpMap(Object.fromEntries(entries));
if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
};
void tick();
timer = window.setInterval(() => void tick(), 1000);
const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
tick();
timer = window.setInterval(tick, 1000);
return () => {
stopped = true;
window.clearInterval(timer);
@@ -326,7 +366,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<SortableTotpRow
key={cipher.id}
cipher={cipher}
live={totpMap[cipher.id] || null}
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
+3 -5
View File
@@ -58,6 +58,7 @@ interface VaultPageProps {
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
}
@@ -131,12 +132,9 @@ export default function VaultPage(props: VaultPageProps) {
}, []);
useEffect(() => {
const onToggleSidebar = () => {
if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open);
};
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
}, [props.mobileSidebarToggleKey]);
useEffect(() => {
const onQuickAdd = () => {
@@ -105,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}>
<div className="actions detail-unlock-actions">
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button>
@@ -117,7 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div>
{props.selectedCipher.login && (
+1 -1
View File
@@ -299,7 +299,7 @@ export default function VaultEditor(props: VaultEditorProps) {
</DndContext>
{props.draft.loginFido2Credentials.length > 0 && (
<>
<div className="section-head" style={{ marginTop: '18px' }}>
<div className="section-head passkeys-section-head">
<h4>{t('txt_passkeys')}</h4>
</div>
<div className="attachment-list">
@@ -36,7 +36,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
];
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: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
@@ -433,23 +433,41 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img
className="list-icon"
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
</span>
);
}
return (
@@ -131,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog();
await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled'));
+19 -3
View File
@@ -122,9 +122,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Profile;
if (!parsed?.email || !parsed?.key) return null;
if (!parsed?.email) return null;
if (email && parsed.email !== email) return null;
return parsed;
const snapshot = stripProfileSecrets(parsed);
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
return snapshot;
} catch {
return null;
}
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
}
export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
}
export function stripProfileSecrets(profile: Profile | null): Profile | null {
if (!profile) return null;
return {
id: String(profile.id || ''),
email: String(profile.email || ''),
name: String(profile.name || ''),
role: profile.role === 'admin' ? 'admin' : 'user',
masterPasswordHint: profile.masterPasswordHint ?? null,
publicKey: profile.publicKey ?? null,
key: '',
privateKey: null,
};
}
export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
}
+19 -10
View File
@@ -260,18 +260,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
return payload;
}
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> {
export async function accessPublicSend(
accessId: string,
keyPart?: string | null,
password?: string,
options?: { signal?: AbortSignal }
): Promise<unknown> {
const payload = await buildPublicSendAccessPayload(password, keyPart);
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: options?.signal,
});
if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send');
throw createApiError(message, resp.status);
}
return (await parseJson<any>(resp)) || null;
return (await parseJson<unknown>(resp)) || null;
}
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
@@ -290,19 +296,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
return body.url;
}
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
export async function decryptPublicSend(accessData: unknown, urlSafeKey: string): Promise<unknown> {
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
const sendKey = await toSendKeyParts(sendKeyMaterial);
const out: any = { ...accessData };
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
if (accessData?.text?.text) {
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
const out: Record<string, unknown> = { ...source };
out.decName = await decryptStr(String(source.name || ''), sendKey.enc, sendKey.mac);
if (text?.text) {
out.decText = await decryptStr(String(text.text), sendKey.enc, sendKey.mac);
}
if (accessData?.file?.fileName) {
if (file?.fileName) {
try {
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
} catch {
out.decFileName = String(accessData.file.fileName);
out.decFileName = String(file.fileName);
}
}
return out;
+154 -17
View File
@@ -13,6 +13,7 @@ import {
parseErrorMessage,
parseJson,
uploadDirectEncryptedPayload,
uploadWithProgress,
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
@@ -273,6 +274,98 @@ export async function deleteCipherAttachment(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
}
export async function repairCipherAttachmentMetadata(
authedFetch: AuthedFetch,
cipherId: string,
attachmentId: string,
metadata: { fileName?: string; key?: string | null }
): Promise<void> {
const resp = await authedFetch(
`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}/metadata`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
}
);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update attachment metadata failed'));
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
async function decryptCipherStringWithKey(
value: string,
enc: Uint8Array,
mac: Uint8Array
): Promise<Uint8Array | null> {
try {
return await decryptBw(value, enc, mac);
} catch {
return null;
}
}
async function decryptAttachmentFileName(
rawFileName: string,
itemKeys: { enc: Uint8Array; mac: Uint8Array },
userKeys: { enc: Uint8Array; mac: Uint8Array }
): Promise<{ fileName: string; source: 'plain' | 'item' | 'user' }> {
const fallback = rawFileName || 'attachment.bin';
if (!rawFileName || !looksLikeCipherString(rawFileName)) return { fileName: fallback, source: 'plain' };
try {
const fileName = await decryptStr(rawFileName, itemKeys.enc, itemKeys.mac);
if (fileName) return { fileName, source: 'item' };
} catch {
// 继续尝试旧 user key 文件名。
}
if (!sameBytes(itemKeys.enc, userKeys.enc) || !sameBytes(itemKeys.mac, userKeys.mac)) {
try {
const fileName = await decryptStr(rawFileName, userKeys.enc, userKeys.mac);
if (fileName) return { fileName, source: 'user' };
} catch {
// 保留原始文件名。
}
}
return { fileName: fallback, source: 'plain' };
}
type AttachmentDecryptMode = 'attachment-item' | 'attachment-user' | 'legacy-item' | 'legacy-user';
interface AttachmentDecryptCandidate {
mode: AttachmentDecryptMode;
enc: Uint8Array;
mac: Uint8Array;
rawAttachmentKey: Uint8Array | null;
}
async function uploadRepairedAttachmentBlob(
authedFetch: AuthedFetch,
session: SessionState,
cipherId: string,
attachmentId: string,
encryptedBytes: Uint8Array
): Promise<void> {
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
const resp = await uploadWithProgress(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`, {
accessToken: session.accessToken,
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: payload,
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair attachment upload failed'));
}
export async function downloadCipherAttachmentDecrypted(
authedFetch: AuthedFetch,
session: SessionState,
@@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
const userKeys = { enc: userEnc, mac: userMac };
let fileEnc = itemKeys.enc;
let fileMac = itemKeys.mac;
const candidates: AttachmentDecryptCandidate[] = [];
const keyCipher = String(info.key || '').trim();
if (keyCipher && looksLikeCipherString(keyCipher)) {
try {
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
if (fileRawKey.length >= 64) {
fileEnc = fileRawKey.slice(0, 32);
fileMac = fileRawKey.slice(32, 64);
}
} catch {
// fallback to item key
}
const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
if (itemWrappedKey && itemWrappedKey.length >= 64) {
candidates.push({
mode: 'attachment-item',
enc: itemWrappedKey.slice(0, 32),
mac: itemWrappedKey.slice(32, 64),
rawAttachmentKey: itemWrappedKey,
});
}
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
const userWrappedKey = await decryptCipherStringWithKey(keyCipher, userEnc, userMac);
if (userWrappedKey && userWrappedKey.length >= 64) {
candidates.push({
mode: 'attachment-user',
enc: userWrappedKey.slice(0, 32),
mac: userWrappedKey.slice(32, 64),
rawAttachmentKey: userWrappedKey,
});
}
}
}
candidates.push({ mode: 'legacy-item', enc: itemKeys.enc, mac: itemKeys.mac, rawAttachmentKey: null });
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
candidates.push({ mode: 'legacy-user', enc: userEnc, mac: userMac, rawAttachmentKey: null });
}
let plainBytes: Uint8Array | null = null;
let usedCandidate: AttachmentDecryptCandidate | null = null;
for (const candidate of candidates) {
try {
plainBytes = await decryptBwFileData(encryptedBytes, candidate.enc, candidate.mac);
usedCandidate = candidate;
break;
} catch {
// 继续尝试下一种旧附件格式。
}
}
if (!plainBytes || !usedCandidate) throw new Error('Attachment decryption failed');
const fileNameRaw = String(info.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${aid}`;
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
const fileName = nameResult.fileName || `attachment-${aid}`;
try {
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
} catch {
// keep fallback name
const metadata: { fileName?: string; key?: string | null } = {};
if (nameResult.source === 'user') {
metadata.fileName = await encryptTextValue(fileName, itemKeys.enc, itemKeys.mac) || undefined;
}
if (usedCandidate.mode === 'attachment-user' && usedCandidate.rawAttachmentKey) {
metadata.key = await encryptBw(usedCandidate.rawAttachmentKey, itemKeys.enc, itemKeys.mac);
} else if (usedCandidate.mode === 'legacy-item') {
metadata.key = null;
} else if (usedCandidate.mode === 'legacy-user') {
const repairedBytes = await encryptBwFileData(plainBytes, itemKeys.enc, itemKeys.mac);
await uploadRepairedAttachmentBlob(authedFetch, session, cid, aid, repairedBytes);
metadata.key = null;
}
if (Object.keys(metadata).length > 0) {
await repairCipherAttachmentMetadata(authedFetch, cid, aid, metadata);
}
} catch {
// 修复失败不影响本次下载,旧附件内容已经成功解密。
}
return { fileName, bytes: plainBytes };
+28 -8
View File
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
export async function performUnlock(
session: SessionState,
profile: Profile,
profile: Profile | null,
password: string,
fallbackIterations: number
): Promise<SessionState> {
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
const keys = await unlockVaultKey(profile.key, derived.masterKey);
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession) {
throw new Error('Session expired');
): Promise<PasswordLoginResult> {
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
if ('access_token' in token && token.access_token) {
return {
kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
};
}
return { ...refreshedSession, ...keys };
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
return {
kind: 'totp',
pendingTotp: {
email: normalizedEmail,
passwordHash: derived.hash,
masterKey: derived.masterKey,
},
};
}
return {
kind: 'error',
message: tokenError.error_description || tokenError.error || 'Unlock failed',
};
}
+48
View File
@@ -309,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel",
txt_continue: "Continue",
txt_card: "Card",
txt_card_details: "Card Details",
txt_cardholder_name: "Cardholder Name",
@@ -417,6 +418,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_encrypted_file_2: "Encrypted file",
txt_enter_a_folder_name: "Enter a folder name.",
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
txt_enter_master_password_to_continue: "Enter your master password to continue.",
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)",
@@ -598,6 +600,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_recover_two_step_login: "Recover Two-step Login",
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
txt_recovery_code: "Recovery Code",
txt_recovery_code_and_api_key: "Recovery Code and API Key",
txt_recovery_code_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded",
@@ -1041,6 +1044,7 @@ const zhCNOverrides: Record<string, string> = {
txt_confirm_master_password: '确认主密码',
txt_submit: '提交',
txt_cancel: '取消',
txt_continue: '继续',
txt_yes: '是',
txt_no: '否',
txt_loading: '加载中...',
@@ -1308,6 +1312,7 @@ const zhCNOverrides: Record<string, string> = {
txt_encrypted_file_2: '加密文件',
txt_enter_a_folder_name: '请输入文件夹名称',
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
txt_enter_master_password_to_continue: '输入主密码以继续',
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
txt_expiry: '有效期',
txt_expiry_month: '有效期月',
@@ -1376,6 +1381,7 @@ const zhCNOverrides: Record<string, string> = {
txt_recover_2fa_failed: '恢复 2FA 失败',
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_and_api_key: '恢复代码和 API 密钥',
txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载',
txt_api_key: 'API 密钥',
@@ -1485,6 +1491,48 @@ zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_menu = '菜单';
zhCNOverrides.txt_settings = '设置';
zhCNOverrides.txt_back = '返回';
messages.en.txt_auto_lock = 'Auto-lock';
messages.en.txt_auto_lock_description = 'Locks after inactivity. Closing and reopening the page always starts locked.';
messages.en.txt_auto_lock_updated = 'Auto-lock updated';
messages.en.txt_session_timeout = 'Session timeout';
messages.en.txt_session_timeout_updated = 'Session timeout updated';
messages.en.txt_timeout_time = 'Timeout time';
messages.en.txt_timeout_action = 'Timeout action';
messages.en.txt_timeout_action_logout = 'Log out';
messages.en.txt_timeout_action_lock = 'Lock';
messages.en.txt_in_planning = 'In planning';
messages.en.txt_security_preferences = 'Security Preferences';
messages.en.txt_timeout_1_minute = '1 minute';
messages.en.txt_timeout_5_minutes = '5 minutes';
messages.en.txt_timeout_15_minutes = '15 minutes';
messages.en.txt_timeout_30_minutes = '30 minutes';
messages.en.txt_timeout_never = 'Never';
messages.en.txt_lock_after_1_minute = 'After 1 minute';
messages.en.txt_lock_after_5_minutes = 'After 5 minutes';
messages.en.txt_lock_after_15_minutes = 'After 15 minutes';
messages.en.txt_lock_after_30_minutes = 'After 30 minutes';
messages.en.txt_lock_after_never = 'Never for inactivity';
zhCNOverrides.txt_auto_lock = '会话超时';
zhCNOverrides.txt_auto_lock_description = '页面闲置后执行会话超时动作;关闭页面或浏览器后再次打开始终进入锁定页。';
zhCNOverrides.txt_auto_lock_updated = '会话超时已更新';
zhCNOverrides.txt_session_timeout = '会话超时';
zhCNOverrides.txt_session_timeout_updated = '会话超时已更新';
zhCNOverrides.txt_timeout_time = '超时时间';
zhCNOverrides.txt_timeout_action = '超时动作';
zhCNOverrides.txt_timeout_action_logout = '注销';
zhCNOverrides.txt_timeout_action_lock = '锁定';
zhCNOverrides.txt_in_planning = '构思中';
zhCNOverrides.txt_security_preferences = '安全偏好';
zhCNOverrides.txt_timeout_1_minute = '1 分钟';
zhCNOverrides.txt_timeout_5_minutes = '5 分钟';
zhCNOverrides.txt_timeout_15_minutes = '15 分钟';
zhCNOverrides.txt_timeout_30_minutes = '30 分钟';
zhCNOverrides.txt_timeout_never = '从不';
zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后';
zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后';
zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后';
zhCNOverrides.txt_lock_after_30_minutes = '闲置 30 分钟后';
zhCNOverrides.txt_lock_after_never = '不因闲置锁定';
zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件';
+1
View File
@@ -1,6 +1,7 @@
import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './tailwind.css';
import './styles.css';
const queryClient = new QueryClient({
+262 -1
View File
@@ -9,4 +9,265 @@
@import './styles/motion.css';
@import './styles/responsive.css';
@import './styles/dark.css';
@import './styles/reduced-motion.css';
/* Unified product polish: clean, flat, quiet surfaces across desktop, mobile, and dark mode. */
.app-shell,
.auth-card,
.dialog-card,
.card,
.list-panel,
.sidebar-block,
.settings-subcard,
.backup-operations-sidebar,
.backup-destination-sidebar,
.backup-detail-panel,
.restore-progress-card,
.backup-recommendation-card,
.backup-destination-item,
.backup-browser-list,
.backup-browser-path,
.totp-code-row,
.mobile-settings-link,
.table tr {
border-color: var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow-sm);
}
.app-shell {
background: var(--panel-soft);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.topbar,
.mobile-tabbar,
.app-side {
background: var(--panel-soft);
border-color: var(--line-soft);
}
.brand-logo,
.brand-wordmark,
.standalone-brand-logo,
.standalone-brand-wordmark {
filter: none;
}
.btn,
.input,
.search-input,
.side-link,
.mobile-tab,
.tree-btn,
.list-item,
.dialog-card,
.mobile-sidebar-sheet,
.mobile-detail-sheet,
.create-menu,
.sort-menu,
.toast-item {
transition-duration: 150ms;
}
.btn:hover:not(:disabled),
.side-link:hover,
.mobile-tab:hover,
.tree-btn:hover,
.list-item:hover,
.create-menu-item:hover,
.sort-menu-item:hover,
.folder-delete-btn:hover,
.eye-btn:hover,
.password-toggle:hover {
transform: none;
}
.btn-primary {
background: var(--primary);
border-color: transparent;
color: #ffffff;
box-shadow: none;
}
.btn-primary:hover {
background: var(--primary-hover);
box-shadow: none;
}
.btn-secondary,
.btn-danger {
box-shadow: none;
}
.btn-secondary:hover,
.side-link:hover,
.mobile-tab:hover,
.tree-btn:hover,
.list-item:hover,
.backup-destination-item:hover,
.mobile-settings-link:hover {
background: var(--panel-subtle);
}
.side-link.active,
.mobile-tab.active,
.tree-btn.active,
.list-item.active,
.sort-menu-item.active,
.backup-destination-item.active,
.backup-interval-preset.active,
.mobile-settings-link.active {
background: color-mix(in srgb, var(--primary) 12%, var(--panel));
border-color: color-mix(in srgb, var(--primary) 32%, var(--line));
color: var(--primary-strong);
box-shadow: none;
}
.list-item::before,
.topbar-actions .btn::before,
.user-chip::before,
.side-link::before,
.mobile-tab::before {
display: none;
}
.stagger-item {
opacity: 1;
animation: none;
}
.dialog-mask {
background: rgba(15, 23, 42, 0.42);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dialog-mask.warning {
background: rgba(15, 23, 42, 0.56);
}
.dialog-card.warning,
:root[data-theme='dark'] .dialog-card.warning {
background: var(--panel);
border-color: color-mix(in srgb, var(--danger) 28%, var(--line));
box-shadow: var(--shadow-lg);
}
.dialog-warning-badge {
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--danger);
box-shadow: none;
}
.dialog-warning-kicker {
letter-spacing: 0;
text-transform: none;
}
.dialog-message.warning,
:root[data-theme='dark'] .dialog-message.warning {
background: color-mix(in srgb, var(--danger) 8%, var(--panel));
border-color: color-mix(in srgb, var(--danger) 20%, var(--line));
box-shadow: none;
color: var(--text);
}
.mobile-sidebar-sheet,
.mobile-detail-sheet {
transform: none;
box-shadow: var(--shadow-md);
}
.mobile-sidebar-sheet.open,
.mobile-detail-sheet.open {
transform: none;
}
.mobile-fab-trigger {
box-shadow: var(--shadow-md);
}
.theme-switch-slider,
.theme-switch-input:checked + .theme-switch-slider,
:root[data-theme='dark'] .theme-switch-slider {
background: var(--panel-muted);
border-color: var(--line);
}
.theme-switch-slider::before,
:root[data-theme='dark'] .theme-switch-slider::before {
background: var(--panel);
box-shadow: var(--shadow-sm);
}
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card,
:root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .sidebar-block,
: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'] .backup-recommendation-card,
:root[data-theme='dark'] .backup-recommendation-dav-item,
:root[data-theme='dark'] .backup-destination-item,
:root[data-theme='dark'] .backup-browser-list,
:root[data-theme='dark'] .backup-browser-path,
:root[data-theme='dark'] .totp-code-row,
:root[data-theme='dark'] .mobile-settings-link,
:root[data-theme='dark'] .table tr,
:root[data-theme='dark'] .list-item,
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .search-input,
: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 {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: var(--shadow-sm);
}
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .mobile-tabbar,
:root[data-theme='dark'] .app-side {
background: var(--panel-soft);
}
:root[data-theme='dark'] .btn-secondary {
background: var(--panel);
border-color: var(--line);
color: var(--primary);
box-shadow: none;
}
:root[data-theme='dark'] .btn-primary {
background: var(--primary);
border-color: transparent;
color: #08111f;
box-shadow: none;
}
:root[data-theme='dark'] .btn-danger {
background: var(--panel);
border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
color: var(--danger);
box-shadow: none;
}
: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,
:root[data-theme='dark'] .side-link:hover,
:root[data-theme='dark'] .mobile-tab:hover,
:root[data-theme='dark'] .tree-btn:hover {
background: var(--panel-subtle);
}
+40 -91
View File
@@ -1,134 +1,96 @@
.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;
@apply grid h-full place-items-center text-lg text-muted;
}
.auth-page {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
position: relative;
background: transparent;
@apply relative grid min-h-full place-items-center bg-transparent p-6;
}
.public-send-page {
min-height: 80vh;
align-items: center;
@apply min-h-[80vh] items-center;
justify-items: center;
}
.public-send-title {
@apply mt-2;
}
.public-send-card {
@apply mt-2.5;
}
.inline-status-icon {
@apply align-text-bottom;
}
.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;
@apply relative w-full overflow-hidden border bg-panel p-[30px] shadow-elevated;
border-color: var(--line);
@apply rounded-[22px];
}
.auth-card h1 {
margin: 0 0 4px 0;
text-align: center;
@apply m-0 mb-1 text-center;
}
.standalone-shell {
width: min(640px, 100%);
display: grid;
gap: 14px;
animation: fade-in-up 420ms var(--ease-out-strong) both;
@apply grid w-[min(640px,100%)] gap-3.5;
}
.standalone-brand {
display: inline-flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
@apply mb-3 inline-flex items-center gap-3.5;
}
.standalone-brand-outside {
justify-content: center;
width: 100%;
margin-bottom: 2px;
@apply mb-0.5 w-full justify-center;
}
.standalone-brand-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
@apply h-14 w-14 flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.standalone-brand-wordmark {
display: block;
height: auto;
@apply block h-auto max-w-full;
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;
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
}
.standalone-muted {
text-align: left;
@apply text-left;
}
.jwt-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
@apply mb-2.5 flex items-center justify-center gap-2.5 text-center;
color: #b45309;
text-align: center;
}
.jwt-warning-box {
border: 1px solid #f1d8a5;
border-radius: 12px;
background: #fffaf0;
padding: 12px 14px;
@apply rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3;
}
.jwt-warning-label {
font-size: 13px;
font-weight: 700;
@apply mb-1.5 text-[13px] font-bold;
color: #92400e;
margin-bottom: 6px;
}
.jwt-warning-copy {
margin: 0 0 14px;
@apply m-0 mb-3.5 leading-[1.6];
color: #475569;
line-height: 1.6;
}
.jwt-warning-list {
margin: 0;
padding-left: 18px;
@apply m-0 pl-[18px] leading-[1.55];
color: #334155;
line-height: 1.55;
}
.jwt-inline-link {
@apply font-bold no-underline;
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.jwt-inline-link:hover {
@@ -136,16 +98,12 @@
}
.jwt-secret-fields {
margin-top: 8px;
display: grid;
gap: 6px;
@apply mt-2 grid gap-1.5;
}
.jwt-secret-row {
display: grid;
@apply grid items-start gap-2;
grid-template-columns: 88px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.jwt-secret-row > span {
@@ -153,34 +111,25 @@
}
.jwt-generator {
margin-top: 14px;
@apply mt-3.5;
}
.jwt-generator-actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
@apply mt-2.5 flex flex-wrap items-center gap-2.5;
}
.jwt-copy-hint {
@apply text-[13px] font-bold;
color: #15803d;
font-size: 13px;
font-weight: 700;
}
.standalone-footer {
width: 100%;
text-align: center;
font-size: 13px;
color: #64748b;
@apply w-full text-center text-[13px] text-slate-500;
}
.standalone-footer a {
@apply font-bold no-underline;
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.standalone-footer a:hover {
@@ -188,6 +137,6 @@
}
.standalone-version {
font-weight: 700;
@apply font-bold;
color: #1d4ed8;
}
+5 -10
View File
@@ -1,27 +1,22 @@
* {
box-sizing: border-box;
@apply box-border;
}
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
@apply m-0 h-full w-full p-0;
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);
@apply relative antialiased;
transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth);
}
body.dialog-open {
overflow: hidden;
@apply overflow-hidden;
overscroll-behavior: contain;
}
+75 -381
View File
@@ -2,85 +2,14 @@
:root[data-theme='dark'] #root,
:root[data-theme='dark'] .app-page,
:root[data-theme='dark'] .auth-page {
background: transparent;
background: var(--bg-accent);
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'] h1,
:root[data-theme='dark'] h2,
:root[data-theme='dark'] h3,
:root[data-theme='dark'] h4,
:root[data-theme='dark'] .brand,
:root[data-theme='dark'] .mobile-page-title,
:root[data-theme='dark'] .detail-title,
@@ -89,277 +18,6 @@
: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,
@@ -376,6 +34,20 @@
color: var(--text);
}
: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,
:root[data-theme='dark'] .totp-code-username,
:root[data-theme='dark'] .backup-destination-meta,
:root[data-theme='dark'] .backup-browser-meta,
@@ -385,67 +57,89 @@
: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 {
:root[data-theme='dark'] .tree-label {
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'] .input,
:root[data-theme='dark'] .textarea,
:root[data-theme='dark'] select.input,
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .dialog input,
:root[data-theme='dark'] .dialog textarea,
:root[data-theme='dark'] .dialog select {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: none;
}
:root[data-theme='dark'] .backup-destination-type {
background: #1d3048;
color: #c9d8eb;
: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: color-mix(in srgb, var(--muted) 76%, transparent);
}
:root[data-theme='dark'] .backup-help-trigger {
border-color: #38618f;
background: #173150;
color: #9ec5ff;
: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: color-mix(in srgb, var(--primary) 54%, var(--line));
background: var(--panel);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
}
: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'] .input-readonly {
background: var(--panel-muted);
color: var(--muted-strong);
}
:root[data-theme='dark'] .backup-help-bubble {
:root[data-theme='dark'] .input:disabled,
:root[data-theme='dark'] .btn:disabled {
background: var(--panel-muted);
border-color: var(--line-soft);
color: color-mix(in srgb, var(--muted) 62%, transparent);
}
:root[data-theme='dark'] .mobile-sidebar-mask,
:root[data-theme='dark'] .dialog-mask {
background: var(--overlay-strong);
}
:root[data-theme='dark'] .toast-item {
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'] .toast-item.error,
:root[data-theme='dark'] .toast-item.warning {
border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--text);
}
:root[data-theme='dark'] .table td {
border-bottom-color: #203047;
: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: var(--warning);
}
:root[data-theme='dark'] .local-error {
color: #ff9bb0;
color: var(--danger);
}
:root[data-theme='dark'] .status-ok {
color: #9be2bd;
}
:root[data-theme='dark'] .totp-qr {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
color: var(--success);
}
:root[data-theme='dark'] .totp-qr,
:root[data-theme='dark'] .totp-qr svg,
:root[data-theme='dark'] .totp-qr img {
background: #ffffff;
border-radius: 8px;
border-color: rgba(15, 23, 42, 0.12);
}
+43 -150
View File
@@ -1,45 +1,27 @@
.muted {
margin: 0 0 16px 0;
text-align: center;
color: var(--muted);
line-height: 1.65;
@apply m-0 mb-4 text-center leading-relaxed text-muted;
}
.field {
display: block;
margin-bottom: 14px;
@apply mb-3.5 block;
}
.field > span {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
@apply mb-2 mt-2.5 block text-sm font-semibold;
}
.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);
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
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);
border-color: rgba(74, 103, 150, 0.34);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
select.input {
@apply pr-[42px];
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%);
@@ -51,23 +33,14 @@ select.input {
}
input[type='file'].input {
height: auto;
min-height: 48px;
padding: 8px 10px;
font-size: 14px;
line-height: 1.4;
@apply h-auto min-h-12 px-2.5 py-2 text-sm leading-[1.4];
}
input[type='file'].input::file-selector-button {
height: 32px;
border: 1px solid #3f5b9e;
border-radius: 999px;
padding: 0 12px;
@apply mr-2.5 h-8 cursor-pointer rounded-full border px-3 font-bold;
background: #eef4ff;
border-color: #9db8ea;
color: #1f4ea0;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
input[type='file'].input::file-selector-button:hover {
@@ -76,65 +49,38 @@ input[type='file'].input::file-selector-button:hover {
}
.textarea {
min-height: 110px;
height: auto;
resize: vertical;
@apply h-auto min-h-28 resize-y;
}
.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);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.10), 0 8px 18px rgba(37, 99, 235, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.input-readonly {
background: #eef2f7;
color: #475569;
@apply bg-slate-100 text-slate-600;
}
.input:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
@apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
}
.password-wrap {
position: relative;
@apply relative;
}
.password-wrap .input {
padding-right: 44px;
@apply pr-11;
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
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);
@apply absolute bottom-2.5 right-2.5 grid h-8 w-8 cursor-pointer place-items-center border-0 bg-transparent text-slate-700 transition;
}
.password-toggle:hover,
@@ -144,35 +90,14 @@ input[type='file'].input::file-selector-button:hover {
}
.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);
@apply inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full border border-transparent px-4 text-[15px] font-bold no-underline transition;
}
.topbar-actions .btn,
.user-chip,
.side-link,
.mobile-tab {
--mag-x: 0px;
--mag-y: 0px;
position: relative;
overflow: hidden;
@apply relative overflow-hidden;
}
.topbar-actions .btn::before,
@@ -180,15 +105,9 @@ input[type='file'].input::file-selector-button:hover {
.side-link::before,
.mobile-tab::before {
content: '';
position: absolute;
left: var(--mx, 50%);
top: var(--my, 50%);
width: 110px;
height: 110px;
border-radius: 999px;
@apply absolute left-1/2 top-1/2 h-[110px] w-[110px] rounded-full opacity-0;
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),
@@ -199,12 +118,11 @@ input[type='file'].input::file-selector-button:hover {
.user-chip:hover::before,
.side-link:hover::before,
.mobile-tab:hover::before {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
opacity: 0;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.01);
transform: translateY(-1px);
}
.btn:active:not(:disabled) {
@@ -212,34 +130,27 @@ input[type='file'].input::file-selector-button:hover {
}
.btn-icon {
flex-shrink: 0;
@apply shrink-0;
}
.btn.full {
width: 100%;
height: 50px;
font-size: 22px;
margin: 10px 0;
@apply my-2.5 h-12 w-full text-lg;
}
.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);
@apply border-blue-700/30 bg-blue-600 text-white;
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.20);
}
.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);
@apply bg-blue-700;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.22);
}
.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);
@apply bg-panel text-brand-strong;
border-color: rgba(37, 99, 235, 0.20);
box-shadow: 0 6px 14px rgba(13, 31, 68, 0.04);
}
.btn-secondary:hover {
@@ -248,9 +159,8 @@ input[type='file'].input::file-selector-button:hover {
}
.btn-danger {
background: rgba(255, 255, 255, 0.8);
@apply bg-white/80 text-danger;
border-color: rgba(217, 45, 87, 0.28);
color: var(--danger);
}
.btn-danger:hover {
@@ -259,42 +169,27 @@ input[type='file'].input::file-selector-button:hover {
}
.btn:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
@apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
}
.or {
text-align: center;
margin: 10px 0;
color: #334155;
@apply my-2.5 text-center text-slate-700;
}
.field-help {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: #667085;
@apply mt-2 text-[13px] leading-normal text-slate-500;
}
.check-line-compact {
@apply mb-0;
}
.auth-support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: -2px 0 12px;
@apply -mt-0.5 mb-3 flex items-center justify-between gap-2.5;
}
.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);
@apply cursor-pointer border-0 bg-transparent p-0 text-[13px] font-bold text-blue-700 transition;
}
.auth-link-btn:hover {
@@ -303,7 +198,5 @@ input[type='file'].input::file-selector-button:hover {
}
.auth-link-btn:disabled {
color: #94a3b8;
cursor: not-allowed;
text-decoration: none;
@apply cursor-not-allowed text-slate-400 no-underline;
}
File diff suppressed because it is too large Load Diff
+11 -18
View File
@@ -21,77 +21,63 @@
@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);
}
}
@@ -107,21 +93,28 @@
@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);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
+33 -106
View File
@@ -1,27 +1,17 @@
.dialog-mask {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
@apply fixed inset-0 grid h-dvh w-screen place-items-center p-5 opacity-0;
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;
@apply rounded-[20px] border bg-white p-5 text-center;
width: min(500px, 100%);
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;
}
@@ -45,20 +35,11 @@
}
.dialog-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
@apply mb-2 flex items-center justify-center gap-3;
}
.dialog-warning-badge {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
@apply inline-flex h-12 w-12 items-center justify-center rounded-2xl;
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
color: #dc2626;
box-shadow:
@@ -67,10 +48,7 @@
}
.dialog-warning-kicker {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
@apply text-xs font-extrabold uppercase tracking-[0.16em];
color: #b91c1c;
}
@@ -83,69 +61,50 @@
}
.dialog-card .field {
text-align: left;
@apply text-left;
}
.dialog-title {
margin: 6px 0;
font-size: 30px;
@apply my-1.5 text-3xl;
}
.dialog-message {
@apply mb-2.5;
color: #475467;
margin-bottom: 10px;
}
.dialog-card.warning .dialog-title {
@apply mb-2.5;
color: #7f1d1d;
margin-bottom: 10px;
}
.dialog-message.warning {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 16px;
@apply mb-4 rounded-2xl px-4 py-3.5 leading-[1.65];
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;
@apply mt-2 h-[50px] w-full text-xl;
}
.dialog-extra {
margin-top: 8px;
@apply mt-2;
}
.dialog-divider {
height: 1px;
@apply my-2 mb-2.5 h-px;
background: var(--line);
margin: 8px 0 10px;
}
.import-summary-dialog {
max-width: 520px;
text-align: left;
position: relative;
padding-top: 16px;
@apply relative max-w-[520px] pt-4 text-left;
}
.import-summary-close {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 24px;
line-height: 1;
cursor: pointer;
@apply absolute right-2.5 top-2.5 cursor-pointer border-0 bg-transparent text-2xl leading-none text-slate-500;
}
.import-summary-close:hover {
@@ -153,34 +112,29 @@
}
.import-summary-table-wrap {
margin-top: 8px;
@apply mt-2 overflow-hidden rounded-[10px];
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.import-summary-table {
width: 100%;
@apply w-full text-sm;
border-collapse: collapse;
font-size: 14px;
}
.import-summary-table th,
.import-summary-table td {
padding: 10px 12px;
@apply px-3 py-2.5;
border-bottom: 1px solid var(--line);
}
.import-summary-table th {
text-align: left;
@apply bg-slate-50 text-left;
color: #475467;
background: #f8fafc;
}
.import-summary-table td:last-child,
.import-summary-table th:last-child {
text-align: right;
width: 96px;
@apply w-24 text-right;
}
.import-summary-table tbody tr:last-child td {
@@ -188,72 +142,53 @@
}
.import-summary-failed-list {
margin-top: 10px;
padding: 10px 12px;
@apply mt-2.5 rounded-[10px] px-3 py-2.5 text-[13px];
border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.import-summary-failed-title {
font-weight: 700;
margin-bottom: 6px;
@apply mb-1.5 font-bold;
}
.import-summary-failed-list ul {
margin: 0;
padding-left: 18px;
@apply m-0 pl-[18px];
}
.import-summary-failed-list li + li {
margin-top: 4px;
@apply mt-1;
}
.settings-twofactor-grid {
display: grid;
@apply grid gap-3;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-subcard {
@apply rounded-xl bg-white p-3;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.settings-subcard h3 {
margin-top: 0;
margin-bottom: 10px;
@apply mb-2.5 mt-0;
}
.toast-stack {
position: fixed;
@apply fixed grid list-none gap-2.5 p-0;
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;
@apply relative flex items-center justify-between overflow-hidden rounded-[10px] px-3.5 py-3;
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;
}
@@ -270,15 +205,11 @@
}
.toast-text {
font-weight: 700;
padding-right: 10px;
@apply pr-2.5 font-bold;
}
.toast-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 20px;
@apply cursor-pointer border-0 bg-transparent text-xl;
color: inherit;
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
@@ -289,11 +220,7 @@
}
.toast-progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
@apply absolute bottom-0 left-0 h-[3px] w-full;
background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards;
}
-32
View File
@@ -1,32 +0,0 @@
@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;
}
}
+80 -181
View File
@@ -1,11 +1,11 @@
@media (max-width: 1180px) {
.app-page {
padding: 8px;
@apply p-2;
}
.app-shell {
@apply rounded-xl;
height: calc(100vh - 16px);
border-radius: 12px;
}
.app-main {
@@ -13,26 +13,24 @@
}
.app-side {
@apply grid items-start gap-2;
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;
@apply min-h-0;
}
.vault-grid {
grid-template-columns: 1fr;
height: auto;
@apply h-auto;
}
.sidebar {
max-height: 280px;
@apply max-h-[280px];
}
.totp-grid,
.field-grid {
@@ -53,115 +51,95 @@
}
.standalone-title {
font-size: 24px;
@apply text-2xl;
}
.standalone-footer {
font-size: 12px;
line-height: 1.4;
@apply text-xs leading-[1.4];
}
}
@media (max-width: 900px) {
@media (max-width: 1180px) {
.auth-page {
padding: 14px;
align-items: start;
@apply items-start p-3.5;
}
.standalone-shell {
width: 100%;
max-width: 460px;
gap: 10px;
padding-top: 12px;
@apply w-full max-w-[460px] gap-2.5 pt-3;
}
.standalone-brand-outside {
justify-content: flex-start;
@apply justify-start;
}
.standalone-brand-logo {
width: 44px;
height: 44px;
@apply h-11 w-11;
}
.auth-card {
padding: 20px 16px;
border-radius: 18px;
@apply rounded-[18px] px-4 py-5;
}
.btn.full {
height: 48px;
font-size: 18px;
@apply h-12 text-lg;
}
.auth-support-row {
align-items: center;
flex-direction: row;
@apply flex-row items-center;
}
.app-page {
padding: 0;
@apply p-0;
background: transparent;
}
.app-shell {
--mobile-topbar-height: 58px;
--mobile-tabbar-height: 70px;
height: 100dvh;
max-width: none;
@apply h-dvh max-w-none rounded-none border-0;
border: none;
border-radius: 0;
box-shadow: none;
}
.topbar {
@apply relative z-20 px-3;
height: var(--mobile-topbar-height);
padding: 0 12px;
position: relative;
z-index: 20;
}
.brand {
min-width: 0;
gap: 10px;
font-size: 18px;
@apply min-w-0 gap-2.5 text-lg;
}
.brand-logo {
width: 34px;
height: 34px;
@apply h-[34px] w-[34px];
}
.brand-wordmark {
@apply hidden;
}
.mobile-page-title {
display: inline;
@apply inline;
}
.topbar-actions .user-chip,
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
.topbar-actions > .theme-switch-wrap {
display: none;
@apply hidden;
}
.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;
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
}
.mobile-sidebar-toggle .btn-icon,
.mobile-lock-btn .btn-icon {
margin: 0;
@apply m-0;
}
.mobile-theme-btn {
display: inline-flex;
align-items: center;
@apply inline-flex items-center;
}
.mobile-theme-btn .theme-switch {
@@ -170,26 +148,21 @@
}
.app-main {
display: flex;
flex-direction: column;
min-height: 0;
@apply flex min-h-0 flex-col;
}
.app-side {
display: none;
@apply hidden;
}
.content {
flex: 1;
min-height: 0;
@apply min-h-0 flex-1;
-webkit-overflow-scrolling: touch;
}
.mobile-tabbar {
display: grid;
@apply grid items-center gap-1.5;
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);
@@ -197,15 +170,8 @@
}
.mobile-tab {
display: grid;
justify-items: center;
gap: 4px;
@apply grid justify-items-center gap-1 rounded-xl px-1 py-1.5 text-[11px] font-bold no-underline;
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),
@@ -213,7 +179,7 @@
}
.mobile-tab:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
transform: translateY(-1px);
}
.mobile-tab.active {
@@ -223,30 +189,21 @@
}
.vault-grid {
gap: 10px;
padding: 0;
@apply gap-2.5 p-0;
}
.sidebar {
display: none;
@apply hidden;
}
.mobile-sidebar-sheet {
display: block;
position: fixed;
left: 10px;
right: 10px;
@apply fixed left-2.5 right-2.5 z-[55] block overflow-auto rounded-[18px] p-3 opacity-0;
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);
@@ -257,37 +214,26 @@
}
.mobile-sidebar-sheet.open {
opacity: 1;
@apply opacity-100;
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;
@apply mb-2.5 flex items-center justify-between gap-2.5;
}
.mobile-sidebar-title {
font-size: 16px;
font-weight: 800;
@apply text-base font-extrabold;
color: #0f172a;
}
.mobile-sidebar-close {
width: 34px;
height: 34px;
@apply inline-grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-full p-0;
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),
@@ -299,37 +245,31 @@
}
.mobile-sidebar-sheet .sidebar-block {
margin: 0;
padding: 0;
@apply m-0 rounded-none border-0 p-0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.mobile-sidebar-sheet .tree-btn {
margin-bottom: 2px;
@apply mb-0.5;
}
.mobile-sidebar-sheet .folder-row {
align-items: stretch;
gap: 4px;
@apply items-stretch gap-1;
}
.mobile-sidebar-sheet .folder-row .tree-btn {
min-height: 42px;
@apply min-h-[42px];
}
.mobile-sidebar-sheet .sidebar-title,
.mobile-sidebar-sheet .sidebar-title-row {
padding-bottom: 6px;
margin-bottom: 0;
@apply mb-0 pb-1.5;
}
.mobile-sidebar-sheet .tree-btn {
padding-left: 8px;
padding-right: 8px;
border-radius: 10px;
@apply rounded-[10px] px-2;
}
.mobile-sidebar-sheet .tree-btn.active {
@@ -337,56 +277,39 @@
}
.mobile-sidebar-sheet .folder-delete-btn {
width: 28px;
height: 42px;
border-radius: 8px;
@apply h-[42px] w-7 rounded-lg;
}
.list-col {
max-width: none;
@apply max-w-none;
}
.list-head {
display: grid;
@apply grid items-center gap-2;
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;
@apply w-auto whitespace-nowrap text-xs;
}
.list-head .search-input-wrap {
width: 100%;
min-width: 0;
@apply w-full min-w-0;
}
.list-head .search-input {
width: 100%;
min-width: 0;
height: 42px;
border-radius: 14px;
@apply h-[42px] w-full min-w-0 rounded-[14px];
}
.list-icon-btn {
width: auto;
min-width: 0;
padding: 0 12px;
font-size: 13px;
gap: 6px;
white-space: nowrap;
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
}
.toolbar.actions {
justify-content: flex-end;
@apply justify-end overflow-visible pb-0.5;
flex-wrap: unset;
gap: var(--actions-gap);
overflow: visible;
padding-bottom: 2px;
}
.actions {
@@ -394,37 +317,21 @@
}
.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;
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
}
.mobile-fab-wrap {
position: fixed;
right: 14px;
@apply fixed right-3.5 z-[45];
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;
@apply h-14 w-9 gap-0 rounded-full p-0 text-[0];
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
}
.mobile-fab-trigger .btn-icon {
margin: 0;
width: 20px;
height: 20px;
@apply m-0 h-5 w-5;
}
.mobile-fab-wrap .create-menu {
@@ -435,18 +342,15 @@
}
.list-panel {
border-radius: 16px;
overflow: visible;
@apply overflow-visible rounded-2xl;
}
.list-item {
padding: 12px;
border-radius: 14px;
@apply rounded-[14px] p-3;
}
.row-check {
width: 18px;
height: 18px;
@apply h-[18px] w-[18px];
}
.vault-grid.mobile-panel-detail .sidebar,
@@ -454,20 +358,15 @@
.vault-grid.mobile-panel-edit .sidebar,
.vault-grid.mobile-panel-edit .list-col {
display: none;
@apply hidden;
}
.mobile-detail-sheet {
display: block;
position: fixed;
left: 0;
right: 0;
@apply fixed left-0 right-0 z-[35] block overflow-auto opacity-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);
@@ -478,49 +377,44 @@
}
.mobile-detail-sheet.open {
opacity: 1;
@apply opacity-100;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0);
}
.mobile-panel-head {
display: flex;
align-items: center;
margin: 0 10px 10px;
@apply mb-2.5 ml-2.5 mr-2.5 flex items-center;
}
.mobile-panel-back {
min-height: 40px;
@apply min-h-10;
}
.mobile-detail-sheet > .detail-switch-stage,
.mobile-detail-sheet > .card,
.mobile-detail-sheet > .empty {
margin-left: 10px;
margin-right: 10px;
@apply ml-2.5 mr-2.5;
}
.detail-col .card,
.import-export-panel,
.settings-subcard {
border-radius: 16px;
@apply rounded-2xl;
}
.card {
padding: 14px 14px;
@apply p-3.5;
}
.section-head {
align-items: flex-start;
gap: 10px;
flex-direction: column;
@apply flex-col items-start gap-2.5;
}
.detail-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
gap: 5px;
}
.detail-actions .actions {
@@ -568,6 +462,11 @@
gap: 10px;
}
.settings-modules-grid,
.password-settings-grid {
grid-template-columns: 1fr;
}
.import-export-panel .actions .btn,
.settings-subcard .actions .btn,
.section-head .actions .btn {
@@ -734,7 +633,7 @@
}
}
@media (max-width: 900px) {
@media (max-width: 1180px) {
.backup-grid {
grid-template-columns: 1fr;
}
+46 -165
View File
@@ -1,139 +1,81 @@
.app-page {
min-height: 100%;
padding: 20px;
position: relative;
background: transparent;
@apply relative min-h-full bg-transparent p-5;
}
.app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft shadow-elevated;
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;
border-color: var(--line);
@apply rounded-3xl;
}
.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);
@apply flex h-[58px] items-center justify-between border-b px-[18px] text-slate-900 transition;
border-color: var(--line-soft);
background: rgba(244, 248, 255, 0.82);
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 34px;
font-weight: 800;
color: var(--text);
@apply inline-flex items-center gap-2 text-[34px] font-extrabold text-ink;
}
.brand-wordmark {
display: block;
height: auto;
@apply block h-auto max-w-full;
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;
@apply hidden min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[19px] font-extrabold leading-tight text-slate-900;
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;
@apply h-[42px] w-[42px] object-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;
@apply flex items-center gap-2.5;
}
.mobile-tabbar {
display: none;
@apply hidden;
}
.mobile-sidebar-toggle {
display: none;
@apply hidden;
}
.mobile-lock-btn {
display: none;
@apply hidden;
}
.mobile-theme-btn {
display: none;
@apply hidden;
}
.theme-switch-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
@apply inline-flex items-center justify-center;
}
.theme-switch {
position: relative;
display: inline-block;
width: 56px;
height: 32px;
@apply relative inline-block h-8 w-14;
}
.theme-switch-input {
opacity: 0;
width: 0;
height: 0;
@apply h-0 w-0 opacity-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;
@apply absolute inset-0 cursor-pointer rounded-full border transition;
background: #dbeafe;
border-color: #9dbbec;
}
.theme-switch-slider::before {
position: absolute;
@apply absolute h-[26px] w-[26px] rounded-full;
content: '';
height: 26px;
width: 26px;
border-radius: 999px;
left: 2px;
bottom: 2px;
z-index: 2;
@@ -146,24 +88,20 @@
}
.theme-switch .sun svg {
position: absolute;
@apply absolute h-[18px] w-[18px];
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;
@apply absolute h-4 w-4;
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);
}
@@ -182,7 +120,7 @@
}
.theme-switch:hover .theme-switch-slider {
transform: scale(1.02);
transform: none;
}
.theme-switch:hover .sun svg,
@@ -191,131 +129,74 @@
}
.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);
@apply h-[34px] rounded-xl px-3 text-[13px] font-semibold;
transition-duration: 220ms;
}
.topbar-actions .btn:hover:not(:disabled) {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 2px), 0) scale(1.02);
transform: translateY(-1px);
}
.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);
@apply inline-flex h-[34px] items-center gap-1.5 rounded-full border px-3 text-sm font-semibold text-muted-strong transition;
background: rgba(249, 251, 255, 0.94);
border-color: rgba(148, 163, 184, 0.30);
box-shadow: 0 8px 16px rgba(13, 31, 68, 0.04);
}
.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);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
}
.app-main {
flex: 1;
min-height: 0;
display: grid;
@apply grid min-h-0 flex-1;
grid-template-columns: 200px 1fr;
}
.app-side {
border-right: 1px solid var(--line-soft);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
@apply flex flex-col gap-2 border-r px-3 py-4;
border-color: var(--line-soft);
}
.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);
@apply flex items-center gap-2.5 rounded-xl border border-transparent px-3 py-2.5 text-sm font-semibold text-muted-strong no-underline transition;
}
.side-link:hover {
background: #ffffff;
background: #fff;
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);
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
}
.side-link.active {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(59, 130, 246, 0.08));
background: rgba(37, 99, 235, 0.11);
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);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);
}
.content {
min-height: 0;
padding: 14px;
overflow: hidden;
@apply min-h-0 overflow-hidden p-3.5;
}
.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;
}
@apply h-full min-h-0 overflow-auto;
}
.mobile-sidebar-mask {
position: fixed;
inset: 0;
@apply pointer-events-none invisible fixed inset-0 opacity-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;
@apply pointer-events-auto visible opacity-100;
}
.mobile-sidebar-head {
display: none;
@apply hidden;
}
+39 -29
View File
@@ -1,46 +1,56 @@
: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);
--bg-accent: #eef3fa;
--panel: #ffffff;
--panel-soft: #f6f8fc;
--panel-muted: #edf2f8;
--panel-subtle: #f8fafc;
--line: rgba(113, 132, 163, 0.28);
--line-soft: rgba(113, 132, 163, 0.16);
--text: #0b1730;
--muted: #60708b;
--muted-strong: #334765;
--muted: #5f6f85;
--muted-strong: #2f4058;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-strong: #0f3f98;
--danger: #d92d57;
--success: #0f766e;
--warning: #b45309;
--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);
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 14px 38px rgba(15, 23, 42, 0.10);
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--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);
--actions-gap: clamp(5px, 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);
--bg-accent: #0b1020;
--panel: #111827;
--panel-soft: #0f172a;
--panel-muted: #0b1324;
--panel-subtle: #151e2e;
--line: rgba(148, 163, 184, 0.20);
--line-soft: rgba(148, 163, 184, 0.12);
--text: #e5edf8;
--muted: #9aa8bb;
--muted-strong: #c7d2e2;
--primary: #8bb8ff;
--primary-hover: #a9ccff;
--primary-strong: #dceaff;
--danger: #fb7185;
--success: #5eead4;
--warning: #fbbf24;
--overlay-strong: rgba(2, 6, 23, 0.74);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.26);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.30);
--shadow-lg: 0 14px 38px rgba(0, 0, 0, 0.34);
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;