mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
public/
|
||||||
|
public2/
|
||||||
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
Generated
+1638
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -7,7 +7,11 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||||
"deploy": "wrangler deploy"
|
"deploy": "wrangler deploy"
|
||||||
},
|
},
|
||||||
@@ -33,9 +37,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
|
"@preact/preset-vite": "^2.10.3",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
"wrangler": "^4.61.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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>NodeWarden Web</title>
|
<title>NodeWarden</title>
|
||||||
<link rel="stylesheet" href="/web/styles.css">
|
<script type="module" crossorigin src="/assets/index-pVnF_d3f.js"></script>
|
||||||
</head>
|
<link rel="stylesheet" crossorigin href="/assets/index-BL7fH__f.css">
|
||||||
<body>
|
</head>
|
||||||
<div id="app"></div>
|
<body>
|
||||||
<script defer src="/web/vendor/qrcode-generator.min.js"></script>
|
<div id="root"></div>
|
||||||
<script type="module" src="/web/runtime-config.js"></script>
|
</body>
|
||||||
</body>
|
|
||||||
</html>
|
</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