feat(auth): 完善英文初始化与裸机安装

在登录和首个管理员创建前提供中英文切换,并在初始化状态不可达时展示可重试错误,避免误入登录页。

对齐源码构建产物与安装脚本路径,增加 systemd/API 就绪检查、失败诊断及中英文裸机文档。

Closes #104
This commit is contained in:
Awuqing
2026-08-07 05:55:56 +08:00
parent cc50637b4b
commit f8deafcb00
17 changed files with 526 additions and 152 deletions

View File

@@ -60,11 +60,15 @@ docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/back
# Or prebuilt archive
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
# Or build and install on bare metal without Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
For ARM64 hosts, use `backupx-linux-arm64.tar.gz`. The archive contains `backupx`, `web/`, `config.example.yaml`, and `install.sh`; run `install.sh` from the extracted directory.
Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
Open `http://your-server:8340`, choose English or Chinese on the setup screen, create the first administrator account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
## Documentation

View File

@@ -60,11 +60,15 @@ docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/back
# 或使用预编译包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
# 或从源码构建并裸机安装(无需 Docker
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build && sudo ./deploy/install.sh
```
ARM64 主机请下载 `backupx-linux-arm64.tar.gz`。预编译包内包含 `backupx``web/``config.example.yaml``install.sh`,请在解压后的目录内执行 `install.sh`
打开 `http://your-server:8340`创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
打开 `http://your-server:8340`在初始化页选择中文或 English 并创建首个管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
## 文档

View File

@@ -14,7 +14,13 @@ if [ -f "$SCRIPT_DIR/backupx" ] && [ -d "$SCRIPT_DIR/web" ]; then
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$SCRIPT_DIR/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$SCRIPT_DIR/nginx.conf}"
else
BIN_SOURCE="${BIN_SOURCE:-$PROJECT_ROOT/server/backupx}"
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"
# Keep compatibility with contributors who built the historical path by
# hand, while matching the canonical `make build` output first.
if [ ! -f "$SOURCE_BIN_DEFAULT" ] && [ -f "$PROJECT_ROOT/server/backupx" ]; then
SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/backupx"
fi
BIN_SOURCE="${BIN_SOURCE:-$SOURCE_BIN_DEFAULT}"
WEB_SOURCE="${WEB_SOURCE:-$PROJECT_ROOT/web/dist}"
CONFIG_TEMPLATE="${CONFIG_TEMPLATE:-$PROJECT_ROOT/server/config.example.yaml}"
NGINX_SOURCE="${NGINX_SOURCE:-$PROJECT_ROOT/deploy/nginx.conf}"
@@ -27,8 +33,9 @@ if [ "$(id -u)" -ne 0 ]; then
fi
if [ ! -f "$BIN_SOURCE" ]; then
echo "未找到后端二进制:$BIN_SOURCE" >&2
echo "源码树安装请先执行cd \"$PROJECT_ROOT/server\" && go build -o backupx ./cmd/backupx" >&2
echo "Backend binary not found / 未找到后端二进制:$BIN_SOURCE" >&2
echo "源码树安装请先在仓库根目录执行 make build产物server/bin/backupx)。" >&2
echo "For a source install, run 'make build' in the repository root first." >&2
echo "发布包安装请确认当前目录包含 ./backupx、./web 和 ./install.sh。" >&2
exit 1
fi
@@ -92,6 +99,41 @@ fi
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
# systemctl may return before the process has opened its HTTP listener. Verify
# the same unauthenticated endpoint used by the first-administrator screen so a
# broken bare-metal install cannot print a false success message.
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"
READY=0
ATTEMPT=1
while [ "$ATTEMPT" -le 30 ]; do
if systemctl is-active --quiet "$SERVICE_NAME"; then
if command -v curl >/dev/null 2>&1; then
if curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null 2>&1; then
READY=1
break
fi
elif command -v wget >/dev/null 2>&1; then
if wget -q -T 2 -O /dev/null "$HEALTH_URL"; then
READY=1
break
fi
else
echo "Warning / 警告:未找到 curl 或 wget仅验证 systemd 服务状态。" >&2
READY=1
break
fi
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "BackupX did not become ready at $HEALTH_URL / 服务未通过就绪检查。" >&2
systemctl status "$SERVICE_NAME" --no-pager >&2 || true
journalctl -u "$SERVICE_NAME" -n 50 --no-pager >&2 || true
exit 1
fi
if [ -d "/etc/nginx/conf.d" ] && [ -f "$NGINX_SOURCE" ]; then
install -m 0644 "$NGINX_SOURCE" "/etc/nginx/conf.d/$SERVICE_NAME.conf"
if command -v nginx >/dev/null 2>&1; then
@@ -111,6 +153,11 @@ cat <<MESSAGE
Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可访问:
http://<本机IP>:8340
首次访问 / First sign-in:
1. 打开上面的地址,并可在登录页右上角选择 中文 或 English。
2. 页面显示“系统初始化 / System setup”时创建首个管理员用户名和密码。
3. 如果未显示初始化表单,请先检查:$HEALTH_URL
(如已安装 nginx脚本会自动写入反向代理配置可继续用 80 端口访问。)
排查:若服务未监听端口,请查看日志:

View File

@@ -10,20 +10,21 @@ description: systemd + Nginx deployment from the prebuilt release tarball or sou
```bash
# Download the matching tarball
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
# Extract and install
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
tar xzf backupx-linux-amd64.tar.gz && cd backupx-*-linux-amd64
sudo ./install.sh
```
The installer performs these steps automatically:
1. Creates a system user `backupx`
2. Copies the binary to `/opt/backupx/`
3. Generates a default `config.yaml` with safe JWT/encryption secrets
2. Copies the binary to `/opt/backupx/bin/backupx` and the web console to `/opt/backupx/web`
3. Installs the default configuration at `/etc/backupx/config.yaml`
4. Installs `backupx.service` (systemd), enabled at boot
5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx)
6. Verifies the first-setup API before reporting success
For multi-node clusters, edit `/etc/backupx/config.yaml` after installation and set the Master URL that remote Agents can reach:
@@ -63,11 +64,13 @@ After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -79,17 +82,20 @@ Typical operations:
sudo systemctl status backupx
sudo journalctl -u backupx -f # live logs
sudo systemctl restart backupx
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
Open `http://your-server:8340`, switch to English if desired, and create the first administrator on the **System setup** screen. For a custom listen port, run the installer with a matching `HEALTH_URL`.
## Password reset
If the admin password is lost:
```bash
/opt/backupx/backupx reset-password \
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /opt/backupx/config.yaml
--config /etc/backupx/config.yaml
```
Docker equivalent:

View File

@@ -55,10 +55,11 @@ sudo ./install.sh # creates system user, installs to /opt/backupx, sets u
The installer:
1. Creates a `backupx` system user
2. Installs binary to `/opt/backupx/backupx`
3. Creates `/opt/backupx/config.yaml` with safe defaults
2. Installs the binary to `/opt/backupx/bin/backupx` and the web console to `/opt/backupx/web`
3. Creates `/etc/backupx/config.yaml` with safe defaults
4. Installs and enables the `backupx.service` systemd unit
5. (Optional) Configures an Nginx reverse proxy
6. Waits for `/api/auth/setup/status`; if startup fails, prints systemd diagnostics and exits non-zero
## From source
@@ -67,16 +68,17 @@ Requires Go ≥ 1.25 and Node.js ≥ 20.
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
# or, for builds behind the great firewall
make docker-cn
sudo ./deploy/install.sh
```
After `make build`, the binary is at `server/bin/backupx` and the built web UI is at `web/dist/`.
The installer consumes those exact paths, so no Docker runtime is required. If an existing configuration uses a non-default port, set `HEALTH_URL` for the readiness check, for example `sudo HEALTH_URL=http://127.0.0.1:9000/api/auth/setup/status ./deploy/install.sh`.
## Verify the install
```bash
backupx --version # e.g. v1.6.0
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
Then open `http://your-server:8340` to see the initial admin setup screen.
Then open `http://your-server:8340`. Choose **English** or **中文** in the upper-right corner. A fresh database shows **System setup**, where you create the first administrator username and password. If that form does not appear, retry the status request above before attempting to sign in.

View File

@@ -10,20 +10,21 @@ description: 从预编译包或源码部署 BackupXsystemd + Nginx
```bash
# 下载对应平台的压缩包
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
# 解压并安装
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
tar xzf backupx-linux-amd64.tar.gz && cd backupx-*-linux-amd64
sudo ./install.sh
```
安装脚本自动完成以下步骤:
1. 创建系统用户 `backupx`
2. 复制二进制到 `/opt/backupx/`
3. 生成默认 `config.yaml`(含安全的 JWT/加密密钥)
2. 复制二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台复制到 `/opt/backupx/web`
3. 把默认配置安装到 `/etc/backupx/config.yaml`
4. 安装并启用 `backupx.service` systemd 单元
5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx)
6. 验证首次初始化接口就绪后才报告安装成功
如果要部署多节点集群,安装后请编辑 `/etc/backupx/config.yaml`,设置远程 Agent 可访问到的 Master URL
@@ -63,11 +64,13 @@ After=network.target
[Service]
Type=simple
User=backupx
Group=backupx
WorkingDirectory=/opt/backupx
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
ExecStart=/opt/backupx/bin/backupx -config /etc/backupx/config.yaml
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
RestartSec=5
NoNewPrivileges=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -79,17 +82,20 @@ WantedBy=multi-user.target
sudo systemctl status backupx
sudo journalctl -u backupx -f # 实时日志
sudo systemctl restart backupx
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
访问 `http://your-server:8340`,可按需切换到 English然后在“系统初始化 / System setup”页面创建首个管理员。若监听端口不是默认值请为安装脚本传入对应的 `HEALTH_URL`。
## 密码重置
忘记管理员密码时:
```bash
/opt/backupx/backupx reset-password \
/opt/backupx/bin/backupx reset-password \
--username admin \
--password 'newpass123' \
--config /opt/backupx/config.yaml
--config /etc/backupx/config.yaml
```
Docker 等效命令:

View File

@@ -55,10 +55,11 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
安装脚本会自动:
1. 创建 `backupx` 系统用户
2. 安装二进制到 `/opt/backupx/backupx`
3. 生成 `/opt/backupx/config.yaml`(含安全默认值)
2. 安装二进制到 `/opt/backupx/bin/backupx`,并把 Web 控制台安装到 `/opt/backupx/web`
3. 生成 `/etc/backupx/config.yaml`(含安全默认值)
4. 注册并启用 `backupx.service` systemd 单元
5. (可选)配置 Nginx 反向代理
6. 等待 `/api/auth/setup/status` 就绪;启动失败时输出 systemd 诊断并返回非零状态
## 从源码构建
@@ -67,16 +68,17 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
make build
# 或使用国内镜像加速构建 Docker
make docker-cn
sudo ./deploy/install.sh
```
`make build` 完成后,二进制位于 `server/bin/backupx`,构建好的 Web UI 位于 `web/dist/`。
安装脚本会直接使用这两个路径,不需要 Docker 运行时。如果已有配置修改了默认端口,可覆盖就绪检查地址,例如:`sudo HEALTH_URL=http://127.0.0.1:9000/api/auth/setup/status ./deploy/install.sh`。
## 验证安装
```bash
backupx --version # 输出如 v1.6.0
/opt/backupx/bin/backupx --version
curl -fsS http://127.0.0.1:8340/api/auth/setup/status
```
打开浏览器访问 `http://your-server:8340`会进入初始化管理员账户页面
打开浏览器访问 `http://your-server:8340`可在右上角选择 **中文****English**。全新数据库会显示“系统初始化 / System setup”在这里创建首个管理员用户名和密码。如果没有出现初始化表单请先重试上面的状态接口不要直接尝试登录

View File

@@ -39,3 +39,23 @@ func TestDeployInstallScriptSupportsReleasePackageLayout(t *testing.T) {
}
}
}
func TestDeployInstallScriptSupportsSourceBuildAndVerifiesFirstSetup(t *testing.T) {
scriptPath := filepath.Join("..", "..", "..", "deploy", "install.sh")
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SOURCE_BIN_DEFAULT="$PROJECT_ROOT/server/bin/backupx"`,
`For a source install, run 'make build' in the repository root first.`,
`HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8340/api/auth/setup/status}"`,
`systemctl is-active --quiet "$SERVICE_NAME"`,
`System setup`,
} {
if !strings.Contains(script, want) {
t.Fatalf("install.sh missing %q", want)
}
}
}

View File

@@ -0,0 +1,18 @@
import { Select } from '@arco-design/web-react'
import { useTranslation } from 'react-i18next'
import { languageOptions, normalizeLanguage, setApplicationLanguage, type SupportedLanguage } from '../../i18n'
export function LanguageSwitcher() {
const { t, i18n } = useTranslation()
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage)
return (
<Select
aria-label={t('auth.language')}
value={currentLanguage}
options={languageOptions}
style={{ width: 120 }}
onChange={(value) => void setApplicationLanguage(value as SupportedLanguage)}
/>
)
}

21
web/src/i18n.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import i18n, { normalizeLanguage, setApplicationLanguage } from './i18n'
describe('application language', () => {
afterEach(async () => {
await setApplicationLanguage('zh-CN')
})
it('normalizes supported English variants', () => {
expect(normalizeLanguage('en')).toBe('en-US')
expect(normalizeLanguage('en-GB')).toBe('en-US')
expect(normalizeLanguage('zh-CN')).toBe('zh-CN')
})
it('persists the language selected before login', async () => {
await setApplicationLanguage('en-US')
expect(localStorage.getItem('backupx-language')).toBe('en-US')
expect(document.documentElement.lang).toBe('en-US')
expect(i18n.t('auth.setupTitle')).toBe('System setup')
})
})

View File

@@ -3,7 +3,22 @@ import { initReactI18next } from 'react-i18next'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
const savedLanguage = localStorage.getItem('backupx-language') || 'zh-CN'
export type SupportedLanguage = 'zh-CN' | 'en-US'
export const languageOptions: Array<{ label: string; value: SupportedLanguage }> = [
{ label: '中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' },
]
export function normalizeLanguage(value?: string | null): SupportedLanguage {
return value?.toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'
}
const savedLanguage = normalizeLanguage(typeof window === 'undefined' ? null : window.localStorage.getItem('backupx-language'))
if (typeof document !== 'undefined') {
document.documentElement.lang = savedLanguage
}
i18n.use(initReactI18next).init({
resources: {
@@ -17,4 +32,14 @@ i18n.use(initReactI18next).init({
},
})
export async function setApplicationLanguage(language: SupportedLanguage) {
if (typeof window !== 'undefined') {
window.localStorage.setItem('backupx-language', language)
}
if (typeof document !== 'undefined') {
document.documentElement.lang = language
}
await i18n.changeLanguage(language)
}
export default i18n

View File

@@ -40,7 +40,62 @@
"oldPassword": "Old Password",
"newPassword": "New Password",
"loginTitle": "Sign in to BackupX",
"loginSubtitle": "Linux Server Backup Manager"
"loginSubtitle": "Linux Server Backup Manager",
"language": "Language",
"bannerTitle": "Protect your data",
"bannerSubtitle": "Secure and reliable server backup management",
"setupStatusTitle": "Connect to BackupX",
"checkingStatus": "Checking system initialization status...",
"statusErrorTitle": "Unable to check initialization status",
"statusErrorDescription": "The web console could not reach the BackupX setup API. Confirm that the service is running, then retry.",
"retry": "Retry",
"setupTitle": "System setup",
"welcomeTitle": "Welcome back",
"setupSubtitle": "Create the first administrator account.",
"welcomeSubtitle": "Enter an administrator account to open the console.",
"displayName": "Display name",
"displayNamePlaceholder": "Administrator display name",
"usernamePlaceholder": "Administrator username",
"passwordPlaceholder": "Password",
"setupPasswordPlaceholder": "At least 8 characters",
"setupSubmit": "Create administrator and sign in",
"setupSuccess": "Setup complete. Opening the console...",
"loginSuccess": "Signed in",
"credentialsRequired": "Enter your username and password first",
"mfaCode": "Verification or recovery code",
"mfaCodePlaceholder": "TOTP, recovery, email, or SMS code",
"sendEmailCode": "Send email code",
"sendSmsCode": "Send SMS code",
"emailCodeSent": "Email verification code sent",
"smsCodeSent": "SMS verification code sent",
"usePasskey": "Use passkey",
"trustDevice": "Trust this device for 30 days",
"verifyAndLogin": "Verify and sign in",
"requestFailed": "The request failed. Please try again.",
"validation": {
"displayNameRequired": "Enter a display name",
"usernameRequired": "Enter a username",
"usernameLength": "Username must contain at least 3 characters",
"passwordRequired": "Enter a password",
"passwordLength": "Password must contain at least 8 characters",
"mfaRequired": "Enter a verification or recovery code",
"mfaLength": "Code must contain 6 to 32 characters"
},
"errors": {
"AUTH_INVALID_CREDENTIALS": "Invalid username or password",
"AUTH_WRONG_PASSWORD": "Invalid username or password",
"AUTH_USER_DISABLED": "This account is disabled",
"AUTH_RATE_LIMITED": "Too many attempts. Please try again later.",
"AUTH_2FA_REQUIRED": "Complete two-factor authentication to continue",
"AUTH_2FA_INVALID": "The verification or recovery code is invalid",
"AUTH_SETUP_DISABLED": "BackupX is already initialized. Sign in instead.",
"AUTH_USERNAME_EXISTS": "This username already exists",
"AUTH_EMAIL_OTP_DISABLED": "Email verification is not enabled",
"AUTH_SMS_OTP_DISABLED": "SMS verification is not enabled",
"AUTH_EMAIL_REQUIRED": "No email address is configured for this account",
"AUTH_PHONE_REQUIRED": "No phone number is configured for this account",
"AUTH_WEBAUTHN_NOT_ENABLED": "No passkey is configured for this account"
}
},
"dashboard": {
"title": "Dashboard",

View File

@@ -40,7 +40,62 @@
"oldPassword": "旧密码",
"newPassword": "新密码",
"loginTitle": "登录 BackupX",
"loginSubtitle": "Linux 服务器备份管理系统"
"loginSubtitle": "Linux 服务器备份管理系统",
"language": "语言",
"bannerTitle": "守护您的数据资产",
"bannerSubtitle": "安全、可靠的服务器备份管理平台",
"setupStatusTitle": "连接 BackupX",
"checkingStatus": "正在检查系统初始化状态...",
"statusErrorTitle": "无法检查初始化状态",
"statusErrorDescription": "Web 控制台无法访问 BackupX 初始化接口。请确认服务已启动,然后重试。",
"retry": "重试",
"setupTitle": "系统初始化",
"welcomeTitle": "欢迎回来",
"setupSubtitle": "请创建首个管理员账户以完成初始化。",
"welcomeSubtitle": "请输入管理员账户信息登录控制台。",
"displayName": "显示名称",
"displayNamePlaceholder": "请输入管理员显示名称",
"usernamePlaceholder": "请输入管理员用户名",
"passwordPlaceholder": "请输入密码",
"setupPasswordPlaceholder": "请输入至少 8 位密码",
"setupSubmit": "创建管理员并登录",
"setupSuccess": "初始化完成,正在进入控制台...",
"loginSuccess": "登录成功",
"credentialsRequired": "请先输入用户名和密码",
"mfaCode": "验证码或恢复码",
"mfaCodePlaceholder": "请输入 TOTP、恢复码、邮件或短信验证码",
"sendEmailCode": "发送邮件验证码",
"sendSmsCode": "发送短信验证码",
"emailCodeSent": "邮件验证码已发送",
"smsCodeSent": "短信验证码已发送",
"usePasskey": "使用通行密钥",
"trustDevice": "信任此设备 30 天",
"verifyAndLogin": "验证并登录",
"requestFailed": "请求失败,请稍后重试",
"validation": {
"displayNameRequired": "请输入显示名称",
"usernameRequired": "请输入用户名",
"usernameLength": "用户名至少需要 3 个字符",
"passwordRequired": "请输入密码",
"passwordLength": "密码至少需要 8 个字符",
"mfaRequired": "请输入验证码或恢复码",
"mfaLength": "验证码或恢复码需为 6 至 32 个字符"
},
"errors": {
"AUTH_INVALID_CREDENTIALS": "用户名或密码错误",
"AUTH_WRONG_PASSWORD": "用户名或密码错误",
"AUTH_USER_DISABLED": "该账户已被停用",
"AUTH_RATE_LIMITED": "尝试次数过多,请稍后再试",
"AUTH_2FA_REQUIRED": "请完成双因素验证后继续",
"AUTH_2FA_INVALID": "验证码或恢复码无效",
"AUTH_SETUP_DISABLED": "系统已完成初始化,请直接登录",
"AUTH_USERNAME_EXISTS": "该用户名已存在",
"AUTH_EMAIL_OTP_DISABLED": "邮件验证码未启用",
"AUTH_SMS_OTP_DISABLED": "短信验证码未启用",
"AUTH_EMAIL_REQUIRED": "该账户未配置邮箱",
"AUTH_PHONE_REQUIRED": "该账户未配置手机号",
"AUTH_WEBAUTHN_NOT_ENABLED": "该账户未配置通行密钥"
}
},
"dashboard": {
"title": "仪表盘",

View File

@@ -0,0 +1,70 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { setApplicationLanguage } from '../../i18n'
import { LoginPage } from './LoginPage'
const mocks = vi.hoisted(() => ({
fetchSetupStatus: vi.fn(),
login: vi.fn(),
setup: vi.fn(),
}))
vi.mock('../../services/auth', () => ({
beginWebAuthnLogin: vi.fn(),
fetchSetupStatus: mocks.fetchSetupStatus,
sendLoginOtp: vi.fn(),
}))
vi.mock('../../stores/auth', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({
status: 'anonymous',
login: mocks.login,
setup: mocks.setup,
}),
}))
vi.mock('../../utils/webauthn', () => ({
getWebAuthnAssertion: vi.fn(),
}))
describe('LoginPage initialization', () => {
beforeEach(async () => {
mocks.fetchSetupStatus.mockReset()
mocks.login.mockReset()
mocks.setup.mockReset()
await act(() => setApplicationLanguage('en-US'))
})
afterEach(async () => {
cleanup()
await act(() => setApplicationLanguage('zh-CN'))
})
it('shows the first-administrator form in English', async () => {
mocks.fetchSetupStatus.mockResolvedValue({ initialized: false })
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>,
)
expect(await screen.findByText('System setup')).toBeInTheDocument()
expect(screen.getByText('Create the first administrator account.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create administrator and sign in' })).toBeInTheDocument()
})
it('does not mistake an unreachable fresh install for an initialized system', async () => {
mocks.fetchSetupStatus.mockRejectedValue(new Error('connection refused'))
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>,
)
expect(await screen.findByText('Unable to check initialization status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByText('Welcome back')).not.toBeInTheDocument()
})
})

View File

@@ -1,8 +1,10 @@
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { LanguageSwitcher } from '../../components/common/LanguageSwitcher'
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { getWebAuthnAssertion } from '../../utils/webauthn'
@@ -20,17 +22,8 @@ interface LoginFormValues {
rememberDevice?: boolean
}
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? '请求失败,请稍后重试'
}
if (error instanceof Error) {
return error.message
}
return '请求失败,请稍后重试'
}
export function LoginPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const authStatus = useAuthStore((state) => state.status)
const doLogin = useAuthStore((state) => state.login)
@@ -40,6 +33,25 @@ export function LoginPage() {
const [loading, setLoading] = useState(false)
const [mfaActionLoading, setMfaActionLoading] = useState('')
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
const [setupStatusFailed, setSetupStatusFailed] = useState(false)
const setupStatusRequest = useRef(0)
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
const code = error.response?.data?.code
const translationKey = code ? `auth.errors.${code}` : ''
if (translationKey && i18n.exists(translationKey)) {
return t(translationKey)
}
if (i18n.resolvedLanguage === 'zh-CN' && error.response?.data?.message) {
return error.response.data.message
}
}
if (error instanceof Error && i18n.resolvedLanguage === 'zh-CN') {
return error.message
}
return t('auth.requestFailed')
}
function resetTwoFactorPrompt() {
if (!twoFactorRequired) {
@@ -56,30 +68,36 @@ export function LoginPage() {
}
}, [authStatus, navigate])
useEffect(() => {
let mounted = true
void (async () => {
try {
const result = await fetchSetupStatus()
if (mounted) {
setInitialized(result.initialized)
}
} catch {
if (mounted) {
setInitialized(true)
}
const loadSetupStatus = useCallback(async () => {
const requestID = ++setupStatusRequest.current
setInitialized(null)
setSetupStatusFailed(false)
try {
const result = await fetchSetupStatus()
if (requestID === setupStatusRequest.current) {
setInitialized(result.initialized)
}
} catch {
// Do not guess that an unreachable fresh install is initialized. That
// would hide the first-administrator form behind an impossible login.
if (requestID === setupStatusRequest.current) {
setSetupStatusFailed(true)
}
})()
return () => {
mounted = false
}
}, [])
useEffect(() => {
void loadSetupStatus()
return () => {
setupStatusRequest.current++
}
}, [loadSetupStatus])
const handleSetup = async (values: SetupFormValues) => {
setLoading(true)
try {
await doSetup(values)
Message.success('初始化完成,正在进入控制台')
Message.success(t('auth.setupSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
@@ -96,7 +114,7 @@ export function LoginPage() {
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
})
setTwoFactorRequired(false)
Message.success('登录成功')
Message.success(t('auth.loginSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -116,7 +134,7 @@ export function LoginPage() {
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
const values = loginForm.getFieldsValue()
if (!values.username?.trim() || !values.password?.trim()) {
Message.error('请先输入用户名和密码')
Message.error(t('auth.credentialsRequired'))
return null
}
return {
@@ -132,7 +150,7 @@ export function LoginPage() {
setMfaActionLoading(channel)
try {
await sendLoginOtp({ username: values.username, password: values.password, channel })
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
Message.success(channel === 'email' ? t('auth.emailCodeSent') : t('auth.smsCodeSent'))
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
@@ -156,7 +174,7 @@ export function LoginPage() {
trustedDeviceName: navigator.userAgent.slice(0, 120),
})
setTwoFactorRequired(false)
Message.success('登录成功')
Message.success(t('auth.loginSuccess'))
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
@@ -165,128 +183,158 @@ export function LoginPage() {
}
}
const pageTitle = initialized === null
? t('auth.setupStatusTitle')
: initialized
? t('auth.welcomeTitle')
: t('auth.setupTitle')
const pageSubtitle = initialized === null
? setupStatusFailed ? t('auth.statusErrorDescription') : t('auth.checkingStatus')
: initialized
? t('auth.welcomeSubtitle')
: t('auth.setupSubtitle')
return (
<div className="login-shell">
<div className="login-bg" />
<div className="login-container">
<div className="login-banner">
{/* Background decorative circles for the banner */}
<div style={{ position: 'absolute', width: 400, height: 400, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', top: -100, right: -100 }} />
<div style={{ position: 'absolute', width: 300, height: 300, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', bottom: -50, left: -50 }} />
<div className="login-banner-inner">
<svg width="320" height="320" viewBox="0 0 320 320" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16 }}>
{/* Outer pulsing rings */}
<circle cx="160" cy="160" r="120" fill="white" fillOpacity="0.05">
<animate attributeName="r" values="115;125;115" dur="4s" repeatCount="indefinite"/>
<animate attributeName="fill-opacity" values="0.03;0.08;0.03" dur="4s" repeatCount="indefinite"/>
<animate attributeName="r" values="115;125;115" dur="4s" repeatCount="indefinite" />
<animate attributeName="fill-opacity" values="0.03;0.08;0.03" dur="4s" repeatCount="indefinite" />
</circle>
<circle cx="160" cy="160" r="80" fill="white" fillOpacity="0.1">
<animate attributeName="r" values="75;85;75" dur="3s" repeatCount="indefinite"/>
<animate attributeName="r" values="75;85;75" dur="3s" repeatCount="indefinite" />
</circle>
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 0,-8; 0,0" dur="5s" repeatCount="indefinite"/>
{/* Layer 1 (Top) */}
<path d="M120 120C120 111.163 137.909 104 160 104C182.091 104 200 111.163 200 120V144C200 152.837 182.091 160 160 160C137.909 160 120 152.837 120 144V120Z" fill="white" fillOpacity="0.95"/>
<ellipse cx="160" cy="120" rx="40" ry="16" fill="white"/>
{/* Layer 2 (Middle) */}
<path d="M120 152C120 143.163 137.909 136 160 136C182.091 136 200 143.163 200 152V176C200 184.837 182.091 192 160 192C137.909 192 120 184.837 120 176V152Z" fill="white" fillOpacity="0.75"/>
<ellipse cx="160" cy="152" rx="40" ry="16" fill="white" fillOpacity="0.9"/>
{/* Layer 3 (Bottom) */}
<path d="M120 184C120 175.163 137.909 168 160 168C182.091 168 200 175.163 200 184V208C200 216.837 182.091 224 160 224C137.909 224 120 216.837 120 208V184Z" fill="white" fillOpacity="0.5"/>
<ellipse cx="160" cy="184" rx="40" ry="16" fill="white" fillOpacity="0.6"/>
{/* Glowing Dots Output - Animated */}
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 0,-8; 0,0" dur="5s" repeatCount="indefinite" />
<path d="M120 120C120 111.163 137.909 104 160 104C182.091 104 200 111.163 200 120V144C200 152.837 182.091 160 160 160C137.909 160 120 152.837 120 144V120Z" fill="white" fillOpacity="0.95" />
<ellipse cx="160" cy="120" rx="40" ry="16" fill="white" />
<path d="M120 152C120 143.163 137.909 136 160 136C182.091 136 200 143.163 200 152V176C200 184.837 182.091 192 160 192C137.909 192 120 184.837 120 176V152Z" fill="white" fillOpacity="0.75" />
<ellipse cx="160" cy="152" rx="40" ry="16" fill="white" fillOpacity="0.9" />
<path d="M120 184C120 175.163 137.909 168 160 168C182.091 168 200 175.163 200 184V208C200 216.837 182.091 224 160 224C137.909 224 120 216.837 120 208V184Z" fill="white" fillOpacity="0.5" />
<ellipse cx="160" cy="184" rx="40" ry="16" fill="white" fillOpacity="0.6" />
<g fill="var(--color-primary-6, #165dff)">
<circle cx="140" cy="120" r="4">
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0s" repeatCount="indefinite" />
</circle>
<circle cx="140" cy="152" r="4">
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0.6s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="0.6s" repeatCount="indefinite" />
</circle>
<circle cx="140" cy="184" r="4">
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.3;1;0.3" dur="2s" begin="1.2s" repeatCount="indefinite" />
</circle>
</g>
{/* Connecting Data Line */}
<path d="M160 120V152V184" stroke="var(--color-primary-6, #165dff)" strokeWidth="2" strokeDasharray="4 4" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="16" to="0" dur="1s" repeatCount="indefinite" />
</path>
</g>
</svg>
<Typography.Title heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12, fontWeight: 700 }}>
<Typography.Title heading={2} style={{ color: 'white', marginTop: 0, marginBottom: 12 }}>
{t('auth.bannerTitle')}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 16 }}>
{t('auth.bannerSubtitle')}
</Typography.Text>
</div>
</div>
<div className="login-form-wrapper">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<LanguageSwitcher />
</div>
<div style={{ paddingBottom: 8 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, var(--color-primary-5) 0%, var(--color-primary-7) 100%)', marginRight: 12 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 4, background: 'var(--color-primary-6)', marginRight: 12 }}>
<IconCloud style={{ fontSize: 20, color: 'white' }} />
</div>
<Typography.Title heading={4} style={{ margin: 0, fontWeight: 700 }}>
<Typography.Title heading={4} style={{ margin: 0 }}>
BackupX
</Typography.Title>
</div>
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>
{initialized === false ? '系统初始化' : '欢迎回来'}
<Typography.Title heading={3} style={{ marginTop: 0, marginBottom: 8 }}>
{pageTitle}
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 14 }}>
{initialized === false ? '请设定首个管理员账户以启动系统。' : '请输入管理员账户信息登录控制台。'}
{pageSubtitle}
</Typography.Paragraph>
</div>
{initialized === false ? (
{initialized === null ? (
setupStatusFailed ? (
<div>
<Typography.Text>{t('auth.statusErrorTitle')}</Typography.Text>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={loading} onClick={() => void loadSetupStatus()}>
{t('auth.retry')}
</Button>
</div>
</div>
) : (
<Typography.Text type="secondary">{t('auth.checkingStatus')}</Typography.Text>
)
) : initialized === false ? (
<Form<SetupFormValues> layout="vertical" onSubmit={handleSetup}>
<Form.Item field="displayName" label="显示名称" rules={[{ required: true, minLength: 1 }]}>
<Input placeholder="请输入显示名称" prefix={<IconUser />} size="large" />
<Form.Item field="displayName" label={t('auth.displayName')} rules={[{ required: true, minLength: 1, message: t('auth.validation.displayNameRequired') }]}>
<Input autoComplete="name" placeholder={t('auth.displayNamePlaceholder')} prefix={<IconUser />} size="large" />
</Form.Item>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入管理员用户名" prefix={<IconUser />} size="large" />
<Form.Item field="username" label={t('auth.username')} rules={[
{ required: true, message: t('auth.validation.usernameRequired') },
{ minLength: 3, message: t('auth.validation.usernameLength') },
]}>
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} prefix={<IconUser />} size="large" />
</Form.Item>
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入至少 8 位密码" prefix={<IconLock />} size="large" />
<Form.Item field="password" label={t('auth.password')} rules={[
{ required: true, message: t('auth.validation.passwordRequired') },
{ minLength: 8, message: t('auth.validation.passwordLength') },
]}>
<Input.Password autoComplete="new-password" placeholder={t('auth.setupPasswordPlaceholder')} prefix={<IconLock />} size="large" />
</Form.Item>
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 8 }}>
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 8 }}>
{t('auth.setupSubmit')}
</Button>
</Form>
) : (
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
<Form.Item field="username" label={t('auth.username')} rules={[
{ required: true, message: t('auth.validation.usernameRequired') },
{ minLength: 3, message: t('auth.validation.usernameLength') },
]}>
<Input autoComplete="username" placeholder={t('auth.usernamePlaceholder')} prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
<Form.Item field="password" label={t('auth.password')} rules={[
{ required: true, message: t('auth.validation.passwordRequired') },
{ minLength: 8, message: t('auth.validation.passwordLength') },
]}>
<Input.Password autoComplete="current-password" placeholder={t('auth.passwordPlaceholder')} prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
{twoFactorRequired && (
<>
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
<Form.Item field="twoFactorCode" label={t('auth.mfaCode')} rules={[
{ required: true, message: t('auth.validation.mfaRequired') },
{ minLength: 6, maxLength: 32, message: t('auth.validation.mfaLength') },
]}>
<Input autoComplete="one-time-code" placeholder={t('auth.mfaCodePlaceholder')} prefix={<IconSafe />} size="large" maxLength={32} />
</Form.Item>
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}></Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}></Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使</Button>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}>{t('auth.sendEmailCode')}</Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}>{t('auth.sendSmsCode')}</Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>{t('auth.usePasskey')}</Button>
</Space>
<Form.Item field="rememberDevice" triggerPropName="checked">
<Checkbox> 30 </Checkbox>
<Checkbox>{t('auth.trustDevice')}</Checkbox>
</Form.Item>
</>
)}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
{twoFactorRequired ? '验证并登录' : '登录'}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 4, height: 44, marginTop: 16 }}>
{twoFactorRequired ? t('auth.verifyAndLogin') : t('auth.login')}
</Button>
</Form>
)}

View File

@@ -42,48 +42,25 @@ body {
.login-bg {
position: fixed;
inset: 0;
background: linear-gradient(135deg, #111a2c 0%, #1f2d47 100%);
background: #111a2c;
z-index: 0;
}
.login-bg::before {
content: '';
position: absolute;
width: 800px;
height: 800px;
border-radius: 50%;
background: radial-gradient(circle, rgba(52,145,250,0.08) 0%, transparent 70%);
top: -300px;
right: -200px;
}
.login-bg::after {
content: '';
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(114,46,209,0.06) 0%, transparent 70%);
bottom: -200px;
left: -100px;
}
.login-container {
display: flex;
width: 1000px;
max-width: 90vw;
min-height: 560px;
background: var(--color-bg-2);
border-radius: 20px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
z-index: 1;
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.login-banner {
flex: 1;
background: linear-gradient(135deg, var(--color-primary-6, #165dff) 0%, var(--color-primary-8, #0e42d2) 100%);
background: var(--color-primary-6, #165dff);
position: relative;
display: flex;
align-items: center;

View File

@@ -24,3 +24,17 @@ Object.defineProperty(window, 'localStorage', {
value: storage,
configurable: true,
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
})