fix: localize home and split auth setup flow
This commit is contained in:
parent
fdec8960c2
commit
31d63607ca
@ -11,7 +11,7 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"test": "node --test",
|
||||
"check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
|
||||
"check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"aes-js": "^3.1.2",
|
||||
|
||||
114
public/app.js
114
public/app.js
@ -136,13 +136,13 @@ function renderOverviewSummary() {
|
||||
const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length;
|
||||
|
||||
overviewSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Total sources', value: String(sourceCount) },
|
||||
{ label: 'Enhanced sources', value: String(readyCount) },
|
||||
{ label: 'Failed enhancements', value: String(failedCount) },
|
||||
{ label: 'Mapped task names', value: String(mappedCount) },
|
||||
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : 'Not uploaded' },
|
||||
{ label: 'Global WebDAV', value: state.globalDefaults?.configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Active certificate', value: state.tls?.activeSource ?? 'Unavailable' }
|
||||
{ label: '数据源总数', value: String(sourceCount) },
|
||||
{ label: '已增强数据源', value: String(readyCount) },
|
||||
{ label: '增强失败', value: String(failedCount) },
|
||||
{ label: '已映射任务名', value: String(mappedCount) },
|
||||
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' },
|
||||
{ label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' },
|
||||
{ label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' }
|
||||
]);
|
||||
}
|
||||
|
||||
@ -150,15 +150,15 @@ function renderServerDbSummary() {
|
||||
if (!state.serverDb?.available) {
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No server database uploaded yet' },
|
||||
{ label: 'Note', value: 'Task names and inferred target URLs stay unavailable until you upload Duplicati-server.sqlite.' }
|
||||
{ label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Current file', value: state.serverDb.originalFilename },
|
||||
{ label: 'Uploaded at', value: state.serverDb.uploadedAt },
|
||||
{ label: 'Backup entries', value: String(state.serverDb.backupCount) }
|
||||
{ label: '当前文件', value: state.serverDb.originalFilename },
|
||||
{ label: '上传时间', value: state.serverDb.uploadedAt },
|
||||
{ label: '任务条目数', value: String(state.serverDb.backupCount) }
|
||||
]);
|
||||
}
|
||||
|
||||
@ -167,46 +167,46 @@ function renderGlobalDefaultsSummary() {
|
||||
if (!defaults?.configured) {
|
||||
writeGlobalDefaultsForm(null);
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No global defaults saved' },
|
||||
{ label: 'Note', value: 'Save shared credentials here if most tasks use the same WebDAV account and passphrase.' }
|
||||
{ label: '状态', value: '尚未保存全局默认值' },
|
||||
{ label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
writeGlobalDefaultsForm(defaults);
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Fallback URL', value: defaults.webdavBaseUrl ?? 'Not set' },
|
||||
{ label: 'Auth mode', value: defaults.authMode ?? 'Auto' },
|
||||
{ label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
|
||||
{ label: '认证方式', value: defaults.authMode ?? '自动' },
|
||||
{
|
||||
label: 'Stored secrets',
|
||||
label: '已保存凭据',
|
||||
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
|
||||
},
|
||||
{ label: 'Updated at', value: defaults.updatedAt ?? 'Unknown' }
|
||||
{ label: '更新时间', value: defaults.updatedAt ?? '未知' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderTlsSummary() {
|
||||
if (!state.tls) {
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: 'Status', value: 'TLS settings not loaded yet' }]);
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
writeTlsForm(state.tls);
|
||||
tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'HTTP endpoint', value: state.tls.access?.httpBaseUrl ?? 'Unknown' },
|
||||
{ label: 'HTTPS endpoint', value: state.tls.access?.httpsBaseUrl ?? 'Unknown' },
|
||||
{ label: 'Configured mode', value: state.tls.mode ?? 'self-signed' },
|
||||
{ label: 'Active certificate', value: state.tls.activeSource ?? 'self-signed' },
|
||||
{ label: 'Primary domain', value: state.tls.primaryDomain || 'localhost' },
|
||||
{ label: 'Extra SAN entries', value: (state.tls.subjectAltNames ?? []).join(', ') || 'None' },
|
||||
{ label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' },
|
||||
{ label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' },
|
||||
{ label: '配置模式', value: state.tls.mode ?? 'self-signed' },
|
||||
{ label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' },
|
||||
{ label: '主域名', value: state.tls.primaryDomain || 'localhost' },
|
||||
{ label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '无' },
|
||||
{
|
||||
label: 'Validity',
|
||||
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : 'Unknown'
|
||||
label: '有效期',
|
||||
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知'
|
||||
},
|
||||
{ label: 'Fingerprint', value: state.tls.certificate?.fingerprint256 ?? 'Unknown' },
|
||||
{ label: 'Last error', value: state.tls.lastErrorMessage ?? 'None' },
|
||||
{ label: 'Cookie policy', value: state.tls.cookieNote ?? 'Shared between HTTP and HTTPS' }
|
||||
{ label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' },
|
||||
{ label: '最近错误', value: state.tls.lastErrorMessage ?? '无' },
|
||||
{ label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' }
|
||||
]);
|
||||
}
|
||||
|
||||
@ -224,16 +224,16 @@ function renderSources() {
|
||||
sourceListElement.innerHTML = state.sources
|
||||
.map((source) => {
|
||||
const targetHint = source.webdav?.effectiveWebdavBaseUrl
|
||||
? `<div class="summary-chip">Default target URL source: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
|
||||
: '<div class="summary-chip">No effective default target URL yet</div>';
|
||||
? `<div class="summary-chip">默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
|
||||
: '<div class="summary-chip">暂无可用的默认目标 URL</div>';
|
||||
|
||||
return renderSourceSummaryCard(
|
||||
source,
|
||||
`
|
||||
${targetHint}
|
||||
<div class="card-actions">
|
||||
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">Open workspace</a>
|
||||
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">Delete source</button>
|
||||
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
|
||||
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
@ -275,13 +275,13 @@ async function handleTaskDbUpload(event) {
|
||||
|
||||
const file = databaseInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', 'Choose a task SQLite file first.');
|
||||
feedback.set('error', '请先选择一个任务 SQLite 文件。');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadButton.disabled = true;
|
||||
refreshButton.disabled = true;
|
||||
feedback.set('info', 'Uploading task database...');
|
||||
feedback.set('info', '正在上传任务数据库...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@ -302,8 +302,8 @@ async function handleTaskDbUpload(event) {
|
||||
feedback.set(
|
||||
payload.reused ? 'info' : 'success',
|
||||
payload.reused
|
||||
? 'That task database was already uploaded, so the existing source was reused.'
|
||||
: 'Task database uploaded successfully.'
|
||||
? '这个任务数据库之前已经上传过,系统复用了现有 source。'
|
||||
: '任务数据库上传成功。'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@ -319,12 +319,12 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
const file = serverDbInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', 'Choose Duplicati-server.sqlite first.');
|
||||
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
|
||||
return;
|
||||
}
|
||||
|
||||
serverDbUploadButton.disabled = true;
|
||||
feedback.set('info', 'Uploading Server DB and refreshing task-name mappings...');
|
||||
feedback.set('info', '正在上传 Server DB 并刷新任务名映射...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@ -341,7 +341,7 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||||
serverDbForm.reset();
|
||||
feedback.set('success', 'Server DB uploaded successfully.');
|
||||
feedback.set('success', 'Server DB 上传成功。');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@ -353,7 +353,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
globalDefaultsSaveButton.disabled = true;
|
||||
feedback.set('info', 'Saving global WebDAV defaults...');
|
||||
feedback.set('info', '正在保存全局 WebDAV 默认值...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/webdav-defaults', {
|
||||
@ -370,7 +370,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
await loadSources();
|
||||
feedback.set(
|
||||
payload.defaults.configured ? 'success' : 'info',
|
||||
payload.defaults.configured ? 'Global defaults saved.' : 'Global defaults cleared.'
|
||||
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@ -384,7 +384,7 @@ async function handleTlsJsonSave(event) {
|
||||
feedback.clear();
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Saving TLS configuration and applying certificate...');
|
||||
feedback.set('info', '正在保存 TLS 配置并应用证书...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls', {
|
||||
@ -397,7 +397,7 @@ async function handleTlsJsonSave(event) {
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
feedback.set('success', `TLS settings applied. Active certificate source: ${payload.tls.activeSource}.`);
|
||||
feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}。`);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@ -415,13 +415,13 @@ async function handleTlsUpload(event) {
|
||||
const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null;
|
||||
|
||||
if (!certificateFile || !privateKeyFile) {
|
||||
feedback.set('error', 'Upload both the certificate file and the private key file.');
|
||||
feedback.set('error', '请同时上传证书文件和私钥文件。');
|
||||
return;
|
||||
}
|
||||
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Uploading custom certificate...');
|
||||
feedback.set('info', '正在上传自定义证书...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@ -446,7 +446,7 @@ async function handleTlsUpload(event) {
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
tlsUploadForm.reset();
|
||||
feedback.set('success', 'Custom certificate uploaded and applied.');
|
||||
feedback.set('success', '自定义证书已上传并生效。');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@ -457,12 +457,12 @@ async function handleTlsUpload(event) {
|
||||
|
||||
async function handleDeleteCustomTls() {
|
||||
feedback.clear();
|
||||
if (!window.confirm('Delete the stored custom certificate and fall back to a self-signed certificate?')) {
|
||||
if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
tlsDeleteCustomButton.disabled = true;
|
||||
feedback.set('info', 'Deleting custom certificate and switching back to self-signed...');
|
||||
feedback.set('info', '正在删除自定义证书,并切回自签证书...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls/custom-certificate', {
|
||||
@ -471,7 +471,7 @@ async function handleDeleteCustomTls() {
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
feedback.set('success', 'Custom certificate removed. The service is back on a self-signed certificate.');
|
||||
feedback.set('success', '自定义证书已删除,系统已回退到自签证书。');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@ -484,20 +484,20 @@ async function deleteSource(sourceId) {
|
||||
const source = state.sources.find((item) => item.id === sourceId);
|
||||
const label = source?.displayName || source?.originalFilename || sourceId;
|
||||
const confirmed = window.confirm(
|
||||
`Delete this source?\n\n${label}\n\nThis removes only the selected source and its local enhancement data. It does not delete Duplicati-server.sqlite.`
|
||||
`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.set('info', `Deleting ${label}...`);
|
||||
feedback.set('info', `正在删除 ${label}...`);
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||||
feedback.set('success', `Deleted ${payload.deleted.originalFilename}. Server DB was kept untouched.`);
|
||||
feedback.set('success', `已删除 ${payload.deleted.originalFilename},Server DB 保持不变。`);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
@ -529,7 +529,7 @@ tlsDeleteCustomButton.addEventListener('click', () => {
|
||||
|
||||
refreshButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
sourceListElement.textContent = 'Refreshing sources...';
|
||||
sourceListElement.textContent = '正在刷新数据源列表...';
|
||||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
@ -571,8 +571,8 @@ async function bootstrap() {
|
||||
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `Signed in as ${authentication.username}`
|
||||
: 'Not signed in';
|
||||
? `已登录:${authentication.username}`
|
||||
: '未登录';
|
||||
bindLogoutButton(logoutButton, authStatusElement);
|
||||
|
||||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]);
|
||||
@ -582,7 +582,7 @@ void bootstrap().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>Could not load the control center.</p>
|
||||
<p>无法加载控制台。</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Duplicati Control Center</title>
|
||||
<title>数据源控制台</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@ -11,28 +11,28 @@
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
|
||||
<h1>Duplicati Control Center</h1>
|
||||
<h1>数据源控制台</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Manage task databases, Server DB metadata, global WebDAV defaults, and site-wide HTTPS/TLS settings here.
|
||||
Browsing, enhancement, preview, and download stay in the per-source and per-file workspaces.
|
||||
这里负责管理任务数据库、Server DB 元数据、全局 WebDAV 默认值,以及站点级 HTTPS/TLS 配置。
|
||||
目录浏览、增强、预览和下载继续放在单个数据源工作台与文件页里。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">Loading login state...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">Sign out</button>
|
||||
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
<aside class="workspace-nav" aria-label="Control Center menu">
|
||||
<a class="workspace-nav-link" href="#overview" data-view-link="overview">Overview</a>
|
||||
<a class="workspace-nav-link" href="#sources" data-view-link="sources">Sources</a>
|
||||
<a class="workspace-nav-link" href="#upload" data-view-link="upload">Upload task DB</a>
|
||||
<aside class="workspace-nav" aria-label="数据源控制台菜单">
|
||||
<a class="workspace-nav-link" href="#overview" data-view-link="overview">总览</a>
|
||||
<a class="workspace-nav-link" href="#sources" data-view-link="sources">数据源列表</a>
|
||||
<a class="workspace-nav-link" href="#upload" data-view-link="upload">新增任务库</a>
|
||||
<a class="workspace-nav-link" href="#server-db" data-view-link="server-db">Server DB</a>
|
||||
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">Global WebDAV</a>
|
||||
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">全局 WebDAV</a>
|
||||
<a class="workspace-nav-link" href="#tls" data-view-link="tls">HTTPS / TLS</a>
|
||||
</aside>
|
||||
|
||||
@ -42,10 +42,10 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Overview</p>
|
||||
<h2>System summary</h2>
|
||||
<h2>系统总览</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="overviewSummary" class="summary-card">Loading overview...</div>
|
||||
<div id="overviewSummary" class="summary-card">正在读取系统总览...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -54,15 +54,14 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Sources</p>
|
||||
<h2>Uploaded task databases</h2>
|
||||
<h2>已上传任务数据库</h2>
|
||||
</div>
|
||||
<button id="refreshButton" class="ghost-button" type="button">Refresh</button>
|
||||
<button id="refreshButton" class="ghost-button" type="button">刷新</button>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
Each uploaded Duplicati task database becomes one source. Open a source workbench to browse files,
|
||||
start enhancement, or jump into the file workspace.
|
||||
每个上传的 Duplicati 任务数据库都会变成一个独立 source。进入工作台后,可以继续浏览目录、执行增强,或跳转到文件页做预览和下载。
|
||||
</p>
|
||||
<div id="sourceList" class="source-list empty-state">Loading sources...</div>
|
||||
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -71,20 +70,19 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Task DB</p>
|
||||
<h2>Add a new source</h2>
|
||||
<h2>上传新的任务库</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
Upload a Duplicati task database such as <code>TMQRJYNADS.sqlite</code>. Re-uploading the same file
|
||||
reuses the existing source instead of creating a duplicate.
|
||||
上传随机名任务数据库,例如 <code>TMQRJYNADS.sqlite</code>。重复上传同一个数据库时,系统会复用现有 source,而不是重复创建。
|
||||
</p>
|
||||
<form id="uploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>Select task SQLite file</span>
|
||||
<span>选择任务 SQLite 文件</span>
|
||||
<input id="databaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="uploadButton" class="primary-button" type="submit">Upload as new source</button>
|
||||
<button id="uploadButton" class="primary-button" type="submit">上传为新数据源</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -95,23 +93,22 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Server DB</p>
|
||||
<h2>Upload Duplicati-server.sqlite</h2>
|
||||
<h2>上传 Duplicati-server.sqlite</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
The server database lets the UI resolve friendly task names and infer default target URLs from
|
||||
Duplicati metadata.
|
||||
上传后,界面可以自动解析任务名称,并尽量从 Duplicati 元数据中推导默认目标 URL。
|
||||
</p>
|
||||
<form id="serverDbForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>Select server database</span>
|
||||
<span>选择服务器数据库</span>
|
||||
<input id="serverDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="serverDbUploadButton" class="ghost-button" type="submit">Upload Server DB</button>
|
||||
<button id="serverDbUploadButton" class="ghost-button" type="submit">上传 Server DB</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="serverDbSummary" class="summary-card">Loading Server DB status...</div>
|
||||
<div id="serverDbSummary" class="summary-card">正在读取 Server DB 状态...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -120,42 +117,42 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Global WebDAV</p>
|
||||
<h2>Shared defaults</h2>
|
||||
<h2>共享默认值</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
Save credentials and a fallback base URL once when most tasks share the same WebDAV settings.
|
||||
如果大多数任务共用同一套 WebDAV 认证和口令,可以在这里统一保存一次。
|
||||
</p>
|
||||
<form id="globalDefaultsForm" class="secret-form">
|
||||
<label>
|
||||
<span>Fallback WebDAV Base URL</span>
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="Optional shared base URL">
|
||||
<span>兜底 WebDAV Base URL</span>
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,共享兜底 URL">
|
||||
</label>
|
||||
<label>
|
||||
<span>Authentication mode</span>
|
||||
<span>认证方式</span>
|
||||
<select id="globalAuthMode" name="authMode">
|
||||
<option value="">Auto / inherited</option>
|
||||
<option value="">自动 / 继承</option>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
<option value="anonymous">匿名</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="Optional shared username">
|
||||
<span>用户名</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="可选,共享用户名">
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="Optional shared password">
|
||||
<span>密码</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="可选,共享密码">
|
||||
</label>
|
||||
<label>
|
||||
<span>Backup passphrase</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="Optional shared passphrase">
|
||||
<span>备份口令</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="可选,共享备份口令">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">Save defaults</button>
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存默认值</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="globalDefaultsSummary" class="summary-card">Loading WebDAV defaults...</div>
|
||||
<div id="globalDefaultsSummary" class="summary-card">正在读取 WebDAV 默认值...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -164,64 +161,63 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">HTTPS / TLS</p>
|
||||
<h2>Domain and certificate settings</h2>
|
||||
<h2>域名与证书设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
HTTP and HTTPS stay enabled side-by-side. If no custom certificate is configured, the service keeps a
|
||||
self-signed certificate ready so HTTPS is always available.
|
||||
HTTP 和 HTTPS 会同时可用。没有自定义证书时,系统会自动维护一张自签证书,保证 HTTPS 始终可访问。
|
||||
</p>
|
||||
<div id="tlsSummary" class="summary-card">Loading TLS configuration...</div>
|
||||
<div id="tlsSummary" class="summary-card">正在读取 TLS 配置...</div>
|
||||
|
||||
<form id="tlsJsonForm" class="secret-form">
|
||||
<label>
|
||||
<span>Certificate mode</span>
|
||||
<span>证书模式</span>
|
||||
<select id="tlsMode" name="mode">
|
||||
<option value="self-signed">Self-signed</option>
|
||||
<option value="custom-pem">Custom PEM</option>
|
||||
<option value="self-signed">自签证书</option>
|
||||
<option value="custom-pem">自定义 PEM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary domain</span>
|
||||
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="Example: preview.example.com">
|
||||
<span>主域名</span>
|
||||
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="例如:preview.example.com">
|
||||
</label>
|
||||
<label>
|
||||
<span>Additional SAN entries</span>
|
||||
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="One hostname or IP per line. Comma-separated values also work."></textarea>
|
||||
<span>额外 SAN 条目</span>
|
||||
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="每行一个域名或 IP,也支持逗号分隔"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Certificate PEM</span>
|
||||
<span>证书 PEM</span>
|
||||
<textarea id="tlsCertPem" name="certPem" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Private key PEM</span>
|
||||
<span>私钥 PEM</span>
|
||||
<textarea id="tlsKeyPem" name="keyPem" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Optional chain PEM</span>
|
||||
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="Optional intermediate certificates"></textarea>
|
||||
<span>可选链证书 PEM</span>
|
||||
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="可选,中间证书链"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsSaveButton" class="primary-button" type="submit">Save and apply</button>
|
||||
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">Delete custom cert and fall back</button>
|
||||
<button id="tlsSaveButton" class="primary-button" type="submit">保存并应用</button>
|
||||
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">删除自定义证书并回退</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="tlsUploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>Upload certificate file</span>
|
||||
<span>上传证书文件</span>
|
||||
<input id="tlsCertificateInput" name="certificate" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload private key file</span>
|
||||
<span>上传私钥文件</span>
|
||||
<input id="tlsPrivateKeyInput" name="privateKey" type="file" accept=".pem,.key,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload optional chain file</span>
|
||||
<span>上传可选链证书文件</span>
|
||||
<input id="tlsChainInput" name="chain" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsUploadButton" class="ghost-button" type="submit">Apply uploaded certificate</button>
|
||||
<button id="tlsUploadButton" class="ghost-button" type="submit">应用上传的证书</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -11,10 +11,10 @@
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1 id="authHeading">系统登录</h1>
|
||||
<h1>系统登录</h1>
|
||||
</div>
|
||||
<p id="authLead" class="lead">
|
||||
登录后才能访问数据源管理、增强任务、视频预览和文件下载。首次启用时,需要先设置管理员账号。
|
||||
<p class="lead">
|
||||
登录后才能访问数据源管理、增强任务、视频预览和文件下载。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@ -24,14 +24,14 @@
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Account</p>
|
||||
<h2 id="authFormTitle">正在检查系统状态...</h2>
|
||||
<p class="section-label">Login</p>
|
||||
<h2>请输入管理员账号密码</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
|
||||
|
||||
<form id="loginForm" class="secret-form" hidden>
|
||||
<form id="loginForm" class="secret-form">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username">
|
||||
@ -44,24 +44,6 @@
|
||||
<button id="loginButton" class="primary-button" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="setupForm" class="secret-form" hidden>
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input id="setupUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
<span>再次输入密码</span>
|
||||
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
105
public/login.js
105
public/login.js
@ -1,19 +1,10 @@
|
||||
import { createFeedbackController, escapeHtml } from './modules/common.js';
|
||||
import { loadAuthState, redirectToNextPath } from './modules/auth-client.js';
|
||||
import { buildSetupUrl, loadAuthState, redirectToNextPath } from './modules/auth-client.js';
|
||||
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
const authHeadingElement = document.querySelector('#authHeading');
|
||||
const authLeadElement = document.querySelector('#authLead');
|
||||
const authFormTitleElement = document.querySelector('#authFormTitle');
|
||||
const authSummaryElement = document.querySelector('#authSummary');
|
||||
const loginForm = document.querySelector('#loginForm');
|
||||
const setupForm = document.querySelector('#setupForm');
|
||||
const loginButton = document.querySelector('#loginButton');
|
||||
const setupButton = document.querySelector('#setupButton');
|
||||
|
||||
const state = {
|
||||
auth: null
|
||||
};
|
||||
|
||||
function setSummaryRows(rows) {
|
||||
authSummaryElement.innerHTML = `
|
||||
@ -32,49 +23,6 @@ function setSummaryRows(rows) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAuthMode() {
|
||||
const auth = state.auth;
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authenticated) {
|
||||
authHeadingElement.textContent = '登录成功';
|
||||
authLeadElement.textContent = '当前会话已验证,正在跳转到你的目标页面。';
|
||||
authFormTitleElement.textContent = '正在跳转...';
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: `已登录:${auth.username}` },
|
||||
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.setupRequired) {
|
||||
authHeadingElement.textContent = '初始化管理员账号';
|
||||
authLeadElement.textContent = '系统第一次运行时,需要先创建管理员用户名和密码。创建完成后会自动登录。';
|
||||
authFormTitleElement.textContent = '首次初始化';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '尚未配置管理员账号' },
|
||||
{ label: '下一步', value: '创建管理员并进入系统' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
authHeadingElement.textContent = '系统登录';
|
||||
authLeadElement.textContent = '登录后才能访问数据源管理、增强任务、视频预览和文件下载。';
|
||||
authFormTitleElement.textContent = '请输入账号密码';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '管理员账号已配置' },
|
||||
{ label: '当前状态', value: '未登录' }
|
||||
]);
|
||||
loginForm.hidden = false;
|
||||
setupForm.hidden = true;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -92,11 +40,26 @@ async function postJson(url, payload) {
|
||||
}
|
||||
|
||||
async function refreshAuthState() {
|
||||
state.auth = await loadAuthState();
|
||||
renderAuthMode();
|
||||
if (state.auth.authenticated) {
|
||||
const auth = await loadAuthState();
|
||||
|
||||
if (auth.authenticated) {
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: `已登录:${auth.username}` },
|
||||
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
|
||||
]);
|
||||
redirectToNextPath('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.setupRequired) {
|
||||
window.location.assign(buildSetupUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '管理员账号已配置' },
|
||||
{ label: '当前状态', value: '未登录' }
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
@ -117,40 +80,10 @@ async function handleLogin(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetup(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
|
||||
const password = document.querySelector('#setupPassword').value;
|
||||
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
|
||||
if (password !== passwordConfirm) {
|
||||
feedback.set('error', '两次输入的密码不一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
setupButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/setup', {
|
||||
username: document.querySelector('#setupUsername').value.trim(),
|
||||
password
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', (event) => {
|
||||
void handleLogin(event);
|
||||
});
|
||||
|
||||
setupForm.addEventListener('submit', (event) => {
|
||||
void handleSetup(event);
|
||||
});
|
||||
|
||||
void refreshAuthState().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
setSummaryRows([
|
||||
|
||||
@ -11,10 +11,23 @@ export function buildLoginUrl(nextPath = getCurrentRelativeUrl()) {
|
||||
return `${loginUrl.pathname}${loginUrl.search}`;
|
||||
}
|
||||
|
||||
export function buildSetupUrl(nextPath = getCurrentRelativeUrl()) {
|
||||
const setupUrl = new URL('/setup.html', window.location.origin);
|
||||
if (nextPath) {
|
||||
setupUrl.searchParams.set('next', nextPath);
|
||||
}
|
||||
|
||||
return `${setupUrl.pathname}${setupUrl.search}`;
|
||||
}
|
||||
|
||||
export function redirectToLogin(nextPath = getCurrentRelativeUrl()) {
|
||||
window.location.assign(buildLoginUrl(nextPath));
|
||||
}
|
||||
|
||||
export function redirectToSetup(nextPath = getCurrentRelativeUrl()) {
|
||||
window.location.assign(buildSetupUrl(nextPath));
|
||||
}
|
||||
|
||||
export function readNextPath(fallback = '/') {
|
||||
const nextPath = new URLSearchParams(window.location.search).get('next');
|
||||
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
|
||||
@ -51,7 +64,7 @@ export async function logoutCurrentSession() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function bindLogoutButton(button, labelElement, fallbackLabel = 'Signed in') {
|
||||
export function bindLogoutButton(button, labelElement, fallbackLabel = '已登录') {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { redirectToLogin } from './auth-client.js';
|
||||
import { redirectToLogin, redirectToSetup } from './auth-client.js';
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
@ -37,7 +37,11 @@ export async function fetchJson(url, options = {}) {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.location.pathname !== '/login.html') {
|
||||
redirectToLogin();
|
||||
if (payload.error?.code === 'AUTH_SETUP_REQUIRED') {
|
||||
redirectToSetup();
|
||||
} else {
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
|
||||
@ -87,15 +91,15 @@ export function linkToFile(sourceId, fileId) {
|
||||
export function inferEnhancementLabel(source) {
|
||||
switch (source?.enhancement?.status) {
|
||||
case 'ready':
|
||||
return 'Enhanced';
|
||||
return '已增强';
|
||||
case 'running':
|
||||
return 'Running';
|
||||
return '增强中';
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
return '排队中';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
return '增强失败';
|
||||
default:
|
||||
return 'Not ready';
|
||||
return '未增强';
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,7 +120,7 @@ export function inferEnhancementClass(source) {
|
||||
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
const title = escapeHtml(source.displayName || source.originalFilename);
|
||||
const originalFilename = escapeHtml(source.originalFilename);
|
||||
const matchedHint = source.matchedBackupName ? 'Task name resolved from Server DB' : 'Task name not mapped yet';
|
||||
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '尚未匹配任务名';
|
||||
const progress =
|
||||
source.enhancement.totalVolumes > 0
|
||||
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
|
||||
@ -127,8 +131,8 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
<div class="source-main">
|
||||
<div>
|
||||
<p class="source-name">${title}</p>
|
||||
<p class="source-meta">Database file: ${originalFilename}</p>
|
||||
<p class="source-meta">Source ID: ${escapeHtml(source.id)}</p>
|
||||
<p class="source-meta">数据库文件:${originalFilename}</p>
|
||||
<p class="source-meta">Source ID:${escapeHtml(source.id)}</p>
|
||||
</div>
|
||||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||||
</div>
|
||||
@ -137,19 +141,19 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat">
|
||||
<span>Uploaded size</span>
|
||||
<span>上传大小</span>
|
||||
<strong>${formatBytes(source.fileSize)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Latest snapshot</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? 'None')}</strong>
|
||||
<span>最新快照</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无')}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Browse</span>
|
||||
<strong>${source.canBrowse ? 'Available' : 'Unavailable'}</strong>
|
||||
<span>目录浏览</span>
|
||||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>Enhancement</span>
|
||||
<span>增强进度</span>
|
||||
<strong>${escapeHtml(progress)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
public/setup.html
Normal file
57
public/setup.html
Normal file
@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>初始化管理员账号</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell shell-auth">
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">First Run Setup</p>
|
||||
<h1>初始化管理员账号</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
系统首次启用时,需要先创建管理员用户名和密码。完成后会自动登录并进入系统。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="auth-layout">
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Setup</p>
|
||||
<h2>创建首个管理员</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
|
||||
|
||||
<form id="setupForm" class="secret-form">
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input id="setupUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
<span>再次输入密码</span>
|
||||
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
101
public/setup.js
Normal file
101
public/setup.js
Normal file
@ -0,0 +1,101 @@
|
||||
import { createFeedbackController, escapeHtml } from './modules/common.js';
|
||||
import { buildLoginUrl, loadAuthState, redirectToNextPath } from './modules/auth-client.js';
|
||||
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
const authSummaryElement = document.querySelector('#authSummary');
|
||||
const setupForm = document.querySelector('#setupForm');
|
||||
const setupButton = document.querySelector('#setupButton');
|
||||
|
||||
function setSummaryRows(rows) {
|
||||
authSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<div class="summary-row">
|
||||
<span>${escapeHtml(row.label)}</span>
|
||||
<strong>${escapeHtml(row.value)}</strong>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${body.error?.code ?? 'REQUEST_FAILED'}: ${body.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function refreshAuthState() {
|
||||
const auth = await loadAuthState();
|
||||
|
||||
if (auth.authenticated) {
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: `已登录:${auth.username}` },
|
||||
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
|
||||
]);
|
||||
redirectToNextPath('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.setupRequired) {
|
||||
window.location.assign(buildLoginUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '尚未配置管理员账号' },
|
||||
{ label: '下一步', value: '创建管理员账号并进入系统' }
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleSetup(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
|
||||
const password = document.querySelector('#setupPassword').value;
|
||||
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
|
||||
if (password !== passwordConfirm) {
|
||||
feedback.set('error', '两次输入的密码不一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
setupButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/setup', {
|
||||
username: document.querySelector('#setupUsername').value.trim(),
|
||||
password
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
setupForm.addEventListener('submit', (event) => {
|
||||
void handleSetup(event);
|
||||
});
|
||||
|
||||
void refreshAuthState().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: '无法读取认证信息' },
|
||||
{ label: '错误', value: error.message }
|
||||
]);
|
||||
});
|
||||
@ -54,6 +54,15 @@ function toWebRequest(request) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSafeNextTarget(value, fallback = '/') {
|
||||
const candidate = String(value ?? '').trim();
|
||||
if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function parseJsonBody(request) {
|
||||
if (!request.is('application/json')) {
|
||||
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Request body must be application/json.');
|
||||
@ -167,18 +176,44 @@ export async function createServerApp(overrides = {}) {
|
||||
}
|
||||
|
||||
authService.clearSessionCookie(response, request);
|
||||
response.redirect(302, authService.buildLoginRedirectUrl(request));
|
||||
response.redirect(302, authService.buildAuthRedirectUrl(request, authentication));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/login', (request, response) => {
|
||||
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
|
||||
response.redirect(302, `/login.html${nextTarget}`);
|
||||
app.get('/login', async (request, response, next) => {
|
||||
try {
|
||||
const authentication = await authService.resolveRequestAuth(request);
|
||||
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
|
||||
if (authentication.authenticated) {
|
||||
response.redirect(302, resolveSafeNextTarget(request.query.next, '/'));
|
||||
return;
|
||||
}
|
||||
|
||||
response.redirect(302, authentication.setupRequired ? `/setup.html${nextTarget}` : `/login.html${nextTarget}`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/setup', async (request, response, next) => {
|
||||
try {
|
||||
const authentication = await authService.resolveRequestAuth(request);
|
||||
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
|
||||
if (authentication.authenticated) {
|
||||
response.redirect(302, resolveSafeNextTarget(request.query.next, '/'));
|
||||
return;
|
||||
}
|
||||
|
||||
response.redirect(302, authentication.setupRequired ? `/setup.html${nextTarget}` : `/login.html${nextTarget}`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/login.html', sendPage('login.html'));
|
||||
app.get('/setup.html', sendPage('setup.html'));
|
||||
app.get('/', requirePageAuth, sendPage('index.html'));
|
||||
app.get('/index.html', requirePageAuth, sendPage('index.html'));
|
||||
app.get('/source.html', requirePageAuth, sendPage('source.html'));
|
||||
|
||||
@ -247,4 +247,17 @@ export class AuthService {
|
||||
const nextTarget = encodeURIComponent(request.originalUrl || '/');
|
||||
return `/login.html?next=${nextTarget}`;
|
||||
}
|
||||
|
||||
buildSetupRedirectUrl(request) {
|
||||
const nextTarget = encodeURIComponent(request.originalUrl || '/');
|
||||
return `/setup.html?next=${nextTarget}`;
|
||||
}
|
||||
|
||||
buildAuthRedirectUrl(request, resolution = null) {
|
||||
if (resolution?.setupRequired) {
|
||||
return this.buildSetupRedirectUrl(request);
|
||||
}
|
||||
|
||||
return this.buildLoginRedirectUrl(request);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user