mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-27 11:10:38 +08:00
feat(auth): 完善英文初始化与裸机安装
在登录和首个管理员创建前提供中英文切换,并在初始化状态不可达时展示可重试错误,避免误入登录页。 对齐源码构建产物与安装脚本路径,增加 systemd/API 就绪检查、失败诊断及中英文裸机文档。 Closes #104
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) 完成首次备份。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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 端口访问。)
|
||||
|
||||
排查:若服务未监听端口,请查看日志:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,20 +10,21 @@ description: 从预编译包或源码部署 BackupX(systemd + 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 等效命令:
|
||||
|
||||
@@ -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”,在这里创建首个管理员用户名和密码。如果没有出现初始化表单,请先重试上面的状态接口,不要直接尝试登录。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
web/src/components/common/LanguageSwitcher.tsx
Normal file
18
web/src/components/common/LanguageSwitcher.tsx
Normal 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
21
web/src/i18n.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "仪表盘",
|
||||
|
||||
70
web/src/pages/login/LoginPage.test.tsx
Normal file
70
web/src/pages/login/LoginPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user