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/ .tmp/
nodewarden.wiki/ 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", "name": "nodewarden",
"version": "1.4.4", "version": "1.4.6",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -40,6 +40,9 @@
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "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 // GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
// Public download endpoint (uses token for auth instead of header) // Public download endpoint (uses token for auth instead of header)
export async function handlePublicDownloadAttachment( export async function handlePublicDownloadAttachment(
+6
View File
@@ -60,6 +60,7 @@ import {
handleCreateAttachment, handleCreateAttachment,
handleUploadAttachment, handleUploadAttachment,
handleGetAttachment, handleGetAttachment,
handleUpdateAttachmentMetadata,
handleDeleteAttachment, handleDeleteAttachment,
} from './handlers/attachments'; } from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAuthenticatedDeviceRoute } from './router-devices';
@@ -201,6 +202,11 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); 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); const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') { if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]); 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 { 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; if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try { try {
const parsed = new URL(`https://${decoded}`); 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, revokeCurrentSession,
getTotpStatus, getTotpStatus,
saveSession, saveSession,
stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { buildSendShareKey, getSends } from '@/lib/api/send'; import { buildSendShareKey, getSends } from '@/lib/api/send';
import { import {
getCiphers, getCiphers,
getFolders, getFolders,
repairCipherAttachmentMetadata,
updateFolder, updateFolder,
} from '@/lib/api/vault'; } from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
import { import {
buildPublicSendUrl, buildPublicSendUrl,
deriveSendKeyParts, deriveSendKeyParts,
@@ -82,48 +84,12 @@ const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type ThemePreference = 'system' | 'light' | 'dark'; 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() { const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
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);
};
}
function readThemePreference(): ThemePreference { function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system'; if (typeof window === 'undefined') return 'system';
@@ -137,6 +103,18 @@ function resolveSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 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() { export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
@@ -170,6 +148,7 @@ export default function App() {
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true); const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false); const [totpSubmitting, setTotpSubmitting] = useState(false);
@@ -180,10 +159,13 @@ export default function App() {
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); 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 [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
const [mobileSidebarToggleKey, setMobileSidebarToggleKey] = useState(0);
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]); const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]); const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]); const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
@@ -245,7 +227,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; 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); const sync = () => setMobileLayout(media.matches);
sync(); sync();
if (typeof media.addEventListener === 'function') { if (typeof media.addEventListener === 'function') {
@@ -287,12 +269,20 @@ export default function App() {
}, [profile]); }, [profile]);
useEffect(() => { useEffect(() => {
if (phase === 'locked' && profile?.key && session) { if (phase === 'locked' && session?.email) {
setUnlockPreparing(false); setUnlockPreparing(false);
} }
}, [phase, profile, session]); }, [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() { function handleToggleTheme() {
setThemePreference((prev) => { setThemePreference((prev) => {
@@ -307,6 +297,16 @@ export default function App() {
saveSession(next); 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( const authedFetch = useMemo(
() => () =>
createAuthedFetch( createAuthedFetch(
@@ -353,7 +353,7 @@ export default function App() {
setSession(boot.session); setSession(boot.session);
setProfile(boot.profile); setProfile(boot.profile);
setPhase(boot.phase); setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key); setUnlockPreparing(boot.phase === 'locked' && !boot.session?.email);
})(); })();
return () => { return () => {
@@ -377,7 +377,7 @@ export default function App() {
} }
setSession(result.session); setSession(result.session);
if (result.profile) { if (result.profile) {
setProfile(result.profile); setProfile(stripProfileSecrets(result.profile));
} }
})(); })();
return () => { return () => {
@@ -385,17 +385,19 @@ export default function App() {
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setUnlockPassword('');
setPhase('app'); setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault'); navigate('/vault');
} }
pushToast('success', t('txt_login_success')); pushToast('success', successMessage);
void (async () => { void (async () => {
try { try {
const hydratedProfile = await login.profilePromise; const hydratedProfile = await login.profilePromise;
@@ -422,6 +424,7 @@ export default function App() {
} }
if (result.kind === 'totp') { if (result.kind === 'totp') {
setPendingTotp(result.pendingTotp); setPendingTotp(result.pendingTotp);
setPendingTotpMode('login');
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
return; return;
@@ -444,7 +447,7 @@ export default function App() {
setTotpSubmitting(true); setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login); await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success'));
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally { } finally {
@@ -567,20 +570,26 @@ export default function App() {
async function handleUnlock() { async function handleUnlock() {
if (pendingAuthAction) return; if (pendingAuthAction) return;
if (!session || !profile) return; if (!session?.email) return;
if (!unlockPassword) { if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password')); pushToast('error', t('txt_please_input_master_password'));
return; return;
} }
setPendingAuthAction('unlock'); setPendingAuthAction('unlock');
try { try {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession); if (result.kind === 'success') {
setUnlockPassword(''); await finalizeLogin(result.login, t('txt_unlocked'));
setUnlockPreparing(false); return;
setPhase('app'); }
if (location === '/' || location === '/lock') navigate('/vault'); if (result.kind === 'totp') {
pushToast('success', t('txt_unlocked')); setPendingTotp(result.pendingTotp);
setPendingTotpMode('unlock');
setTotpCode('');
setRememberDevice(true);
return;
}
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
} catch { } catch {
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect')); pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
} finally { } finally {
@@ -588,17 +597,30 @@ export default function App() {
} }
} }
function handleLock() { function lockCurrentSession() {
if (!session) return; const currentSession = sessionRef.current;
const nextSession = { ...session }; if (!currentSession) return;
const nextSession = { ...currentSession };
delete nextSession.symEncKey; delete nextSession.symEncKey;
delete nextSession.symMacKey; delete nextSession.symMacKey;
setSession(nextSession); setSession(nextSession);
setProfile((prev) => stripProfileSecrets(prev));
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setUnlockPassword('');
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setUnlockPreparing(false); setUnlockPreparing(false);
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
function handleLock() {
lockCurrentSession();
}
function logoutNow() { function logoutNow() {
void revokeCurrentSession(sessionRef.current); void revokeCurrentSession(sessionRef.current);
setConfirm(null); setConfirm(null);
@@ -607,6 +629,7 @@ export default function App() {
setProfile(null); setProfile(null);
setUnlockPreparing(false); setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setPhase('login'); setPhase('login');
navigate('/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() { function renderPassiveOverlays() {
return ( return (
<AppGlobalOverlays <AppGlobalOverlays
@@ -725,6 +804,34 @@ export default function App() {
return value; 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( const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({ foldersQuery.data.map(async (folder) => ({
@@ -830,10 +937,45 @@ export default function App() {
} }
if (Array.isArray(cipher.attachments)) { if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all( 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, ...attachment,
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac), decFileName: fileNameResult.text,
})) };
})
); );
} }
return nextCipher; return nextCipher;
@@ -1147,6 +1289,7 @@ export default function App() {
profile, profile,
session, session,
mobileLayout, mobileLayout,
mobileSidebarToggleKey,
importRoute: IMPORT_ROUTE, importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE, settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1159,6 +1302,8 @@ export default function App() {
users: usersQuery.data || [], users: usersQuery.data || [],
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!totpStatusQuery.data?.enabled,
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
onNavigate: navigate, onNavigate: navigate,
@@ -1205,6 +1350,8 @@ export default function App() {
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey, onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
@@ -1267,7 +1414,7 @@ export default function App() {
<AuthViews <AuthViews
mode={phase} mode={phase}
pendingAction={pendingAuthAction} pendingAction={pendingAuthAction}
unlockReady={!!profile?.key && !!session} unlockReady={!!session?.email}
unlockPreparing={unlockPreparing} unlockPreparing={unlockPreparing}
loginValues={loginValues} loginValues={loginValues}
registerValues={registerValues} registerValues={registerValues}
@@ -1309,12 +1456,14 @@ export default function App() {
onCancelTotp={() => { onCancelTotp={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
}} }}
onUseRecoveryCode={() => { onUseRecoveryCode={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
navigate('/recover-2fa'); navigate('/recover-2fa');
@@ -1348,6 +1497,7 @@ export default function App() {
onLock={handleLock} onLock={handleLock}
onLogout={handleLogout} onLogout={handleLogout}
onToggleTheme={handleToggleTheme} onToggleTheme={handleToggleTheme}
onToggleMobileSidebar={() => setMobileSidebarToggleKey((key) => key + 1)}
mainRoutesProps={mainRoutesProps} mainRoutesProps={mainRoutesProps}
/> />
@@ -21,6 +21,7 @@ interface AppAuthenticatedShellProps {
onLock: () => void; onLock: () => void;
onLogout: () => void; onLogout: () => void;
onToggleTheme: () => void; onToggleTheme: () => void;
onToggleMobileSidebar: () => void;
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
@@ -51,7 +52,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
className="btn btn-secondary small mobile-sidebar-toggle" className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle} aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle} title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))} onClick={props.onToggleMobileSidebar}
> >
<FolderIcon size={16} className="btn-icon" /> <FolderIcon size={16} className="btn-icon" />
</button> </button>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<span>{t('txt_totp_code')}</span> <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)} /> <input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </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)} /> <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> <span>{t('txt_trust_this_device_for_30_days')}</span>
</label> </label>
+11
View File
@@ -33,6 +33,7 @@ export interface AppMainRoutesProps {
profile: Profile | null; profile: Profile | null;
session: SessionState | null; session: SessionState | null;
mobileLayout: boolean; mobileLayout: boolean;
mobileSidebarToggleKey: number;
importRoute: string; importRoute: string;
settingsHomeRoute: string; settingsHomeRoute: string;
settingsAccountRoute: string; settingsAccountRoute: string;
@@ -45,6 +46,8 @@ export interface AppMainRoutesProps {
users: AdminUser[]; users: AdminUser[];
invites: AdminInvite[]; invites: AdminInvite[];
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
@@ -96,6 +99,8 @@ export interface AppMainRoutesProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (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>; onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
@@ -165,6 +170,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onBulkDelete={props.onBulkDeleteSends} onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName} uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent} sendUploadPercent={props.sendUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
@@ -204,6 +210,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName} uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent} attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/> />
</Suspense> </Suspense>
</Route> </Route>
@@ -222,6 +229,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
@@ -229,6 +238,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
+100 -5
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'preact/compat'; 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 type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact'; import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
body.dataset.dialogCount = String(nextCount); 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) { export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
export default function ConfirmDialog(props: ConfirmDialogProps) { export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); 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(() => { useEffect(() => {
if (props.open) { if (props.open) {
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useDialogLifecycle(present, canDismiss ? props.onCancel : null); 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; if (!present || typeof document === 'undefined') return null;
return createPortal(( return createPortal((
<div <div
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
}} }}
> >
<form <form
ref={cardRef}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={props.title} aria-labelledby={titleId}
aria-describedby={messageId}
tabIndex={-1}
onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (props.confirmDisabled || closing) return; if (props.confirmDisabled || closing) return;
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div> </div>
</> </>
) : null} ) : null}
<h3 className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.title}</h3>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children} {props.children}
<button <button
type="submit" type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled} disabled={props.confirmDisabled}
data-dialog-confirm="true"
> >
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
type="button" type="button"
className="btn btn-secondary dialog-btn" className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled} disabled={props.cancelDisabled}
data-dialog-cancel="true"
onClick={() => { onClick={() => {
if (props.cancelDisabled) return; if (props.cancelDisabled) return;
props.onCancel(); 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 { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto'; import { toBufferSource } from '@/lib/crypto';
@@ -11,29 +11,95 @@ interface PublicSendPageProps {
keyPart: string | null; 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) { export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false); const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null); const [sendData, setSendData] = useState<PublicSendData | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null); const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
const loadAbortRef = useRef<AbortController | null>(null);
async function loadSend(pass?: string): Promise<void> { 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); setBusy(true);
setError(''); setError('');
setLoading(true);
try { 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) { if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key')); setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null); setSendData(null);
return; return;
} }
const decrypted = await decryptPublicSend(data, props.keyPart); 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); setNeedPassword(false);
} catch (e) { } catch (e) {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const err = e as Error & { status?: number }; const err = e as Error & { status?: number };
if (err.status === 401) { if (err.status === 401) {
setNeedPassword(true); setNeedPassword(true);
@@ -43,6 +109,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
} }
setSendData(null); setSendData(null);
} finally { } finally {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
setBusy(false); setBusy(false);
setLoading(false); setLoading(false);
} }
@@ -86,6 +153,9 @@ export default function PublicSendPage(props: PublicSendPageProps) {
useEffect(() => { useEffect(() => {
void loadSend(); void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]); }, [props.accessId, props.keyPart]);
return ( return (
@@ -120,13 +190,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && ( {!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 ? ( {sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="notes">{sendData.decText || ''}</div> <div className="notes">{sendData.decText || ''}</div>
</div> </div>
) : ( ) : (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="kv-line"> <div className="kv-line">
<span>{t('txt_file')}</span> <span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong> <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 && ( {!loading && !sendData && !needPassword && !error && (
<p className="muted"> <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> </p>
)} )}
{!!error && <p className="local-error">{error}</p>} {!!error && <p className="local-error">{error}</p>}
@@ -66,8 +66,8 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div> </div>
</div> </div>
@@ -89,7 +89,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</section> </section>
<section className="card"> <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"> <table className="table">
<thead> <thead>
<tr> <tr>
@@ -169,7 +169,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <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> </td>
</tr> </tr>
)} )}
+9 -12
View File
@@ -14,12 +14,13 @@ interface SendsPageProps {
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string; uploadingSendFileName: string;
sendUploadPercent: number | null; sendUploadPercent: number | null;
mobileSidebarToggleKey: number;
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error', text: string) => void;
} }
type SendTypeFilter = 'all' | 'text' | 'file'; type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1'; 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 { function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback); if (!iso) return String(fallback);
@@ -107,12 +108,9 @@ export default function SendsPage(props: SendsPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open); setMobileSidebarOpen((open) => !open);
}; }, [props.mobileSidebarToggleKey]);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
try { try {
@@ -325,8 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
{filteredSends.map((send, index) => ( {filteredSends.map((send, index) => (
<div <div
key={send.id} key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`} className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('.row-check')) return; if (target.closest('.row-check')) return;
@@ -405,7 +402,7 @@ export default function SendsPage(props: SendsPageProps) {
)} )}
{isEditing && draft && ( {isEditing && draft && (
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage"> <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> <h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>} {!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid"> <div className="field-grid">
@@ -505,12 +502,12 @@ export default function SendsPage(props: SendsPageProps) {
{!isEditing && selectedSend && ( {!isEditing && selectedSend && (
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage"> <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> <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 className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div> </div>
<div className="card stagger-item" style={{ animationDelay: '72ms' }}> <div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4> <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_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> <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> </div>
{!!(selectedSend.decNotes || '').trim() && ( {!!(selectedSend.decNotes || '').trim() && (
<div className="card stagger-item" style={{ animationDelay: '108ms' }}> <div className="card stagger-item stagger-delay-3">
<h4>{t('txt_notes')}</h4> <h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div> <div className="notes">{selectedSend.decNotes || ''}</div>
</div> </div>
+172 -97
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; 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 { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types'; import type { Profile } from '@/lib/types';
@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
@@ -16,9 +18,19 @@ interface SettingsPageProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (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; 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 { function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = ''; 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`; 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) { export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState(''); const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [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 [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState(''); const [recoveryCode, setRecoveryCode] = useState('');
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = 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(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
@@ -79,41 +108,58 @@ export default function SettingsPage(props: SettingsPageProps) {
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { try {
await props.onEnableTotp(secret, token); await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true); setTotpLocked(true);
} catch { } catch {
// Keep inputs editable after a failed attempt. // Keep inputs editable after a failed attempt.
} }
} }
async function loadRecoveryCode(): Promise<void> { function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
const code = await props.onGetRecoveryCode(recoveryMasterPassword); 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); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
} } else if (masterPasswordPrompt === 'apiKey') {
const key = await props.onGetApiKey(masterPassword);
async function loadApiKey(): Promise<void> {
try {
const key = await props.onGetApiKey(apiKeyMasterPassword);
setApiKey(key); setApiKey(key);
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
} catch (error) { } else {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); const key = await props.onRotateApiKey(masterPassword);
}
}
async function doRotateApiKey(): Promise<void> {
try {
const key = await props.onRotateApiKey(apiKeyMasterPassword);
setApiKey(key); setApiKey(key);
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated')); props.onNotify?.('success', t('txt_api_key_rotated'));
}
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
} catch (error) { } 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 { function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash'); if (!value) return t('txt_dash');
const parsed = new Date(value); const parsed = new Date(value);
@@ -122,30 +168,44 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
return ( return (
<div className="stack"> <div className="settings-modules-grid">
<section className="card"> <section className="card settings-module">
<h3>{t('txt_profile')}</h3> <h3>{t('txt_session_timeout')}</h3>
<div className="session-timeout-fields">
<label className="field"> <label className="field">
<span>{t('txt_password_hint_optional')}</span> <span>{t('txt_timeout_time')}</span>
<input <select
className="input" className="input"
maxLength={120} value={String(props.lockTimeoutMinutes)}
value={passwordHint} onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
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')} {LOCK_TIMEOUT_OPTIONS.map((option) => (
</button> <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>
<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> <h3>{t('txt_change_master_password')}</h3>
<label className="field"> <label className="field">
<span>{t('txt_current_password')}</span> <span>{t('txt_current_password')}</span>
@@ -176,9 +236,29 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</section> </section>
<section className="card"> <section className="card settings-module">
<div className="settings-twofactor-grid"> <h3>{t('txt_password_hint_optional')}</h3>
<div className="settings-subcard"> <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> <h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>} {totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid"> <div className="totp-grid">
@@ -223,24 +303,20 @@ export default function SettingsPage(props: SettingsPageProps) {
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')} {t('txt_disable_totp')}
</button> </button>
</div> </section>
<div className="settings-subcard"> <section className="card settings-module">
<h3>{t('txt_recovery_code')}</h3> <h3>{t('txt_recovery_code_and_api_key')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}> <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')} {t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p> </p>
<label className="field"> </div>
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions"> <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" /> <ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')} {t('txt_view_recovery_code')}
</button> </button>
@@ -257,25 +333,19 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</div> </div>
{recoveryCode && ( {recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}> <div className="recovery-code-card">
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div> <div className="recovery-code-value">{recoveryCode}</div>
</div> </div>
)} )}
</div> </div>
<div className="settings-subcard"> <div className="sensitive-action">
<h3>{t('txt_api_key')}</h3> <div>
<label className="field"> <h4>{t('txt_api_key')}</h4>
<span>{t('txt_master_password')}</span> <p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
<input </div>
className="input"
type="password"
value={apiKeyMasterPassword}
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions"> <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" /> <KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')} {t('txt_view_api_key')}
</button> </button>
@@ -291,6 +361,28 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</div> </div>
</section> </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 <ConfirmDialog
open={apiKeyDialogOpen} open={apiKeyDialogOpen}
title={t('txt_api_key')} title={t('txt_api_key')}
@@ -300,30 +392,13 @@ export default function SettingsPage(props: SettingsPageProps) {
onConfirm={() => setApiKeyDialogOpen(false)} onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)}
> >
<div <div className="api-key-warning-panel">
style={{ <div className="api-key-warning-title">{t('txt_warning')}</div>
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)', <div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
</div> </div>
<div <div className="api-key-credentials-panel">
style={{ <div className="api-key-credentials-title">
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<KeyRound size={15} /> <KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span> <span>{t('txt_oauth_client_credentials')}</span>
</div> </div>
@@ -335,7 +410,7 @@ export default function SettingsPage(props: SettingsPageProps) {
] as [string, string][]).map(([label, value]) => ( ] as [string, string][]).map(([label, value]) => (
<label key={label} className="field"> <label key={label} className="field">
<span>{label}</span> <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()} /> <input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button <button
type="button" type="button"
@@ -357,7 +432,7 @@ export default function SettingsPage(props: SettingsPageProps) {
danger danger
onConfirm={() => { onConfirm={() => {
setRotateApiKeyConfirmOpen(false); setRotateApiKeyConfirmOpen(false);
void doRotateApiKey(); openMasterPasswordPrompt('rotateApiKey');
}} }}
onCancel={() => setRotateApiKeyConfirmOpen(false)} 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 TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>(); 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 { function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; 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 uri = firstCipherUri(cipher);
const host = hostFromUri(uri); const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
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(() => { useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false); setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]); }, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img <img
className="list-icon" className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)} src={websiteIconUrl(host)}
alt="" alt=""
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => { ref={syncCachedIconState}
failedIconHosts.add(host); onLoad={() => setLoaded(true)}
setErrored(true); onError={markIconError}
}}
/> />
</span>
); );
} }
return ( return (
@@ -168,7 +194,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
} }
export default function TotpCodesPage(props: TotpCodesPageProps) { 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 [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => { const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
@@ -251,26 +278,39 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
useEffect(() => { useEffect(() => {
if (!totpItems.length) { if (!totpItems.length) {
setTotpMap({}); setTotpCodes({});
return; return;
} }
let stopped = false; let stopped = false;
let activeRun = 0;
let timer = 0; let timer = 0;
const tick = async () => { let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all( const entries = await Promise.all(
totpItems.map(async (cipher) => { totpItems.map(async (cipher) => {
try { try {
const next = await calcTotpNow(cipher.login?.decTotp || ''); const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next] as const; return [cipher.id, next?.code || null] as const;
} catch { } catch {
return [cipher.id, null] as const; 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 () => { return () => {
stopped = true; stopped = true;
window.clearInterval(timer); window.clearInterval(timer);
@@ -326,7 +366,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<SortableTotpRow <SortableTotpRow
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
live={totpMap[cipher.id] || null} live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)} onCopy={(value) => void copyToClipboard(value)}
/> />
))} ))}
+3 -5
View File
@@ -58,6 +58,7 @@ interface VaultPageProps {
attachmentDownloadPercent: number | null; attachmentDownloadPercent: number | null;
uploadingAttachmentName: string; uploadingAttachmentName: string;
attachmentUploadPercent: number | null; attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
} }
@@ -131,12 +132,9 @@ export default function VaultPage(props: VaultPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (!props.mobileSidebarToggleKey) return;
setMobileSidebarOpen((open) => !open); setMobileSidebarOpen((open) => !open);
}; }, [props.mobileSidebarToggleKey]);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
const onQuickAdd = () => { const onQuickAdd = () => {
@@ -105,7 +105,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4> <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="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}> <button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')} <Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button> </button>
@@ -117,7 +117,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <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> </div>
{props.selectedCipher.login && ( {props.selectedCipher.login && (
+1 -1
View File
@@ -299,7 +299,7 @@ export default function VaultEditor(props: VaultEditorProps) {
</DndContext> </DndContext>
{props.draft.loginFido2Credentials.length > 0 && ( {props.draft.loginFido2Credentials.length > 0 && (
<> <>
<div className="section-head" style={{ marginTop: '18px' }}> <div className="section-head passkeys-section-head">
<h4>{t('txt_passkeys')}</h4> <h4>{t('txt_passkeys')}</h4>
</div> </div>
<div className="attachment-list"> <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 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_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
@@ -433,23 +433,41 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const host = hostFromUri(uri); const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
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(() => { useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false); setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]); }, [host]);
if (host && !errored) { if (host && !errored) {
return ( return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img <img
className="list-icon" className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)} src={websiteIconUrl(host)}
alt="" alt=""
loading="lazy" loading="lazy"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => { ref={syncCachedIconState}
failedIconHosts.add(host); onLoad={() => setLoaded(true)}
setErrored(true); onError={markIconError}
}}
/> />
</span>
); );
} }
return ( return (
@@ -131,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
try { try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled')); 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); const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Profile; 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; if (email && parsed.email !== email) return null;
return parsed; const snapshot = stripProfileSecrets(parsed);
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
return snapshot;
} catch { } catch {
return null; return null;
} }
@@ -132,13 +134,27 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void { export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return; if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile)); localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
} }
export function clearProfileSnapshot(): void { export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY); 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 { export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
} }
+19 -10
View File
@@ -260,18 +260,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
return payload; 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 payload = await buildPublicSendAccessPayload(password, keyPart);
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, { const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: options?.signal,
}); });
if (!resp.ok) { if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send'); const message = await parseErrorMessage(resp, 'Failed to access send');
throw createApiError(message, resp.status); 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> { 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; 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 sendKeyMaterial = base64UrlToBytes(urlSafeKey);
const sendKey = await toSendKeyParts(sendKeyMaterial); const sendKey = await toSendKeyParts(sendKeyMaterial);
const out: any = { ...accessData }; const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac); const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
if (accessData?.text?.text) { const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac); 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 { try {
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac); out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
} catch { } catch {
out.decFileName = String(accessData.file.fileName); out.decFileName = String(file.fileName);
} }
} }
return out; return out;
+154 -17
View File
@@ -13,6 +13,7 @@ import {
parseErrorMessage, parseErrorMessage,
parseJson, parseJson,
uploadDirectEncryptedPayload, uploadDirectEncryptedPayload,
uploadWithProgress,
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
@@ -273,6 +274,98 @@ export async function deleteCipherAttachment(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); 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( export async function downloadCipherAttachmentDecrypted(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
@@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
const userEnc = base64ToBytes(session.symEncKey); const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey); const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipher, userEnc, userMac); const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
const userKeys = { enc: userEnc, mac: userMac };
let fileEnc = itemKeys.enc; const candidates: AttachmentDecryptCandidate[] = [];
let fileMac = itemKeys.mac;
const keyCipher = String(info.key || '').trim(); const keyCipher = String(info.key || '').trim();
if (keyCipher && looksLikeCipherString(keyCipher)) { if (keyCipher && looksLikeCipherString(keyCipher)) {
try { const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); if (itemWrappedKey && itemWrappedKey.length >= 64) {
if (fileRawKey.length >= 64) { candidates.push({
fileEnc = fileRawKey.slice(0, 32); mode: 'attachment-item',
fileMac = fileRawKey.slice(32, 64); enc: itemWrappedKey.slice(0, 32),
} mac: itemWrappedKey.slice(32, 64),
} catch { rawAttachmentKey: itemWrappedKey,
// fallback to item key });
}
} }
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(); const fileNameRaw = String(info.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${aid}`; const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { const fileName = nameResult.fileName || `attachment-${aid}`;
try { try {
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; const metadata: { fileName?: string; key?: string | null } = {};
} catch { if (nameResult.source === 'user') {
// keep fallback name 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 }; return { fileName, bytes: plainBytes };
+29 -9
View File
@@ -372,16 +372,36 @@ export async function performRegistration(args: {
export async function performUnlock( export async function performUnlock(
session: SessionState, session: SessionState,
profile: Profile, profile: Profile | null,
password: string, password: string,
fallbackIterations: number fallbackIterations: number
): Promise<SessionState> { ): Promise<PasswordLoginResult> {
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations); const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const keys = await unlockVaultKey(profile.key, derived.masterKey); const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
const refreshedSession = await maybeRefreshSession(session); const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
if (!refreshedSession) {
throw new Error('Session expired'); if ('access_token' in token && token.access_token) {
} return {
return { ...refreshedSession, ...keys }; kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
};
}
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_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed", txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel", txt_cancel: "Cancel",
txt_continue: "Continue",
txt_card: "Card", txt_card: "Card",
txt_card_details: "Card Details", txt_card_details: "Card Details",
txt_cardholder_name: "Cardholder Name", txt_cardholder_name: "Cardholder Name",
@@ -417,6 +418,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_encrypted_file_2: "Encrypted file", txt_encrypted_file_2: "Encrypted file",
txt_enter_a_folder_name: "Enter a folder name.", 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_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_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date", txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)", 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_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_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
txt_recovery_code: "Recovery Code", 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_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded", txt_recovery_code_loaded: "Recovery code loaded",
@@ -1041,6 +1044,7 @@ const zhCNOverrides: Record<string, string> = {
txt_confirm_master_password: '确认主密码', txt_confirm_master_password: '确认主密码',
txt_submit: '提交', txt_submit: '提交',
txt_cancel: '取消', txt_cancel: '取消',
txt_continue: '继续',
txt_yes: '是', txt_yes: '是',
txt_no: '否', txt_no: '否',
txt_loading: '加载中...', txt_loading: '加载中...',
@@ -1308,6 +1312,7 @@ const zhCNOverrides: Record<string, string> = {
txt_encrypted_file_2: '加密文件', txt_encrypted_file_2: '加密文件',
txt_enter_a_folder_name: '请输入文件夹名称', txt_enter_a_folder_name: '请输入文件夹名称',
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证', txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
txt_enter_master_password_to_continue: '输入主密码以继续',
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目', txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
txt_expiry: '有效期', txt_expiry: '有效期',
txt_expiry_month: '有效期月', txt_expiry_month: '有效期月',
@@ -1376,6 +1381,7 @@ const zhCNOverrides: Record<string, string> = {
txt_recover_2fa_failed: '恢复 2FA 失败', txt_recover_2fa_failed: '恢复 2FA 失败',
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录', txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_and_api_key: '恢复代码和 API 密钥',
txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载', txt_recovery_code_loaded: '恢复代码已加载',
txt_api_key: 'API 密钥', txt_api_key: 'API 密钥',
@@ -1485,6 +1491,48 @@ zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_menu = '菜单'; zhCNOverrides.txt_menu = '菜单';
zhCNOverrides.txt_settings = '设置'; zhCNOverrides.txt_settings = '设置';
zhCNOverrides.txt_back = '返回'; 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_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件';
+1
View File
@@ -1,6 +1,7 @@
import { render } from 'preact'; import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
import './tailwind.css';
import './styles.css'; import './styles.css';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
+262 -1
View File
@@ -9,4 +9,265 @@
@import './styles/motion.css'; @import './styles/motion.css';
@import './styles/responsive.css'; @import './styles/responsive.css';
@import './styles/dark.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 { .loading-screen {
height: 100%; @apply grid h-full place-items-center text-lg text-muted;
display: grid;
place-items: center;
color: var(--muted);
font-size: 18px;
animation: fade-in-up var(--dur-panel) var(--ease-out-strong) both;
} }
.auth-page { .auth-page {
min-height: 100%; @apply relative grid min-h-full place-items-center bg-transparent p-6;
display: grid;
place-items: center;
padding: 24px;
position: relative;
background: transparent;
} }
.public-send-page { .public-send-page {
min-height: 80vh; @apply min-h-[80vh] items-center;
align-items: center;
justify-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 { .auth-card {
width: 100%; @apply relative w-full overflow-hidden border bg-panel p-[30px] shadow-elevated;
position: relative; border-color: var(--line);
background: var(--panel); @apply rounded-[22px];
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow-lg);
padding: 30px;
overflow: hidden;
transform-origin: 50% 24%;
animation: surface-enter 520ms var(--ease-out-strong) both;
} }
.auth-card h1 { .auth-card h1 {
margin: 0 0 4px 0; @apply m-0 mb-1 text-center;
text-align: center;
} }
.standalone-shell { .standalone-shell {
width: min(640px, 100%); @apply grid w-[min(640px,100%)] gap-3.5;
display: grid;
gap: 14px;
animation: fade-in-up 420ms var(--ease-out-strong) both;
} }
.standalone-brand { .standalone-brand {
display: inline-flex; @apply mb-3 inline-flex items-center gap-3.5;
align-items: center;
gap: 14px;
margin-bottom: 12px;
} }
.standalone-brand-outside { .standalone-brand-outside {
justify-content: center; @apply mb-0.5 w-full justify-center;
width: 100%;
margin-bottom: 2px;
} }
.standalone-brand-logo { .standalone-brand-logo {
width: 56px; @apply h-14 w-14 flex-shrink-0 object-contain;
height: 56px;
object-fit: contain;
flex-shrink: 0;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22)); filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
} }
.standalone-brand-wordmark { .standalone-brand-wordmark {
display: block; @apply block h-auto max-w-full;
height: auto;
width: clamp(200px, 30vw, 360px); width: clamp(200px, 30vw, 360px);
max-width: 100%;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18)); filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
} }
.standalone-title { .standalone-title {
margin: 0 0 4px 0; @apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
text-align: left;
font-size: 31px;
line-height: 1.15;
letter-spacing: -0.035em;
} }
.standalone-muted { .standalone-muted {
text-align: left; @apply text-left;
} }
.jwt-warning-head { .jwt-warning-head {
display: flex; @apply mb-2.5 flex items-center justify-center gap-2.5 text-center;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
color: #b45309; color: #b45309;
text-align: center;
} }
.jwt-warning-box { .jwt-warning-box {
border: 1px solid #f1d8a5; @apply rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3;
border-radius: 12px;
background: #fffaf0;
padding: 12px 14px;
} }
.jwt-warning-label { .jwt-warning-label {
font-size: 13px; @apply mb-1.5 text-[13px] font-bold;
font-weight: 700;
color: #92400e; color: #92400e;
margin-bottom: 6px;
} }
.jwt-warning-copy { .jwt-warning-copy {
margin: 0 0 14px; @apply m-0 mb-3.5 leading-[1.6];
color: #475569; color: #475569;
line-height: 1.6;
} }
.jwt-warning-list { .jwt-warning-list {
margin: 0; @apply m-0 pl-[18px] leading-[1.55];
padding-left: 18px;
color: #334155; color: #334155;
line-height: 1.55;
} }
.jwt-inline-link { .jwt-inline-link {
@apply font-bold no-underline;
color: #1d4ed8; color: #1d4ed8;
font-weight: 700;
text-decoration: none;
} }
.jwt-inline-link:hover { .jwt-inline-link:hover {
@@ -136,16 +98,12 @@
} }
.jwt-secret-fields { .jwt-secret-fields {
margin-top: 8px; @apply mt-2 grid gap-1.5;
display: grid;
gap: 6px;
} }
.jwt-secret-row { .jwt-secret-row {
display: grid; @apply grid items-start gap-2;
grid-template-columns: 88px minmax(0, 1fr); grid-template-columns: 88px minmax(0, 1fr);
gap: 8px;
align-items: start;
} }
.jwt-secret-row > span { .jwt-secret-row > span {
@@ -153,34 +111,25 @@
} }
.jwt-generator { .jwt-generator {
margin-top: 14px; @apply mt-3.5;
} }
.jwt-generator-actions { .jwt-generator-actions {
margin-top: 10px; @apply mt-2.5 flex flex-wrap items-center gap-2.5;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
} }
.jwt-copy-hint { .jwt-copy-hint {
@apply text-[13px] font-bold;
color: #15803d; color: #15803d;
font-size: 13px;
font-weight: 700;
} }
.standalone-footer { .standalone-footer {
width: 100%; @apply w-full text-center text-[13px] text-slate-500;
text-align: center;
font-size: 13px;
color: #64748b;
} }
.standalone-footer a { .standalone-footer a {
@apply font-bold no-underline;
color: #1d4ed8; color: #1d4ed8;
font-weight: 700;
text-decoration: none;
} }
.standalone-footer a:hover { .standalone-footer a:hover {
@@ -188,6 +137,6 @@
} }
.standalone-version { .standalone-version {
font-weight: 700; @apply font-bold;
color: #1d4ed8; color: #1d4ed8;
} }
+5 -10
View File
@@ -1,27 +1,22 @@
* { * {
box-sizing: border-box; @apply box-border;
} }
html, html,
body, body,
#root { #root {
margin: 0; @apply m-0 h-full w-full p-0;
padding: 0;
width: 100%;
height: 100%;
color: var(--text); color: var(--text);
background: var(--bg-accent); background: var(--bg-accent);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
} }
body { body {
position: relative; @apply relative antialiased;
transition: transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth);
background-color var(--dur-medium) var(--ease-smooth),
color var(--dur-medium) var(--ease-smooth);
} }
body.dialog-open { body.dialog-open {
overflow: hidden; @apply overflow-hidden;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
+75 -381
View File
@@ -2,85 +2,14 @@
:root[data-theme='dark'] #root, :root[data-theme='dark'] #root,
:root[data-theme='dark'] .app-page, :root[data-theme='dark'] .app-page,
:root[data-theme='dark'] .auth-page { :root[data-theme='dark'] .auth-page {
background: transparent; background: var(--bg-accent);
color: var(--text); color: var(--text);
} }
:root[data-theme='dark'] .app-shell, :root[data-theme='dark'] h1,
:root[data-theme='dark'] .auth-card, :root[data-theme='dark'] h2,
:root[data-theme='dark'] .dialog, :root[data-theme='dark'] h3,
:root[data-theme='dark'] .jwt-warning-box, :root[data-theme='dark'] h4,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .card,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .empty {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: var(--shadow-lg);
}
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .mobile-tabbar,
:root[data-theme='dark'] .sort-menu,
:root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .mobile-sidebar-sheet,
:root[data-theme='dark'] .mobile-detail-sheet {
background: var(--panel-soft);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .dialog-card.warning {
border-color: rgba(248, 113, 113, 0.36);
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
box-shadow:
0 36px 90px rgba(5, 5, 5, 0.56),
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
}
:root[data-theme='dark'] .dialog-mask.warning {
background:
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
}
:root[data-theme='dark'] .dialog-warning-badge {
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
color: #fda4af;
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.32),
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
}
:root[data-theme='dark'] .dialog-warning-kicker,
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
color: #fecaca;
}
:root[data-theme='dark'] .dialog-message.warning {
border-color: rgba(248, 113, 113, 0.18);
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
color: #fecdd3;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
}
:root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .sidebar,
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
background: var(--panel-muted);
border-color: var(--line);
}
:root[data-theme='dark'] .auth-card {
background: var(--panel);
}
:root[data-theme='dark'] .brand, :root[data-theme='dark'] .brand,
:root[data-theme='dark'] .mobile-page-title, :root[data-theme='dark'] .mobile-page-title,
:root[data-theme='dark'] .detail-title, :root[data-theme='dark'] .detail-title,
@@ -89,277 +18,6 @@
:root[data-theme='dark'] .kv-main strong, :root[data-theme='dark'] .kv-main strong,
:root[data-theme='dark'] .list-title, :root[data-theme='dark'] .list-title,
:root[data-theme='dark'] .sidebar-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'] .totp-code-name,
:root[data-theme='dark'] .backup-destination-name, :root[data-theme='dark'] .backup-destination-name,
:root[data-theme='dark'] .backup-browser-entry, :root[data-theme='dark'] .backup-browser-entry,
@@ -376,6 +34,20 @@
color: var(--text); 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'] .totp-code-username,
:root[data-theme='dark'] .backup-destination-meta, :root[data-theme='dark'] .backup-destination-meta,
:root[data-theme='dark'] .backup-browser-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-recommendation-linked-item,
:root[data-theme='dark'] .backup-inline-suffix, :root[data-theme='dark'] .backup-inline-suffix,
:root[data-theme='dark'] .folder-delete-btn, :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'] .tree-label,
:root[data-theme='dark'] .list-sub {
color: var(--muted); color: var(--muted);
} }
:root[data-theme='dark'] .import-export-panel p, :root[data-theme='dark'] .input,
:root[data-theme='dark'] .dialog-message, :root[data-theme='dark'] .textarea,
:root[data-theme='dark'] .local-error, :root[data-theme='dark'] select.input,
:root[data-theme='dark'] .status-ok { :root[data-theme='dark'] .search-input,
color: var(--muted); :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 { :root[data-theme='dark'] .input::placeholder,
background: #1d3048; :root[data-theme='dark'] .textarea::placeholder,
color: #c9d8eb; :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 { :root[data-theme='dark'] .input:focus,
border-color: #38618f; :root[data-theme='dark'] .textarea:focus,
background: #173150; :root[data-theme='dark'] .search-input:focus,
color: #9ec5ff; :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'] .input-readonly {
:root[data-theme='dark'] .backup-help-trigger:focus-visible { background: var(--panel-muted);
border-color: #5f92d7; color: var(--muted-strong);
background: #20426a;
} }
: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); background: var(--panel);
border-color: var(--line); border-color: var(--line);
color: var(--text); color: var(--text);
} }
:root[data-theme='dark'] .backup-help-bubble::before { :root[data-theme='dark'] .toast-item.error,
background: var(--panel); :root[data-theme='dark'] .toast-item.warning {
border-left-color: var(--line); border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
border-top-color: var(--line); background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--text);
} }
:root[data-theme='dark'] .table td { :root[data-theme='dark'] .jwt-warning-head,
border-bottom-color: #203047; :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 { :root[data-theme='dark'] .local-error {
color: #ff9bb0; color: var(--danger);
} }
:root[data-theme='dark'] .status-ok { :root[data-theme='dark'] .status-ok {
color: #9be2bd; color: var(--success);
}
:root[data-theme='dark'] .totp-qr {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
} }
:root[data-theme='dark'] .totp-qr,
:root[data-theme='dark'] .totp-qr svg, :root[data-theme='dark'] .totp-qr svg,
:root[data-theme='dark'] .totp-qr img { :root[data-theme='dark'] .totp-qr img {
background: #ffffff; background: #ffffff;
border-radius: 8px; border-color: rgba(15, 23, 42, 0.12);
} }
+43 -150
View File
@@ -1,45 +1,27 @@
.muted { .muted {
margin: 0 0 16px 0; @apply m-0 mb-4 text-center leading-relaxed text-muted;
text-align: center;
color: var(--muted);
line-height: 1.65;
} }
.field { .field {
display: block; @apply mb-3.5 block;
margin-bottom: 14px;
} }
.field > span { .field > span {
display: block; @apply mb-2 mt-2.5 block text-sm font-semibold;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
} }
.input { .input {
width: 100%; @apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
height: 48px;
border: 1px solid rgba(74, 103, 150, 0.42);
border-radius: 14px;
padding: 10px 14px;
font-size: 16px;
outline: none;
color: var(--text);
background: var(--panel); background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); border-color: rgba(74, 103, 150, 0.34);
transition: box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
border-color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
} }
select.input { select.input {
@apply pr-[42px];
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
padding-right: 42px;
background-image: background-image:
linear-gradient(45deg, transparent 50%, #365fa8 50%), linear-gradient(45deg, transparent 50%, #365fa8 50%),
linear-gradient(135deg, #365fa8 50%, transparent 50%); linear-gradient(135deg, #365fa8 50%, transparent 50%);
@@ -51,23 +33,14 @@ select.input {
} }
input[type='file'].input { input[type='file'].input {
height: auto; @apply h-auto min-h-12 px-2.5 py-2 text-sm leading-[1.4];
min-height: 48px;
padding: 8px 10px;
font-size: 14px;
line-height: 1.4;
} }
input[type='file'].input::file-selector-button { input[type='file'].input::file-selector-button {
height: 32px; @apply mr-2.5 h-8 cursor-pointer rounded-full border px-3 font-bold;
border: 1px solid #3f5b9e;
border-radius: 999px;
padding: 0 12px;
background: #eef4ff; background: #eef4ff;
border-color: #9db8ea;
color: #1f4ea0; color: #1f4ea0;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
} }
input[type='file'].input::file-selector-button:hover { input[type='file'].input::file-selector-button:hover {
@@ -76,65 +49,38 @@ input[type='file'].input::file-selector-button:hover {
} }
.textarea { .textarea {
min-height: 110px; @apply h-auto min-h-28 resize-y;
height: auto;
resize: vertical;
} }
.input:focus { .input:focus {
border-color: rgba(43, 102, 217, 0.6); border-color: rgba(43, 102, 217, 0.6);
background-color: #fbfdff; 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); 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);
transform: translateY(-1px);
} }
.input-readonly { .input-readonly {
background: #eef2f7; @apply bg-slate-100 text-slate-600;
color: #475569;
} }
.input:disabled { .input:disabled {
background: #e2e8f0; @apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
} }
.password-wrap { .password-wrap {
position: relative; @apply relative;
} }
.password-wrap .input { .password-wrap .input {
padding-right: 44px; @apply pr-11;
} }
.password-toggle { .password-toggle {
position: absolute; @apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
right: 8px;
top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
border: none;
background: transparent;
color: #275ac2;
cursor: pointer;
display: grid;
place-items: center;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
} }
.eye-btn { .eye-btn {
position: absolute; @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;
right: 10px;
bottom: 9px;
width: 30px;
height: 30px;
border: none;
background: transparent;
cursor: pointer;
display: grid;
place-items: center;
color: #334155;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
} }
.password-toggle:hover, .password-toggle:hover,
@@ -144,35 +90,14 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn { .btn {
height: 36px; @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;
border: 1px solid transparent;
border-radius: 999px;
padding: 0 16px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
transition:
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
opacity var(--dur-fast) var(--ease-smooth);
} }
.topbar-actions .btn, .topbar-actions .btn,
.user-chip, .user-chip,
.side-link, .side-link,
.mobile-tab { .mobile-tab {
--mag-x: 0px; @apply relative overflow-hidden;
--mag-y: 0px;
position: relative;
overflow: hidden;
} }
.topbar-actions .btn::before, .topbar-actions .btn::before,
@@ -180,15 +105,9 @@ input[type='file'].input::file-selector-button:hover {
.side-link::before, .side-link::before,
.mobile-tab::before { .mobile-tab::before {
content: ''; content: '';
position: absolute; @apply absolute left-1/2 top-1/2 h-[110px] w-[110px] rounded-full opacity-0;
left: var(--mx, 50%);
top: var(--my, 50%);
width: 110px;
height: 110px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.08) 42%, transparent 72%); 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); transform: translate(-50%, -50%) scale(0.68);
opacity: 0;
pointer-events: none; pointer-events: none;
transition: transition:
opacity var(--dur-fast) var(--ease-smooth), opacity var(--dur-fast) var(--ease-smooth),
@@ -199,12 +118,11 @@ input[type='file'].input::file-selector-button:hover {
.user-chip:hover::before, .user-chip:hover::before,
.side-link:hover::before, .side-link:hover::before,
.mobile-tab:hover::before { .mobile-tab:hover::before {
opacity: 1; opacity: 0;
transform: translate(-50%, -50%) scale(1);
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.01); transform: translateY(-1px);
} }
.btn:active:not(:disabled) { .btn:active:not(:disabled) {
@@ -212,34 +130,27 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn-icon { .btn-icon {
flex-shrink: 0; @apply shrink-0;
} }
.btn.full { .btn.full {
width: 100%; @apply my-2.5 h-12 w-full text-lg;
height: 50px;
font-size: 22px;
margin: 10px 0;
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #2563eb, #3b82f6 72%); @apply border-blue-700/30 bg-blue-600 text-white;
border-color: rgba(15, 63, 152, 0.32); box-shadow: 0 10px 22px rgba(37, 99, 235, 0.20);
color: #fff;
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, #1d4ed8, #3377f0 72%); @apply bg-blue-700;
border-color: rgba(15, 63, 152, 0.38); box-shadow: 0 12px 26px rgba(37, 99, 235, 0.22);
box-shadow: 0 18px 34px rgba(37, 99, 235, 0.28);
} }
.btn-secondary { .btn-secondary {
background: var(--panel); @apply bg-panel text-brand-strong;
border-color: rgba(37, 99, 235, 0.22); border-color: rgba(37, 99, 235, 0.20);
color: var(--primary-strong); box-shadow: 0 6px 14px rgba(13, 31, 68, 0.04);
box-shadow: 0 8px 18px rgba(13, 31, 68, 0.05);
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -248,9 +159,8 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn-danger { .btn-danger {
background: rgba(255, 255, 255, 0.8); @apply bg-white/80 text-danger;
border-color: rgba(217, 45, 87, 0.28); border-color: rgba(217, 45, 87, 0.28);
color: var(--danger);
} }
.btn-danger:hover { .btn-danger:hover {
@@ -259,42 +169,27 @@ input[type='file'].input::file-selector-button:hover {
} }
.btn:disabled { .btn:disabled {
background: #e2e8f0; @apply cursor-not-allowed border-slate-300 bg-slate-200 text-slate-400;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
} }
.or { .or {
text-align: center; @apply my-2.5 text-center text-slate-700;
margin: 10px 0;
color: #334155;
} }
.field-help { .field-help {
margin-top: 8px; @apply mt-2 text-[13px] leading-normal text-slate-500;
font-size: 13px; }
line-height: 1.5;
color: #667085; .check-line-compact {
@apply mb-0;
} }
.auth-support-row { .auth-support-row {
display: flex; @apply -mt-0.5 mb-3 flex items-center justify-between gap-2.5;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: -2px 0 12px;
} }
.auth-link-btn { .auth-link-btn {
border: none; @apply cursor-pointer border-0 bg-transparent p-0 text-[13px] font-bold text-blue-700 transition;
background: transparent;
padding: 0;
color: #1d4ed8;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
} }
.auth-link-btn:hover { .auth-link-btn:hover {
@@ -303,7 +198,5 @@ input[type='file'].input::file-selector-button:hover {
} }
.auth-link-btn:disabled { .auth-link-btn:disabled {
color: #94a3b8; @apply cursor-not-allowed text-slate-400 no-underline;
cursor: not-allowed;
text-decoration: none;
} }
File diff suppressed because it is too large Load Diff
+11 -18
View File
@@ -21,77 +21,63 @@
@keyframes fade-in-up { @keyframes fade-in-up {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 16px, 0);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0);
} }
} }
@keyframes shell-enter { @keyframes shell-enter {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.992);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@keyframes surface-enter { @keyframes surface-enter {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.985);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@keyframes menu-in { @keyframes menu-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.96);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@keyframes dialog-in { @keyframes dialog-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.96);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@keyframes toast-in { @keyframes toast-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(18px, 0, 0) scale(0.97);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@keyframes stagger-rise { @keyframes stagger-rise {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.985);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
} }
@@ -107,21 +93,28 @@
@keyframes dialog-out { @keyframes dialog-out {
from { from {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
} }
to { to {
opacity: 0; opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.972);
} }
} }
@keyframes route-stage-in { @keyframes route-stage-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 14px, 0);
} }
to { to {
opacity: 1; 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 { .dialog-mask {
position: fixed; @apply fixed inset-0 grid h-dvh w-screen place-items-center p-5 opacity-0;
inset: 0;
width: 100vw;
height: 100dvh;
background: rgba(15, 23, 42, 0.5); background: rgba(15, 23, 42, 0.5);
display: grid;
place-items: center;
z-index: 1200; z-index: 1200;
padding: 20px;
opacity: 0;
animation: fade-in var(--dur-medium) var(--ease-smooth) both; animation: fade-in var(--dur-medium) var(--ease-smooth) both;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
} }
.dialog-card { .dialog-card {
width: min(460px, 100%); @apply rounded-[20px] border bg-white p-5 text-center;
background: #fff; width: min(500px, 100%);
border-radius: 20px;
border: 1px solid var(--line); border: 1px solid var(--line);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2); box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
padding: 20px;
text-align: center;
transform-origin: 50% 30%; transform-origin: 50% 30%;
animation: dialog-in 240ms var(--ease-out-strong) both; animation: dialog-in 240ms var(--ease-out-strong) both;
} }
@@ -45,20 +35,11 @@
} }
.dialog-warning-head { .dialog-warning-head {
display: flex; @apply mb-2 flex items-center justify-center gap-3;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
} }
.dialog-warning-badge { .dialog-warning-badge {
width: 48px; @apply inline-flex h-12 w-12 items-center justify-center rounded-2xl;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: linear-gradient(180deg, #fff1f2, #ffe4e6); background: linear-gradient(180deg, #fff1f2, #ffe4e6);
color: #dc2626; color: #dc2626;
box-shadow: box-shadow:
@@ -67,10 +48,7 @@
} }
.dialog-warning-kicker { .dialog-warning-kicker {
font-size: 12px; @apply text-xs font-extrabold uppercase tracking-[0.16em];
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #b91c1c; color: #b91c1c;
} }
@@ -83,69 +61,50 @@
} }
.dialog-card .field { .dialog-card .field {
text-align: left; @apply text-left;
} }
.dialog-title { .dialog-title {
margin: 6px 0; @apply my-1.5 text-3xl;
font-size: 30px;
} }
.dialog-message { .dialog-message {
@apply mb-2.5;
color: #475467; color: #475467;
margin-bottom: 10px;
} }
.dialog-card.warning .dialog-title { .dialog-card.warning .dialog-title {
@apply mb-2.5;
color: #7f1d1d; color: #7f1d1d;
margin-bottom: 10px;
} }
.dialog-message.warning { .dialog-message.warning {
margin-bottom: 16px; @apply mb-4 rounded-2xl px-4 py-3.5 leading-[1.65];
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(220, 38, 38, 0.16); 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)); background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
color: #7a2832; color: #7a2832;
line-height: 1.65;
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset; box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
} }
.dialog-btn { .dialog-btn {
width: 100%; @apply mt-2 h-[50px] w-full text-xl;
height: 50px;
font-size: 20px;
margin-top: 8px;
} }
.dialog-extra { .dialog-extra {
margin-top: 8px; @apply mt-2;
} }
.dialog-divider { .dialog-divider {
height: 1px; @apply my-2 mb-2.5 h-px;
background: var(--line); background: var(--line);
margin: 8px 0 10px;
} }
.import-summary-dialog { .import-summary-dialog {
max-width: 520px; @apply relative max-w-[520px] pt-4 text-left;
text-align: left;
position: relative;
padding-top: 16px;
} }
.import-summary-close { .import-summary-close {
position: absolute; @apply absolute right-2.5 top-2.5 cursor-pointer border-0 bg-transparent text-2xl leading-none text-slate-500;
top: 10px;
right: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 24px;
line-height: 1;
cursor: pointer;
} }
.import-summary-close:hover { .import-summary-close:hover {
@@ -153,34 +112,29 @@
} }
.import-summary-table-wrap { .import-summary-table-wrap {
margin-top: 8px; @apply mt-2 overflow-hidden rounded-[10px];
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
} }
.import-summary-table { .import-summary-table {
width: 100%; @apply w-full text-sm;
border-collapse: collapse; border-collapse: collapse;
font-size: 14px;
} }
.import-summary-table th, .import-summary-table th,
.import-summary-table td { .import-summary-table td {
padding: 10px 12px; @apply px-3 py-2.5;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.import-summary-table th { .import-summary-table th {
text-align: left; @apply bg-slate-50 text-left;
color: #475467; color: #475467;
background: #f8fafc;
} }
.import-summary-table td:last-child, .import-summary-table td:last-child,
.import-summary-table th:last-child { .import-summary-table th:last-child {
text-align: right; @apply w-24 text-right;
width: 96px;
} }
.import-summary-table tbody tr:last-child td { .import-summary-table tbody tr:last-child td {
@@ -188,72 +142,53 @@
} }
.import-summary-failed-list { .import-summary-failed-list {
margin-top: 10px; @apply mt-2.5 rounded-[10px] px-3 py-2.5 text-[13px];
padding: 10px 12px;
border: 1px solid #fecaca; border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2; background: #fef2f2;
color: #991b1b; color: #991b1b;
font-size: 13px;
} }
.import-summary-failed-title { .import-summary-failed-title {
font-weight: 700; @apply mb-1.5 font-bold;
margin-bottom: 6px;
} }
.import-summary-failed-list ul { .import-summary-failed-list ul {
margin: 0; @apply m-0 pl-[18px];
padding-left: 18px;
} }
.import-summary-failed-list li + li { .import-summary-failed-list li + li {
margin-top: 4px; @apply mt-1;
} }
.settings-twofactor-grid { .settings-twofactor-grid {
display: grid; @apply grid gap-3;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.settings-subcard { .settings-subcard {
@apply rounded-xl bg-white p-3;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
} }
.settings-subcard h3 { .settings-subcard h3 {
margin-top: 0; @apply mb-2.5 mt-0;
margin-bottom: 10px;
} }
.toast-stack { .toast-stack {
position: fixed; @apply fixed grid list-none gap-2.5 p-0;
top: 16px; top: 16px;
right: 16px; right: 16px;
z-index: 1400; z-index: 1400;
width: min(420px, calc(100vw - 20px)); width: min(420px, calc(100vw - 20px));
list-style: none;
margin: 0; margin: 0;
padding: 0;
display: grid;
gap: 10px;
} }
.toast-item { .toast-item {
position: relative; @apply relative flex items-center justify-between overflow-hidden rounded-[10px] px-3.5 py-3;
border-radius: 10px;
border: 1px solid #bbdfc6; border: 1px solid #bbdfc6;
background: #dff4e5; background: #dff4e5;
color: #0f5132; color: #0f5132;
padding: 12px 14px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); 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; animation: toast-in 240ms var(--ease-out-strong) both;
} }
@@ -270,15 +205,11 @@
} }
.toast-text { .toast-text {
font-weight: 700; @apply pr-2.5 font-bold;
padding-right: 10px;
} }
.toast-close { .toast-close {
border: none; @apply cursor-pointer border-0 bg-transparent text-xl;
background: transparent;
cursor: pointer;
font-size: 20px;
color: inherit; color: inherit;
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth); transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
} }
@@ -289,11 +220,7 @@
} }
.toast-progress { .toast-progress {
position: absolute; @apply absolute bottom-0 left-0 h-[3px] w-full;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: rgba(15, 23, 42, 0.2); background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards; 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) { @media (max-width: 1180px) {
.app-page { .app-page {
padding: 8px; @apply p-2;
} }
.app-shell { .app-shell {
@apply rounded-xl;
height: calc(100vh - 16px); height: calc(100vh - 16px);
border-radius: 12px;
} }
.app-main { .app-main {
@@ -13,26 +13,24 @@
} }
.app-side { .app-side {
@apply grid items-start gap-2;
border-right: none; border-right: none;
border-bottom: 1px solid #d9e0ea; border-bottom: 1px solid #d9e0ea;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
align-self: start; align-self: start;
height: fit-content; height: fit-content;
gap: 8px;
} }
.app-side > .side-link { .app-side > .side-link {
min-height: 0; @apply min-h-0;
} }
.vault-grid { .vault-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
height: auto; @apply h-auto;
} }
.sidebar { .sidebar {
max-height: 280px; @apply max-h-[280px];
} }
.totp-grid, .totp-grid,
.field-grid { .field-grid {
@@ -53,115 +51,95 @@
} }
.standalone-title { .standalone-title {
font-size: 24px; @apply text-2xl;
} }
.standalone-footer { .standalone-footer {
font-size: 12px; @apply text-xs leading-[1.4];
line-height: 1.4;
} }
} }
@media (max-width: 900px) { @media (max-width: 1180px) {
.auth-page { .auth-page {
padding: 14px; @apply items-start p-3.5;
align-items: start;
} }
.standalone-shell { .standalone-shell {
width: 100%; @apply w-full max-w-[460px] gap-2.5 pt-3;
max-width: 460px;
gap: 10px;
padding-top: 12px;
} }
.standalone-brand-outside { .standalone-brand-outside {
justify-content: flex-start; @apply justify-start;
} }
.standalone-brand-logo { .standalone-brand-logo {
width: 44px; @apply h-11 w-11;
height: 44px;
} }
.auth-card { .auth-card {
padding: 20px 16px; @apply rounded-[18px] px-4 py-5;
border-radius: 18px;
} }
.btn.full { .btn.full {
height: 48px; @apply h-12 text-lg;
font-size: 18px;
} }
.auth-support-row { .auth-support-row {
align-items: center; @apply flex-row items-center;
flex-direction: row;
} }
.app-page { .app-page {
padding: 0; @apply p-0;
background: transparent; background: transparent;
} }
.app-shell { .app-shell {
--mobile-topbar-height: 58px; --mobile-topbar-height: 58px;
--mobile-tabbar-height: 70px; --mobile-tabbar-height: 70px;
height: 100dvh; @apply h-dvh max-w-none rounded-none border-0;
max-width: none;
border: none; border: none;
border-radius: 0;
box-shadow: none; box-shadow: none;
} }
.topbar { .topbar {
@apply relative z-20 px-3;
height: var(--mobile-topbar-height); height: var(--mobile-topbar-height);
padding: 0 12px;
position: relative;
z-index: 20;
} }
.brand { .brand {
min-width: 0; @apply min-w-0 gap-2.5 text-lg;
gap: 10px;
font-size: 18px;
} }
.brand-logo { .brand-logo {
width: 34px; @apply h-[34px] w-[34px];
height: 34px; }
.brand-wordmark {
@apply hidden;
} }
.mobile-page-title { .mobile-page-title {
display: inline; @apply inline;
} }
.topbar-actions .user-chip, .topbar-actions .user-chip,
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn), .topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
.topbar-actions > .theme-switch-wrap { .topbar-actions > .theme-switch-wrap {
display: none; @apply hidden;
} }
.mobile-sidebar-toggle, .mobile-sidebar-toggle,
.mobile-lock-btn { .mobile-lock-btn {
display: inline-flex; @apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
justify-content: center;
font-size: 0;
gap: 0;
} }
.mobile-sidebar-toggle .btn-icon, .mobile-sidebar-toggle .btn-icon,
.mobile-lock-btn .btn-icon { .mobile-lock-btn .btn-icon {
margin: 0; @apply m-0;
} }
.mobile-theme-btn { .mobile-theme-btn {
display: inline-flex; @apply inline-flex items-center;
align-items: center;
} }
.mobile-theme-btn .theme-switch { .mobile-theme-btn .theme-switch {
@@ -170,26 +148,21 @@
} }
.app-main { .app-main {
display: flex; @apply flex min-h-0 flex-col;
flex-direction: column;
min-height: 0;
} }
.app-side { .app-side {
display: none; @apply hidden;
} }
.content { .content {
flex: 1; @apply min-h-0 flex-1;
min-height: 0;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.mobile-tabbar { .mobile-tabbar {
display: grid; @apply grid items-center gap-1.5;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 6px;
min-height: var(--mobile-tabbar-height); min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
@@ -197,15 +170,8 @@
} }
.mobile-tab { .mobile-tab {
display: grid; @apply grid justify-items-center gap-1 rounded-xl px-1 py-1.5 text-[11px] font-bold no-underline;
justify-items: center;
gap: 4px;
color: #64748b; color: #64748b;
text-decoration: none;
font-size: 11px;
font-weight: 700;
padding: 6px 4px;
border-radius: 12px;
transition: transition:
transform 220ms var(--ease-out-soft), transform 220ms var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth), background-color var(--dur-fast) var(--ease-smooth),
@@ -213,7 +179,7 @@
} }
.mobile-tab:hover { .mobile-tab:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0); transform: translateY(-1px);
} }
.mobile-tab.active { .mobile-tab.active {
@@ -223,30 +189,21 @@
} }
.vault-grid { .vault-grid {
gap: 10px; @apply gap-2.5 p-0;
padding: 0;
} }
.sidebar { .sidebar {
display: none; @apply hidden;
} }
.mobile-sidebar-sheet { .mobile-sidebar-sheet {
display: block; @apply fixed left-2.5 right-2.5 z-[55] block overflow-auto rounded-[18px] p-3 opacity-0;
position: fixed;
left: 10px;
right: 10px;
top: calc(var(--mobile-topbar-height) + 10px); top: calc(var(--mobile-topbar-height) + 10px);
bottom: auto; bottom: auto;
max-height: calc(100dvh - 145px); max-height: calc(100dvh - 145px);
z-index: 55;
overflow: auto;
border: 1px solid #d8dee8; border: 1px solid #d8dee8;
border-radius: 18px;
background: #fff; background: #fff;
padding: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transform: translate3d(0, 10px, 0) scale(0.98); transform: translate3d(0, 10px, 0) scale(0.98);
@@ -257,37 +214,26 @@
} }
.mobile-sidebar-sheet.open { .mobile-sidebar-sheet.open {
opacity: 1; @apply opacity-100;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
transform: translate3d(0, 0, 0) scale(1); transform: translate3d(0, 0, 0) scale(1);
} }
.mobile-sidebar-head { .mobile-sidebar-head {
display: flex; @apply mb-2.5 flex items-center justify-between gap-2.5;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
} }
.mobile-sidebar-title { .mobile-sidebar-title {
font-size: 16px; @apply text-base font-extrabold;
font-weight: 800;
color: #0f172a; color: #0f172a;
} }
.mobile-sidebar-close { .mobile-sidebar-close {
width: 34px; @apply inline-grid h-[34px] w-[34px] cursor-pointer place-items-center rounded-full p-0;
height: 34px;
border: 1px solid #d7dde6; border: 1px solid #d7dde6;
border-radius: 999px;
background: #fff; background: #fff;
color: #0f172a; color: #0f172a;
display: inline-grid;
place-items: center;
cursor: pointer;
padding: 0;
transition: transition:
transform var(--dur-fast) var(--ease-out-soft), transform var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth), background-color var(--dur-fast) var(--ease-smooth),
@@ -299,37 +245,31 @@
} }
.mobile-sidebar-sheet .sidebar-block { .mobile-sidebar-sheet .sidebar-block {
margin: 0; @apply m-0 rounded-none border-0 p-0;
padding: 0;
border: none; border: none;
border-radius: 0;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
} }
.mobile-sidebar-sheet .tree-btn { .mobile-sidebar-sheet .tree-btn {
margin-bottom: 2px; @apply mb-0.5;
} }
.mobile-sidebar-sheet .folder-row { .mobile-sidebar-sheet .folder-row {
align-items: stretch; @apply items-stretch gap-1;
gap: 4px;
} }
.mobile-sidebar-sheet .folder-row .tree-btn { .mobile-sidebar-sheet .folder-row .tree-btn {
min-height: 42px; @apply min-h-[42px];
} }
.mobile-sidebar-sheet .sidebar-title, .mobile-sidebar-sheet .sidebar-title,
.mobile-sidebar-sheet .sidebar-title-row { .mobile-sidebar-sheet .sidebar-title-row {
padding-bottom: 6px; @apply mb-0 pb-1.5;
margin-bottom: 0;
} }
.mobile-sidebar-sheet .tree-btn { .mobile-sidebar-sheet .tree-btn {
padding-left: 8px; @apply rounded-[10px] px-2;
padding-right: 8px;
border-radius: 10px;
} }
.mobile-sidebar-sheet .tree-btn.active { .mobile-sidebar-sheet .tree-btn.active {
@@ -337,56 +277,39 @@
} }
.mobile-sidebar-sheet .folder-delete-btn { .mobile-sidebar-sheet .folder-delete-btn {
width: 28px; @apply h-[42px] w-7 rounded-lg;
height: 42px;
border-radius: 8px;
} }
.list-col { .list-col {
max-width: none; @apply max-w-none;
} }
.list-head { .list-head {
display: grid; @apply grid items-center gap-2;
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
align-items: center;
} }
.list-count { .list-count {
grid-column: auto; grid-column: auto;
width: auto; @apply w-auto whitespace-nowrap text-xs;
font-size: 12px;
white-space: nowrap;
} }
.list-head .search-input-wrap { .list-head .search-input-wrap {
width: 100%; @apply w-full min-w-0;
min-width: 0;
} }
.list-head .search-input { .list-head .search-input {
width: 100%; @apply h-[42px] w-full min-w-0 rounded-[14px];
min-width: 0;
height: 42px;
border-radius: 14px;
} }
.list-icon-btn { .list-icon-btn {
width: auto; @apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
min-width: 0;
padding: 0 12px;
font-size: 13px;
gap: 6px;
white-space: nowrap;
} }
.toolbar.actions { .toolbar.actions {
justify-content: flex-end; @apply justify-end overflow-visible pb-0.5;
flex-wrap: unset; flex-wrap: unset;
gap: var(--actions-gap); gap: var(--actions-gap);
overflow: visible;
padding-bottom: 2px;
} }
.actions { .actions {
@@ -394,37 +317,21 @@
} }
.toolbar.actions .btn.small { .toolbar.actions .btn.small {
width: auto; @apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
min-width: 0;
height: 34px;
padding: 0 12px;
font-size: 13px;
gap: 6px;
border-radius: 999px;
white-space: nowrap;
} }
.mobile-fab-wrap { .mobile-fab-wrap {
position: fixed; @apply fixed right-3.5 z-[45];
right: 14px;
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom)); bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 45;
} }
.mobile-fab-trigger { .mobile-fab-trigger {
width: 36px; @apply h-14 w-9 gap-0 rounded-full p-0 text-[0];
height: 56px;
padding: 0;
border-radius: 999px;
font-size: 0;
gap: 0;
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28); box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
} }
.mobile-fab-trigger .btn-icon { .mobile-fab-trigger .btn-icon {
margin: 0; @apply m-0 h-5 w-5;
width: 20px;
height: 20px;
} }
.mobile-fab-wrap .create-menu { .mobile-fab-wrap .create-menu {
@@ -435,18 +342,15 @@
} }
.list-panel { .list-panel {
border-radius: 16px; @apply overflow-visible rounded-2xl;
overflow: visible;
} }
.list-item { .list-item {
padding: 12px; @apply rounded-[14px] p-3;
border-radius: 14px;
} }
.row-check { .row-check {
width: 18px; @apply h-[18px] w-[18px];
height: 18px;
} }
.vault-grid.mobile-panel-detail .sidebar, .vault-grid.mobile-panel-detail .sidebar,
@@ -454,20 +358,15 @@
.vault-grid.mobile-panel-edit .sidebar, .vault-grid.mobile-panel-edit .sidebar,
.vault-grid.mobile-panel-edit .list-col { .vault-grid.mobile-panel-edit .list-col {
display: none; display: none;
@apply hidden;
} }
.mobile-detail-sheet { .mobile-detail-sheet {
display: block; @apply fixed left-0 right-0 z-[35] block overflow-auto opacity-0;
position: fixed;
left: 0;
right: 0;
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top)); top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom)); bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 35;
overflow: auto;
background: transparent; background: transparent;
padding: 0 0 18px; padding: 0 0 18px;
opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
transform: translate3d(0, 18px, 0); transform: translate3d(0, 18px, 0);
@@ -478,49 +377,44 @@
} }
.mobile-detail-sheet.open { .mobile-detail-sheet.open {
opacity: 1; @apply opacity-100;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
.mobile-panel-head { .mobile-panel-head {
display: flex; @apply mb-2.5 ml-2.5 mr-2.5 flex items-center;
align-items: center;
margin: 0 10px 10px;
} }
.mobile-panel-back { .mobile-panel-back {
min-height: 40px; @apply min-h-10;
} }
.mobile-detail-sheet > .detail-switch-stage, .mobile-detail-sheet > .detail-switch-stage,
.mobile-detail-sheet > .card, .mobile-detail-sheet > .card,
.mobile-detail-sheet > .empty { .mobile-detail-sheet > .empty {
margin-left: 10px; @apply ml-2.5 mr-2.5;
margin-right: 10px;
} }
.detail-col .card, .detail-col .card,
.import-export-panel, .import-export-panel,
.settings-subcard { .settings-subcard {
border-radius: 16px; @apply rounded-2xl;
} }
.card { .card {
padding: 14px 14px; @apply p-3.5;
} }
.section-head { .section-head {
align-items: flex-start; @apply flex-col items-start gap-2.5;
gap: 10px;
flex-direction: column;
} }
.detail-actions { .detail-actions {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 10px; gap: 5px;
} }
.detail-actions .actions { .detail-actions .actions {
@@ -568,6 +462,11 @@
gap: 10px; gap: 10px;
} }
.settings-modules-grid,
.password-settings-grid {
grid-template-columns: 1fr;
}
.import-export-panel .actions .btn, .import-export-panel .actions .btn,
.settings-subcard .actions .btn, .settings-subcard .actions .btn,
.section-head .actions .btn { .section-head .actions .btn {
@@ -734,7 +633,7 @@
} }
} }
@media (max-width: 900px) { @media (max-width: 1180px) {
.backup-grid { .backup-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+46 -165
View File
@@ -1,139 +1,81 @@
.app-page { .app-page {
min-height: 100%; @apply relative min-h-full bg-transparent p-5;
padding: 20px;
position: relative;
background: transparent;
} }
.app-shell { .app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft shadow-elevated;
height: calc(100vh - 40px); height: calc(100vh - 40px);
max-width: 1600px; border-color: var(--line);
margin: 0 auto; @apply rounded-3xl;
position: relative;
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
animation: shell-enter 560ms var(--ease-out-strong) both;
} }
.topbar { .topbar {
height: 58px; @apply flex h-[58px] items-center justify-between border-b px-[18px] text-slate-900 transition;
border-bottom: 1px solid var(--line-soft); border-color: var(--line-soft);
color: #0f172a; background: rgba(244, 248, 255, 0.82);
background: rgba(244, 248, 255, 0.72);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
transition: background-color var(--dur-fast) var(--ease-smooth), border-color var(--dur-fast) var(--ease-smooth);
} }
.brand { .brand {
display: inline-flex; @apply inline-flex items-center gap-2 text-[34px] font-extrabold text-ink;
align-items: center;
gap: 8px;
font-size: 34px;
font-weight: 800;
color: var(--text);
} }
.brand-wordmark { .brand-wordmark {
display: block; @apply block h-auto max-w-full;
height: auto;
width: clamp(210px, 20vw, 290px); width: clamp(210px, 20vw, 290px);
max-width: 100%;
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12)); filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
} }
.mobile-page-title { .mobile-page-title {
display: none; @apply hidden min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[19px] font-extrabold leading-tight text-slate-900;
min-width: 0;
max-width: min(58vw, 240px); 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 { .brand-logo {
width: 42px; @apply h-[42px] w-[42px] object-contain;
height: 42px;
object-fit: contain;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22)); 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 { .topbar-actions {
display: flex; @apply flex items-center gap-2.5;
align-items: center;
gap: 10px;
} }
.mobile-tabbar { .mobile-tabbar {
display: none; @apply hidden;
} }
.mobile-sidebar-toggle { .mobile-sidebar-toggle {
display: none; @apply hidden;
} }
.mobile-lock-btn { .mobile-lock-btn {
display: none; @apply hidden;
} }
.mobile-theme-btn { .mobile-theme-btn {
display: none; @apply hidden;
} }
.theme-switch-wrap { .theme-switch-wrap {
display: inline-flex; @apply inline-flex items-center justify-center;
align-items: center;
justify-content: center;
} }
.theme-switch { .theme-switch {
position: relative; @apply relative inline-block h-8 w-14;
display: inline-block;
width: 56px;
height: 32px;
} }
.theme-switch-input { .theme-switch-input {
opacity: 0; @apply h-0 w-0 opacity-0;
width: 0;
height: 0;
} }
.theme-switch-slider { .theme-switch-slider {
position: absolute; @apply absolute inset-0 cursor-pointer rounded-full border transition;
cursor: pointer; background: #dbeafe;
top: 0; border-color: #9dbbec;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, #dceaff, #c8dcff);
border: 1px solid #9dbbec;
transition:
background var(--dur-medium) var(--ease-out-soft),
border-color var(--dur-medium) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
transform var(--dur-fast) var(--ease-out-soft);
border-radius: 999px;
} }
.theme-switch-slider::before { .theme-switch-slider::before {
position: absolute; @apply absolute h-[26px] w-[26px] rounded-full;
content: ''; content: '';
height: 26px;
width: 26px;
border-radius: 999px;
left: 2px; left: 2px;
bottom: 2px; bottom: 2px;
z-index: 2; z-index: 2;
@@ -146,24 +88,20 @@
} }
.theme-switch .sun svg { .theme-switch .sun svg {
position: absolute; @apply absolute h-[18px] w-[18px];
top: 6px; top: 6px;
left: 32px; left: 32px;
z-index: 1; z-index: 1;
width: 18px;
height: 18px;
opacity: 0.95; opacity: 0.95;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth); transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
} }
.theme-switch .moon svg { .theme-switch .moon svg {
fill: #5b86d6; fill: #5b86d6;
position: absolute; @apply absolute h-4 w-4;
top: 7px; top: 7px;
left: 7px; left: 7px;
z-index: 1; z-index: 1;
width: 16px;
height: 16px;
opacity: 0.88; opacity: 0.88;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth); 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 { .theme-switch:hover .theme-switch-slider {
transform: scale(1.02); transform: none;
} }
.theme-switch:hover .sun svg, .theme-switch:hover .sun svg,
@@ -191,131 +129,74 @@
} }
.topbar-actions .btn { .topbar-actions .btn {
height: 34px; @apply h-[34px] rounded-xl px-3 text-[13px] font-semibold;
border-radius: 12px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition-duration: 220ms; transition-duration: 220ms;
} }
.topbar-actions .btn:hover:not(:disabled) { .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 { .user-chip {
display: inline-flex; @apply inline-flex h-[34px] items-center gap-1.5 rounded-full border px-3 text-sm font-semibold text-muted-strong transition;
align-items: center; background: rgba(249, 251, 255, 0.94);
gap: 6px; border-color: rgba(148, 163, 184, 0.30);
height: 34px; box-shadow: 0 8px 16px rgba(13, 31, 68, 0.04);
border-radius: 999px;
padding: 0 12px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(249, 251, 255, 0.92);
color: var(--muted-strong);
font-size: 14px;
font-weight: 600;
box-shadow: 0 10px 18px rgba(13, 31, 68, 0.05);
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition:
transform 220ms var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
border-color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth);
} }
.user-chip:hover { .user-chip:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
} }
.app-main { .app-main {
flex: 1; @apply grid min-h-0 flex-1;
min-height: 0;
display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr;
} }
.app-side { .app-side {
border-right: 1px solid var(--line-soft); @apply flex flex-col gap-2 border-r px-3 py-4;
padding: 16px 12px; border-color: var(--line-soft);
display: flex;
flex-direction: column;
gap: 8px;
} }
.side-link { .side-link {
display: flex; @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;
align-items: center;
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
color: var(--muted-strong);
text-decoration: none;
border: 1px solid transparent;
font-weight: 600;
font-size: 14px;
transition:
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft);
} }
.side-link:hover { .side-link:hover {
background: #ffffff; background: #fff;
border-color: rgba(128, 152, 192, 0.18); border-color: rgba(128, 152, 192, 0.18);
color: var(--text); color: var(--text);
transform: translate3d(calc(var(--mag-x) + 3px), var(--mag-y), 0); box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04);
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.05);
} }
.side-link.active { .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); border-color: rgba(37, 99, 235, 0.28);
color: var(--primary-strong); 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 { .content {
min-height: 0; @apply min-h-0 overflow-hidden p-3.5;
padding: 14px;
overflow: hidden;
} }
.route-stage { .route-stage {
height: 100%; @apply h-full min-h-0 overflow-auto;
min-height: 0;
overflow: auto;
}
@media (min-width: 901px) {
.route-stage {
animation: route-stage-in 240ms var(--ease-out-soft) both;
}
} }
.mobile-sidebar-mask { .mobile-sidebar-mask {
position: fixed; @apply pointer-events-none invisible fixed inset-0 opacity-0;
inset: 0;
background: rgba(15, 23, 42, 0.36); background: rgba(15, 23, 42, 0.36);
z-index: 54; z-index: 54;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: transition:
opacity 220ms var(--ease-smooth), opacity 220ms var(--ease-smooth),
visibility 220ms var(--ease-smooth); visibility 220ms var(--ease-smooth);
} }
.mobile-sidebar-mask.open { .mobile-sidebar-mask.open {
opacity: 1; @apply pointer-events-auto visible opacity-100;
visibility: visible;
pointer-events: auto;
} }
.mobile-sidebar-head { .mobile-sidebar-head {
display: none; @apply hidden;
} }
+39 -29
View File
@@ -1,46 +1,56 @@
:root { :root {
--bg-accent: #e7edf8; --bg-accent: #eef3fa;
--panel: #f9fbff; --panel: #ffffff;
--panel-soft: #f2f6fd; --panel-soft: #f6f8fc;
--panel-muted: #e8eff9; --panel-muted: #edf2f8;
--line: rgba(128, 152, 192, 0.32); --panel-subtle: #f8fafc;
--line-soft: rgba(143, 167, 206, 0.18); --line: rgba(113, 132, 163, 0.28);
--line-soft: rgba(113, 132, 163, 0.16);
--text: #0b1730; --text: #0b1730;
--muted: #60708b; --muted: #5f6f85;
--muted-strong: #334765; --muted-strong: #2f4058;
--primary: #2563eb; --primary: #2563eb;
--primary-hover: #1d4ed8; --primary-hover: #1d4ed8;
--primary-strong: #0f3f98; --primary-strong: #0f3f98;
--danger: #d92d57; --danger: #d92d57;
--success: #0f766e;
--warning: #b45309;
--overlay-strong: rgba(15, 23, 42, 0.56); --overlay-strong: rgba(15, 23, 42, 0.56);
--shadow-sm: 0 10px 22px rgba(13, 31, 68, 0.045); --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 22px 48px rgba(13, 31, 68, 0.08); --shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 28px 76px rgba(13, 31, 68, 0.11); --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-strong: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1); --ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 180ms; --dur-fast: 180ms;
--dur-medium: 240ms; --dur-medium: 240ms;
--dur-panel: 280ms; --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'] { :root[data-theme='dark'] {
--bg-accent: #06111d; --bg-accent: #0b1020;
--panel: #0d192b; --panel: #111827;
--panel-soft: #112136; --panel-soft: #0f172a;
--panel-muted: #0a1626; --panel-muted: #0b1324;
--line: rgba(108, 141, 190, 0.28); --panel-subtle: #151e2e;
--line-soft: rgba(120, 152, 198, 0.16); --line: rgba(148, 163, 184, 0.20);
--text: #edf4ff; --line-soft: rgba(148, 163, 184, 0.12);
--muted: #8fa6c6; --text: #e5edf8;
--muted-strong: #c3d5ef; --muted: #9aa8bb;
--primary: #84b6ff; --muted-strong: #c7d2e2;
--primary-hover: #a6ccff; --primary: #8bb8ff;
--primary-strong: #f3f8ff; --primary-hover: #a9ccff;
--danger: #ff8ba8; --primary-strong: #dceaff;
--overlay-strong: rgba(2, 8, 20, 0.84); --danger: #fb7185;
--shadow-sm: 0 14px 28px rgba(1, 7, 18, 0.24); --success: #5eead4;
--shadow-md: 0 24px 52px rgba(1, 7, 18, 0.36); --warning: #fbbf24;
--shadow-lg: 0 34px 88px rgba(1, 7, 18, 0.46); --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;