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

@@ -1,4 +1,4 @@
import { create } from 'zustand';
import { create, type StoreApi } from 'zustand';
import { persist } from 'zustand/middleware';
import { hostApiFetch } from '@/lib/host-api';
import {
@@ -64,6 +64,16 @@ type RefreshSessionOptions = {
forceRefresh?: boolean;
};
type PasswordLoginInput = {
username: string;
password: string;
};
type MobileLoginInput = {
phone: string;
code: string;
};
type AuthState = {
initialized: boolean;
loading: boolean;
@@ -80,7 +90,8 @@ type AuthState = {
user: AuthUser | null;
moduleAccess: ModuleAccess;
init: () => Promise<void>;
loginWithBrowser: () => Promise<void>;
loginWithPassword: (input: PasswordLoginInput) => Promise<void>;
loginWithMobile: (input: MobileLoginInput) => Promise<void>;
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
getValidAccessToken: () => Promise<string | null>;
markActivity: () => Promise<void>;
@@ -242,6 +253,50 @@ async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAc
}
}
async function loginViaHost(
path: '/api/auth/login' | '/api/auth/mobile-login',
input: PasswordLoginInput | MobileLoginInput,
set: StoreApi<AuthState>['setState'],
): Promise<void> {
advanceAuthSessionEpoch();
const operationEpoch = authSessionEpoch;
set({ loading: true, error: null });
try {
const response = await hostApiFetch<AuthTokenResponse>(path, {
method: 'POST',
body: JSON.stringify(input),
});
const session = parseMainSession(response.session);
if (!response.success || !response.token || !session) {
throw new Error(response.error || 'Login failed');
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({
initialized: true,
loading: false,
error: null,
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const terminal = isTerminalAuthError(error);
const message = terminal
? '登录已过期,请重新授权。'
: (error instanceof Error ? error.message : String(error));
advanceAuthSessionEpoch();
set({ loading: false, error: message, ...getClearedSession() });
throw new Error(message, { cause: error });
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -371,44 +426,9 @@ export const useAuthStore = create<AuthState>()(
set({ initialized: true, loading: false, error: null, moduleAccess });
},
loginWithBrowser: async () => {
advanceAuthSessionEpoch();
const operationEpoch = authSessionEpoch;
set({ loading: true, error: null });
try {
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/browser/start', {
method: 'POST',
});
const session = parseMainSession(response.session);
if (!response.success || !response.token || !session) {
throw new Error(response.error || 'Browser authorization failed');
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
loginWithPassword: (input) => loginViaHost('/api/auth/login', input, set),
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({
initialized: true,
loading: false,
error: null,
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const terminal = isTerminalAuthError(error);
const message = terminal
? '登录已过期,请重新授权。'
: (error instanceof Error ? error.message : String(error));
advanceAuthSessionEpoch();
set({ loading: false, error: message, ...getClearedSession() });
throw new Error(message, { cause: error });
}
},
loginWithMobile: (input) => loginViaHost('/api/auth/mobile-login', input, set),
refreshSession: async (options = {}) => {
const state = get();