fix login entry fallback
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 16:06:54 +08:00
parent 9a79494c68
commit 3178c26095
5 changed files with 114 additions and 11 deletions

View File

@@ -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. 完成定义

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
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(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
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();

View File

@@ -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<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(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'