mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
1191 lines
40 KiB
TypeScript
1191 lines
40 KiB
TypeScript
import { Env } from '../types';
|
|
import { StorageService } from '../services/storage';
|
|
import { htmlResponse } from '../utils/response';
|
|
import { JwtSecretState } from './setupPages';
|
|
|
|
function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {
|
|
const jwtStateJson = JSON.stringify(jwtState);
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>NodeWarden</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f3f4f6;
|
|
--card: #ffffff;
|
|
--border: #d0d5dd;
|
|
--text: #101828;
|
|
--muted: #475467;
|
|
--muted2: #667085;
|
|
--danger: #b42318;
|
|
--ok: #027a48;
|
|
--shadow: 0 16px 44px rgba(16, 24, 40, 0.08);
|
|
--radius: 20px;
|
|
--radius2: 16px;
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px 24px;
|
|
}
|
|
|
|
.shell {
|
|
width: min(980px, 100%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.panel {
|
|
border: 1px solid var(--border);
|
|
background: var(--card);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
padding: 34px 34px 24px;
|
|
min-height: 720px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.top {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.mark {
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 16px;
|
|
background: #111418;
|
|
border: 1px solid #111418;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 24px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.6px;
|
|
color: #ffffff;
|
|
text-transform: uppercase;
|
|
user-select: none;
|
|
}
|
|
|
|
.title { display: flex; flex-direction: column; gap: 4px; }
|
|
.title h1 { font-size: 30px; margin: 0; letter-spacing: -0.6px; }
|
|
.title p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; }
|
|
|
|
.message {
|
|
display: none;
|
|
border-radius: 12px;
|
|
padding: 14px;
|
|
margin: 0 0 12px 0;
|
|
font-size: 15px;
|
|
line-height: 1.45;
|
|
border: 1px solid var(--border);
|
|
background: #fafbfc;
|
|
flex-shrink: 0;
|
|
}
|
|
.message.error {
|
|
display: block;
|
|
border-color: #fecdca;
|
|
background: #fff6f5;
|
|
color: var(--danger);
|
|
}
|
|
.message.success {
|
|
display: block;
|
|
border-color: #abefc6;
|
|
background: #f0fdf4;
|
|
color: var(--ok);
|
|
}
|
|
|
|
.stage {
|
|
position: relative;
|
|
min-height: 500px;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.step {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
transform: translateX(16px);
|
|
pointer-events: none;
|
|
transition: opacity 180ms ease, transform 180ms ease;
|
|
overflow: auto;
|
|
padding-right: 4px;
|
|
}
|
|
.step.active {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
h2 { font-size: 22px; margin: 8px 0 14px; letter-spacing: -0.3px; }
|
|
h3 { font-size: 17px; margin: 0 0 10px; color: #1d2939; }
|
|
.lead { margin: 0; color: #344054; font-size: 16px; line-height: 1.75; }
|
|
|
|
.kv {
|
|
border-radius: var(--radius2);
|
|
border: 1px solid var(--border);
|
|
background: #fafbfc;
|
|
padding: 18px;
|
|
margin-top: 14px;
|
|
}
|
|
.kv p { margin: 0; font-size: 15px; line-height: 1.65; color: var(--muted); }
|
|
.kv ul, .kv ol { margin: 8px 0 0 20px; padding: 0; color: var(--muted); line-height: 1.72; }
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 14px;
|
|
margin-top: 14px;
|
|
}
|
|
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
|
|
|
.field { display: flex; flex-direction: column; gap: 7px; margin-top: 10px; }
|
|
label { font-size: 14px; color: var(--muted); letter-spacing: 0.1px; }
|
|
input {
|
|
height: 50px;
|
|
padding: 0 14px;
|
|
border-radius: 14px;
|
|
border: 1px solid #d5dae1;
|
|
background: #ffffff;
|
|
color: var(--text);
|
|
outline: none;
|
|
font-size: 16px;
|
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
}
|
|
input::placeholder { color: #98a2b3; }
|
|
input:focus {
|
|
border-color: #111418;
|
|
box-shadow: 0 0 0 5px rgba(17, 20, 24, 0.08);
|
|
}
|
|
|
|
.hint { margin: 0; color: var(--muted2); font-size: 14px; line-height: 1.6; }
|
|
|
|
.btn {
|
|
height: 46px;
|
|
min-width: 100px;
|
|
padding: 0 16px;
|
|
border-radius: 14px;
|
|
border: 1px solid #d5dae1;
|
|
background: #ffffff;
|
|
color: #111418;
|
|
font-weight: 700;
|
|
font-size: 15px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
transition: filter 120ms ease;
|
|
}
|
|
.btn:hover { filter: brightness(0.99); }
|
|
.btn:disabled { opacity: 0.55; cursor: not-allowed; filter: none; }
|
|
.btn.primary {
|
|
border-color: #111418;
|
|
background: #111418;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.server, .code {
|
|
margin-top: 10px;
|
|
font-family: var(--mono);
|
|
font-size: 14px;
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
background: #ffffff;
|
|
border: 1px solid #d5dae1;
|
|
color: #111418;
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.mode-tabs {
|
|
display: inline-flex;
|
|
border: 1px solid #d5dae1;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
margin-bottom: 12px;
|
|
}
|
|
.mode-tab {
|
|
border: none;
|
|
background: #fff;
|
|
color: #111418;
|
|
padding: 9px 12px;
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
.mode-tab.active { background: #111418; color: #fff; }
|
|
|
|
.mode-panel { display: none; }
|
|
.mode-panel.active { display: block; }
|
|
|
|
.flow-bottom {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
min-height: 48px;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.flow-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.flow-actions.right {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.dots {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
min-height: 26px;
|
|
}
|
|
|
|
.dot {
|
|
width: 9px;
|
|
height: 9px;
|
|
border-radius: 999px;
|
|
background: #cfd5de;
|
|
transition: all 120ms ease;
|
|
}
|
|
.dot.active {
|
|
width: 24px;
|
|
height: 10px;
|
|
background: #111418;
|
|
}
|
|
|
|
.footer {
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
font-size: 14px;
|
|
color: var(--muted2);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
a { color: #175cd3; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<aside class="panel">
|
|
<div class="top">
|
|
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
<div class="title">
|
|
<h1 id="t_app">NodeWarden</h1>
|
|
<p id="t_tag">Minimal Bitwarden-compatible server on Cloudflare Workers.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="message" class="message"></div>
|
|
|
|
<div class="stage">
|
|
<section id="step1" class="step active">
|
|
<h2 id="t_s1_title">Welcome</h2>
|
|
<p class="lead" id="t_s1_desc"></p>
|
|
<div class="kv">
|
|
<h3 id="t_s1_adv_title">Highlights</h3>
|
|
<ul>
|
|
<li id="t_s1_adv_1"></li>
|
|
<li id="t_s1_adv_2"></li>
|
|
<li id="t_s1_adv_3"></li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="step2" class="step">
|
|
<h2 id="t_s2_title">JWT secret check</h2>
|
|
<p class="lead" id="t_s2_desc"></p>
|
|
|
|
<div class="grid">
|
|
<div class="kv">
|
|
<h3 id="t_s2_fix_title">Fix steps</h3>
|
|
<ol id="s2_steps_list"></ol>
|
|
</div>
|
|
<div class="kv">
|
|
<h3 id="t_s2_gen_title">Random JWT_SECRET</h3>
|
|
<p id="t_s2_gen_desc"></p>
|
|
<div class="server" id="secret"></div>
|
|
<div style="height:10px"></div>
|
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
|
<button class="btn primary" type="button" id="refreshSecretBtn" onclick="refreshSecret()">Refresh</button>
|
|
<button class="btn" type="button" id="copySecretBtn" onclick="copySecret()">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="step3" class="step">
|
|
<h2 id="t_s3_title">Create account</h2>
|
|
<p class="lead" id="t_s3_desc"></p>
|
|
|
|
<div id="setup-form">
|
|
<form id="form" onsubmit="handleSubmit(event)">
|
|
<div class="grid">
|
|
<div class="field">
|
|
<label for="name" id="t_name_label">Name</label>
|
|
<input type="text" id="name" name="name" required placeholder="Your name" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="email" id="t_email_label">Email</label>
|
|
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="password" id="t_pw_label">Master password</label>
|
|
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password" />
|
|
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
|
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password" />
|
|
</div>
|
|
|
|
<div style="margin-top: 14px;">
|
|
<button type="submit" id="submitBtn" class="btn primary" style="width:100%; height:52px;">Create account</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div id="registered-inline" class="kv" style="display:none;">
|
|
<h3 id="t_registered_inline_title">Account already created</h3>
|
|
<p id="t_registered_inline_desc"></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="step4" class="step">
|
|
<h2 id="t_s4_title">Sync setup</h2>
|
|
<p class="lead" id="t_s4_desc"></p>
|
|
|
|
<div class="kv">
|
|
<h3 id="t_s4_common_title">Common required steps</h3>
|
|
<ol>
|
|
<li id="t_s4_common_1"></li>
|
|
<li id="t_s4_common_2"></li>
|
|
<li id="t_s4_common_3"></li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="kv">
|
|
<div class="mode-tabs">
|
|
<button class="mode-tab active" id="manualTab" onclick="setSyncMode('manual')">Manual sync</button>
|
|
<button class="mode-tab" id="autoTab" onclick="setSyncMode('auto')">Auto sync</button>
|
|
</div>
|
|
|
|
<div id="manualPanel" class="mode-panel active">
|
|
<p id="t_s4_manual_text"></p>
|
|
<ol>
|
|
<li id="t_s4_manual_1"></li>
|
|
<li id="t_s4_manual_2"></li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div id="autoPanel" class="mode-panel">
|
|
<p id="t_s4_auto_text"></p>
|
|
<ol>
|
|
<li id="t_s4_auto_1"></li>
|
|
<li id="t_s4_auto_2"></li>
|
|
<li id="t_s4_auto_3"></li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="hint" id="t_s4_hint"></p>
|
|
</section>
|
|
|
|
<section id="step5" class="step">
|
|
<h2 id="t_s5_title">Done</h2>
|
|
<p class="lead" id="t_s5_desc"></p>
|
|
|
|
<div class="kv">
|
|
<h3 id="t_server_title">Server URL</h3>
|
|
<div class="server" id="serverUrl"></div>
|
|
</div>
|
|
|
|
<div class="kv">
|
|
<h3 id="t_limit_title">Important</h3>
|
|
<p id="t_limit_text"></p>
|
|
</div>
|
|
|
|
<div class="kv">
|
|
<h3 id="t_hide_title">Hide setup page</h3>
|
|
<p id="t_hide_desc"></p>
|
|
<div style="margin-top:10px;">
|
|
<button type="button" id="hideBtn" class="btn primary" onclick="disableSetupPage()">Hide setup page</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<div><span id="t_by">By</span> <a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a></div>
|
|
<div><a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a></div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="flow-bottom">
|
|
<div class="flow-actions">
|
|
<button id="prevBtn" class="btn" type="button">Previous</button>
|
|
</div>
|
|
<div class="dots">
|
|
<span class="dot active" data-step="1"></span>
|
|
<span class="dot" data-step="2"></span>
|
|
<span class="dot" data-step="3"></span>
|
|
<span class="dot" data-step="4"></span>
|
|
<span class="dot" data-step="5"></span>
|
|
</div>
|
|
<div class="flow-actions right">
|
|
<button id="nextBtn" class="btn primary" type="button">Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const JWT_STATE = ${jwtStateJson};
|
|
let isRegistered = false;
|
|
let currentStep = 1;
|
|
let syncMode = 'manual';
|
|
|
|
function isChinese() {
|
|
const lang = (navigator.language || '').toLowerCase();
|
|
return lang.startsWith('zh');
|
|
}
|
|
|
|
function t(key) {
|
|
const zh = {
|
|
app: 'NodeWarden',
|
|
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
|
|
by: '作者',
|
|
|
|
s1Title: '恭喜你,NodeWarden 部署成功',
|
|
s1Desc: '这是一个无需自建服务器的 Bitwarden 第三方服务端:部署快、维护轻、可直接使用官方客户端连接。点击右下角“下一页”开始。',
|
|
s1AdvTitle: '核心优势',
|
|
s1Adv1: '无需 VPS,直接运行在 Cloudflare Workers',
|
|
s1Adv2: '兼容 Bitwarden 官方客户端',
|
|
s1Adv3: '单用户场景更简单、维护成本更低',
|
|
|
|
s2Title: '环境检测:JWT_SECRET',
|
|
s2DescGood: 'JWT_SECRET 已通过检测,此步骤会自动跳过。',
|
|
s2DescMissing: '检测到 JWT_SECRET 未配置,请先添加。',
|
|
s2DescDefault: '检测到 JWT_SECRET 仍为默认值,请先更换。',
|
|
s2DescShort: '检测到 JWT_SECRET 长度小于 32,请先更换。',
|
|
s2FixTitleAdd: '当前是“未配置”,请按以下步骤添加:',
|
|
s2FixTitleReplace: '当前是“默认值/长度不足”,请按以下步骤更换:',
|
|
s2Step1: 'Cloudflare 控制台 → Workers 和 Pages → 你的 nodewarden 服务。',
|
|
s2Step2Add: '设置 → 变量和机密 → 新增变量 JWT_SECRET(类型选“密钥”)。',
|
|
s2Step2Replace: '设置 → 变量和机密 → 找到 JWT_SECRET 并编辑为新的随机值。',
|
|
s2Step3: '保存并等待部署完成。',
|
|
s2Step4: '设置完以后,刷新当前页面继续。',
|
|
s2GenTitle: '随机密钥生成器',
|
|
s2GenDesc: '建议至少 32 位,推荐 64 位。复制后粘贴到 JWT_SECRET。',
|
|
refresh: '刷新',
|
|
copy: '复制',
|
|
copied: '已复制',
|
|
|
|
s3Title: '先创建账号',
|
|
s3Desc: '请先创建你的唯一账号。创建完成后会进入同步设置。',
|
|
nameLabel: '昵称',
|
|
emailLabel: '邮箱',
|
|
pwLabel: '主密码',
|
|
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
|
pw2Label: '确认主密码',
|
|
create: '创建账号',
|
|
creating: '正在创建…',
|
|
regExistsTitle: '账号已创建',
|
|
regExistsDesc: '当前实例已注册,注册步骤已跳过。请继续下一页进行同步设置。',
|
|
|
|
s4Title: '同步设置',
|
|
s4Desc: '你可以手动同步或自动同步;本页内容可稍后再做,不影响当前使用。',
|
|
s4CommonTitle: '共同前置步骤',
|
|
s4Common1: '如果还没 fork,请先 fork 本项目。',
|
|
s4Common2: 'Cloudflare 控制台 → Workers 和 Pages → 你的服务 → 设置 → Builds / Git repository → 先 Disconnect(解绑原一键部署仓库)。',
|
|
s4Common3: '在同一位置点击 Connect repository,选择你自己的 fork 仓库和分支完成绑定。',
|
|
manualSync: '手动同步',
|
|
autoSync: '自动同步',
|
|
s4ManualText: '手动同步(GitHub 网页一键完成):',
|
|
s4Manual1: '打开你的 fork 仓库首页,看到上游更新提示时,点击 “Sync fork”。',
|
|
s4Manual2: '在弹窗中点击 “Update branch”。',
|
|
s4AutoText: '自动同步(不用看代码):',
|
|
s4Auto1: '你的 fork 会带上工作流文件,你只需要打开仓库的 Actions 页面。',
|
|
s4Auto2: '点击 “I understand my workflows, go ahead and enable them” 启用。',
|
|
s4Auto3: '启用后默认每天凌晨 3 点自动同步,也可手动 Run workflow。',
|
|
s4Hint: '提示:这一步可稍后再做,随时可以回来。',
|
|
|
|
s5Title: '全部完成',
|
|
s5Desc: '引导结束。你现在可以在 Bitwarden 客户端中使用你的服务地址登录。',
|
|
serverTitle: '服务地址',
|
|
limitTitle: '重要提示',
|
|
limitText: '本项目为单用户设计:不能添加新用户;不支持修改主密码;若忘记主密码需重新部署并重新注册。',
|
|
hideTitle: '隐藏初始化页',
|
|
hideDesc: '隐藏后,初始化页会返回 404;不影响你正常使用密码库。',
|
|
hideBtn: '隐藏初始化页',
|
|
hideWorking: '正在隐藏…',
|
|
hideDone: '已隐藏,此页面将返回 404。',
|
|
hideFailed: '隐藏失败',
|
|
hideConfirm: '确认隐藏初始化页?隐藏后页面不可访问,但不影响密码库使用。',
|
|
|
|
prev: '上一页',
|
|
next: '下一页',
|
|
finish: '完成',
|
|
refreshToContinue: '设置完请刷新页面',
|
|
errPwNotMatch: '两次输入的密码不一致',
|
|
errPwTooShort: '密码长度至少 12 位',
|
|
errGeneric: '发生错误:',
|
|
errRegisterFailed: '注册失败',
|
|
};
|
|
|
|
const en = {
|
|
app: 'NodeWarden',
|
|
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
|
|
by: 'By',
|
|
|
|
s1Title: 'NodeWarden deployed successfully',
|
|
s1Desc: 'A Bitwarden-compatible server without managing your own VPS: quick deploy, low maintenance, and official client compatibility. Click Next to continue.',
|
|
s1AdvTitle: 'Highlights',
|
|
s1Adv1: 'No VPS required, runs on Cloudflare Workers',
|
|
s1Adv2: 'Compatible with official Bitwarden clients',
|
|
s1Adv3: 'Simple and stable single-user experience',
|
|
|
|
s2Title: 'Environment check: JWT_SECRET',
|
|
s2DescGood: 'JWT_SECRET is valid. This step will be skipped automatically.',
|
|
s2DescMissing: 'JWT_SECRET is missing. Add it first.',
|
|
s2DescDefault: 'JWT_SECRET is still default/sample. Replace it first.',
|
|
s2DescShort: 'JWT_SECRET is shorter than 32 chars. Replace it first.',
|
|
s2FixTitleAdd: 'Current state is missing, add it:',
|
|
s2FixTitleReplace: 'Current state is default/too short, replace it:',
|
|
s2Step1: 'Cloudflare Dashboard → Workers & Pages → your nodewarden service.',
|
|
s2Step2Add: 'Settings → Variables and Secrets → add JWT_SECRET (Secret type).',
|
|
s2Step2Replace: 'Settings → Variables and Secrets → edit JWT_SECRET with a new random value.',
|
|
s2Step3: 'Save and wait for deploy to finish.',
|
|
s2Step4: 'After setting it, refresh this page to continue.',
|
|
s2GenTitle: 'Random secret generator',
|
|
s2GenDesc: 'Use 32+ chars (64 recommended). Copy and paste into JWT_SECRET.',
|
|
refresh: 'Refresh',
|
|
copy: 'Copy',
|
|
copied: 'Copied',
|
|
|
|
s3Title: 'Create account first',
|
|
s3Desc: 'Create your single account first. After success, you will enter sync setup.',
|
|
nameLabel: 'Name',
|
|
emailLabel: 'Email',
|
|
pwLabel: 'Master password',
|
|
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
|
pw2Label: 'Confirm password',
|
|
create: 'Create account',
|
|
creating: 'Creating…',
|
|
regExistsTitle: 'Account already exists',
|
|
regExistsDesc: 'This instance is already registered. Registration step is skipped. Continue to sync setup.',
|
|
|
|
s4Title: 'Sync setup',
|
|
s4Desc: 'You can choose manual or automatic sync. You may also do this later.',
|
|
s4CommonTitle: 'Common prerequisites',
|
|
s4Common1: 'If you have not forked yet, fork this project first.',
|
|
s4Common2: 'Cloudflare Dashboard → Workers & Pages → your service → Settings → Builds / Git repository → Disconnect current one-click-deploy repo.',
|
|
s4Common3: 'On the same page, click Connect repository, then choose your fork repo and branch.',
|
|
manualSync: 'Manual sync',
|
|
autoSync: 'Auto sync',
|
|
s4ManualText: 'Manual sync (one click on GitHub UI):',
|
|
s4Manual1: 'Open your fork repository home page, then click “Sync fork” when updates are available.',
|
|
s4Manual2: 'Click “Update branch” in the dialog.',
|
|
s4AutoText: 'Automatic sync (no code needed):',
|
|
s4Auto1: 'Your fork includes workflow files; just open the repository Actions page.',
|
|
s4Auto2: 'Click “I understand my workflows, go ahead and enable them”.',
|
|
s4Auto3: 'After enabled, it runs every day at 03:00, and can be run manually as well.',
|
|
s4Hint: 'Tip: you can do this later at any time.',
|
|
|
|
s5Title: 'All done',
|
|
s5Desc: 'Onboarding is complete. You can now sign in from Bitwarden clients with your server URL.',
|
|
serverTitle: 'Server URL',
|
|
limitTitle: 'Important',
|
|
limitText: 'Single-user design: no extra users, no master password change. If forgotten, redeploy and register again.',
|
|
hideTitle: 'Hide setup page',
|
|
hideDesc: 'After hiding, setup page returns 404; vault usage is unaffected.',
|
|
hideBtn: 'Hide setup page',
|
|
hideWorking: 'Hiding…',
|
|
hideDone: 'Hidden. This page will now return 404.',
|
|
hideFailed: 'Failed to hide setup page',
|
|
hideConfirm: 'Hide setup page? It will be unavailable, but vault usage is unaffected.',
|
|
|
|
prev: 'Previous',
|
|
next: 'Next',
|
|
finish: 'Finish',
|
|
refreshToContinue: 'Refresh page after updating secret',
|
|
errPwNotMatch: 'Passwords do not match',
|
|
errPwTooShort: 'Password must be at least 12 characters',
|
|
errGeneric: 'An error occurred: ',
|
|
errRegisterFailed: 'Registration failed',
|
|
};
|
|
|
|
return (isChinese() ? zh : en)[key] || key;
|
|
}
|
|
|
|
function setText(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = value;
|
|
}
|
|
|
|
function renderStep2FixList() {
|
|
const list = document.getElementById('s2_steps_list');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
|
|
if (!JWT_STATE) {
|
|
const li = document.createElement('li');
|
|
li.textContent = isChinese() ? 'JWT_SECRET 已通过检测,无需修改。' : 'JWT_SECRET is valid. No change required.';
|
|
list.appendChild(li);
|
|
return;
|
|
}
|
|
|
|
const titleLi = document.createElement('li');
|
|
titleLi.style.fontWeight = '700';
|
|
titleLi.style.color = '#1d2939';
|
|
titleLi.textContent = JWT_STATE === 'missing' ? t('s2FixTitleAdd') : t('s2FixTitleReplace');
|
|
list.appendChild(titleLi);
|
|
|
|
const steps = [
|
|
t('s2Step1'),
|
|
JWT_STATE === 'missing' ? t('s2Step2Add') : t('s2Step2Replace'),
|
|
t('s2Step3'),
|
|
t('s2Step4'),
|
|
];
|
|
|
|
steps.forEach((text) => {
|
|
const li = document.createElement('li');
|
|
li.textContent = text;
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function applyI18n() {
|
|
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
|
setText('t_app', t('app'));
|
|
setText('t_tag', t('tag'));
|
|
setText('t_by', t('by'));
|
|
|
|
setText('t_s1_title', t('s1Title'));
|
|
setText('t_s1_desc', t('s1Desc'));
|
|
setText('t_s1_adv_title', t('s1AdvTitle'));
|
|
setText('t_s1_adv_1', t('s1Adv1'));
|
|
setText('t_s1_adv_2', t('s1Adv2'));
|
|
setText('t_s1_adv_3', t('s1Adv3'));
|
|
|
|
setText('t_s2_title', t('s2Title'));
|
|
setText('t_s2_gen_title', t('s2GenTitle'));
|
|
setText('t_s2_gen_desc', t('s2GenDesc'));
|
|
setText('refreshSecretBtn', t('refresh'));
|
|
setText('copySecretBtn', t('copy'));
|
|
|
|
setText('t_s3_title', t('s3Title'));
|
|
setText('t_s3_desc', t('s3Desc'));
|
|
setText('t_name_label', t('nameLabel'));
|
|
setText('t_email_label', t('emailLabel'));
|
|
setText('t_pw_label', t('pwLabel'));
|
|
setText('t_pw_hint', t('pwHint'));
|
|
setText('t_pw2_label', t('pw2Label'));
|
|
setText('submitBtn', t('create'));
|
|
setText('t_registered_inline_title', t('regExistsTitle'));
|
|
setText('t_registered_inline_desc', t('regExistsDesc'));
|
|
|
|
setText('t_s4_title', t('s4Title'));
|
|
setText('t_s4_desc', t('s4Desc'));
|
|
setText('t_s4_common_title', t('s4CommonTitle'));
|
|
setText('t_s4_common_1', t('s4Common1'));
|
|
setText('t_s4_common_2', t('s4Common2'));
|
|
setText('t_s4_common_3', t('s4Common3'));
|
|
setText('manualTab', t('manualSync'));
|
|
setText('autoTab', t('autoSync'));
|
|
setText('t_s4_manual_text', t('s4ManualText'));
|
|
setText('t_s4_manual_1', t('s4Manual1'));
|
|
setText('t_s4_manual_2', t('s4Manual2'));
|
|
setText('t_s4_auto_text', t('s4AutoText'));
|
|
setText('t_s4_auto_1', t('s4Auto1'));
|
|
setText('t_s4_auto_2', t('s4Auto2'));
|
|
setText('t_s4_auto_3', t('s4Auto3'));
|
|
setText('t_s4_hint', t('s4Hint'));
|
|
|
|
setText('t_s5_title', t('s5Title'));
|
|
setText('t_s5_desc', t('s5Desc'));
|
|
setText('t_server_title', t('serverTitle'));
|
|
setText('t_limit_title', t('limitTitle'));
|
|
setText('t_limit_text', t('limitText'));
|
|
setText('t_hide_title', t('hideTitle'));
|
|
setText('t_hide_desc', t('hideDesc'));
|
|
setText('hideBtn', t('hideBtn'));
|
|
|
|
setText('prevBtn', t('prev'));
|
|
setText('nextBtn', t('next'));
|
|
|
|
const d = document.getElementById('t_s2_desc');
|
|
if (d) {
|
|
if (!JWT_STATE) d.textContent = t('s2DescGood');
|
|
else if (JWT_STATE === 'missing') d.textContent = t('s2DescMissing');
|
|
else if (JWT_STATE === 'default') d.textContent = t('s2DescDefault');
|
|
else d.textContent = t('s2DescShort');
|
|
}
|
|
|
|
renderStep2FixList();
|
|
}
|
|
|
|
function refreshSecret() {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
const bytes = new Uint8Array(64);
|
|
crypto.getRandomValues(bytes);
|
|
let out = '';
|
|
for (let i = 0; i < bytes.length; i++) out += chars[bytes[i] % chars.length];
|
|
const el = document.getElementById('secret');
|
|
if (el) el.textContent = out;
|
|
}
|
|
|
|
async function copySecret() {
|
|
const el = document.getElementById('secret');
|
|
if (!el) return;
|
|
const value = el.textContent || '';
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
} catch {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = value;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
ta.remove();
|
|
}
|
|
const btn = document.getElementById('copySecretBtn');
|
|
if (btn) {
|
|
const old = btn.textContent;
|
|
btn.textContent = t('copied');
|
|
setTimeout(() => { btn.textContent = old; }, 1000);
|
|
}
|
|
}
|
|
|
|
function setSyncMode(mode) {
|
|
syncMode = mode;
|
|
const manualTab = document.getElementById('manualTab');
|
|
const autoTab = document.getElementById('autoTab');
|
|
const manualPanel = document.getElementById('manualPanel');
|
|
const autoPanel = document.getElementById('autoPanel');
|
|
if (!manualTab || !autoTab || !manualPanel || !autoPanel) return;
|
|
const isManual = mode === 'manual';
|
|
manualTab.classList.toggle('active', isManual);
|
|
autoTab.classList.toggle('active', !isManual);
|
|
manualPanel.classList.toggle('active', isManual);
|
|
autoPanel.classList.toggle('active', !isManual);
|
|
}
|
|
|
|
function visibleSteps() {
|
|
// 流程固定为:
|
|
// 1 欢迎 → 2 密钥检测(若已通过自动跳过) → 3 注册(已注册则跳过) → 4 同步 → 5 完成
|
|
const steps = [1];
|
|
if (JWT_STATE) steps.push(2);
|
|
if (!isRegistered) steps.push(3);
|
|
steps.push(4, 5);
|
|
return steps;
|
|
}
|
|
|
|
function goToStep(step) {
|
|
const steps = visibleSteps();
|
|
if (!steps.includes(step)) {
|
|
step = steps[0];
|
|
}
|
|
currentStep = step;
|
|
|
|
for (let i = 1; i <= 5; i++) {
|
|
const el = document.getElementById('step' + i);
|
|
if (el) el.classList.toggle('active', i === currentStep);
|
|
}
|
|
|
|
const dots = document.querySelectorAll('.dot');
|
|
dots.forEach((dot) => {
|
|
const s = Number(dot.getAttribute('data-step'));
|
|
dot.classList.toggle('active', s === currentStep);
|
|
dot.style.display = steps.includes(s) ? 'inline-block' : 'none';
|
|
});
|
|
|
|
updateNavButtons();
|
|
}
|
|
|
|
function updateNavButtons() {
|
|
const steps = visibleSteps();
|
|
const idx = steps.indexOf(currentStep);
|
|
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
if (!prevBtn || !nextBtn) return;
|
|
|
|
prevBtn.style.visibility = idx <= 0 ? 'hidden' : 'visible';
|
|
|
|
// 第 5 页为完成页:隐藏下一页
|
|
if (idx === steps.length - 1) {
|
|
nextBtn.style.visibility = 'hidden';
|
|
} else {
|
|
nextBtn.style.visibility = 'visible';
|
|
}
|
|
|
|
// 第 2 页且密钥异常:不允许下一页,必须刷新后继续
|
|
if (currentStep === 2 && !!JWT_STATE) {
|
|
nextBtn.disabled = true;
|
|
nextBtn.textContent = t('refreshToContinue');
|
|
} else {
|
|
nextBtn.disabled = false;
|
|
nextBtn.textContent = t('next');
|
|
}
|
|
}
|
|
|
|
function nextStep() {
|
|
const steps = visibleSteps();
|
|
const idx = steps.indexOf(currentStep);
|
|
if (idx < 0 || idx >= steps.length - 1) return;
|
|
goToStep(steps[idx + 1]);
|
|
}
|
|
|
|
function prevStep() {
|
|
const steps = visibleSteps();
|
|
const idx = steps.indexOf(currentStep);
|
|
if (idx <= 0) return;
|
|
goToStep(steps[idx - 1]);
|
|
}
|
|
|
|
function showMessage(text, type) {
|
|
const msg = document.getElementById('message');
|
|
if (!msg) return;
|
|
msg.textContent = text;
|
|
msg.className = 'message ' + type;
|
|
}
|
|
|
|
function showRegisteredState() {
|
|
isRegistered = true;
|
|
const form = document.getElementById('setup-form');
|
|
const inline = document.getElementById('registered-inline');
|
|
if (form) form.style.display = 'none';
|
|
if (inline) inline.style.display = 'block';
|
|
|
|
const serverUrl = document.getElementById('serverUrl');
|
|
if (serverUrl) serverUrl.textContent = window.location.origin;
|
|
}
|
|
|
|
async function checkStatus() {
|
|
try {
|
|
const res = await fetch('/setup/status');
|
|
const data = await res.json();
|
|
if (data && data.registered) {
|
|
showRegisteredState();
|
|
// 已注册时直接从同步步骤开始(不再显示注册页)
|
|
goToStep(4);
|
|
} else {
|
|
goToStep(1);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check status:', e);
|
|
goToStep(1);
|
|
}
|
|
}
|
|
|
|
async function disableSetupPage() {
|
|
if (!isRegistered) return;
|
|
if (!confirm(t('hideConfirm'))) return;
|
|
|
|
const btn = document.getElementById('hideBtn');
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = t('hideWorking');
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/setup/disable', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
showMessage(t('hideDone'), 'success');
|
|
setTimeout(() => window.location.reload(), 700);
|
|
return;
|
|
}
|
|
showMessage((data && data.error) || t('hideFailed'), 'error');
|
|
} catch {
|
|
showMessage(t('hideFailed'), 'error');
|
|
}
|
|
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = t('hideBtn');
|
|
}
|
|
}
|
|
|
|
async function pbkdf2(password, salt, iterations, keyLen) {
|
|
const encoder = new TextEncoder();
|
|
const passwordBytes = (password instanceof Uint8Array) ? password : encoder.encode(password);
|
|
const saltBytes = (salt instanceof Uint8Array) ? salt : encoder.encode(salt);
|
|
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
passwordBytes,
|
|
'PBKDF2',
|
|
false,
|
|
['deriveBits']
|
|
);
|
|
|
|
const derivedBits = await crypto.subtle.deriveBits(
|
|
{ name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' },
|
|
keyMaterial,
|
|
keyLen * 8
|
|
);
|
|
|
|
return new Uint8Array(derivedBits);
|
|
}
|
|
|
|
async function hkdfExpand(prk, info, length) {
|
|
const encoder = new TextEncoder();
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
prk,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const infoBytes = encoder.encode(info);
|
|
const result = new Uint8Array(length);
|
|
let prev = new Uint8Array(0);
|
|
let offset = 0;
|
|
let counter = 1;
|
|
|
|
while (offset < length) {
|
|
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
|
input.set(prev);
|
|
input.set(infoBytes, prev.length);
|
|
input[input.length - 1] = counter;
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', key, input);
|
|
prev = new Uint8Array(signature);
|
|
|
|
const toCopy = Math.min(prev.length, length - offset);
|
|
result.set(prev.slice(0, toCopy), offset);
|
|
offset += toCopy;
|
|
counter++;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function generateSymmetricKey() {
|
|
return crypto.getRandomValues(new Uint8Array(64));
|
|
}
|
|
|
|
async function encryptAesCbc(data, key, iv) {
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
key,
|
|
{ name: 'AES-CBC' },
|
|
false,
|
|
['encrypt']
|
|
);
|
|
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: 'AES-CBC', iv: iv },
|
|
cryptoKey,
|
|
data
|
|
);
|
|
|
|
return new Uint8Array(encrypted);
|
|
}
|
|
|
|
async function hmacSha256(key, data) {
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
key,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
return new Uint8Array(signature);
|
|
}
|
|
|
|
function base64Encode(bytes) {
|
|
return btoa(String.fromCharCode.apply(null, bytes));
|
|
}
|
|
|
|
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
const encrypted = await encryptAesCbc(data, encKey, iv);
|
|
|
|
const macData = new Uint8Array(iv.length + encrypted.length);
|
|
macData.set(iv);
|
|
macData.set(encrypted, iv.length);
|
|
const mac = await hmacSha256(macKey, macData);
|
|
|
|
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
|
}
|
|
|
|
async function generateRsaKeyPair() {
|
|
const keyPair = await crypto.subtle.generateKey(
|
|
{
|
|
name: 'RSA-OAEP',
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: 'SHA-1'
|
|
},
|
|
true,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
|
|
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
|
|
|
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
|
|
|
return {
|
|
publicKey: publicKeyB64,
|
|
privateKey: privateKeyBytes
|
|
};
|
|
}
|
|
|
|
async function handleSubmit(event) {
|
|
event.preventDefault();
|
|
|
|
if (isRegistered) {
|
|
nextStep();
|
|
return;
|
|
}
|
|
|
|
const name = document.getElementById('name').value;
|
|
const email = document.getElementById('email').value.toLowerCase();
|
|
const password = document.getElementById('password').value;
|
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
|
|
if (password !== confirmPassword) {
|
|
showMessage(t('errPwNotMatch'), 'error');
|
|
return;
|
|
}
|
|
|
|
if (password.length < 12) {
|
|
showMessage(t('errPwTooShort'), 'error');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('submitBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = t('creating');
|
|
|
|
try {
|
|
const iterations = 600000;
|
|
const masterKey = await pbkdf2(password, email, iterations, 32);
|
|
|
|
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
|
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
|
|
|
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
|
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
|
|
const symmetricKey = generateSymmetricKey();
|
|
|
|
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
|
|
|
const rsaKeys = await generateRsaKeyPair();
|
|
|
|
const encryptedPrivateKey = await encryptToBitwardenFormat(
|
|
rsaKeys.privateKey,
|
|
symmetricKey.slice(0, 32),
|
|
symmetricKey.slice(32, 64)
|
|
);
|
|
|
|
const response = await fetch('/api/accounts/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: email,
|
|
name: name,
|
|
masterPasswordHash: masterPasswordHashB64,
|
|
key: encryptedKey,
|
|
kdf: 0,
|
|
kdfIterations: iterations,
|
|
keys: {
|
|
publicKey: rsaKeys.publicKey,
|
|
encryptedPrivateKey: encryptedPrivateKey
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.success) {
|
|
showRegisteredState();
|
|
showMessage(t('s5Title'), 'success');
|
|
// 关键:先注册,再显示同步页
|
|
goToStep(4);
|
|
} else {
|
|
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = t('create');
|
|
}
|
|
} catch (error) {
|
|
console.error('Registration error:', error);
|
|
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = t('create');
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
applyI18n();
|
|
refreshSecret();
|
|
setSyncMode('manual');
|
|
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
if (prevBtn) prevBtn.addEventListener('click', prevStep);
|
|
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
if (nextBtn) nextBtn.addEventListener('click', nextStep);
|
|
|
|
checkStatus();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
|
|
const storage = new StorageService(env.DB);
|
|
const disabled = await storage.isSetupDisabled();
|
|
if (disabled) {
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
return htmlResponse(renderRegisterPageHTML(jwtState));
|
|
} |