feat: add cryptographic utilities and types for secure data handling

This commit is contained in:
shuaiplus
2026-02-28 01:02:34 +08:00
committed by Shuai
parent 7c7d32de30
commit 5509492563
29 changed files with 5757 additions and 2786 deletions
+3
View File
@@ -11,6 +11,9 @@ tests/selfcheck.ts
# Build output # Build output
dist/ dist/
build/ build/
public/
public2/
# IDE # IDE
.vscode/ .vscode/
+1638
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -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
View File
@@ -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>
-2
View File
@@ -1,2 +0,0 @@
export { startNodewardenApp } from './main.js';
-150
View File
@@ -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 };
}
-216
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -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 });
-812
View File
@@ -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; }
}
-44
View File
@@ -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 '';
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -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>
+756
View File
@@ -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))} />
</>
);
}
+116
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+23
View File
@@ -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
+597
View File
@@ -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');
}
+174
View File
@@ -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 };
}
+222
View File
@@ -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;
}
+20
View File
@@ -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')!
);
+700
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+22
View File
@@ -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"]
}
+35
View File
@@ -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',
},
},
});