This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
当前阶段只解决一件事:
|
当前阶段只解决一件事:
|
||||||
|
|
||||||
1. 由 `Axum` 根据服务端配置,返回当前环境启用的登录方式列表。
|
1. 由 `Axum` 根据服务端配置,返回当前环境启用的登录方式列表。
|
||||||
|
2. 密码登录入口由 Rust `password_entry` 固定承载,作为登录弹窗的保底入口。
|
||||||
|
|
||||||
本阶段明确不包含:
|
本阶段明确不包含:
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"availableLoginMethods": ["phone", "wechat"]
|
"availableLoginMethods": ["phone", "password", "wechat"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
1. `availableLoginMethods` 为字符串数组
|
1. `availableLoginMethods` 为字符串数组
|
||||||
2. 当前阶段只允许出现:
|
2. 当前阶段只允许出现:
|
||||||
- `phone`
|
- `phone`
|
||||||
|
- `password`
|
||||||
- `wechat`
|
- `wechat`
|
||||||
|
|
||||||
### 3.3 返回顺序
|
### 3.3 返回顺序
|
||||||
@@ -47,7 +49,8 @@
|
|||||||
返回顺序固定为:
|
返回顺序固定为:
|
||||||
|
|
||||||
1. 先 `phone`
|
1. 先 `phone`
|
||||||
2. 再 `wechat`
|
2. 再 `password`
|
||||||
|
3. 再 `wechat`
|
||||||
|
|
||||||
这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。
|
这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。
|
||||||
|
|
||||||
@@ -61,8 +64,9 @@
|
|||||||
映射规则固定为:
|
映射规则固定为:
|
||||||
|
|
||||||
1. `SMS_AUTH_ENABLED=true` 时返回 `phone`
|
1. `SMS_AUTH_ENABLED=true` 时返回 `phone`
|
||||||
2. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
|
2. Rust 密码登录主链可用时固定返回 `password`
|
||||||
3. 两者都关闭时返回空数组
|
3. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
|
||||||
|
4. 短信与微信都关闭时仍返回 `["password"]`
|
||||||
|
|
||||||
## 5. crate 边界
|
## 5. crate 边界
|
||||||
|
|
||||||
@@ -84,13 +88,15 @@
|
|||||||
|
|
||||||
1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口
|
1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口
|
||||||
2. 不再假设某种登录方式一定存在
|
2. 不再假设某种登录方式一定存在
|
||||||
|
3. 若 `/api/auth/login-options` 联调失败或返回空数组,前端仍保留 `password` 入口,避免登录弹窗显示“当前登录入口暂不可用”后无法继续操作。
|
||||||
|
|
||||||
## 6. 测试要求
|
## 6. 测试要求
|
||||||
|
|
||||||
至少覆盖:
|
至少覆盖:
|
||||||
|
|
||||||
1. 默认配置下返回空数组
|
1. 默认配置下返回 `["password"]`
|
||||||
2. 同时启用短信与微信时返回 `["phone", "wechat"]`
|
2. 同时启用短信、密码与微信时返回 `["phone", "password", "wechat"]`
|
||||||
|
3. 前端在 `login-options` 读取失败或返回空数组时,仍展示密码登录表单
|
||||||
|
|
||||||
## 7. 完成定义
|
## 7. 完成定义
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() {
|
async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[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);
|
let methods = build_available_login_methods(true, true, true);
|
||||||
|
|
||||||
assert_eq!(
|
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]
|
#[test]
|
||||||
fn password_entry_request_uses_camel_case_fields() {
|
fn password_entry_request_uses_camel_case_fields() {
|
||||||
let payload = serde_json::to_value(PasswordEntryRequest {
|
let payload = serde_json::to_value(PasswordEntryRequest {
|
||||||
|
|||||||
@@ -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();
|
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 () => {
|
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onAuthenticated = vi.fn();
|
const onAuthenticated = vi.fn();
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ type AuthStatus =
|
|||||||
| 'ready'
|
| 'ready'
|
||||||
| 'error';
|
| '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) {
|
export function AuthGate({ children }: AuthGateProps) {
|
||||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
@@ -202,7 +216,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailableLoginMethods(options.availableLoginMethods);
|
setAvailableLoginMethods(
|
||||||
|
normalizeAvailableLoginMethods(options.availableLoginMethods),
|
||||||
|
);
|
||||||
return options;
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,7 +236,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailableLoginMethods([]);
|
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(
|
setError(
|
||||||
optionsError instanceof Error
|
optionsError instanceof Error
|
||||||
@@ -245,13 +261,17 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!nextSession.user) {
|
if (!nextSession.user) {
|
||||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
setAvailableLoginMethods(
|
||||||
|
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||||
|
);
|
||||||
await resolveGuestFallback();
|
await resolveGuestFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(nextSession.user);
|
setUser(nextSession.user);
|
||||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
setAvailableLoginMethods(
|
||||||
|
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||||
|
);
|
||||||
setStatus(
|
setStatus(
|
||||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||||
? 'pending_bind_phone'
|
? 'pending_bind_phone'
|
||||||
|
|||||||
Reference in New Issue
Block a user