Makelore 2.0 initial clean snapshot
This commit is contained in:
292
src/stores/auth.ts
Normal file
292
src/stores/auth.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user