mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ee922777 | |||
| 2ea0b2c14c | |||
| 4ec1926888 | |||
| 3995e01336 | |||
| 481536ba24 | |||
| db8b9263a1 | |||
| a1f7250e90 | |||
| e4bc1b9bbe | |||
| 514889adfc | |||
| fccc85c4bb | |||
| acd59a7387 | |||
| d40b0514fd | |||
| 033d44808f |
@@ -42,3 +42,4 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden.wiki/
|
||||||
|
AGENTS.md
|
||||||
Generated
+951
-15
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.4';
|
export const APP_VERSION = '1.4.6';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
kind: 'success',
|
||||||
|
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { ...refreshedSession, ...keys };
|
|
||||||
|
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||||
|
if (tokenError.TwoFactorProviders) {
|
||||||
|
return {
|
||||||
|
kind: 'totp',
|
||||||
|
pendingTotp: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash: derived.hash,
|
||||||
|
masterKey: derived.masterKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: tokenError.error_description || tokenError.error || 'Unlock failed',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-372
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-455
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
Reference in New Issue
Block a user