Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

292
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,292 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { hostApiFetch } from '@/lib/host-api';
import {
NIANCODE_AUTH_CLIENT_ID,
NIANCODE_AUTH_GATEWAY_URL,
} from '../../shared/auth-public';
export type AuthUser = {
username: string;
userId: string | null;
tenantId: string | number | null;
deptId: string | number | null;
authorities: string[];
};
type AuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
username?: unknown;
user_id?: unknown;
tenant_id?: unknown;
dept_id?: unknown;
authorities?: unknown;
};
type AuthActionResponse = {
success: boolean;
error?: string;
};
type AuthTokenResponse = AuthActionResponse & {
token?: AuthTokenPayload;
};
type AuthState = {
initialized: boolean;
loading: boolean;
error: string | null;
authBase: string;
clientId: string;
accessToken: string | null;
refreshToken: string | null;
tokenType: string | null;
expiresAt: number | null;
user: AuthUser | null;
init: () => void;
loginWithBrowser: () => Promise<void>;
refreshSession: () => Promise<string | null>;
getValidAccessToken: () => Promise<string | null>;
logout: () => Promise<void>;
isAuthenticated: () => boolean;
};
const DEFAULT_CLIENT_ID = NIANCODE_AUTH_CLIENT_ID;
const DEFAULT_AUTH_BASE = NIANCODE_AUTH_GATEWAY_URL;
const TOKEN_EXPIRY_SKEW_MS = 30_000;
function trimTrailingSlash(value: string): string {
return value.trim().replace(/\/+$/, '');
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function asStringOrNumber(value: unknown): string | number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
return asString(value);
}
function asAuthorities(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
function getExpiresAt(expiresIn: unknown): number | null {
const seconds = typeof expiresIn === 'number'
? expiresIn
: (typeof expiresIn === 'string' ? Number(expiresIn) : NaN);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Date.now() + seconds * 1000;
}
function createSessionFromToken(token: AuthTokenPayload, fallbackRefreshToken: string | null = null) {
const accessToken = asString(token.access_token);
if (!accessToken) {
throw new Error('Login response did not include access_token');
}
return {
accessToken,
refreshToken: asString(token.refresh_token) ?? fallbackRefreshToken,
tokenType: asString(token.token_type) ?? 'Bearer',
expiresAt: getExpiresAt(token.expires_in),
user: {
username: asString(token.username) ?? '',
userId: asString(token.user_id),
tenantId: asStringOrNumber(token.tenant_id),
deptId: asStringOrNumber(token.dept_id),
authorities: asAuthorities(token.authorities),
},
};
}
function getClearedSession() {
return {
accessToken: null,
refreshToken: null,
tokenType: null,
expiresAt: null,
user: null,
};
}
async function syncMainSession(session: {
accessToken: string | null;
refreshToken: string | null;
tokenType: string | null;
expiresAt: number | null;
}): Promise<void> {
if (!session.accessToken) return;
try {
await hostApiFetch<AuthActionResponse>('/api/auth/session/sync', {
method: 'POST',
body: JSON.stringify({
accessToken: session.accessToken,
refreshToken: session.refreshToken,
tokenType: session.tokenType,
expiresAt: session.expiresAt,
}),
});
} catch {
// The renderer session remains authoritative for UI; Main can resync later.
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
initialized: false,
loading: false,
error: null,
authBase: DEFAULT_AUTH_BASE,
clientId: DEFAULT_CLIENT_ID,
...getClearedSession(),
init: () => {
const state = get();
const authBaseChanged = Boolean(
state.authBase
&& trimTrailingSlash(state.authBase) !== trimTrailingSlash(DEFAULT_AUTH_BASE),
);
const expired = Boolean(
state.accessToken
&& state.expiresAt
&& state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS,
);
set({
initialized: true,
loading: false,
error: null,
authBase: DEFAULT_AUTH_BASE,
clientId: DEFAULT_CLIENT_ID,
...(expired || authBaseChanged ? getClearedSession() : {}),
});
if (!expired && !authBaseChanged && state.accessToken) {
void syncMainSession({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
tokenType: state.tokenType,
expiresAt: state.expiresAt,
});
}
},
loginWithBrowser: async () => {
const authBase = trimTrailingSlash(DEFAULT_AUTH_BASE);
const clientId = DEFAULT_CLIENT_ID;
set({ loading: true, error: null });
try {
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/browser/start', {
method: 'POST',
});
if (!response.success || !response.token) {
throw new Error(response.error || 'Browser authorization failed');
}
const session = createSessionFromToken(response.token);
set({
initialized: true,
loading: false,
error: null,
authBase,
clientId,
...session,
});
await syncMainSession(session);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({ loading: false, error: message, ...getClearedSession() });
throw new Error(message, { cause: error });
}
},
refreshSession: async () => {
const { refreshToken } = get();
if (!refreshToken) return null;
try {
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
if (!response.success || !response.token) {
throw new Error(response.error || 'Refresh failed');
}
const session = createSessionFromToken(response.token, refreshToken);
set({
initialized: true,
loading: false,
error: null,
...session,
});
await syncMainSession(session);
return session.accessToken;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({ loading: false, error: message });
return null;
}
},
getValidAccessToken: async () => {
const { accessToken, expiresAt, refreshToken } = get();
const expired = Boolean(
accessToken
&& expiresAt
&& expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS,
);
if (!accessToken || expired) {
return refreshToken ? await get().refreshSession() : null;
}
return accessToken;
},
logout: async () => {
const { accessToken } = get();
try {
if (accessToken) {
await hostApiFetch<AuthActionResponse>('/api/auth/logout', {
method: 'POST',
body: JSON.stringify({ accessToken }),
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({ error: message });
} finally {
set({ loading: false, ...getClearedSession() });
}
},
isAuthenticated: () => {
const { accessToken, expiresAt } = get();
if (!accessToken) return false;
if (expiresAt && expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS) return false;
return true;
},
}),
{
name: 'niancode-auth',
partialize: (state) => ({
authBase: state.authBase,
clientId: state.clientId,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
tokenType: state.tokenType,
expiresAt: state.expiresAt,
user: state.user,
}),
},
),
);