Scert/frontend/src/views/SetupWizard/SetupWizard.vue

709 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { useSetupWizard } from "./SetupWizard.js";
import AccessShell from "../components/AccessShell/AccessShell.vue";
const props = defineProps({
status: {
type: Object,
required: true,
},
});
const emit = defineEmits(["initialized"]);
const {
scertLogo,
submitting,
error,
resultMessage,
dnsCatalogLoading,
dnsCatalogError,
dnsTesting,
dnsTestError,
dnsProviders,
dnsCredentials,
dnsCredentialsVisible,
maskCredential,
dnsTestDomain,
dnsVerification,
currentStep,
targetType,
form,
commonEmailDomains,
selectedEmailDomain,
emailLocalPart,
targetLabel,
resetURLPreview,
selectedDNSProvider,
dnsVerificationValid,
formatVerificationExpiry,
applyEmailDomain,
handleEmailInput,
testDNS,
handleSubmit,
previousStep,
} = useSetupWizard(props, emit);
</script>
<template>
<AccessShell variant="setup" aria-label="Scert 初始化向导">
<template #brand>
<img class="access-logo" :src="scertLogo" alt="Scert 标志" />
<p class="access-eyebrow">SCERT 首次运行</p>
<h1 class="access-brand-title">配置证书管理服务</h1>
<p class="access-brand-description">
Scert 与 Scert-admin 共享本次配置。完成初始化后,即可通过管理页面统一管理证书。
</p>
<div class="warning-card">
<strong>初始化提示</strong>
<span
>请先完成 DNS API 在线验证。证书签发和 HTTPS
切换将在后续任务中执行,本页不会伪报申请成功。</span
>
</div>
</template>
<section class="setup-card">
<header>
<div>
<p class="setup-eyebrow">初始化向导</p>
<h2>基础配置</h2>
</div>
<span class="local-badge">仅限本机</span>
</header>
<nav class="setup-progress" aria-label="初始化步骤">
<span
class="progress-step"
:class="{ active: currentStep === 1, complete: currentStep > 1 }"
><b>1</b><strong>管理员</strong><small>账号安全</small></span
>
<i aria-hidden="true"></i>
<span
class="progress-step"
:class="{ active: currentStep === 2, complete: currentStep > 2 }"
><b>2</b><strong>服务通知</strong><small>端口与邮件</small></span
>
<i aria-hidden="true"></i>
<span
class="progress-step"
:class="{ active: currentStep === 3, complete: currentStep > 3 }"
><b>3</b><strong>DNS 验证</strong><small>权限测试</small></span
>
<i aria-hidden="true"></i>
<span class="progress-step" :class="{ active: currentStep === 4 }"
><b>4</b><strong>管理页面证书</strong><small>访问方式</small></span
>
</nav>
<form
id="setup-form"
name="setup_form"
class="setup-form"
@submit.prevent="handleSubmit"
>
<div class="setup-step-grid">
<fieldset v-if="currentStep === 1" class="setup-step-card">
<legend><span>01</span>超级管理员</legend>
<div class="setup-grid two-columns">
<label
><span>用户名</span
><input
id="admin-username"
name="admin_username"
v-model.trim="form.admin_username"
required
autocomplete="username"
placeholder="例如 scert-admin"
/></label>
<label class="email-field"
><span>绑定邮箱</span
><div class="email-input-group">
<input
id="admin-email"
name="admin_email"
:value="emailLocalPart"
@input="handleEmailInput($event.target.value)"
required
type="text"
autocomplete="email"
inputmode="email"
placeholder="输入邮箱名称,例如 likb_blue"
/>
<select
id="admin-email-domain"
v-model="selectedEmailDomain"
aria-label="常用邮箱"
@change="applyEmailDomain(selectedEmailDomain)"
>
<option
v-for="option in commonEmailDomains"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<small>可直接输入完整邮箱,也可先输入邮箱名称再选择常用邮箱。</small>
</label>
<label
><span>密码</span
><input
id="admin-password"
name="admin_password"
v-model="form.admin_password"
required
minlength="8"
type="password"
autocomplete="new-password"
placeholder="至少 8 个字符"
/></label>
<label
><span>确认密码</span
><input
id="confirm-password"
name="confirm_password"
v-model="form.confirm_password"
required
minlength="8"
type="password"
autocomplete="new-password"
placeholder="再次输入密码"
/></label>
</div>
</fieldset>
<fieldset
v-if="currentStep === 2"
class="setup-step-card setup-notification-step"
>
<legend><span>02</span>服务与通知</legend>
<div class="notification-layout">
<div class="notification-row notification-service-row">
<label>
<span>Scert 主服务端口</span>
<input
id="server-port"
name="server_port"
v-model.number="form.server_port"
required
min="1"
max="65535"
type="number"
/>
</label>
<label class="confirm-row compact-row">
<input
id="email-http-enabled"
v-model="form.email_http_enabled"
type="checkbox"
/>
<span>
<strong>启用 HTTP 邮件通知</strong>
<small>兼容 Smail 等 HTTP 邮件服务。</small>
</span>
</label>
<label v-if="form.email_http_enabled">
<span>第三方邮箱服务标识</span>
<input
id="email-provider"
name="email_provider"
v-model.trim="form.email_provider"
placeholder="例如 company-mail-api"
/>
</label>
</div>
<div
v-if="form.email_http_enabled"
class="notification-row notification-api-row"
>
<label>
<span>邮件 API 地址</span>
<input
id="email-endpoint"
name="email_endpoint"
v-model.trim="form.email_endpoint"
type="url"
placeholder="https://mail.example.com/send"
/>
</label>
<label>
<span>HTTP 请求路径</span>
<input
id="email-http-path"
v-model.trim="form.email_http_path"
placeholder="/api/email/send"
/>
</label>
<label>
<span>HTTP 认证方式</span>
<select
id="email-http-auth-mode"
v-model="form.email_http_auth_mode"
>
<option value="none">无认证</option>
<option value="bearer">Bearer Token</option>
<option value="hmac_sha256">HMAC-SHA256</option>
<option value="session">账号登录会话</option>
</select>
</label>
</div>
<div
v-if="form.email_http_enabled"
class="notification-row notification-auth-row"
>
<label v-if="form.email_http_auth_mode === 'session'">
<span>登录请求路径</span>
<input
id="email-http-login-path"
v-model.trim="form.email_http_login_path"
placeholder="/api/login"
/>
</label>
<label>
<span>已有凭据引用(可选)</span>
<input
id="email-http-credential-ref"
v-model.trim="form.email_http_credential_ref"
placeholder="已有 secret://notification/... 引用"
/>
</label>
<div
v-if="form.email_http_auth_mode === 'none'"
class="notification-auth-note"
>
当前接口不启用认证,不需要填写凭据。
</div>
</div>
<div
v-if="
form.email_http_enabled &&
form.email_http_auth_mode !== 'none'
"
class="notification-credential-field"
>
<div class="credential-visibility-row">
<span>{{ emailCredentialLabel }}</span>
<label class="credential-visibility-control">
<input
v-model="emailCredentialsVisible"
type="checkbox"
/>
<span>显示凭据明文</span>
</label>
</div>
<textarea
v-if="emailCredentialsVisible"
id="email-http-credentials-visible"
v-model="emailCredentials"
rows="3"
autocomplete="off"
:placeholder="emailCredentialPlaceholder"
></textarea>
<textarea
v-else
id="email-http-credentials-masked"
:value="maskCredential(emailCredentials)"
rows="3"
readonly
autocomplete="off"
></textarea>
<small>
配置时允许明文显示;提交后仅保存加密凭据引用,普通配置文件不会保存密码或令牌。
</small>
</div>
<div
v-if="form.email_http_enabled"
class="provider-description reset-preview"
>
<strong>密码重置地址</strong>
<span>{{ resetURLPreview }}</span>
<span>
无需配置独立端口;只有手动启动 Scert-admin
后,此地址才会临时可用。
</span>
</div>
<div
v-if="form.email_http_enabled"
class="notification-row notification-sender-row"
>
<label>
<span>发件人名称</span>
<input
id="email-sender-name"
v-model.trim="form.email_sender_name"
placeholder="Scert"
/>
</label>
<label>
<span>发件人地址</span>
<input
id="email-sender-email"
v-model.trim="form.email_sender_email"
type="email"
placeholder="留空使用管理员邮箱"
/>
</label>
<label>
<span>通知收件人</span>
<input
id="recipients"
name="recipients"
v-model="form.recipients"
placeholder="多个地址使用逗号分隔"
/>
</label>
</div>
<div
v-if="form.email_http_enabled"
class="notification-row notification-delivery-row"
>
<label>
<span>通知事件模式</span>
<select v-model="form.email_event_mode">
<option value="detailed">详细事件</option>
<option value="unified">统一事件</option>
</select>
</label>
<label>
<span>请求超时(秒)</span>
<input
v-model.number="form.email_timeout_secs"
type="number"
min="1"
/>
</label>
<label>
<span>重试次数</span>
<input
v-model.number="form.email_retry_count"
type="number"
min="1"
/>
</label>
<label>
<span>重试间隔(秒)</span>
<input
v-model.trim="form.email_retry_intervals_secs"
placeholder="30,120,600"
/>
</label>
</div>
<div
v-if="form.email_http_enabled"
class="notification-bottom-row"
>
<label class="email-headers-field">
<span>HTTP 自定义请求头(JSON)</span>
<textarea
v-model.trim="form.email_http_headers"
rows="2"
placeholder='例如:{"X-Tenant":"scert"}'
></textarea>
</label>
<div class="setup-grid three-columns compact-options email-option-grid">
<label class="confirm-row compact-row">
<input
v-model="form.email_include_deployment_details"
type="checkbox"
/>
<span>部署明细</span>
</label>
<label class="confirm-row compact-row">
<input
v-model="form.email_include_certificate_serial"
type="checkbox"
/>
<span>证书序列号</span>
</label>
<label class="confirm-row compact-row">
<input
v-model="form.email_include_management_url"
type="checkbox"
/>
<span>管理后台链接</span>
</label>
</div>
</div>
</div>
</fieldset>
<fieldset v-if="currentStep === 3" class="setup-step-card">
<legend><span>03</span>DNS 验证</legend>
<div class="setup-grid two-columns">
<label
><span>DNS 服务商</span
><select
id="dns-provider"
name="dns_provider"
v-model="form.dns_provider"
required
:disabled="dnsCatalogLoading"
>
<option v-if="dnsCatalogLoading" value="">
正在读取服务商……
</option>
<option
v-for="provider in dnsProviders"
:key="provider.id"
:value="provider.id"
>
{{ provider.name
}}{{ provider.available ? "" : "(适配器待开放)" }}
</option>
</select></label
>
<label
><span>DNS 权限测试域名</span
><input
id="dns-test-domain"
name="dns_test_domain"
v-model.trim="dnsTestDomain"
required
placeholder="例如 example.com"
/><small
>使用域名时可填写管理页面域名;使用公网 IP
时必须另填一个由当前 DNS 账号管理的域名。</small
></label
>
</div>
<div class="setup-grid two-columns propagation-grid">
<label>
<span>DNS 传播最大等待时间(秒)</span>
<input
id="propagation-timeout-secs"
name="propagation_timeout_secs"
v-model.number="form.propagation_timeout_secs"
type="number"
min="1"
max="86400"
required
/>
<small>默认 900 秒,最长允许等待 86400 秒。</small>
</label>
<label>
<span>DNS 传播检测间隔(秒)</span>
<input
id="propagation-polling-interval-secs"
name="propagation_polling_interval_secs"
v-model.number="form.propagation_polling_interval_secs"
type="number"
min="1"
:max="form.propagation_timeout_secs"
required
/>
<small>默认 10 秒,不能大于最大等待时间。</small>
</label>
</div>
<p v-if="dnsCatalogError" class="inline-error">
{{ dnsCatalogError }}
</p>
<div
v-if="selectedDNSProvider"
class="provider-description"
:class="{ unavailable: !selectedDNSProvider.available }"
>
<strong>{{
selectedDNSProvider.available
? "在线验证可用"
: "当前仅提供字段模型"
}}</strong>
<span>{{ selectedDNSProvider.description }}</span>
</div>
<div
v-if="selectedDNSProvider"
class="setup-grid two-columns credential-grid"
>
<div class="credential-visibility-row">
<span>DNS 凭据</span>
<label class="credential-visibility-control">
<input
v-model="dnsCredentialsVisible"
type="checkbox"
/>
<span>显示凭据明文</span>
</label>
</div>
<label
v-for="field in selectedDNSProvider.credential_fields"
:key="field.name"
>
<span
>{{ field.label
}}{{ field.required ? "(必填)" : "(可选)" }}</span
>
<textarea
v-if="field.name.includes('json') && dnsCredentialsVisible"
:id="`dns-credential-json-${field.name}`"
:name="`dns_credential_${field.name}`"
v-model="dnsCredentials[field.name]"
:required="field.required"
:placeholder="field.placeholder"
rows="4"
autocomplete="off"
></textarea>
<textarea
v-else-if="field.name.includes('json')"
:id="`dns-credential-masked-${field.name}`"
:name="`dns_credential_${field.name}`"
:value="maskCredential(dnsCredentials[field.name])"
rows="4"
readonly
autocomplete="off"
></textarea>
<input
v-else
:id="`dns-credential-value-${field.name}`"
:name="`dns_credential_${field.name}`"
v-model="dnsCredentials[field.name]"
:required="field.required"
:type="field.secret && !dnsCredentialsVisible ? 'password' : 'text'"
:placeholder="field.placeholder"
autocomplete="off"
/>
</label>
</div>
<div class="dns-test-actions">
<button
class="dns-test-button"
type="button"
:disabled="
dnsTesting ||
dnsCatalogLoading ||
!selectedDNSProvider?.available
"
@click="testDNS"
>
{{ dnsTesting ? "正在创建并清理测试记录……" : "测试 DNS API" }}
</button>
<span
>测试会临时创建 DNS-01 TXT
记录并自动清理,明文凭据不会写入普通配置文件。</span
>
</div>
<p v-if="dnsTestError" class="inline-error">{{ dnsTestError }}</p>
<div v-if="dnsVerificationValid" class="dns-verification-success">
<strong>DNS API 在线验证通过</strong>
<span
>凭据已加密保存,本次验证结果有效至
{{
formatVerificationExpiry(dnsVerification.expires_at)
}}。修改服务商、测试域名或凭据后需要重新测试。</span
>
</div>
</fieldset>
<fieldset v-if="currentStep === 4" class="setup-step-card">
<legend><span>04</span>管理页面与证书目录</legend>
<div class="target-switch">
<button
type="button"
:class="{ active: targetType === 'hostname' }"
@click="targetType = 'hostname'"
>
使用域名</button
><button
type="button"
:class="{ active: targetType === 'ip' }"
@click="targetType = 'ip'"
>
使用公网 IP
</button>
</div>
<div class="setup-grid two-columns">
<label v-if="targetType === 'hostname'"
><span>管理页面域名</span
><input
id="web-hostname"
name="web_hostname"
v-model.trim="form.web_hostname"
required
placeholder="scert.example.com"
/></label>
<label v-else
><span>公网 IP 地址</span
><input
id="web-public-ip"
name="web_public_ip"
v-model.trim="form.web_public_ip"
required
placeholder="203.0.113.10"
/></label>
<label
><span>证书部署根目录</span
><input
id="certificate-root"
name="certificate_root"
v-model.trim="form.certificate_root"
required
/></label>
<label
><span>HTTPS 模式</span
><select
id="https-mode"
name="https_mode"
v-model="form.https_mode"
>
<option value="direct">默认:核心服务直接终止 HTTPS</option>
<option value="proxy">
高级:Nginx/Caddy 终止公网 HTTPS
</option>
</select></label
>
</div>
<label class="confirm-row"
><input
id="web-enabled"
name="web_enabled"
v-model="form.web_enabled"
type="checkbox"
/><span
><strong>初始化完成后开启管理页面</strong
><small
>桌面端可通过此开关决定是否启用浏览器管理页面。</small
></span
></label
>
</fieldset>
</div>
<p v-if="error" class="setup-error">{{ error }}</p>
<p v-if="resultMessage" class="setup-success">{{ resultMessage }}</p>
<footer>
<span
>初始化程序将自动配置本机防火墙;云安全组只需放行主服务端口。配置完成前仅保存在内存中。</span
>
<div class="wizard-actions">
<button
v-if="currentStep > 1"
class="back-button"
type="button"
@click="previousStep"
>
上一步</button
><button type="submit" :disabled="submitting || dnsTesting">
{{
currentStep < 4
? "下一步"
: submitting
? "正在保存……"
: "完成初始化"
}}
</button>
</div>
</footer>
</form>
</section>
</AccessShell>
</template>
<style scoped src="./SetupWizard.css"></style>