mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Compare commits
5 Commits
b444c0f4b8
...
f6169b7610
| Author | SHA1 | Date | |
|---|---|---|---|
| f6169b7610 | |||
| 493f901ec1 | |||
| b4dfb0409b | |||
| a06cb0ed71 | |||
| b0242265f4 |
@@ -68,6 +68,7 @@ export interface BackupPayload {
|
|||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
webauthn_credentials?: SqlRow[];
|
webauthn_credentials?: SqlRow[];
|
||||||
|
trusted_two_factor_device_tokens?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
|
|||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||||
|
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
|
|||||||
accountPasskeyIds.add(id);
|
accountPasskeyIds.add(id);
|
||||||
accountPasskeyCredentialIds.add(credentialId);
|
accountPasskeyCredentialIds.add(credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trustedTwoFactorTokens = new Set<string>();
|
||||||
|
for (const row of trustedTwoFactorTokenRows) {
|
||||||
|
const token = String(row.token || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const deviceIdentifier = String(row.device_identifier || '').trim();
|
||||||
|
const expiresAt = Number(row.expires_at || 0);
|
||||||
|
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||||
|
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
|
||||||
|
}
|
||||||
|
if (trustedTwoFactorTokens.has(token)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
|
||||||
|
}
|
||||||
|
trustedTwoFactorTokens.add(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||||
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
|
|||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
attachments: exportedAttachmentRows.length,
|
attachments: exportedAttachmentRows.length,
|
||||||
webauthn_credentials: accountPasskeyRows.length,
|
webauthn_credentials: accountPasskeyRows.length,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
webauthn_credentials: accountPasskeyRows,
|
webauthn_credentials: accountPasskeyRows,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type BackupTableName =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'domain_settings'
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'trusted_two_factor_device_tokens'
|
||||||
| 'webauthn_credentials'
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
|||||||
'users',
|
'users',
|
||||||
'domain_settings',
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'trusted_two_factor_device_tokens',
|
||||||
'webauthn_credentials',
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings: number;
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens: number;
|
||||||
webauthnCredentials: number;
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
'DELETE FROM webauthn_credentials',
|
'DELETE FROM webauthn_credentials',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
'DELETE FROM domain_settings',
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
['token', 'user_id', 'device_identifier', 'expires_at'],
|
||||||
|
payload.trusted_two_factor_device_tokens || []
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ 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 cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return createPortal((
|
return createPortal((
|
||||||
<div
|
<div
|
||||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
|
|||||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
|
|
||||||
const checkService = async () => {
|
const checkService = async () => {
|
||||||
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
|
|||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reachable = await probeNodeWardenService();
|
await probeNodeWardenService();
|
||||||
if (!cancelled) {
|
|
||||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNextCheck = () => {
|
const scheduleNextCheck = () => {
|
||||||
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
|
|||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener('online', handleOnline);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
|||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
buildAccountEncryptedBitwardenJsonString,
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenCsvString,
|
||||||
buildBitwardenZipBytes,
|
buildBitwardenZipBytes,
|
||||||
buildExportFileName,
|
buildExportFileName,
|
||||||
buildNodeWardenAttachmentRecords,
|
buildNodeWardenAttachmentRecords,
|
||||||
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
bytes: new TextEncoder().encode(await getPlainJson()),
|
||||||
};
|
};
|
||||||
|
} else if (format === 'bitwarden_csv') {
|
||||||
|
result = {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'text/csv;charset=utf-8',
|
||||||
|
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
|
||||||
|
};
|
||||||
} else if (format === 'bitwarden_encrypted_json') {
|
} else if (format === 'bitwarden_encrypted_json') {
|
||||||
if (request.encryptedJsonMode === 'password') {
|
if (request.encryptedJsonMode === 'password') {
|
||||||
const plainJson = await getPlainJson();
|
const plainJson = await getPlainJson();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||||
|
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(input, { ...init, headers });
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
recordNodeWardenReachable();
|
||||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt === maxAttempts - 1) {
|
if (attempt === maxAttempts - 1) {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings?: number;
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens?: number;
|
||||||
webauthnCredentials?: number;
|
webauthnCredentials?: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
|
|||||||
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
|
|||||||
fallbackProfile: Profile | null = null
|
fallbackProfile: Profile | null = null
|
||||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||||
let serviceReachable = true;
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (hasOfflineUnlock) {
|
return {
|
||||||
serviceReachable = await probeNodeWardenService();
|
session,
|
||||||
if (!serviceReachable) {
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
return {
|
};
|
||||||
session,
|
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
if (!refreshedSession?.accessToken) {
|
if (!refreshedSession?.accessToken) {
|
||||||
if (hasOfflineUnlock && !serviceReachable) {
|
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
@@ -571,14 +567,8 @@ export async function performUnlock(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasOfflineUnlock) {
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (browserReportsOffline()) {
|
return unlockOffline();
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
const serviceReachable = await probeNodeWardenService();
|
|
||||||
if (!serviceReachable) {
|
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
|||||||
|
|
||||||
export const EXPORT_FORMATS = [
|
export const EXPORT_FORMATS = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
||||||
|
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
|
||||||
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
||||||
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
||||||
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
||||||
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csvText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value: unknown): string {
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!/[",\r\n]/.test(text)) return text;
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvString(rows: string[][]): string {
|
||||||
|
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleRowCsvString(values: string[]): string {
|
||||||
|
return values.map(escapeCsvCell).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
function isCipherString(value: string): boolean {
|
function isCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
|||||||
return JSON.stringify(doc, null, 2);
|
return JSON.stringify(doc, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BITWARDEN_CSV_HEADERS = [
|
||||||
|
'folder',
|
||||||
|
'favorite',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'fields',
|
||||||
|
'reprompt',
|
||||||
|
'login_uri',
|
||||||
|
'login_username',
|
||||||
|
'login_password',
|
||||||
|
'login_totp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function bitwardenCsvType(type: number): 'login' | 'note' {
|
||||||
|
return type === 1 ? 'login' : 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeLabel(type: number): string {
|
||||||
|
if (type === 3) return 'card';
|
||||||
|
if (type === 4) return 'identity';
|
||||||
|
if (type === 5) return 'sshKey';
|
||||||
|
if (type === 2) return 'note';
|
||||||
|
return `type ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
|
||||||
|
const key = csvText(name).trim();
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!key || !text) return;
|
||||||
|
lines.push(`${key}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
for (const [key, fieldValue] of Object.entries(value)) {
|
||||||
|
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const fields = Array.isArray(item.fields) ? item.fields : [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!isRecord(field)) continue;
|
||||||
|
appendFieldLine(lines, field.name, field.value);
|
||||||
|
}
|
||||||
|
if (type !== 1 && type !== 2) {
|
||||||
|
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
|
||||||
|
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!isRecord(folder)) continue;
|
||||||
|
const id = csvText(folder.id).trim();
|
||||||
|
if (!id) continue;
|
||||||
|
out.set(id, csvText(folder.name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
|
||||||
|
const uris = Array.isArray(login?.uris) ? login.uris : [];
|
||||||
|
return buildSingleRowCsvString(uris
|
||||||
|
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
|
||||||
|
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
|
||||||
|
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
|
||||||
|
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
|
||||||
|
for (const itemRaw of items) {
|
||||||
|
if (!isRecord(itemRaw)) continue;
|
||||||
|
const type = normalizeNumber(itemRaw.type, 1);
|
||||||
|
const isLogin = type === 1;
|
||||||
|
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
|
||||||
|
const folderId = csvText(itemRaw.folderId).trim();
|
||||||
|
rows.push([
|
||||||
|
folderNameById.get(folderId) || '',
|
||||||
|
itemRaw.favorite ? '1' : '0',
|
||||||
|
bitwardenCsvType(type),
|
||||||
|
csvText(itemRaw.name) || '--',
|
||||||
|
csvText(itemRaw.notes),
|
||||||
|
buildBitwardenCsvFields(itemRaw, type),
|
||||||
|
String(normalizeNumber(itemRaw.reprompt, 0)),
|
||||||
|
isLogin ? buildBitwardenCsvLoginUri(login) : '',
|
||||||
|
isLogin ? csvText(login?.username) : '',
|
||||||
|
isLogin ? csvText(login?.password) : '',
|
||||||
|
isLogin ? csvText(login?.totp) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return `\uFEFF${buildCsvString(rows)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
|
|||||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
const stamp = nowStamp();
|
const stamp = nowStamp();
|
||||||
if (
|
if (
|
||||||
|
format === 'bitwarden_csv' ||
|
||||||
format === 'bitwarden_json' ||
|
format === 'bitwarden_json' ||
|
||||||
format === 'bitwarden_encrypted_json' ||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
format === 'nodewarden_json' ||
|
format === 'nodewarden_json' ||
|
||||||
format === 'nodewarden_encrypted_json'
|
format === 'nodewarden_encrypted_json'
|
||||||
) {
|
) {
|
||||||
|
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
return `bitwarden_export_${stamp}.json`;
|
return `bitwarden_export_${stamp}.json`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
export type NetworkStatus = 'online' | 'offline';
|
export type NetworkStatus = 'online' | 'offline';
|
||||||
|
|
||||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
const STATUS_PROBE_TIMEOUT_MS = 8000;
|
||||||
const STATUS_PROBE_CACHE_MS = 5000;
|
const STATUS_PROBE_CACHE_MS = 5000;
|
||||||
|
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
|
||||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||||
let pendingProbe: Promise<boolean> | null = null;
|
let pendingProbe: Promise<boolean> | null = null;
|
||||||
let lastProbeAt = 0;
|
let lastProbeAt = 0;
|
||||||
let lastProbeResult = currentStatus === 'online';
|
let lastProbeResult = currentStatus === 'online';
|
||||||
|
let consecutiveProbeFailures = 0;
|
||||||
|
|
||||||
export function browserReportsOffline(): boolean {
|
export function browserReportsOffline(): boolean {
|
||||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenReachable(): void {
|
||||||
|
consecutiveProbeFailures = 0;
|
||||||
|
lastProbeResult = true;
|
||||||
|
setCurrentNetworkStatus('online');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenUnreachable(): void {
|
||||||
|
lastProbeResult = false;
|
||||||
|
consecutiveProbeFailures += 1;
|
||||||
|
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeNodeWardenService(): Promise<boolean> {
|
export async function probeNodeWardenService(): Promise<boolean> {
|
||||||
if (browserReportsOffline()) {
|
if (browserReportsOffline()) {
|
||||||
|
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
|
||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
lastProbeAt = Date.now();
|
lastProbeAt = Date.now();
|
||||||
lastProbeResult = result;
|
if (result) {
|
||||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
recordNodeWardenReachable();
|
||||||
|
} else {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Send } from './types';
|
import type { Send } from './types';
|
||||||
import { getCurrentNetworkStatus } from './network-status';
|
|
||||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||||
|
|
||||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||||
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
|||||||
function getWorker(): Worker | null {
|
function getWorker(): Worker | null {
|
||||||
if (typeof Worker === 'undefined') return null;
|
if (typeof Worker === 'undefined') return null;
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
|
||||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
|||||||
@@ -810,6 +810,101 @@ h4 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger,
|
||||||
|
.side-sub-link,
|
||||||
|
.tree-btn,
|
||||||
|
.mobile-settings-link,
|
||||||
|
.backup-destination-item,
|
||||||
|
.backup-browser-entry,
|
||||||
|
.sort-menu-item,
|
||||||
|
.create-menu-item,
|
||||||
|
.nav-layout-option {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active,
|
||||||
|
.side-group-trigger.active,
|
||||||
|
.side-sub-link.active,
|
||||||
|
.tree-btn.active,
|
||||||
|
.mobile-tab.active,
|
||||||
|
.mobile-settings-link.active,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title,
|
||||||
|
.list-count,
|
||||||
|
.field > span,
|
||||||
|
.table th,
|
||||||
|
.dialog-warning-kicker,
|
||||||
|
.backup-recommendation-group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sub,
|
||||||
|
.detail-sub,
|
||||||
|
.backup-destination-meta,
|
||||||
|
.totp-code-username,
|
||||||
|
.field-help,
|
||||||
|
.settings-field-note {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.input,
|
||||||
|
.search-input,
|
||||||
|
.user-chip,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-danger,
|
||||||
|
.btn.full,
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h4,
|
||||||
|
.settings-module h3,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head h4,
|
||||||
|
.detail-title,
|
||||||
|
.totp-code-name,
|
||||||
|
.backup-destination-name,
|
||||||
|
.mobile-sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-item,
|
.toast-item,
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ body,
|
|||||||
@apply m-0 h-full w-full p-0;
|
@apply m-0 h-full w-full p-0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: var(--font-base);
|
font-size: var(--font-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
letter-spacing: var(--tracking-normal);
|
letter-spacing: var(--tracking-normal);
|
||||||
font-feature-settings: 'liga' 1, 'kern' 1;
|
font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -33,8 +34,8 @@ body.dialog-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: 0;
|
||||||
line-height: var(--leading-tight);
|
line-height: var(--leading-tight);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,16 +46,22 @@
|
|||||||
--dur-slow: 350ms;
|
--dur-slow: 350ms;
|
||||||
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
|
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
|
||||||
|
|
||||||
|
/* Typography Families */
|
||||||
|
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
|
||||||
|
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
|
||||||
|
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--font-xs: 11px;
|
--font-xs: 11px;
|
||||||
--font-sm: 13px;
|
--font-sm: 14px;
|
||||||
--font-base: 14px;
|
--font-base: 15px;
|
||||||
--font-md: 15px;
|
--font-md: 16px;
|
||||||
--font-lg: 16px;
|
--font-lg: 17px;
|
||||||
--font-xl: 18px;
|
--font-xl: 19px;
|
||||||
--font-2xl: 20px;
|
--font-2xl: 21px;
|
||||||
--font-3xl: 24px;
|
--font-3xl: 25px;
|
||||||
--font-4xl: 28px;
|
--font-4xl: 29px;
|
||||||
|
|
||||||
/* Line Heights */
|
/* Line Heights */
|
||||||
--leading-tight: 1.25;
|
--leading-tight: 1.25;
|
||||||
@@ -65,11 +71,11 @@
|
|||||||
--leading-loose: 1.75;
|
--leading-loose: 1.75;
|
||||||
|
|
||||||
/* Letter Spacing */
|
/* Letter Spacing */
|
||||||
--tracking-tighter: -0.02em;
|
--tracking-tighter: 0;
|
||||||
--tracking-tight: -0.01em;
|
--tracking-tight: 0;
|
||||||
--tracking-normal: 0;
|
--tracking-normal: 0;
|
||||||
--tracking-wide: 0.01em;
|
--tracking-wide: 0;
|
||||||
--tracking-wider: 0.02em;
|
--tracking-wider: 0;
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: 4px;
|
--space-1: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user