diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md
index 712c3f4f..a3b56830 100644
--- a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md
+++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md
@@ -11,6 +11,7 @@
当前阶段只解决一件事:
1. 由 `Axum` 根据服务端配置,返回当前环境启用的登录方式列表。
+2. 密码登录入口由 Rust `password_entry` 固定承载,作为登录弹窗的保底入口。
本阶段明确不包含:
@@ -31,7 +32,7 @@
```json
{
- "availableLoginMethods": ["phone", "wechat"]
+ "availableLoginMethods": ["phone", "password", "wechat"]
}
```
@@ -40,6 +41,7 @@
1. `availableLoginMethods` 为字符串数组
2. 当前阶段只允许出现:
- `phone`
+ - `password`
- `wechat`
### 3.3 返回顺序
@@ -47,7 +49,8 @@
返回顺序固定为:
1. 先 `phone`
-2. 再 `wechat`
+2. 再 `password`
+3. 再 `wechat`
这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。
@@ -61,8 +64,9 @@
映射规则固定为:
1. `SMS_AUTH_ENABLED=true` 时返回 `phone`
-2. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
-3. 两者都关闭时返回空数组
+2. Rust 密码登录主链可用时固定返回 `password`
+3. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
+4. 短信与微信都关闭时仍返回 `["password"]`
## 5. crate 边界
@@ -84,13 +88,15 @@
1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口
2. 不再假设某种登录方式一定存在
+3. 若 `/api/auth/login-options` 联调失败或返回空数组,前端仍保留 `password` 入口,避免登录弹窗显示“当前登录入口暂不可用”后无法继续操作。
## 6. 测试要求
至少覆盖:
-1. 默认配置下返回空数组
-2. 同时启用短信与微信时返回 `["phone", "wechat"]`
+1. 默认配置下返回 `["password"]`
+2. 同时启用短信、密码与微信时返回 `["phone", "password", "wechat"]`
+3. 前端在 `login-options` 读取失败或返回空数组时,仍展示密码登录表单
## 7. 完成定义
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 73e091b1..9446bcd4 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -1437,6 +1437,34 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn auth_login_options_keeps_password_entry_when_external_methods_disabled() {
+ let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/api/auth/login-options")
+ .body(Body::empty())
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body should collect")
+ .to_bytes();
+ let payload: Value = serde_json::from_slice(&body).expect("body should be valid json");
+
+ assert_eq!(
+ payload["availableLoginMethods"],
+ serde_json::json!(["password"])
+ );
+ }
+
#[tokio::test]
async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() {
let config = AppConfig {
diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs
index faa7ba03..9821054b 100644
--- a/server-rs/crates/shared-contracts/src/auth.rs
+++ b/server-rs/crates/shared-contracts/src/auth.rs
@@ -215,7 +215,7 @@ mod tests {
use serde_json::json;
#[test]
- fn available_login_methods_keep_phone_then_wechat_order() {
+ fn available_login_methods_keep_phone_password_wechat_order() {
let methods = build_available_login_methods(true, true, true);
assert_eq!(
@@ -228,6 +228,13 @@ mod tests {
);
}
+ #[test]
+ fn available_login_methods_keep_password_as_default_entry() {
+ let methods = build_available_login_methods(false, true, false);
+
+ assert_eq!(methods, vec![AUTH_LOGIN_METHOD_PASSWORD.to_string()]);
+ }
+
#[test]
fn password_entry_request_uses_camel_case_fields() {
let payload = serde_json::to_value(PasswordEntryRequest {
diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx
index 24d4aa7b..ddbcca76 100644
--- a/src/components/auth/AuthGate.test.tsx
+++ b/src/components/auth/AuthGate.test.tsx
@@ -216,6 +216,48 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
expect(await screen.findByText('应用内容')).toBeTruthy();
});
+test('auth gate keeps password entry available when login options are empty', async () => {
+ const user = userEvent.setup();
+
+ authMocks.getCurrentAuthUser.mockResolvedValue({
+ user: null,
+ availableLoginMethods: [],
+ });
+ authMocks.getAuthLoginOptions.mockResolvedValue({
+ availableLoginMethods: [],
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole('button', { name: '进入作品' }));
+
+ const dialog = screen.getByRole('dialog', { name: '账号入口' });
+ expect(within(dialog).getByLabelText('密码')).toBeTruthy();
+ expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
+});
+
+test('auth gate falls back to password entry when login options request fails', async () => {
+ const user = userEvent.setup();
+
+ authMocks.getAuthLoginOptions.mockRejectedValue(new Error('读取登录方式失败'));
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole('button', { name: '进入作品' }));
+
+ const dialog = screen.getByRole('dialog', { name: '账号入口' });
+ expect(within(dialog).getByLabelText('密码')).toBeTruthy();
+ expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
+});
+
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx
index 0e01ba41..4263167b 100644
--- a/src/components/auth/AuthGate.tsx
+++ b/src/components/auth/AuthGate.tsx
@@ -57,6 +57,20 @@ type AuthStatus =
| 'ready'
| 'error';
+const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
+
+function normalizeAvailableLoginMethods(
+ methods: AuthLoginMethod[] | null | undefined,
+): AuthLoginMethod[] {
+ const normalizedMethods = Array.from(new Set(methods ?? []));
+
+ // 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关。
+ // 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。
+ return normalizedMethods.length > 0
+ ? normalizedMethods
+ : FALLBACK_LOGIN_METHODS;
+}
+
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState('checking');
const [user, setUser] = useState(null);
@@ -202,7 +216,9 @@ export function AuthGate({ children }: AuthGateProps) {
return null;
}
- setAvailableLoginMethods(options.availableLoginMethods);
+ setAvailableLoginMethods(
+ normalizeAvailableLoginMethods(options.availableLoginMethods),
+ );
return options;
};
@@ -220,7 +236,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
- setAvailableLoginMethods([]);
+ setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setUser(null);
setError(
optionsError instanceof Error
@@ -245,13 +261,17 @@ export function AuthGate({ children }: AuthGateProps) {
}
if (!nextSession.user) {
- setAvailableLoginMethods(nextSession.availableLoginMethods);
+ setAvailableLoginMethods(
+ normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
+ );
await resolveGuestFallback();
return;
}
setUser(nextSession.user);
- setAvailableLoginMethods(nextSession.availableLoginMethods);
+ setAvailableLoginMethods(
+ normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
+ );
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'