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'