Add runtime configuration loader and styles for web application

This commit is contained in:
shuaiplus
2026-02-27 01:56:32 +08:00
committed by Shuai
parent b8c4bcef0c
commit 363aec1652
8 changed files with 49 additions and 58 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeWarden Web</title>
<link rel="stylesheet" href="/web/styles.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/web/runtime-config.js"></script>
</body>
</html>
+11 -16
View File
@@ -1,8 +1,7 @@
export function renderWebClientScript(defaultKdfIterations: number): string {
return ` export function startNodewardenApp(runtimeConfig) {
(function () {
var app = document.getElementById('app'); var app = document.getElementById('app');
var defaultKdfIterations = ${defaultKdfIterations}; var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000;
var state = { var state = {
phase: 'loading', phase: 'loading',
lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en',
@@ -429,7 +428,7 @@
function hostFromUri(uri){ function hostFromUri(uri){
try{ try{
if(!uri) return ''; if(!uri) return '';
var fixed=/^https?:\\/\\//i.test(uri)?uri:('https://'+uri); var fixed=/^https?:\/\//i.test(uri)?uri:('https://'+uri);
return new URL(fixed).hostname; return new URL(fixed).hostname;
}catch(e){ return ''; } }catch(e){ return ''; }
} }
@@ -452,7 +451,7 @@
function extractTotpSecret(raw){ function extractTotpSecret(raw){
var s=String(raw||'').trim(); var s=String(raw||'').trim();
if(!s) return ''; if(!s) return '';
if(/^otpauth:\\/\\//i.test(s)){ if(/^otpauth:\/\//i.test(s)){
try{ try{
var u=new URL(s); var u=new URL(s);
var sec=u.searchParams.get('secret'); var sec=u.searchParams.get('secret');
@@ -1097,7 +1096,7 @@
var uri=firstCipherUri(c); var uri=firstCipherUri(c);
var host=hostFromUri(uri); var host=hostFromUri(uri);
var icon=host var icon=host
? ('<span class="vault-item-icon-wrap"><img class="vault-item-icon" src="/icons/'+esc(host)+'/icon.png" alt="" onerror="this.style.display=\\'none\\';this.nextElementSibling.style.display=\\'inline-flex\\';"><span class="vault-item-icon vault-item-icon-fallback" style="display:none;">🌐</span></span>') ? ('<span class="vault-item-icon-wrap"><img class="vault-item-icon" src="/icons/'+esc(host)+'/icon.png" alt="" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'inline-flex\';"><span class="vault-item-icon vault-item-icon-fallback" style="display:none;">🌐</span></span>')
: '<span class="vault-item-icon vault-item-icon-fallback">🌐</span>'; : '<span class="vault-item-icon vault-item-icon-fallback">🌐</span>';
rows += '' rows += ''
+ '<div class="vault-item '+(c.id===state.selectedCipherId?'active':'')+'" data-action="pick-cipher" data-id="'+esc(c.id)+'">' + '<div class="vault-item '+(c.id===state.selectedCipherId?'active':'')+'" data-action="pick-cipher" data-id="'+esc(c.id)+'">'
@@ -1376,7 +1375,7 @@
setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err');
} }
} }
async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); }
function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); }
async function onDisableTotpSubmit(form){ async function onDisableTotpSubmit(form){
var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||'');
@@ -1395,7 +1394,7 @@
} }
async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;i<ids.length;i++) await authFetch('/api/ciphers/'+encodeURIComponent(ids[i]),{method:'DELETE'}); state.selectedMap={}; await loadVault(); render(); setMsg('Deleted selected items.', 'ok'); } async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;i<ids.length;i++) await authFetch('/api/ciphers/'+encodeURIComponent(ids[i]),{method:'DELETE'}); state.selectedMap={}; await loadVault(); render(); setMsg('Deleted selected items.', 'ok'); }
async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var opts=['0) No folder']; for(var i=0;i<state.folders.length;i++){ var f=state.folders[i]; var label=(f.decName||f.name||f.id); opts.push(String(i+1)+') '+String(label)); } var pick=window.prompt('Move selected items to:\\n'+opts.join('\\n')+'\\n\\nInput number (empty to cancel):','0'); if(pick===null) return; pick=String(pick).trim(); if(!pick) return; var idx=Number(pick); if(!Number.isInteger(idx)||idx<0||idx>state.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var opts=['0) No folder']; for(var i=0;i<state.folders.length;i++){ var f=state.folders[i]; var label=(f.decName||f.name||f.id); opts.push(String(i+1)+') '+String(label)); } var pick=window.prompt('Move selected items to:\n'+opts.join('\n')+'\n\nInput number (empty to cancel):','0'); if(pick===null) return; pick=String(pick).trim(); if(!pick) return; var idx=Number(pick); if(!Number.isInteger(idx)||idx<0||idx>state.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); }
async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); } async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); }
async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); } async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); }
@@ -1449,9 +1448,9 @@
if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i<list.length;i++) state.selectedMap[list[i].id]=true; render(); return; } if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i<list.length;i++) state.selectedMap[list[i].id]=true; render(); return; }
if(a==='select-none'){ state.selectedMap={}; render(); return; } if(a==='select-none'){ state.selectedMap={}; render(); return; }
if(a==='toggle-password'){ state.showSelectedPassword=!state.showSelectedPassword; render(); return; } if(a==='toggle-password'){ state.showSelectedPassword=!state.showSelectedPassword; render(); return; }
if(a==='copy-totp-current'){ var tv=document.getElementById('totp-live-value'); var txt=tv?String(tv.textContent||'').replace(/\\s+/g,''):''; if(!txt) return setMsg('No current TOTP code.', 'err'); navigator.clipboard.writeText(txt).then(function(){ setMsg('Copied to clipboard.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; } if(a==='copy-totp-current'){ var tv=document.getElementById('totp-live-value'); var txt=tv?String(tv.textContent||'').replace(/\s+/g,''):''; if(!txt) return setMsg('No current TOTP code.', 'err'); navigator.clipboard.writeText(txt).then(function(){ setMsg('Copied to clipboard.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; }
if(a==='copy-field'){ var val=n.getAttribute('data-value')||''; navigator.clipboard.writeText(val).then(function(){ setMsg('Copied to clipboard.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; } if(a==='copy-field'){ var val=n.getAttribute('data-value')||''; navigator.clipboard.writeText(val).then(function(){ setMsg('Copied to clipboard.', 'ok'); }).catch(function(){ setMsg('Copy failed.', 'err'); }); return; }
if(a==='open-uri'){ var v=n.getAttribute('data-value')||''; if(!v) return; if(!/^https?:\\/\\//i.test(v)) v='https://'+v; window.open(v, '_blank', 'noopener'); return; } if(a==='open-uri'){ var v=n.getAttribute('data-value')||''; if(!v) return; if(!/^https?:\/\//i.test(v)) v='https://'+v; window.open(v, '_blank', 'noopener'); return; }
if(a==='bulk-delete') return void onBulkDelete(); if(a==='bulk-delete') return void onBulkDelete();
if(a==='bulk-move') return void onBulkMove(); if(a==='bulk-move') return void onBulkMove();
if(a==='vault-refresh'){ loadVault().then(function(){ render(); setMsg('Vault refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; } if(a==='vault-refresh'){ loadVault().then(function(){ render(); setMsg('Vault refreshed.', 'ok'); }).catch(function(e){ setMsg('Refresh failed: '+(e&&e.message?e.message:String(e)), 'err'); }); return; }
@@ -1516,8 +1515,4 @@
}); });
init(); init();
})(); }
`;
}
+15
View File
@@ -0,0 +1,15 @@
import { startNodewardenApp } from './app.js';
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 };
}
}
const cfg = await loadRuntimeConfig();
startNodewardenApp(cfg || { defaultKdfIterations: 600000 });
@@ -1,4 +1,4 @@
export const WEB_CLIENT_STYLES = `
:root { :root {
--bg: #F3F5F8; --bg: #F3F5F8;
--panel: #FFFFFF; --panel: #FFFFFF;
@@ -561,5 +561,4 @@
.vault-grid { grid-template-columns: 1fr; } .vault-grid { grid-template-columns: 1fr; }
} }
`;
-9
View File
@@ -1,9 +0,0 @@
import { Env } from '../types';
import { htmlResponse } from '../utils/response';
import { renderWebClientHTML } from '../webclient/page';
export async function handleWebClientPage(request: Request, env: Env): Promise<Response> {
void request;
void env;
return htmlResponse(renderWebClientHTML());
}
+7 -6
View File
@@ -49,7 +49,6 @@ import { handleSync } from './handlers/sync';
// Setup handlers // Setup handlers
import { handleSetupStatus } from './handlers/setup'; import { handleSetupStatus } from './handlers/setup';
import { handleWebClientPage } from './handlers/web';
import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices';
// Import handler // Import handler
@@ -186,16 +185,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Route matching // Route matching
try { try {
// Web client entry (single-path app)
if ((path === '/' || path === '/register' || path === '/login' || path === '/setup' || path === '/setup/legacy') && method === 'GET') {
return handleWebClientPage(request, env);
}
// Setup status // Setup status
if (path === '/setup/status' && method === 'GET') { if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env); return handleSetupStatus(request, env);
} }
// Web runtime config for static client bootstrap
if (path === '/api/web/config' && method === 'GET') {
return jsonResponse({
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
});
}
// Browser/devtools probe endpoint // Browser/devtools probe endpoint
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') { if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', { return new Response('{}', {
-25
View File
@@ -1,25 +0,0 @@
import { LIMITS } from '../config/limits';
import { renderWebClientScript } from './script';
import { WEB_CLIENT_STYLES } from './styles';
export function renderWebClientHTML(): string {
const defaultKdfIterations = LIMITS.auth.defaultKdfIterations;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden Web</title>
<style>
${WEB_CLIENT_STYLES}
</style>
</head>
<body>
<div id="app"></div>
<script>
${renderWebClientScript(defaultKdfIterations)}
</script>
</body>
</html>`;
}
+1
View File
@@ -1,6 +1,7 @@
name = "nodewarden" name = "nodewarden"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
assets = { directory = "./public", not_found_handling = "single-page-application", run_worker_first = ["/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*", "/favicon.ico", "/favicon.svg"] }
# D1 Database for storing vault data # D1 Database for storing vault data
[[d1_databases]] [[d1_databases]]