feat: add native password and sms login

This commit is contained in:
2026-08-19 12:00:34 +08:00
parent abece81fdb
commit 5a048da8a6
9 changed files with 1539 additions and 859 deletions

View File

@@ -43,7 +43,7 @@ describe('auth store', () => {
vi.useRealTimers();
});
it('starts browser authorization through the host api and stores the session', async () => {
it('logs in with a password through the host api and stores the hydrated session', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
@@ -77,12 +77,21 @@ describe('auth store', () => {
},
});
await useAuthStore.getState().loginWithBrowser();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
method: 'POST',
await useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'zhangsan', password: 'secret' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
expect(hostApiFetchMock).not.toHaveBeenCalledWith(
'/api/auth/browser/start',
expect.anything(),
);
const state = useAuthStore.getState();
expect(state.isAuthenticated()).toBe(true);
expect(state.accessToken).toBe('access-token');
@@ -107,6 +116,91 @@ describe('auth store', () => {
);
});
it('logs in with a mobile code using only the phone and code payload', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'mobile-access-token',
token_type: 'Bearer',
username: '13800138000',
user_id: '2',
},
session: {
accessToken: 'mobile-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockResolvedValueOnce({
success: true,
moduleAccess: { learning: false },
});
await useAuthStore.getState().loginWithMobile({
phone: '13800138000',
code: '123456',
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/mobile-login', {
method: 'POST',
body: JSON.stringify({ phone: '13800138000', code: '123456' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
expect(useAuthStore.getState()).toMatchObject({
initialized: true,
loading: false,
accessToken: 'mobile-access-token',
user: { username: '13800138000', userId: '2' },
moduleAccess: {
programming: true,
design: true,
learning: false,
robot: true,
},
});
});
it('keeps a new session with safe defaults when module hydration is temporarily unavailable', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'access-token',
token_type: 'Bearer',
username: 'zhangsan',
},
session: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockRejectedValueOnce(new Error('Host API unavailable'));
await useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
expect(useAuthStore.getState()).toMatchObject({
initialized: true,
loading: false,
error: null,
accessToken: 'access-token',
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
},
});
});
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
@@ -226,7 +320,10 @@ describe('auth store', () => {
details: { status: 401 },
}));
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
await expect(useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
})).rejects.toThrow(
'登录已过期,请重新授权。',
);
@@ -244,23 +341,47 @@ describe('auth store', () => {
});
});
it('surfaces browser authorization failures and does not keep a partial session', async () => {
it('surfaces password login failures and does not keep a partial session', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,
error: 'Authorization timed out',
error: 'Invalid credentials',
});
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
'Authorization timed out',
await expect(useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'wrong',
})).rejects.toThrow(
'Invalid credentials',
);
const state = useAuthStore.getState();
expect(state.loading).toBe(false);
expect(state.error).toBe('Authorization timed out');
expect(state.error).toBe('Invalid credentials');
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated()).toBe(false);
});
it('rejects an invalid mobile login snapshot without retaining token data', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
token: { access_token: 'partial-token', username: '13800138000' },
session: { accessToken: 'partial-token' },
});
await expect(useAuthStore.getState().loginWithMobile({
phone: '13800138000',
code: '123456',
})).rejects.toThrow('Login failed');
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState()).toMatchObject({
loading: false,
error: 'Login failed',
accessToken: null,
user: null,
});
});
it('clears a persisted session when the configured SSO gateway changes', async () => {
useAuthStore.setState({
initialized: false,
@@ -476,13 +597,16 @@ describe('auth store', () => {
});
});
it('does not let delayed browser login revive a Main-terminal session', async () => {
it('does not let a delayed password login revive a Main-terminal session', async () => {
let resolveLogin!: (value: unknown) => void;
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveLogin = resolve;
}));
const login = useAuthStore.getState().loginWithBrowser();
const login = useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
useAuthStore.getState().applyMainSession(null);
resolveLogin({
success: true,