mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add cryptographic utilities and types for secure data handling
This commit is contained in:
@@ -11,6 +11,9 @@ tests/selfcheck.ts
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
public/
|
||||
public2/
|
||||
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
Generated
+1638
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -7,7 +7,11 @@
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev": "npm run web:build && wrangler dev -c wrangler.toml",
|
||||
"dev:worker": "wrangler dev -c wrangler.toml",
|
||||
"web:dev": "vite --config webapp/vite.config.ts",
|
||||
"web:build": "vite build --config webapp/vite.config.ts",
|
||||
"web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
|
||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
@@ -33,9 +37,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
"@preact/preset-vite": "^2.10.3",
|
||||
"@types/node": "^25.2.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.61.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"preact": "^10.28.4",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-11
@@ -1,14 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NodeWarden Web</title>
|
||||
<link rel="stylesheet" href="/web/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script defer src="/web/vendor/qrcode-generator.min.js"></script>
|
||||
<script type="module" src="/web/runtime-config.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NodeWarden</title>
|
||||
<script type="module" crossorigin src="/assets/index-pVnF_d3f.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BL7fH__f.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { startNodewardenApp } from './main.js';
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
export function bytesToBase64(bytes) {
|
||||
var s = '';
|
||||
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
export function base64ToBytes(b64) {
|
||||
var bin = atob(b64);
|
||||
var bytes = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function concatBytes(a, b) {
|
||||
var o = new Uint8Array(a.length + b.length);
|
||||
o.set(a, 0);
|
||||
o.set(b, a.length);
|
||||
return o;
|
||||
}
|
||||
|
||||
export async function pbkdf2(passwordOrBytes, saltOrBytes, iterations, keyLen) {
|
||||
var pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||
var saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||
var key = await crypto.subtle.importKey('raw', pwdBytes, 'PBKDF2', false, ['deriveBits']);
|
||||
var bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations: iterations }, key, keyLen * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export async function hkdfExpand(prk, info, length) {
|
||||
var enc = new TextEncoder();
|
||||
var key = await crypto.subtle.importKey('raw', prk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
var infoBytes = enc.encode(info || '');
|
||||
var result = new Uint8Array(length);
|
||||
var prev = new Uint8Array(0);
|
||||
var off = 0;
|
||||
var cnt = 1;
|
||||
while (off < length) {
|
||||
var inp = new Uint8Array(prev.length + infoBytes.length + 1);
|
||||
inp.set(prev, 0);
|
||||
inp.set(infoBytes, prev.length);
|
||||
inp[inp.length - 1] = cnt & 0xff;
|
||||
prev = new Uint8Array(await crypto.subtle.sign('HMAC', key, inp));
|
||||
var c = Math.min(prev.length, length - off);
|
||||
result.set(prev.slice(0, c), off);
|
||||
off += c;
|
||||
cnt += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function hmacSha256(keyBytes, dataBytes) {
|
||||
var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, dataBytes));
|
||||
}
|
||||
|
||||
export async function encryptAesCbc(data, key, iv) {
|
||||
var ck = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['encrypt']);
|
||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: iv }, ck, data));
|
||||
}
|
||||
|
||||
export async function decryptAesCbc(data, key, iv) {
|
||||
var ck = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt']);
|
||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, ck, data));
|
||||
}
|
||||
|
||||
export async function encryptBw(data, encKey, macKey) {
|
||||
var iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
var cipher = await encryptAesCbc(data, encKey, iv);
|
||||
var mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
return '2.' + bytesToBase64(iv) + '|' + bytesToBase64(cipher) + '|' + bytesToBase64(mac);
|
||||
}
|
||||
|
||||
export function parseCipherString(s) {
|
||||
if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
|
||||
if (s === 'null' || s === 'undefined') throw new Error('invalid encrypted string');
|
||||
var p = s.indexOf('.');
|
||||
if (p <= 0) throw new Error('invalid encrypted string');
|
||||
var type = Number(s.slice(0, p));
|
||||
var body = s.slice(p + 1);
|
||||
var parts = body.split('|');
|
||||
if (type === 2 && parts.length === 3) return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
|
||||
if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) return { type: type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
|
||||
throw new Error('unsupported enc type or format');
|
||||
}
|
||||
|
||||
export async function decryptBw(cipherString, encKey, macKey) {
|
||||
var parsed = parseCipherString(cipherString);
|
||||
if (parsed.type === 2 && macKey && parsed.mac) {
|
||||
var expect = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
|
||||
if (bytesToBase64(expect) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
|
||||
}
|
||||
return decryptAesCbc(parsed.ct, encKey, parsed.iv);
|
||||
}
|
||||
|
||||
export async function decryptStr(cipherString, encKey, macKey) {
|
||||
if (!cipherString || typeof cipherString !== 'string') return '';
|
||||
var plain = await decryptBw(cipherString, encKey, macKey);
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
|
||||
export function extractTotpSecret(raw) {
|
||||
if (!raw) return '';
|
||||
var s = String(raw).trim();
|
||||
if (!s) return '';
|
||||
if (/^otpauth:\/\//i.test(s)) {
|
||||
try {
|
||||
var u = new URL(s);
|
||||
var qp = u.searchParams.get('secret') || '';
|
||||
return qp.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
} catch (_) {}
|
||||
}
|
||||
return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base32ToBytes(input) {
|
||||
var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
var clean = String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
var bits = 0, value = 0, out = [];
|
||||
for (var i = 0; i < clean.length; i++) {
|
||||
var idx = alphabet.indexOf(clean.charAt(i));
|
||||
if (idx < 0) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export async function calcTotpNow(rawSecret) {
|
||||
var secret = extractTotpSecret(rawSecret);
|
||||
if (!secret) return null;
|
||||
var keyBytes = base32ToBytes(secret);
|
||||
if (!keyBytes.length) return null;
|
||||
var step = 30;
|
||||
var epoch = Math.floor(Date.now() / 1000);
|
||||
var counter = Math.floor(epoch / step);
|
||||
var remain = step - (epoch % step);
|
||||
var msg = new Uint8Array(8);
|
||||
var c = counter;
|
||||
for (var i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
|
||||
var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||||
var hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
|
||||
var off = hs[hs.length - 1] & 0x0f;
|
||||
var bin = ((hs[off] & 0x7f) << 24) | ((hs[off + 1] & 0xff) << 16) | ((hs[off + 2] & 0xff) << 8) | (hs[off + 3] & 0xff);
|
||||
var code = (bin % 1000000).toString().padStart(6, '0');
|
||||
return { code: code, remain: remain };
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
export const I18N = {
|
||||
en: {
|
||||
brand: 'NodeWarden',
|
||||
subtitle: 'Open Source Password Manager',
|
||||
login: 'Log In',
|
||||
register: 'Create Account',
|
||||
email: 'Email Address',
|
||||
masterPwd: 'Master Password',
|
||||
confirmPwd: 'Confirm Master Password',
|
||||
name: 'Name',
|
||||
inviteCode: 'Invite Code (Optional)',
|
||||
loginBtn: 'Log In',
|
||||
registerBtn: 'Create Account',
|
||||
backToLogin: 'Back to Log In',
|
||||
vault: 'Vault',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
help: 'Help',
|
||||
logout: 'Log Out',
|
||||
folders: 'Folders',
|
||||
allItems: 'All Items',
|
||||
noFolder: 'No Folder',
|
||||
searchVault: 'Search vault',
|
||||
filter: 'Search',
|
||||
typeAll: 'All items',
|
||||
typeLogin: 'Logins',
|
||||
typeCard: 'Cards',
|
||||
typeIdentity: 'Identities',
|
||||
typeNote: 'Secure notes',
|
||||
typeOther: 'Other',
|
||||
addWebsite: '+ Add website',
|
||||
addField: '+ Add field',
|
||||
fieldType: 'Field type',
|
||||
fieldLabel: 'Field label',
|
||||
fieldValue: 'Field value',
|
||||
fieldText: 'Text',
|
||||
fieldHidden: 'Hidden',
|
||||
fieldBoolean: 'Boolean',
|
||||
fieldLinked: 'Linked',
|
||||
add: 'Add',
|
||||
newTypeLogin: 'Login',
|
||||
newTypeCard: 'Card',
|
||||
newTypeIdentity: 'Identity',
|
||||
newTypeNote: 'Note',
|
||||
newTypeSsh: 'SSH key',
|
||||
refresh: 'Sync',
|
||||
move: 'Move',
|
||||
delete: 'Delete',
|
||||
selectAll: 'Select All',
|
||||
clear: 'Cancel',
|
||||
noItems: 'There are no items to list.',
|
||||
selectItem: 'Select an item to view details.',
|
||||
profile: 'Profile',
|
||||
saveProfile: 'Save Profile',
|
||||
changePwd: 'Change Master Password',
|
||||
currentPwd: 'Current Master Password',
|
||||
newPwd: 'New Master Password',
|
||||
totpSetup: 'Two-Step Login (TOTP)',
|
||||
totpLiveIn: 'Refresh in',
|
||||
enableTotp: 'Enable TOTP',
|
||||
disableTotp: 'Disable TOTP',
|
||||
secret: 'Authenticator Key',
|
||||
verifyCode: 'Verification Code',
|
||||
credentials: 'Login credentials',
|
||||
autofillOptions: 'Autofill',
|
||||
itemHistory: 'Item history',
|
||||
website: 'Website',
|
||||
folder: 'Folder',
|
||||
createdAt: 'Created',
|
||||
updatedAt: 'Last edited',
|
||||
open: 'Open',
|
||||
copy: 'Copy',
|
||||
reveal: 'Reveal',
|
||||
hide: 'Hide',
|
||||
users: 'Users',
|
||||
invites: 'Invites',
|
||||
createInvite: 'Create Invite',
|
||||
expiresIn: 'Expires in (hours)',
|
||||
copyLink: 'Copy Link',
|
||||
revoke: 'Revoke',
|
||||
ban: 'Ban',
|
||||
unban: 'Unban',
|
||||
status: 'Status',
|
||||
role: 'Role',
|
||||
action: 'Options',
|
||||
loading: 'Loading NodeWarden...',
|
||||
totpVerify: 'Two-step verification',
|
||||
totpVerifySub: 'Password is already verified.',
|
||||
totpCode: 'TOTP Code',
|
||||
verify: 'Verify',
|
||||
cancel: 'Cancel',
|
||||
totpDisableSub: 'Enter master password to disable two-step verification.',
|
||||
helpSync: 'Upstream Sync',
|
||||
helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).',
|
||||
helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.',
|
||||
helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.',
|
||||
helpErr: 'Common Errors',
|
||||
helpErr1: '401 Unauthorized: token expired or revoked, login again.',
|
||||
helpErr2: '403 Account disabled: admin must unban user in User Management.',
|
||||
helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.',
|
||||
helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.',
|
||||
helpTb: 'Troubleshooting',
|
||||
helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.',
|
||||
helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.',
|
||||
helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.',
|
||||
helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.',
|
||||
langSwitch: '中文',
|
||||
},
|
||||
zh: {
|
||||
brand: 'NodeWarden',
|
||||
subtitle: '开源密码管理器',
|
||||
login: '登录',
|
||||
register: '创建账号',
|
||||
email: '电子邮件地址',
|
||||
masterPwd: '主密码',
|
||||
confirmPwd: '确认主密码',
|
||||
name: '姓名',
|
||||
inviteCode: '邀请码 (可选)',
|
||||
loginBtn: '登录',
|
||||
registerBtn: '创建账号',
|
||||
backToLogin: '返回登录',
|
||||
vault: '密码库',
|
||||
settings: '设置',
|
||||
admin: '管理',
|
||||
help: '帮助',
|
||||
logout: '退出登录',
|
||||
folders: '文件夹',
|
||||
allItems: '所有项目',
|
||||
noFolder: '无文件夹',
|
||||
searchVault: '搜索密码库',
|
||||
filter: '筛选',
|
||||
typeAll: '所有项目',
|
||||
typeLogin: '登录',
|
||||
typeCard: '支付卡',
|
||||
typeIdentity: '身份',
|
||||
typeNote: '备注',
|
||||
typeOther: '其他',
|
||||
addWebsite: '+ 添加网站',
|
||||
addField: '+ 添加字段',
|
||||
fieldType: '字段类型',
|
||||
fieldLabel: '字段标签',
|
||||
fieldValue: '字段值',
|
||||
fieldText: '文本型',
|
||||
fieldHidden: '隐藏型',
|
||||
fieldBoolean: '复选框型',
|
||||
fieldLinked: '链接型',
|
||||
add: '添加',
|
||||
newTypeLogin: '登录',
|
||||
newTypeCard: '支付卡',
|
||||
newTypeIdentity: '身份',
|
||||
newTypeNote: '笔记',
|
||||
newTypeSsh: 'SSH 密钥',
|
||||
refresh: '同步',
|
||||
move: '移动',
|
||||
delete: '删除',
|
||||
selectAll: '全选',
|
||||
clear: '取消',
|
||||
noItems: '没有可列出的项目。',
|
||||
selectItem: '选择一个项目以查看详细信息。',
|
||||
profile: '个人资料',
|
||||
saveProfile: '保存个人资料',
|
||||
changePwd: '更改主密码',
|
||||
currentPwd: '当前主密码',
|
||||
newPwd: '新主密码',
|
||||
totpSetup: '两步登录 (TOTP)',
|
||||
totpLiveIn: '刷新剩余',
|
||||
enableTotp: '启用 TOTP',
|
||||
disableTotp: '禁用 TOTP',
|
||||
secret: '身份验证器密钥',
|
||||
verifyCode: '验证码',
|
||||
credentials: '登录凭据',
|
||||
autofillOptions: '自动填充',
|
||||
itemHistory: '项目历史记录',
|
||||
website: '网站',
|
||||
folder: '文件夹',
|
||||
createdAt: '创建于',
|
||||
updatedAt: '最后编辑',
|
||||
open: '打开',
|
||||
copy: '复制',
|
||||
reveal: '显示',
|
||||
hide: '隐藏',
|
||||
users: '用户',
|
||||
invites: '邀请',
|
||||
createInvite: '创建邀请',
|
||||
expiresIn: '过期时间 (小时)',
|
||||
copyLink: '复制链接',
|
||||
revoke: '撤销',
|
||||
ban: '封禁',
|
||||
unban: '解封',
|
||||
status: '状态',
|
||||
role: '角色',
|
||||
action: '选项',
|
||||
loading: '正在加载 NodeWarden...',
|
||||
totpVerify: '两步验证',
|
||||
totpVerifySub: '密码已验证。',
|
||||
totpCode: 'TOTP 验证码',
|
||||
verify: '验证',
|
||||
cancel: '取消',
|
||||
totpDisableSub: '输入主密码以禁用两步验证。',
|
||||
helpSync: '上游同步',
|
||||
helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。',
|
||||
helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。',
|
||||
helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。',
|
||||
helpErr: '常见错误',
|
||||
helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。',
|
||||
helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。',
|
||||
helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。',
|
||||
helpErr4: '429 请求过多:等待重试时间,避免突发写入。',
|
||||
helpTb: '排障指南',
|
||||
helpTb1: '登录成功但显示密文:检查 profile key 和 KDF 参数是否一致。',
|
||||
helpTb2: 'TOTP 持续失败:同步设备时间并使用最新密钥重新扫码。',
|
||||
helpTb3: '修改密码失败:确认当前密码正确且新密码至少 12 位。',
|
||||
helpTb4: '同步冲突:先刷新密码库,再逐个操作重试。',
|
||||
langSwitch: 'English',
|
||||
},
|
||||
};
|
||||
-1522
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
import { startNodewardenApp } from './app.js';
|
||||
|
||||
async function ensureQrLibrary() {
|
||||
if (typeof window.qrcode === 'function') return;
|
||||
await new Promise((resolve) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '/web/vendor/qrcode-generator.min.js';
|
||||
s.async = true;
|
||||
s.onload = () => resolve(null);
|
||||
s.onerror = () => resolve(null);
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRuntimeConfig() {
|
||||
try {
|
||||
const resp = await fetch('/api/web/config', { method: 'GET' });
|
||||
if (!resp.ok) throw new Error('runtime config request failed');
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return { defaultKdfIterations: 600000 };
|
||||
}
|
||||
}
|
||||
|
||||
await ensureQrLibrary();
|
||||
const cfg = await loadRuntimeConfig();
|
||||
startNodewardenApp(cfg || { defaultKdfIterations: 600000 });
|
||||
@@ -1,812 +0,0 @@
|
||||
|
||||
:root {
|
||||
--bg: #F3F5F8;
|
||||
--panel: #FFFFFF;
|
||||
--line: #DEE2E6;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6C757D;
|
||||
--primary: #175DDC;
|
||||
--primary-hover: #144eb8;
|
||||
--danger: #DC3545;
|
||||
--danger-hover: #C82333;
|
||||
--danger-bg: #F8D7DA;
|
||||
--success: #198754;
|
||||
--success-bg: #D1E7DD;
|
||||
--border-color: #DEE2E6;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#app { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
/* Auth Pages */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.lang-switch {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lang-switch:hover { color: var(--primary); }
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.auth-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--primary);
|
||||
border-radius: 12px;
|
||||
margin: 0 auto 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
}
|
||||
.auth-logo::after { content: "NW"; }
|
||||
.auth-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.auth-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
.auth-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.auth-footer a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 8px 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(23, 93, 220, 0.15);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover { background: #F8F9FA; }
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { background: var(--danger-hover); }
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; }
|
||||
.alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; }
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1200;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
}
|
||||
.toast-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid #c9e9d6;
|
||||
background: #dff4e5;
|
||||
color: #0f5132;
|
||||
padding: 14px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.toast-item.error {
|
||||
border-color: #f5c2c7;
|
||||
background: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
.toast-item.warning {
|
||||
border-color: #ffe69c;
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
}
|
||||
.toast-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.toast-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.toast-close:hover { opacity: 1; }
|
||||
.toast-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.12);
|
||||
transform-origin: left center;
|
||||
animation: toastBar 4.5s linear forwards;
|
||||
}
|
||||
@keyframes toastBar { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.dialog-card {
|
||||
width: min(540px, 100%);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 24px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.dialog-icon {
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
color: #f4b400;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dialog-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 34px;
|
||||
line-height: 1.15;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dialog-msg {
|
||||
margin: 0 auto 18px auto;
|
||||
color: #334155;
|
||||
font-size: 20px;
|
||||
max-width: 90%;
|
||||
}
|
||||
.dialog-btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.form-dialog {
|
||||
text-align: left;
|
||||
}
|
||||
.form-dialog .dialog-title {
|
||||
font-size: 30px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.form-dialog .dialog-msg {
|
||||
font-size: 16px;
|
||||
max-width: 100%;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.form-dialog .dialog-btn {
|
||||
font-size: 22px;
|
||||
}
|
||||
.dialog-error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c2c7;
|
||||
color: #842029;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.unlock-card {
|
||||
max-width: 620px;
|
||||
padding: 30px 34px;
|
||||
}
|
||||
.unlock-pwd-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.unlock-pwd-input {
|
||||
padding-right: 88px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border-color: #3f5b9e;
|
||||
}
|
||||
.auth-page .form-input {
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border-color: #3f5b9e;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.auth-page .form-input:focus {
|
||||
border-color: #3f5b9e;
|
||||
box-shadow: none;
|
||||
}
|
||||
.unlock-eye-btn {
|
||||
position: absolute;
|
||||
right: 42px;
|
||||
bottom: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #233a72;
|
||||
font-size: 17px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.unlock-main-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.unlock-secondary-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #fff;
|
||||
}
|
||||
.unlock-or {
|
||||
text-align: center;
|
||||
color: #1f2f4f;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.totp-qr-card {
|
||||
background:#fff;
|
||||
padding:16px;
|
||||
border:1px solid var(--border-color);
|
||||
border-radius:8px;
|
||||
width: 200px;
|
||||
min-height: 200px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
.totp-qr-fallback {
|
||||
width:100%;
|
||||
min-height:168px;
|
||||
border:1px dashed var(--border-color);
|
||||
border-radius:8px;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:var(--text-secondary);
|
||||
text-align:center;
|
||||
padding:8px;
|
||||
}
|
||||
|
||||
/* App Layout */
|
||||
.navbar {
|
||||
height: 64px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.nav-logo::after { content: "NW"; }
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.nav-link {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-link:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.nav-link.active { color: #fff; background: rgba(255,255,255,0.2); }
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-user .lang-switch {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
.nav-user .lang-switch:hover { color: #fff; }
|
||||
.nav-user .btn-secondary {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.nav-user .btn-secondary:hover { background: rgba(255,255,255,0.2); }
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: min(1520px, calc(100vw - 40px));
|
||||
margin: 14px auto 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
height: calc(100vh - 64px - 30px);
|
||||
}
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-block {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid #2f6fec;
|
||||
border-radius: 9px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
}
|
||||
.tree-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.tree-btn:hover { background: var(--bg); }
|
||||
.tree-btn.active { color: var(--primary); font-weight: 700; background: #eef4ff; }
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
.content .btn {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.content .btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.content .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.content .btn-secondary {
|
||||
background: #fff;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.content .btn-secondary:hover { background: #edf3ff; }
|
||||
.content .btn-danger {
|
||||
background: #fff;
|
||||
border-color: #e11d48;
|
||||
color: #e11d48;
|
||||
}
|
||||
.content .btn-danger:hover { background: #fff1f2; }
|
||||
.content .btn-danger-icon {
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #e11d48;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
.content .btn-danger-icon:hover {
|
||||
border: 1px solid #fecdd3;
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
/* Vault Grid */
|
||||
.vault-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(380px, 44%) 1fr;
|
||||
gap: 16px;
|
||||
height: calc(100vh - 145px);
|
||||
}
|
||||
.vault-list-col { min-width: 0; display:flex; flex-direction:column; }
|
||||
.vault-list-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.vault-list {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.vault-item {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
.vault-item:hover { background: #f6f9ff; }
|
||||
.vault-item.active { background: #ecf3ff; }
|
||||
.vault-item:last-child { border-bottom: none; }
|
||||
.vault-item-check { width:18px; height:18px; }
|
||||
.vault-item-main { display:flex; align-items:center; gap:12px; min-width:0; }
|
||||
.vault-item-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.vault-item-icon-wrap { width:24px; height:24px; position:relative; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.vault-item-icon-fallback {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size: 24px;
|
||||
color: #6b7a90;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.vault-item-text { min-width:0; }
|
||||
.vault-item-title {
|
||||
color: #1457d6;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vault-item-sub {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vault-detail {
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.vault-detail-head .vault-detail-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||||
.vault-detail-folder { color: #334155; font-size: 14px; }
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
}
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
.field-label { color: #64748b; font-size: 13px; margin-bottom: 3px; }
|
||||
.field-value { color: #0f172a; font-size: 17px; word-break: break-all; }
|
||||
.field-sub { color: #64748b; font-size: 12px; margin-top: 2px; }
|
||||
.icon-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.icon-btn:hover { background: #f8fafc; }
|
||||
.link-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #1457d6;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
.create-menu-wrap { position: relative; }
|
||||
.create-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 30;
|
||||
padding: 6px;
|
||||
}
|
||||
.create-menu-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.create-menu-item:hover { background: #eff5ff; }
|
||||
.field-modal { max-width: 560px; }
|
||||
.field-modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px; }
|
||||
.field-modal-head h3 { margin: 0; }
|
||||
.history-line { color: #64748b; font-size: 13px; line-height: 1.8; }
|
||||
.detail-input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
}
|
||||
.detail-input:focus { outline: none; border-color: #2f6fec; box-shadow: 0 0 0 3px rgba(47,111,236,0.12); }
|
||||
.detail-textarea { min-height: 100px; resize: vertical; }
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.detail-actions .btn { margin-right: 8px; }
|
||||
.detail-actions .btn:last-child { margin-right: 0; }
|
||||
.vault-empty {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Common Components */
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.panel h3 { margin: 0 0 20px 0; font-size: 18px; font-weight: 600; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; }
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.table th, .table td { padding: 12px 16px; border-bottom: 1px solid var(--border-color); text-align: left; }
|
||||
.table th { font-weight: 600; color: var(--text-secondary); background: var(--bg); }
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge.success { background: var(--success-bg); color: var(--success); }
|
||||
.badge.danger { background: var(--danger-bg); color: var(--danger); }
|
||||
|
||||
.kv { margin-bottom: 12px; font-size: 14px; line-height: 1.5; display: flex; }
|
||||
.kv b { color: var(--text-secondary); font-weight: 600; width: 120px; flex-shrink: 0; }
|
||||
|
||||
.totp-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.totp-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.app-body {
|
||||
width: calc(100vw - 16px);
|
||||
margin: 8px auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sidebar { width: 280px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.app-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.vault-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export function parseFieldType(v) {
|
||||
if (v === null || v === undefined) return 0;
|
||||
if (typeof v === 'number' && isFinite(v)) return v === 1 || v === 2 || v === 3 ? v : 0;
|
||||
var s = String(v).trim().toLowerCase();
|
||||
if (s === '1' || s === 'hidden') return 1;
|
||||
if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
|
||||
if (s === '3' || s === 'linked' || s === 'link') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function selectedCount(selectedMap) {
|
||||
var n = 0;
|
||||
for (var k in selectedMap) if (selectedMap[k]) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function cipherTypeKey(c) {
|
||||
var tnum = Number(c && c.type || 1);
|
||||
if (tnum === 1) return 'login';
|
||||
if (tnum === 3) return 'card';
|
||||
if (tnum === 4) return 'identity';
|
||||
if (tnum === 2) return 'note';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function hostFromUri(uri) {
|
||||
if (!uri) return '';
|
||||
try {
|
||||
var normalized = /^https?:\/\//i.test(uri) ? uri : ('https://' + uri);
|
||||
return new URL(normalized).hostname || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function firstCipherUri(c) {
|
||||
var uris = c && c.login && Array.isArray(c.login.uris) ? c.login.uris : [];
|
||||
for (var i = 0; i < uris.length; i++) {
|
||||
var u = uris[i] && (uris[i].decUri || uris[i].uri);
|
||||
if (u) return u;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NodeWarden</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,756 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import AuthViews from '@/components/AuthViews';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import SettingsPage from '@/components/SettingsPage';
|
||||
import AdminPage from '@/components/AdminPage';
|
||||
import HelpPage from '@/components/HelpPage';
|
||||
import {
|
||||
changeMasterPassword,
|
||||
createCipher,
|
||||
createAuthedFetch,
|
||||
createInvite,
|
||||
deleteCipher,
|
||||
deleteUser,
|
||||
deriveLoginHash,
|
||||
bulkMoveCiphers,
|
||||
getCiphers,
|
||||
getFolders,
|
||||
getProfile,
|
||||
getSetupStatus,
|
||||
getWebConfig,
|
||||
listAdminInvites,
|
||||
listAdminUsers,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
registerAccount,
|
||||
revokeInvite,
|
||||
saveSession,
|
||||
setTotp,
|
||||
setUserStatus,
|
||||
updateCipher,
|
||||
unlockVaultKey,
|
||||
updateProfile,
|
||||
} from '@/lib/api';
|
||||
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
||||
import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
|
||||
interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [location, navigate] = useLocation();
|
||||
const [phase, setPhase] = useState<AppPhase>('loading');
|
||||
const [session, setSessionState] = useState<SessionState | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000);
|
||||
const [setupRegistered, setSetupRegistered] = useState(true);
|
||||
|
||||
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
|
||||
const [registerValues, setRegisterValues] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
inviteCode: '',
|
||||
});
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
|
||||
const [confirm, setConfirm] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||
|
||||
function setSession(next: SessionState | null) {
|
||||
setSessionState(next);
|
||||
saveSession(next);
|
||||
}
|
||||
|
||||
function pushToast(type: ToastMessage['type'], text: string) {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((x) => x.id !== id));
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
const authedFetch = useMemo(
|
||||
() =>
|
||||
createAuthedFetch(
|
||||
() => session,
|
||||
(next) => {
|
||||
setSession(next);
|
||||
if (!next) {
|
||||
setProfile(null);
|
||||
setPhase(setupRegistered ? 'login' : 'register');
|
||||
}
|
||||
}
|
||||
),
|
||||
[session, setupRegistered]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
|
||||
if (!mounted) return;
|
||||
setSetupRegistered(setup.registered);
|
||||
setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000));
|
||||
|
||||
const loaded = loadSession();
|
||||
if (!loaded) {
|
||||
setPhase(setup.registered ? 'login' : 'register');
|
||||
return;
|
||||
}
|
||||
setSession(loaded);
|
||||
|
||||
try {
|
||||
const profileResp = await getProfile(
|
||||
createAuthedFetch(
|
||||
() => loaded,
|
||||
(next) => {
|
||||
if (!next) return;
|
||||
setSession(next);
|
||||
}
|
||||
)
|
||||
);
|
||||
if (!mounted) return;
|
||||
setProfile(profileResp);
|
||||
setPhase('locked');
|
||||
} catch {
|
||||
setSession(null);
|
||||
setPhase(setup.registered ? 'login' : 'register');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) {
|
||||
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
() => {}
|
||||
);
|
||||
const profileResp = await getProfile(tempFetch);
|
||||
const keys = await unlockVaultKey(profileResp.key, masterKey);
|
||||
const nextSession = { ...baseSession, ...keys };
|
||||
setSession(nextSession);
|
||||
setProfile(profileResp);
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||
navigate('/vault');
|
||||
}
|
||||
pushToast('success', 'Login success');
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginValues.email || !loginValues.password) {
|
||||
pushToast('error', 'Please input email and password');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
|
||||
const token = await loginWithPassword(loginValues.email, derived.hash);
|
||||
if ('access_token' in token && token.access_token) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
|
||||
return;
|
||||
}
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
setPendingTotp({
|
||||
email: loginValues.email.toLowerCase(),
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
});
|
||||
setTotpCode('');
|
||||
return;
|
||||
}
|
||||
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Login failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', 'Please input TOTP code');
|
||||
return;
|
||||
}
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim());
|
||||
if ('access_token' in token && token.access_token) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return;
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!registerValues.email || !registerValues.password) {
|
||||
pushToast('error', 'Please input email and password');
|
||||
return;
|
||||
}
|
||||
if (registerValues.password.length < 12) {
|
||||
pushToast('error', 'Master password must be at least 12 chars');
|
||||
return;
|
||||
}
|
||||
if (registerValues.password !== registerValues.password2) {
|
||||
pushToast('error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
const resp = await registerAccount({
|
||||
email: registerValues.email.toLowerCase(),
|
||||
name: registerValues.name.trim(),
|
||||
password: registerValues.password,
|
||||
inviteCode: registerValues.inviteCode.trim(),
|
||||
fallbackIterations: defaultKdfIterations,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
pushToast('error', resp.message);
|
||||
return;
|
||||
}
|
||||
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
|
||||
setPhase('login');
|
||||
pushToast('success', 'Registration succeeded. Please sign in.');
|
||||
}
|
||||
|
||||
async function handleUnlock() {
|
||||
if (!session || !profile) return;
|
||||
if (!unlockPassword) {
|
||||
pushToast('error', 'Please input master password');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
|
||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||
setSession({ ...session, ...keys });
|
||||
setUnlockPassword('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/lock') navigate('/vault');
|
||||
pushToast('success', 'Unlocked');
|
||||
} catch {
|
||||
pushToast('error', 'Unlock failed. Master password is incorrect.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
if (!session) return;
|
||||
const nextSession = { ...session };
|
||||
delete nextSession.symEncKey;
|
||||
delete nextSession.symMacKey;
|
||||
setSession(nextSession);
|
||||
setPhase('locked');
|
||||
navigate('/lock');
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
setConfirm({
|
||||
title: 'Log Out',
|
||||
message: 'Are you sure you want to log out?',
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setPendingTotp(null);
|
||||
setPhase(setupRegistered ? 'login' : 'register');
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ciphersQuery = useQuery({
|
||||
queryKey: ['ciphers', session?.accessToken],
|
||||
queryFn: () => getCiphers(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
||||
});
|
||||
const foldersQuery = useQuery({
|
||||
queryKey: ['folders', session?.accessToken],
|
||||
queryFn: () => getFolders(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
||||
});
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users', session?.accessToken],
|
||||
queryFn: () => listAdminUsers(authedFetch),
|
||||
enabled: phase === 'app' && profile?.role === 'admin',
|
||||
});
|
||||
const invitesQuery = useQuery({
|
||||
queryKey: ['admin-invites', session?.accessToken],
|
||||
queryFn: () => listAdminInvites(authedFetch),
|
||||
enabled: phase === 'app' && profile?.role === 'admin',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
setDecryptedFolders([]);
|
||||
setDecryptedCiphers([]);
|
||||
return;
|
||||
}
|
||||
if (!foldersQuery.data || !ciphersQuery.data) return;
|
||||
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const encKey = base64ToBytes(session.symEncKey!);
|
||||
const macKey = base64ToBytes(session.symMacKey!);
|
||||
|
||||
const folders = await Promise.all(
|
||||
foldersQuery.data.map(async (folder) => ({
|
||||
...folder,
|
||||
decName: await decryptStr(folder.name, encKey, macKey),
|
||||
}))
|
||||
);
|
||||
|
||||
const ciphers = await Promise.all(
|
||||
ciphersQuery.data.map(async (cipher) => {
|
||||
let itemEnc = encKey;
|
||||
let itemMac = macKey;
|
||||
if (cipher.key) {
|
||||
try {
|
||||
const itemKey = await decryptBw(cipher.key, encKey, macKey);
|
||||
itemEnc = itemKey.slice(0, 32);
|
||||
itemMac = itemKey.slice(32, 64);
|
||||
} catch {
|
||||
// keep user key when item key decrypt fails
|
||||
}
|
||||
}
|
||||
|
||||
const nextCipher: Cipher = {
|
||||
...cipher,
|
||||
decName: await decryptStr(cipher.name || '', itemEnc, itemMac),
|
||||
decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac),
|
||||
};
|
||||
if (cipher.login) {
|
||||
nextCipher.login = {
|
||||
...cipher.login,
|
||||
decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac),
|
||||
decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac),
|
||||
decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac),
|
||||
uris: await Promise.all(
|
||||
(cipher.login.uris || []).map(async (u) => ({
|
||||
...u,
|
||||
decUri: await decryptStr(u.uri || '', itemEnc, itemMac),
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
if (cipher.card) {
|
||||
nextCipher.card = {
|
||||
...cipher.card,
|
||||
decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac),
|
||||
decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac),
|
||||
decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac),
|
||||
decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac),
|
||||
decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac),
|
||||
decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
if (cipher.identity) {
|
||||
nextCipher.identity = {
|
||||
...cipher.identity,
|
||||
decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac),
|
||||
decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac),
|
||||
decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac),
|
||||
decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac),
|
||||
decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac),
|
||||
decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac),
|
||||
decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac),
|
||||
decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac),
|
||||
decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac),
|
||||
decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac),
|
||||
decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac),
|
||||
decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac),
|
||||
decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac),
|
||||
decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac),
|
||||
decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac),
|
||||
decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac),
|
||||
decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac),
|
||||
decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
if (cipher.sshKey) {
|
||||
nextCipher.sshKey = {
|
||||
...cipher.sshKey,
|
||||
decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac),
|
||||
decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac),
|
||||
decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
|
||||
};
|
||||
}
|
||||
if (cipher.fields) {
|
||||
nextCipher.fields = await Promise.all(
|
||||
cipher.fields.map(async (field) => ({
|
||||
...field,
|
||||
decName: await decryptStr(field.name || '', itemEnc, itemMac),
|
||||
decValue: await decryptStr(field.value || '', itemEnc, itemMac),
|
||||
}))
|
||||
);
|
||||
}
|
||||
return nextCipher;
|
||||
})
|
||||
);
|
||||
|
||||
if (!active) return;
|
||||
setDecryptedFolders(folders);
|
||||
setDecryptedCiphers(ciphers);
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
|
||||
|
||||
async function saveProfileAction(name: string, email: string) {
|
||||
try {
|
||||
const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
|
||||
setProfile(updated);
|
||||
pushToast('success', 'Profile updated');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Save profile failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
||||
if (!profile) return;
|
||||
if (!currentPassword || !nextPassword) {
|
||||
pushToast('error', 'Current/new password is required');
|
||||
return;
|
||||
}
|
||||
if (nextPassword.length < 12) {
|
||||
pushToast('error', 'New password must be at least 12 chars');
|
||||
return;
|
||||
}
|
||||
if (nextPassword !== nextPassword2) {
|
||||
pushToast('error', 'New passwords do not match');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changeMasterPassword(authedFetch, {
|
||||
email: profile.email,
|
||||
currentPassword,
|
||||
newPassword: nextPassword,
|
||||
currentIterations: defaultKdfIterations,
|
||||
profileKey: profile.key,
|
||||
});
|
||||
handleLogout();
|
||||
pushToast('success', 'Master password changed. Please login again.');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Change password failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTotpAction(secret: string, token: string) {
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
pushToast('error', 'Secret and code are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
||||
pushToast('success', 'TOTP enabled');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotpAction() {
|
||||
if (!profile) return;
|
||||
if (!disableTotpPassword) {
|
||||
pushToast('error', 'Please input master password');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
setDisableTotpOpen(false);
|
||||
setDisableTotpPassword('');
|
||||
pushToast('success', 'TOTP disabled');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVault() {
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Vault synced');
|
||||
}
|
||||
|
||||
async function createVaultItem(draft: VaultDraft) {
|
||||
if (!session) return;
|
||||
try {
|
||||
await createCipher(authedFetch, session, draft);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Item created');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Create item failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
|
||||
if (!session) return;
|
||||
try {
|
||||
await updateCipher(authedFetch, session, cipher, draft);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Item updated');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Update item failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
await deleteCipher(authedFetch, cipher.id);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Item deleted');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Delete item failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
for (const id of ids) {
|
||||
await deleteCipher(authedFetch, id);
|
||||
}
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Deleted selected items');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
pushToast('success', 'Moved selected items');
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : 'Bulk move failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && location === '/') navigate('/vault');
|
||||
}, [phase, location, navigate]);
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<>
|
||||
<div className="loading-screen">Loading NodeWarden...</div>
|
||||
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'register' || phase === 'login' || phase === 'locked') {
|
||||
return (
|
||||
<>
|
||||
<AuthViews
|
||||
mode={phase}
|
||||
loginValues={loginValues}
|
||||
registerValues={registerValues}
|
||||
unlockPassword={unlockPassword}
|
||||
emailForLock={profile?.email || session?.email || ''}
|
||||
onChangeLogin={setLoginValues}
|
||||
onChangeRegister={setRegisterValues}
|
||||
onChangeUnlock={setUnlockPassword}
|
||||
onSubmitLogin={() => void handleLogin()}
|
||||
onSubmitRegister={() => void handleRegister()}
|
||||
onSubmitUnlock={() => void handleUnlock()}
|
||||
onGotoLogin={() => setPhase('login')}
|
||||
onGotoRegister={() => setPhase('register')}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingTotp}
|
||||
title="Two-step verification"
|
||||
message="Password is already verified."
|
||||
confirmText="Verify"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => void handleTotpVerify()}
|
||||
onCancel={() => {
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>TOTP Code</span>
|
||||
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">NodeWarden</div>
|
||||
<nav className="nav">
|
||||
<Link href="/vault" className={`nav-link ${location === '/vault' ? 'active' : ''}`}>
|
||||
Vault
|
||||
</Link>
|
||||
<Link href="/settings" className={`nav-link ${location === '/settings' ? 'active' : ''}`}>
|
||||
Settings
|
||||
</Link>
|
||||
{profile?.role === 'admin' && (
|
||||
<Link href="/admin" className={`nav-link ${location === '/admin' ? 'active' : ''}`}>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/help" className={`nav-link ${location === '/help' ? 'active' : ''}`}>
|
||||
Help
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="topbar-actions">
|
||||
<span className="user-email">{profile?.email}</span>
|
||||
<button type="button" className="btn btn-secondary small" onClick={handleLock}>
|
||||
Lock
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">
|
||||
<Switch>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={decryptedCiphers}
|
||||
folders={decryptedFolders}
|
||||
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
|
||||
onRefresh={refreshVault}
|
||||
onCreate={createVaultItem}
|
||||
onUpdate={updateVaultItem}
|
||||
onDelete={deleteVaultItem}
|
||||
onBulkDelete={bulkDeleteVaultItems}
|
||||
onBulkMove={bulkMoveVaultItems}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{profile && (
|
||||
<SettingsPage
|
||||
profile={profile}
|
||||
onSaveProfile={saveProfileAction}
|
||||
onChangePassword={changePasswordAction}
|
||||
onEnableTotp={enableTotpAction}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<AdminPage
|
||||
users={usersQuery.data || []}
|
||||
invites={invitesQuery.data || []}
|
||||
onRefresh={() => {
|
||||
void usersQuery.refetch();
|
||||
void invitesQuery.refetch();
|
||||
}}
|
||||
onCreateInvite={async (hours) => {
|
||||
await createInvite(authedFetch, hours);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', 'Invite created');
|
||||
}}
|
||||
onToggleUserStatus={async (userId, status) => {
|
||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', 'User status updated');
|
||||
}}
|
||||
onDeleteUser={async (userId) => {
|
||||
setConfirm({
|
||||
title: 'Delete user',
|
||||
message: 'Delete this user and all user data?',
|
||||
danger: true,
|
||||
onConfirm: () => {
|
||||
setConfirm(null);
|
||||
void (async () => {
|
||||
await deleteUser(authedFetch, userId);
|
||||
await usersQuery.refetch();
|
||||
pushToast('success', 'User deleted');
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeInvite={async (code) => {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', 'Invite revoked');
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/help">
|
||||
<HelpPage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={confirm?.title || ''}
|
||||
message={confirm?.message || ''}
|
||||
danger={confirm?.danger}
|
||||
onConfirm={() => confirm?.onConfirm()}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={disableTotpOpen}
|
||||
title="Disable TOTP"
|
||||
message="Enter master password to disable two-step verification."
|
||||
confirmText="Disable TOTP"
|
||||
cancelText="Cancel"
|
||||
danger
|
||||
onConfirm={() => void disableTotpAction()}
|
||||
onCancel={() => {
|
||||
setDisableTotpOpen(false);
|
||||
setDisableTotpPassword('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>Master Password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={disableTotpPassword}
|
||||
onInput={(e) => setDisableTotpPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||
|
||||
interface AdminPageProps {
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
onRefresh: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AdminPage(props: AdminPageProps) {
|
||||
const [inviteHours, setInviteHours] = useState(168);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<h3>Invites</h3>
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
||||
Sync
|
||||
</button>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<input
|
||||
className="input small"
|
||||
type="number"
|
||||
value={inviteHours}
|
||||
min={1}
|
||||
max={720}
|
||||
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
|
||||
Create Invite
|
||||
</button>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.invites.map((invite) => (
|
||||
<tr key={invite.code}>
|
||||
<td>{invite.code}</td>
|
||||
<td>{invite.status}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
{invite.status === 'active' && (
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Users</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td>{user.role}</td>
|
||||
<td>{user.status}</td>
|
||||
<td>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
||||
>
|
||||
{user.status === 'active' ? 'Ban' : 'Unban'}
|
||||
</button>
|
||||
{user.role !== 'admin' && (
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterValues {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password2: string;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
interface AuthViewsProps {
|
||||
mode: 'login' | 'register' | 'locked';
|
||||
loginValues: LoginValues;
|
||||
registerValues: RegisterValues;
|
||||
unlockPassword: string;
|
||||
emailForLock: string;
|
||||
onChangeLogin: (next: LoginValues) => void;
|
||||
onChangeRegister: (next: RegisterValues) => void;
|
||||
onChangeUnlock: (password: string) => void;
|
||||
onSubmitLogin: () => void;
|
||||
onSubmitRegister: () => void;
|
||||
onSubmitUnlock: () => void;
|
||||
onGotoLogin: () => void;
|
||||
onGotoRegister: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
function PasswordField(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
onInput: (v: string) => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<label className="field">
|
||||
<span>{props.label}</span>
|
||||
<div className="password-wrap">
|
||||
<input
|
||||
className="input"
|
||||
type={show ? 'text' : 'password'}
|
||||
value={props.value}
|
||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||
autoFocus={props.autoFocus}
|
||||
/>
|
||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||
{show ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'locked') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Unlock Vault</h1>
|
||||
<p className="muted">{props.emailForLock}</p>
|
||||
<PasswordField
|
||||
label="Master Password"
|
||||
value={props.unlockPassword}
|
||||
autoFocus
|
||||
onInput={props.onChangeUnlock}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
|
||||
Unlock
|
||||
</button>
|
||||
<div className="or">or</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Create Account</h1>
|
||||
<p className="muted">NodeWarden</p>
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={props.registerValues.name}
|
||||
onInput={(e) =>
|
||||
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={props.registerValues.email}
|
||||
onInput={(e) =>
|
||||
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Master Password"
|
||||
value={props.registerValues.password}
|
||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
||||
/>
|
||||
<PasswordField
|
||||
label="Confirm Master Password"
|
||||
value={props.registerValues.password2}
|
||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
||||
/>
|
||||
<label className="field">
|
||||
<span>Invite Code (Optional)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={props.registerValues.inviteCode}
|
||||
onInput={(e) =>
|
||||
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
|
||||
Create Account
|
||||
</button>
|
||||
<div className="or">or</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
|
||||
Back To Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<h1>Log In</h1>
|
||||
<p className="muted">NodeWarden</p>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={props.loginValues.email}
|
||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||
/>
|
||||
</label>
|
||||
<PasswordField
|
||||
label="Master Password"
|
||||
value={props.loginValues.password}
|
||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
|
||||
Log In
|
||||
</button>
|
||||
<div className="or">or</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ComponentChildren } from 'preact';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
if (!props.open) return null;
|
||||
return (
|
||||
<div className="dialog-mask">
|
||||
<div className="dialog-card">
|
||||
<div className="dialog-icon">!</div>
|
||||
<h3 className="dialog-title">{props.title}</h3>
|
||||
<div className="dialog-message">{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
onClick={props.onConfirm}
|
||||
>
|
||||
{props.confirmText || 'Yes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
||||
{props.cancelText || 'No'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>Upstream Sync</h3>
|
||||
<ul>
|
||||
<li>Use fork + scheduled sync workflow.</li>
|
||||
<li>Before merging, compare API routes and auth flow changes.</li>
|
||||
<li>After merging, run migration tests in local dev before deploy.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h3>Common Errors</h3>
|
||||
<ul>
|
||||
<li>401 Unauthorized: token expired, log in again.</li>
|
||||
<li>403 Account disabled: admin must unban your account.</li>
|
||||
<li>403 Invite invalid: invite expired or revoked.</li>
|
||||
<li>429 Too many requests: wait and retry.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
onSaveProfile: (name: string, email: string) => Promise<void>;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
}
|
||||
|
||||
function randomBase32Secret(length: number): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const random = crypto.getRandomValues(new Uint8Array(length));
|
||||
let out = '';
|
||||
for (const x of random) out += alphabet[x % alphabet.length];
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildOtpUri(email: string, secret: string): string {
|
||||
const issuer = 'NodeWarden';
|
||||
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [name, setName] = useState(props.profile.name || '');
|
||||
const [email, setEmail] = useState(props.profile.email || '');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPassword2, setNewPassword2] = useState('');
|
||||
const [secret, setSecret] = useState(randomBase32Secret(32));
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const qrSvg = useMemo(() => {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(buildOtpUri(email || props.profile.email, secret));
|
||||
qr.make();
|
||||
return qr.createSvgTag({ scalable: true, margin: 0 });
|
||||
}, [email, props.profile.email, secret]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>Profile</h3>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input className="input" value={name} onInput={(e) => setName((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void props.onSaveProfile(name, email)}>
|
||||
Save Profile
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Change Master Password</h3>
|
||||
<label className="field">
|
||||
<span>Current Password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>New Password</span>
|
||||
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Confirm Password</span>
|
||||
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>TOTP</h3>
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>Authenticator Key</span>
|
||||
<input className="input" value={secret} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Verification Code</span>
|
||||
<input className="input" value={token} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => void props.onEnableTotp(secret, token)}>
|
||||
Enable TOTP
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
Regenerate
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigator.clipboard.writeText(secret)}>
|
||||
Copy Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
|
||||
Disable TOTP
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ToastMessage } from '@/lib/types';
|
||||
|
||||
interface ToastHostProps {
|
||||
toasts: ToastMessage[];
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ToastHost({ toasts, onClose }: ToastHostProps) {
|
||||
if (!toasts.length) return null;
|
||||
return (
|
||||
<ul className="toast-stack">
|
||||
{toasts.map((toast) => (
|
||||
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
||||
<div className="toast-text">{toast.text}</div>
|
||||
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
|
||||
x
|
||||
</button>
|
||||
<div className="toast-progress" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
|
||||
import type {
|
||||
AdminInvite,
|
||||
AdminUser,
|
||||
Cipher,
|
||||
Folder,
|
||||
ListResponse,
|
||||
Profile,
|
||||
SessionState,
|
||||
SetupStatusResponse,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
VaultDraft,
|
||||
VaultDraftField,
|
||||
WebConfigResponse,
|
||||
} from './types';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
|
||||
type SessionSetter = (next: SessionState | null) => void;
|
||||
|
||||
export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SessionState;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: SessionState | null): void {
|
||||
if (!session) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const persisted: SessionState = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
email: session.email,
|
||||
symEncKey: session.symEncKey,
|
||||
symMacKey: session.symMacKey,
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
async function parseJson<T>(response: Response): Promise<T | null> {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatusResponse> {
|
||||
const resp = await fetch('/setup/status');
|
||||
const body = await parseJson<SetupStatusResponse>(resp);
|
||||
return { registered: !!body?.registered };
|
||||
}
|
||||
|
||||
export async function getWebConfig(): Promise<WebConfigResponse> {
|
||||
const resp = await fetch('/api/web/config');
|
||||
return (await parseJson<WebConfigResponse>(resp)) || {};
|
||||
}
|
||||
|
||||
export interface PreloginResult {
|
||||
hash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
|
||||
const pre = await fetch('/identity/accounts/prelogin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.toLowerCase() }),
|
||||
});
|
||||
if (!pre.ok) throw new Error('prelogin failed');
|
||||
const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {};
|
||||
const iterations = Number(data.kdfIterations || fallbackIterations);
|
||||
const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32);
|
||||
const hash = await pbkdf2(masterKey, password, 1, 32);
|
||||
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||
}
|
||||
|
||||
export async function loginWithPassword(email: string, passwordHash: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', email.toLowerCase());
|
||||
body.set('password', passwordHash);
|
||||
body.set('scope', 'api offline_access');
|
||||
if (totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorToken', totpCode);
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (!resp.ok) return json;
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
return json || null;
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
fallbackIterations: number;
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
try {
|
||||
const { email, name, password, inviteCode, fallbackIterations } = args;
|
||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
const sym = crypto.getRandomValues(new Uint8Array(64));
|
||||
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
||||
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-1',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
||||
|
||||
const resp = await fetch('/api/accounts/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
masterPasswordHash: bytesToBase64(masterHash),
|
||||
key: encryptedVaultKey,
|
||||
kdf: 0,
|
||||
kdfIterations: fallbackIterations,
|
||||
inviteCode: inviteCode || undefined,
|
||||
keys: {
|
||||
publicKey: bytesToBase64(publicKey),
|
||||
encryptedPrivateKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
return { ok: false, message: json?.error_description || json?.error || 'Register failed' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'Register failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
||||
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
setSession(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
const retryHeaders = new Headers(init.headers || {});
|
||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||
resp = await fetch(input, { ...init, headers: retryHeaders });
|
||||
return resp;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProfile(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Profile> {
|
||||
const resp = await authedFetch('/api/accounts/profile');
|
||||
if (!resp.ok) throw new Error('Failed to load profile');
|
||||
const body = await parseJson<Profile>(resp);
|
||||
if (!body) throw new Error('Invalid profile');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
const keyBytes = await decryptBw(profileKey, encKey, macKey);
|
||||
if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key');
|
||||
return {
|
||||
symEncKey: bytesToBase64(keyBytes.slice(0, 32)),
|
||||
symMacKey: bytesToBase64(keyBytes.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFolders(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Folder[]> {
|
||||
const resp = await authedFetch('/api/folders');
|
||||
if (!resp.ok) throw new Error('Failed to load folders');
|
||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
||||
const resp = await authedFetch('/api/ciphers');
|
||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
payload: { name: string; email: string }
|
||||
): Promise<Profile> {
|
||||
const resp = await authedFetch('/api/accounts/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Save profile failed');
|
||||
const body = await parseJson<Profile>(resp);
|
||||
if (!body) throw new Error('Invalid profile');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function changeMasterPassword(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
args: {
|
||||
email: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
currentIterations: number;
|
||||
profileKey: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations);
|
||||
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
|
||||
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
|
||||
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
|
||||
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
|
||||
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
|
||||
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
||||
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
||||
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
||||
|
||||
const resp = await authedFetch('/api/accounts/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentPasswordHash: current.hash,
|
||||
newMasterPasswordHash: bytesToBase64(nextHash),
|
||||
newKey,
|
||||
kdf: 0,
|
||||
kdfIterations: current.kdfIterations,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Change master password failed');
|
||||
}
|
||||
|
||||
export async function setTotp(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string }
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/accounts/totp', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'TOTP update failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
|
||||
const resp = await authedFetch('/api/admin/users');
|
||||
if (!resp.ok) throw new Error('Failed to load users');
|
||||
const body = await parseJson<ListResponse<AdminUser>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function listAdminInvites(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminInvite[]> {
|
||||
const resp = await authedFetch('/api/admin/invites?includeInactive=true');
|
||||
if (!resp.ok) throw new Error('Failed to load invites');
|
||||
const body = await parseJson<ListResponse<AdminInvite>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createInvite(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, hours: number): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresInHours: hours }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create invite failed');
|
||||
}
|
||||
|
||||
export async function revokeInvite(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, code: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Revoke invite failed');
|
||||
}
|
||||
|
||||
export async function setUserStatus(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
userId: string,
|
||||
status: 'active' | 'banned'
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update user status failed');
|
||||
}
|
||||
|
||||
export async function deleteUser(authedFetch: (input: string, init?: RequestInit) => Promise<Response>, userId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete user failed');
|
||||
}
|
||||
|
||||
function asNullable(v: string): string | null {
|
||||
const s = String(v || '').trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseFieldType(v: number | string): 0 | 1 | 2 | 3 {
|
||||
if (typeof v === 'number') {
|
||||
if (v === 1 || v === 2 || v === 3) return v;
|
||||
return 0;
|
||||
}
|
||||
const s = String(v).trim().toLowerCase();
|
||||
if (s === '1' || s === 'hidden') return 1;
|
||||
if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
|
||||
if (s === '3' || s === 'linked' || s === 'link') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
|
||||
const s = String(value || '');
|
||||
if (!s.trim()) return null;
|
||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptCustomFields(fields: VaultDraftField[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
|
||||
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
|
||||
for (const field of fields || []) {
|
||||
const label = String(field.label || '').trim();
|
||||
if (!label) continue;
|
||||
out.push({
|
||||
type: parseFieldType(field.type),
|
||||
name: await encryptTextValue(label, enc, mac),
|
||||
value: await encryptTextValue(String(field.value || ''), enc, mac),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
|
||||
const out: Array<{ uri: string | null; match: null }> = [];
|
||||
for (const uri of uris || []) {
|
||||
const trimmed = String(uri || '').trim();
|
||||
if (!trimmed) continue;
|
||||
out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
|
||||
if (cipher?.key) {
|
||||
try {
|
||||
const raw = await decryptBw(cipher.key, userEnc, userMac);
|
||||
if (raw.length >= 64) return { enc: raw.slice(0, 32), mac: raw.slice(32, 64), key: cipher.key };
|
||||
} catch {
|
||||
// use user key
|
||||
}
|
||||
}
|
||||
return { enc: userEnc, mac: userMac, key: null };
|
||||
}
|
||||
|
||||
export async function createCipher(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
session: SessionState,
|
||||
draft: VaultDraft
|
||||
): Promise<void> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const enc = base64ToBytes(session.symEncKey);
|
||||
const mac = base64ToBytes(session.symMacKey);
|
||||
const type = Number(draft.type || 1);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
type,
|
||||
folderId: asNullable(draft.folderId),
|
||||
reprompt: draft.reprompt ? 1 : 0,
|
||||
name: await encryptTextValue(draft.name, enc, mac),
|
||||
notes: await encryptTextValue(draft.notes, enc, mac),
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], enc, mac),
|
||||
};
|
||||
|
||||
if (type === 1) {
|
||||
payload.login = {
|
||||
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
||||
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
||||
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, enc, mac),
|
||||
number: await encryptTextValue(draft.cardNumber, enc, mac),
|
||||
brand: await encryptTextValue(draft.cardBrand, enc, mac),
|
||||
expMonth: await encryptTextValue(draft.cardExpMonth, enc, mac),
|
||||
expYear: await encryptTextValue(draft.cardExpYear, enc, mac),
|
||||
code: await encryptTextValue(draft.cardCode, enc, mac),
|
||||
};
|
||||
} else if (type === 4) {
|
||||
payload.identity = {
|
||||
title: await encryptTextValue(draft.identTitle, enc, mac),
|
||||
firstName: await encryptTextValue(draft.identFirstName, enc, mac),
|
||||
middleName: await encryptTextValue(draft.identMiddleName, enc, mac),
|
||||
lastName: await encryptTextValue(draft.identLastName, enc, mac),
|
||||
username: await encryptTextValue(draft.identUsername, enc, mac),
|
||||
company: await encryptTextValue(draft.identCompany, enc, mac),
|
||||
ssn: await encryptTextValue(draft.identSsn, enc, mac),
|
||||
passportNumber: await encryptTextValue(draft.identPassportNumber, enc, mac),
|
||||
licenseNumber: await encryptTextValue(draft.identLicenseNumber, enc, mac),
|
||||
email: await encryptTextValue(draft.identEmail, enc, mac),
|
||||
phone: await encryptTextValue(draft.identPhone, enc, mac),
|
||||
address1: await encryptTextValue(draft.identAddress1, enc, mac),
|
||||
address2: await encryptTextValue(draft.identAddress2, enc, mac),
|
||||
address3: await encryptTextValue(draft.identAddress3, enc, mac),
|
||||
city: await encryptTextValue(draft.identCity, enc, mac),
|
||||
state: await encryptTextValue(draft.identState, enc, mac),
|
||||
postalCode: await encryptTextValue(draft.identPostalCode, enc, mac),
|
||||
country: await encryptTextValue(draft.identCountry, enc, mac),
|
||||
};
|
||||
} else if (type === 5) {
|
||||
payload.sshKey = {
|
||||
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
|
||||
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
|
||||
fingerprint: await encryptTextValue(draft.sshFingerprint, enc, mac),
|
||||
};
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/ciphers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create item failed');
|
||||
}
|
||||
|
||||
export async function updateCipher(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
draft: VaultDraft
|
||||
): Promise<void> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const type = Number(draft.type || cipher.type || 1);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
id: cipher.id,
|
||||
type,
|
||||
key: keys.key,
|
||||
folderId: asNullable(draft.folderId),
|
||||
favorite: !!cipher.favorite,
|
||||
reprompt: draft.reprompt ? 1 : 0,
|
||||
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
|
||||
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||
};
|
||||
|
||||
if (type === 1) {
|
||||
payload.login = {
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
|
||||
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
|
||||
expMonth: await encryptTextValue(draft.cardExpMonth, keys.enc, keys.mac),
|
||||
expYear: await encryptTextValue(draft.cardExpYear, keys.enc, keys.mac),
|
||||
code: await encryptTextValue(draft.cardCode, keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 4) {
|
||||
payload.identity = {
|
||||
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
|
||||
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
|
||||
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
|
||||
lastName: await encryptTextValue(draft.identLastName, keys.enc, keys.mac),
|
||||
username: await encryptTextValue(draft.identUsername, keys.enc, keys.mac),
|
||||
company: await encryptTextValue(draft.identCompany, keys.enc, keys.mac),
|
||||
ssn: await encryptTextValue(draft.identSsn, keys.enc, keys.mac),
|
||||
passportNumber: await encryptTextValue(draft.identPassportNumber, keys.enc, keys.mac),
|
||||
licenseNumber: await encryptTextValue(draft.identLicenseNumber, keys.enc, keys.mac),
|
||||
email: await encryptTextValue(draft.identEmail, keys.enc, keys.mac),
|
||||
phone: await encryptTextValue(draft.identPhone, keys.enc, keys.mac),
|
||||
address1: await encryptTextValue(draft.identAddress1, keys.enc, keys.mac),
|
||||
address2: await encryptTextValue(draft.identAddress2, keys.enc, keys.mac),
|
||||
address3: await encryptTextValue(draft.identAddress3, keys.enc, keys.mac),
|
||||
city: await encryptTextValue(draft.identCity, keys.enc, keys.mac),
|
||||
state: await encryptTextValue(draft.identState, keys.enc, keys.mac),
|
||||
postalCode: await encryptTextValue(draft.identPostalCode, keys.enc, keys.mac),
|
||||
country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 5) {
|
||||
payload.sshKey = {
|
||||
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
|
||||
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
|
||||
fingerprint: await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
}
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update item failed');
|
||||
}
|
||||
|
||||
export async function deleteCipher(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete item failed');
|
||||
}
|
||||
|
||||
export async function bulkMoveCiphers(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
ids: string[],
|
||||
folderId: string | null
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/ciphers/move', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, folderId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk move failed');
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer;
|
||||
}
|
||||
|
||||
export async function pbkdf2(
|
||||
passwordOrBytes: string | Uint8Array,
|
||||
saltOrBytes: string | Uint8Array,
|
||||
iterations: number,
|
||||
keyLen: number
|
||||
): Promise<Uint8Array> {
|
||||
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
|
||||
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
|
||||
key,
|
||||
keyLen * 8
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
|
||||
const infoBytes = new TextEncoder().encode(info || '');
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const result = new Uint8Array(length);
|
||||
let previous = new Uint8Array(0);
|
||||
let offset = 0;
|
||||
let counter = 1;
|
||||
|
||||
while (offset < length) {
|
||||
const input = new Uint8Array(previous.length + infoBytes.length + 1);
|
||||
input.set(previous, 0);
|
||||
input.set(infoBytes, previous.length);
|
||||
input[input.length - 1] = counter & 0xff;
|
||||
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
|
||||
const copyLen = Math.min(previous.length, length - offset);
|
||||
result.set(previous.slice(0, copyLen), offset);
|
||||
offset += copyLen;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
||||
}
|
||||
|
||||
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
|
||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||
}
|
||||
|
||||
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
|
||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||
}
|
||||
|
||||
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
|
||||
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
|
||||
}
|
||||
|
||||
function parseCipherString(s: string): { type: number; iv: Uint8Array; ct: Uint8Array; mac: Uint8Array | null } {
|
||||
if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
|
||||
const p = s.indexOf('.');
|
||||
if (p <= 0) throw new Error('invalid encrypted string');
|
||||
const type = Number(s.slice(0, p));
|
||||
const body = s.slice(p + 1);
|
||||
const parts = body.split('|');
|
||||
if (type === 2 && parts.length === 3) {
|
||||
return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
|
||||
}
|
||||
if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) {
|
||||
return { type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
|
||||
}
|
||||
throw new Error('unsupported enc type');
|
||||
}
|
||||
|
||||
export async function decryptBw(cipherString: string, encKey: Uint8Array, macKey?: Uint8Array): Promise<Uint8Array> {
|
||||
const parsed = parseCipherString(cipherString);
|
||||
if (parsed.type === 2 && macKey && parsed.mac) {
|
||||
const expected = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
|
||||
if (bytesToBase64(expected) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
|
||||
}
|
||||
return decryptAesCbc(parsed.ct, encKey, parsed.iv);
|
||||
}
|
||||
|
||||
export async function decryptStr(cipherString: string | null | undefined, encKey: Uint8Array, macKey?: Uint8Array): Promise<string> {
|
||||
if (!cipherString || typeof cipherString !== 'string') return '';
|
||||
const plain = await decryptBw(cipherString, encKey, macKey);
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
|
||||
export function extractTotpSecret(raw: string): string {
|
||||
if (!raw) return '';
|
||||
const s = raw.trim();
|
||||
if (!s) return '';
|
||||
if (/^otpauth:\/\//i.test(s)) {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base32ToBytes(input: string): Uint8Array {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < clean.length; i += 1) {
|
||||
const idx = alphabet.indexOf(clean.charAt(i));
|
||||
if (idx < 0) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
|
||||
const secret = extractTotpSecret(rawSecret);
|
||||
if (!secret) return null;
|
||||
const keyBytes = base32ToBytes(secret);
|
||||
if (!keyBytes.length) return null;
|
||||
const step = 30;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
const counter = Math.floor(epoch / step);
|
||||
const remain = step - (epoch % step);
|
||||
|
||||
const message = new Uint8Array(8);
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i -= 1) {
|
||||
message[i] = c & 0xff;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
|
||||
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
|
||||
const offset = hs[hs.length - 1] & 0x0f;
|
||||
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
|
||||
const code = (bin % 1000000).toString().padStart(6, '0');
|
||||
return { code, remain };
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
export type AppPhase = 'loading' | 'register' | 'login' | 'locked' | 'app';
|
||||
|
||||
export interface SessionState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
email: string;
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
key: string;
|
||||
role: 'admin' | 'user';
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
decName?: string;
|
||||
}
|
||||
|
||||
export interface CipherLoginUri {
|
||||
uri?: string | null;
|
||||
decUri?: string;
|
||||
}
|
||||
|
||||
export interface CipherLogin {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
uris?: CipherLoginUri[] | null;
|
||||
decUsername?: string;
|
||||
decPassword?: string;
|
||||
decTotp?: string;
|
||||
}
|
||||
|
||||
export interface CipherCard {
|
||||
cardholderName?: string | null;
|
||||
number?: string | null;
|
||||
brand?: string | null;
|
||||
expMonth?: string | null;
|
||||
expYear?: string | null;
|
||||
code?: string | null;
|
||||
decCardholderName?: string;
|
||||
decNumber?: string;
|
||||
decBrand?: string;
|
||||
decExpMonth?: string;
|
||||
decExpYear?: string;
|
||||
decCode?: string;
|
||||
}
|
||||
|
||||
export interface CipherIdentity {
|
||||
title?: string | null;
|
||||
firstName?: string | null;
|
||||
middleName?: string | null;
|
||||
lastName?: string | null;
|
||||
username?: string | null;
|
||||
company?: string | null;
|
||||
ssn?: string | null;
|
||||
passportNumber?: string | null;
|
||||
licenseNumber?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address1?: string | null;
|
||||
address2?: string | null;
|
||||
address3?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
decTitle?: string;
|
||||
decFirstName?: string;
|
||||
decMiddleName?: string;
|
||||
decLastName?: string;
|
||||
decUsername?: string;
|
||||
decCompany?: string;
|
||||
decSsn?: string;
|
||||
decPassportNumber?: string;
|
||||
decLicenseNumber?: string;
|
||||
decEmail?: string;
|
||||
decPhone?: string;
|
||||
decAddress1?: string;
|
||||
decAddress2?: string;
|
||||
decAddress3?: string;
|
||||
decCity?: string;
|
||||
decState?: string;
|
||||
decPostalCode?: string;
|
||||
decCountry?: string;
|
||||
}
|
||||
|
||||
export interface CipherSshKey {
|
||||
privateKey?: string | null;
|
||||
publicKey?: string | null;
|
||||
fingerprint?: string | null;
|
||||
decPrivateKey?: string;
|
||||
decPublicKey?: string;
|
||||
decFingerprint?: string;
|
||||
}
|
||||
|
||||
export interface CipherField {
|
||||
type?: number | string | null;
|
||||
name?: string | null;
|
||||
value?: string | null;
|
||||
decName?: string;
|
||||
decValue?: string;
|
||||
}
|
||||
|
||||
export interface Cipher {
|
||||
id: string;
|
||||
type: number;
|
||||
folderId?: string | null;
|
||||
favorite?: boolean;
|
||||
reprompt?: number;
|
||||
name?: string | null;
|
||||
notes?: string | null;
|
||||
key?: string | null;
|
||||
login?: CipherLogin | null;
|
||||
card?: CipherCard | null;
|
||||
identity?: CipherIdentity | null;
|
||||
sshKey?: CipherSshKey | null;
|
||||
fields?: CipherField[] | null;
|
||||
decName?: string;
|
||||
decNotes?: string;
|
||||
}
|
||||
|
||||
export type CustomFieldType = 0 | 1 | 2 | 3;
|
||||
|
||||
export interface VaultDraftField {
|
||||
type: CustomFieldType;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface VaultDraft {
|
||||
id?: string;
|
||||
type: number;
|
||||
name: string;
|
||||
folderId: string;
|
||||
notes: string;
|
||||
reprompt: boolean;
|
||||
loginUsername: string;
|
||||
loginPassword: string;
|
||||
loginTotp: string;
|
||||
loginUris: string[];
|
||||
cardholderName: string;
|
||||
cardNumber: string;
|
||||
cardBrand: string;
|
||||
cardExpMonth: string;
|
||||
cardExpYear: string;
|
||||
cardCode: string;
|
||||
identTitle: string;
|
||||
identFirstName: string;
|
||||
identMiddleName: string;
|
||||
identLastName: string;
|
||||
identUsername: string;
|
||||
identCompany: string;
|
||||
identSsn: string;
|
||||
identPassportNumber: string;
|
||||
identLicenseNumber: string;
|
||||
identEmail: string;
|
||||
identPhone: string;
|
||||
identAddress1: string;
|
||||
identAddress2: string;
|
||||
identAddress3: string;
|
||||
identCity: string;
|
||||
identState: string;
|
||||
identPostalCode: string;
|
||||
identCountry: string;
|
||||
sshPrivateKey: string;
|
||||
sshPublicKey: string;
|
||||
sshFingerprint: string;
|
||||
customFields: VaultDraftField[];
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
object: 'list';
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export interface SetupStatusResponse {
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
export interface WebConfigResponse {
|
||||
defaultKdfIterations?: number;
|
||||
}
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
TwoFactorProviders?: unknown;
|
||||
}
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminInvite {
|
||||
code: string;
|
||||
inviteLink?: string;
|
||||
status: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
document.getElementById('root')!
|
||||
);
|
||||
@@ -0,0 +1,700 @@
|
||||
:root {
|
||||
--bg: #f3f5f8;
|
||||
--panel: #ffffff;
|
||||
--line: #d7dde6;
|
||||
--text: #0f172a;
|
||||
--muted: #667085;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #e11d48;
|
||||
--danger-hover: #be123c;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.loading-screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(640px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.08);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0 0 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0 0 16px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: 1px solid #3f5b9e;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 110px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #2f5fd8;
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-wrap .input {
|
||||
padding-right: 88px;
|
||||
}
|
||||
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 42px;
|
||||
bottom: 9px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #eff5ff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff;
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
width: min(1540px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(360px, 43%) 1fr;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 64px - 28px);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.list-panel,
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #475467;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-btn.active {
|
||||
background: #eef4ff;
|
||||
color: #175ddc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.list-item.active {
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-icon-wrap {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.list-icon-fallback {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.list-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: block;
|
||||
color: #175ddc;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detail-col {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-sub {
|
||||
color: #667085;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.kv-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #ecf0f5;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.kv-line:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.kv-line > span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.notes {
|
||||
white-space: pre-wrap;
|
||||
color: #334155;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #667085;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.totp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 220px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.totp-qr svg {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.create-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 6px);
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.create-menu-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
padding: 11px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.create-menu-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.uri-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-type-pill {
|
||||
align-self: center;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
color: #175ddc;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.local-error {
|
||||
margin-top: 10px;
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kv-line strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.check-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.input.small {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: min(460px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-card .field {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
font-size: 34px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 6px 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
color: #475467;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1400;
|
||||
width: min(420px, calc(100vw - 20px));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #bbdfc6;
|
||||
background: #dff4e5;
|
||||
color: #0f5132;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
border-color: #f2b8c1;
|
||||
background: #fde7eb;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.toast-text {
|
||||
font-weight: 700;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
animation: toast-life 4.5s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-life {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left center;
|
||||
}
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.vault-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
.sidebar {
|
||||
max-height: 280px;
|
||||
}
|
||||
.totp-grid,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.uri-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'qrcode-generator' {
|
||||
interface QrCode {
|
||||
addData(data: string): void;
|
||||
make(): void;
|
||||
createSvgTag(options?: { scalable?: boolean; margin?: number }): string;
|
||||
}
|
||||
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import preact from '@preact/preset-vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: rootDir,
|
||||
plugins: [preact()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(rootDir, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(rootDir, '../public'),
|
||||
emptyOutDir: false,
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787',
|
||||
'/identity': 'http://127.0.0.1:8787',
|
||||
'/setup': 'http://127.0.0.1:8787',
|
||||
'/icons': 'http://127.0.0.1:8787',
|
||||
'/config': 'http://127.0.0.1:8787',
|
||||
'/notifications': 'http://127.0.0.1:8787',
|
||||
'/.well-known': 'http://127.0.0.1:8787',
|
||||
'/favicon.ico': 'http://127.0.0.1:8787',
|
||||
'/favicon.svg': 'http://127.0.0.1:8787',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user