feat(auth): 添加认证客户端和服务端控制功能
- 新增 auth-client.ts 文件,提供登录、注册和前端跳转URL构建功能 - 实现 fetchJSON 工具函数,支持请求超时控制和错误处理 - 添加 runtime-control.ts 文件,提供部署控制API调用功能 - 实现标准化Token响应数据结构的功能 - 支持前端基础URL自动解析和手off码处理
This commit is contained in:
107
auth-portal/src/lib/auth-client.ts
Normal file
107
auth-portal/src/lib/auth-client.ts
Normal file
@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
|
||||
function normalizeBaseUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getFrontendBaseUrl(response: TokenResponse): string | null {
|
||||
const explicit = normalizeBaseUrl(response.backend_connection?.frontend_base_url);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const fromBackend = normalizeBaseUrl(
|
||||
response.backend_connection?.api_base_url ||
|
||||
response.backend_connection?.public_base_url ||
|
||||
response.local_backend?.public_base_url
|
||||
);
|
||||
return fromBackend;
|
||||
}
|
||||
|
||||
function buildApiUrl(path: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed.detail === 'string') {
|
||||
detail = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`接口错误 ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<TokenResponse> {
|
||||
return fetchJSON('/api/runtime/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function register(username: string, email: string, password: string): Promise<TokenResponse> {
|
||||
return fetchJSON('/api/runtime/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||
const frontendBaseUrl = getFrontendBaseUrl(response);
|
||||
if (!frontendBaseUrl) {
|
||||
throw new Error('后端未返回目标前端地址');
|
||||
}
|
||||
const handoffCode = response.handoff_code?.trim();
|
||||
if (!handoffCode) {
|
||||
throw new Error('后端未返回 handoff code');
|
||||
}
|
||||
|
||||
const url = new URL('/handoff', frontendBaseUrl);
|
||||
url.searchParams.set('code', handoffCode);
|
||||
if (nextPath) {
|
||||
url.searchParams.set('next', nextPath);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function withNext(path: '/login' | '/register', nextPath: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (nextPath) {
|
||||
params.set('next', nextPath);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
117
auth-portal/src/lib/runtime-control.ts
Normal file
117
auth-portal/src/lib/runtime-control.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
|
||||
const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, '');
|
||||
const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let payload: unknown = {};
|
||||
if (raw) {
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
payload = { detail: raw };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = asString(asObject(payload).detail) || `request failed with status ${response.status}`;
|
||||
throw new HttpError(response.status, detail);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new HttpError(504, 'request timed out');
|
||||
}
|
||||
throw new HttpError(502, error instanceof Error ? error.message : 'request failed');
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function callDeployControl<T>(path: string, payload: JsonObject): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (DEPLOY_API_TOKEN) {
|
||||
headers.Authorization = `Bearer ${DEPLOY_API_TOKEN}`;
|
||||
}
|
||||
return fetchJson<T>(`${DEPLOY_API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function callInstanceApi<T>(apiBaseUrl: string, path: string, payload: JsonObject): Promise<T> {
|
||||
const baseUrl = apiBaseUrl.trim().replace(/\/+$/, '');
|
||||
if (!baseUrl) {
|
||||
throw new HttpError(500, 'instance api base url is missing');
|
||||
}
|
||||
return fetchJson<T>(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTokenResponse(
|
||||
response: TokenResponse,
|
||||
routing: {
|
||||
frontend_base_url?: unknown;
|
||||
api_base_url?: unknown;
|
||||
public_url?: unknown;
|
||||
}
|
||||
): TokenResponse {
|
||||
const frontendBaseUrl = asString(routing.frontend_base_url);
|
||||
const apiBaseUrl = asString(routing.api_base_url) || asString(routing.public_url);
|
||||
const publicUrl = asString(routing.public_url) || apiBaseUrl;
|
||||
const backendConnection = asObject(response.backend_connection);
|
||||
|
||||
const mergedBackendConnection = {
|
||||
...backendConnection,
|
||||
frontend_base_url: asString(backendConnection.frontend_base_url) || frontendBaseUrl || publicUrl || null,
|
||||
api_base_url: asString(backendConnection.api_base_url) || apiBaseUrl || publicUrl || null,
|
||||
public_base_url: asString(backendConnection.public_base_url) || publicUrl || apiBaseUrl || null,
|
||||
};
|
||||
|
||||
return {
|
||||
...response,
|
||||
backend_connection: mergedBackendConnection,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user