feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
import { createPKCEPair } from '@/lib/oauth/pkce';
import type { ApiEnvelope } from '@/lib/api/types';
export type TokenPair = {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
/** 用户名密码 + PKCE 换 authorization_code,再 POST /oauth/token。 */
export async function loginWithPassword(params: {
userName: string;
password: string;
tenantId?: string;
state?: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const redirectUri = getOAuthRedirectUri();
const { verifier, challenge } = await createPKCEPair();
const res = await fetch(`${origin}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
tenant_id: params.tenantId ?? '',
user_name: params.userName,
password: params.password,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: challenge,
code_challenge_method: 'S256',
state: params.state ?? '',
scope: 'openid',
}),
});
const json = (await res.json()) as ApiEnvelope<{
authorization_code?: string;
state?: string;
}>;
if (res.status === 401) {
throw new Error(json.msg || '用户名或密码错误');
}
if (res.status === 403) {
throw new Error(json.msg || '无权限');
}
if (!res.ok) {
throw new Error(json.msg || `HTTP ${res.status}`);
}
if (json.code !== 200 || !json.data?.authorization_code) {
throw new Error(json.msg || '登录失败');
}
return exchangeCodeForTokens({
code: json.data.authorization_code,
codeVerifier: verifier,
clientId,
redirectUri,
});
}
export async function exchangeCodeForTokens(params: {
code: string;
codeVerifier: string;
clientId: string;
redirectUri: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: params.codeVerifier,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '换取 access_token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '刷新 token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function logoutRequest(): Promise<void> {
const origin = getPublicApiOrigin();
const res = await fetch(`${origin}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { msg?: string };
throw new Error(j.msg || '登出失败');
}
}
/** RFC 7662 风格,用于解析 opaque access_token 的 `sub`(用户 id */
export async function introspectAccessToken(accessToken: string): Promise<{
active: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
}> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
token: accessToken,
token_type_hint: 'access_token',
});
const res = await fetch(`${origin}/oauth/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
active?: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
};
return {
active: Boolean(data.active),
sub: data.sub,
scope: data.scope,
exp: data.exp,
client_id: data.client_id,
};
}