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

926 lines
36 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,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsProviders,
dnsCredentials,
dnsCredentialsVisible,
maskCredential,
emailCredentials,
emailCredentialsVisible,
dnsTestDomain,
dnsVerification,
currentStep,
notificationAdvancedVisible,
targetType,
form,
commonEmailDomains,
selectedEmailDomain,
emailLocalPart,
emailInputElement,
targetLabel,
resetURLPreview,
emailCredentialLabel,
emailCredentialPlaceholder,
selectedDNSProvider,
dnsVerificationValid,
formatVerificationExpiry,
formatVerificationStageStatus,
applyEmailDomain,
toggleNotificationAdvanced,
handleEmailInput,
synchronizeEmailField,
handleEmailPaste,
handleEmailBlur,
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 在线验证。提交初始化时将申请管理页面证书;只有证书校验和配置保存成功后才会完成初始化。
</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
ref="emailAutofillElement"
class="email-autofill-probe"
id="admin-email-autofill"
name="email"
autocomplete="section-scert email"
tabindex="-1"
aria-hidden="true"
type="email"
@input="handleEmailAutofill"
@change="handleEmailAutofill"
@blur="handleEmailAutofill"
/>
<input
id="admin-email"
name="admin_email"
ref="emailInputElement"
:value="emailLocalPart"
@input="handleEmailInput($event.target.value)"
@paste="handleEmailPaste"
@change="synchronizeEmailField($event.target.value)"
@focus="synchronizeEmailField($event.target.value)"
@blur="handleEmailBlur($event.target.value)"
@animationstart="handleEmailAutofill"
type="text"
autocomplete="email"
inputmode="email"
placeholder="请输入邮箱名称"
/>
<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"
maxlength="12"
type="password"
autocomplete="new-password"
placeholder="8-12 个字符"
/></label>
<label
><span>确认密码</span
><input
id="confirm-password"
name="confirm_password"
v-model="form.confirm_password"
required
minlength="8"
maxlength="12"
type="password"
autocomplete="new-password"
placeholder="再次输入密码"
/></label>
</div>
<div class="setup-section-heading">
<strong>服务访问配置</strong>
<span>先确定 Scert 主服务和管理页面的访问方式,后续步骤会使用这里的配置。</span>
</div>
<div class="setup-grid two-columns">
<label>
<span>Scert 主服务端口</span>
<input
id="server-port"
name="server_port"
v-model.number="form.server_port"
required
min="1"
max="65535"
type="number"
/>
<small>管理页面和 API 对外提供服务使用的端口。</small>
</label>
<div class="access-target-field">
<span>管理页面访问方式</span>
<div class="target-switch" role="group" aria-label="管理页面访问方式">
<button
type="button"
:class="{ active: targetType === 'hostname' }"
:aria-pressed="targetType === 'hostname'"
@click="targetType = 'hostname'"
>
使用域名
</button>
<button
type="button"
:class="{ active: targetType === 'ip' }"
:aria-pressed="targetType === 'ip'"
@click="targetType = 'ip'"
>
使用公网 IP
</button>
</div>
</div>
<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>
</div>
<div class="provider-description reset-preview">
<strong>密码重置地址预览</strong>
<span>{{ resetURLPreview }}</span>
<span>只有手动启动 Scert-admin 后,此地址才会临时可用。</span>
</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-guide">
<strong>先完成基础配置</strong>
<span>填写邮件服务地址即可继续,其他项目已经提供合理默认值。</span>
</div>
<div class="notification-row notification-service-row">
<label class="confirm-row compact-row">
<input
id="email-http-enabled"
v-model="form.email_http_enabled"
type="checkbox"
/>
<span>
<strong>启用 HTTP 邮件通知</strong>
<small>通过第三方邮件 API 发送任务通知。</small>
</span>
</label>
<label class="confirm-row compact-row">
<input
id="email-smtp-enabled"
v-model="form.email_smtp_enabled"
type="checkbox"
/>
<span>
<strong>启用 SMTP 通知</strong>
<small>通过 SMTP 出站客户端发送任务通知。</small>
</span>
</label>
</div>
<div v-if="form.email_smtp_enabled" class="notification-basic-panel smtp-basic-panel">
<div class="notification-section-heading">
<div>
<strong>SMTP 通知配置</strong>
<span>Scert 只作为 SMTP 出站客户端,不提供 SMTP、IMAP 或 POP3 服务端。</span>
</div>
<span class="notification-recommended-badge">推荐 STARTTLS</span>
</div>
<div class="notification-row smtp-config-row">
<label>
<span>SMTP 主机</span>
<input
id="email-smtp-host"
name="email_smtp_host"
v-model.trim="form.email_smtp_host"
placeholder="smtp.example.com"
autocomplete="off"
/>
</label>
<label>
<span>SMTP 端口</span>
<input
id="email-smtp-port"
name="email_smtp_port"
v-model.number="form.email_smtp_port"
type="number"
min="1"
max="65535"
inputmode="numeric"
/>
</label>
<label>
<span>SMTP 用户名</span>
<input
id="email-smtp-username"
name="email_smtp_username"
v-model.trim="form.email_smtp_username"
placeholder="发件邮箱或账号"
autocomplete="username"
/>
</label>
<label>
<span>SMTP 密码</span>
<input
id="email-smtp-password"
name="email_smtp_password"
v-model="form.email_smtp_password"
type="password"
placeholder="请输入 SMTP 密码或授权码"
autocomplete="new-password"
/>
</label>
</div>
<div class="notification-row smtp-options-row">
<label>
<span>连接模式</span>
<select v-model="form.email_smtp_mode">
<option value="starttls">STARTTLS(推荐)</option>
<option value="implicit_tls">隐式 TLS</option>
<option value="plain">明文</option>
</select>
</label>
<label class="confirm-row compact-row smtp-plaintext-row">
<input
id="email-smtp-allow-plaintext-auth"
v-model="form.email_smtp_allow_plaintext_auth"
type="checkbox"
/>
<span>
<strong>允许明文认证</strong>
<small>仅在明文模式下使用,存在凭据泄露风险。</small>
</span>
</label>
</div>
<div class="notification-smtp-tip">
SMTP 密码只会写入加密敏感信息存储,不会回显到配置文件、接口响应或日志。建议优先使用 STARTTLS 或隐式 TLS。
</div>
</div>
<div v-if="form.email_http_enabled" class="notification-basic-panel">
<div class="notification-section-heading">
<div>
<strong>邮件通知基础配置</strong>
<span>填写服务地址、发件人和收件人即可,技术参数无需修改。</span>
</div>
<span class="notification-recommended-badge">推荐配置</span>
</div>
<div class="notification-row notification-basic-row">
<label>
<span>邮件服务地址</span>
<input
id="email-endpoint"
name="email_endpoint"
v-model.trim="form.email_endpoint"
type="url"
placeholder="https://mail.example.com"
/>
<small>填写邮件服务提供的地址,不需要自行拼接接口路径。</small>
</label>
<label>
<span>邮件服务名称(可选)</span>
<input
id="email-provider"
name="email_provider"
v-model.trim="form.email_provider"
placeholder="例如:Smail"
/>
<small>仅用于日志和通知内容中的识别。</small>
</label>
</div>
<div 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="多个地址使用逗号分隔"
/>
<small>续期、部署和错误通知将发送到这些地址。</small>
</label>
</div>
</div>
<div class="provider-description reset-preview">
<strong>密码重置地址</strong>
<span>{{ resetURLPreview }}</span>
<span>只有手动启动 Scert-admin 后,此地址才会临时可用。</span>
</div>
<div v-if="form.email_http_enabled" class="notification-advanced-panel">
<button
class="notification-advanced-toggle"
type="button"
:aria-expanded="notificationAdvancedVisible"
@click="toggleNotificationAdvanced"
>
<span>
<strong>高级配置</strong>
<small>认证、请求路径、重试策略和通知内容选项</small>
</span>
<span class="notification-advanced-action">
{{ notificationAdvancedVisible ? "收起配置" : "展开配置" }}
</span>
</button>
<div v-if="notificationAdvancedVisible" class="notification-advanced-content">
<div class="notification-advanced-tip">
默认值适用于常见 HTTP 邮件服务。只有服务方提供了特殊要求时,才需要修改这里的内容。
</div>
<div class="notification-row notification-api-row">
<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_auth_mode === 'session'" class="notification-row notification-auth-row">
<label>
<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="已有安全凭据引用"
/>
</label>
</div>
<div v-else class="notification-row notification-auth-row">
<label>
<span>已有凭据引用(可选)</span>
<input
id="email-http-credential-ref"
v-model.trim="form.email_http_credential_ref"
placeholder="已有安全凭据引用"
/>
</label>
<div class="notification-auth-note">
当前接口不启用认证,不需要填写凭据。
</div>
</div>
<div
v-if="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 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 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>
</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>默认 6 秒,不能大于最大等待时间。</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>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</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>
<div
v-if="dnsVerification && dnsVerification.test_report"
class="dns-verification-report"
>
<div class="dns-report-heading">
<strong>在线测试过程</strong>
<span v-if="dnsVerification.test_report.record_name">
临时记录:{{ dnsVerification.test_report.record_name }}
</span>
</div>
<div
v-for="stage in dnsVerification.test_report.stages"
:key="stage.name"
class="dns-verification-stage"
:class="`is-${stage.status}`"
>
<span class="dns-stage-name">{{ stage.name }}</span>
<strong>{{ formatVerificationStageStatus(stage.status) }}</strong>
<small>{{ stage.message }}</small>
</div>
</div>
</fieldset>
<fieldset v-if="currentStep === 4" class="setup-step-card">
<legend><span>04</span>证书目录与剩余设置</legend>
<div class="setup-grid two-columns">
<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>