feat(web): 增加统一认证接入与运维页面
系统设置新增统一认证配对、验证、激活、重验证、回滚与禁用流程,安全事件改为统一能力状态。前端登录入口运行时读取公开配置,不再依赖 VITE_OIDC 构建变量。\n\n验证:web 31 项测试;web typecheck;pnpm lint;web production build
This commit is contained in:
+15
-9
@@ -125,8 +125,7 @@ import {
|
|||||||
import { restoreOIDCBrowserSession } from './lib/oidc-browser-session';
|
import { restoreOIDCBrowserSession } from './lib/oidc-browser-session';
|
||||||
import {
|
import {
|
||||||
consumeOIDCCallbackError,
|
consumeOIDCCallbackError,
|
||||||
oidcBrowserSessionEnabled,
|
loadOIDCRuntimeConfiguration,
|
||||||
oidcLoginEnabled,
|
|
||||||
startOIDCLogin,
|
startOIDCLogin,
|
||||||
startOIDCLogout,
|
startOIDCLogout,
|
||||||
} from './lib/oidc';
|
} from './lib/oidc';
|
||||||
@@ -261,6 +260,7 @@ export function App() {
|
|||||||
const [state, setState] = useState<LoadState>('idle');
|
const [state, setState] = useState<LoadState>('idle');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
|
const [oidcCallbackError, setOIDCCallbackError] = useState(() => consumeOIDCCallbackError());
|
||||||
|
const [oidcEnabled, setOIDCEnabled] = useState(false);
|
||||||
const loadedDataKeysRef = useRef(new Set<DataKey>());
|
const loadedDataKeysRef = useRef(new Set<DataKey>());
|
||||||
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
||||||
const loadedTaskQueryKeyRef = useRef('');
|
const loadedTaskQueryKeyRef = useRef('');
|
||||||
@@ -294,10 +294,11 @@ export function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
const loadIdentityRuntime = async (force = false) => {
|
||||||
if (oidcCallbackError) return;
|
const configuration = await loadOIDCRuntimeConfiguration(force);
|
||||||
if (!oidcBrowserSessionEnabled()) return;
|
if (cancelled) return;
|
||||||
if (readStoredAccessToken() || !oidcLoginEnabled()) return;
|
setOIDCEnabled(configuration.enabled && configuration.oidcLogin);
|
||||||
|
if (oidcCallbackError || readStoredAccessToken() || !configuration.enabled || !configuration.oidcLogin) return;
|
||||||
const restored = await restoreOIDCBrowserSession();
|
const restored = await restoreOIDCBrowserSession();
|
||||||
if (!restored || cancelled) return;
|
if (!restored || cancelled) return;
|
||||||
setCurrentUser(restored.user);
|
setCurrentUser(restored.user);
|
||||||
@@ -305,13 +306,17 @@ export function App() {
|
|||||||
setToken(restored.credential);
|
setToken(restored.credential);
|
||||||
setState('idle');
|
setState('idle');
|
||||||
setError('');
|
setError('');
|
||||||
})().catch((err) => {
|
};
|
||||||
|
const runtimeChanged = () => { void loadIdentityRuntime(true); };
|
||||||
|
window.addEventListener('identity-runtime-changed', runtimeChanged);
|
||||||
|
void loadIdentityRuntime().catch((err) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setState('error');
|
setState('error');
|
||||||
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
setError(err instanceof Error ? err.message : '统一认证登录失败');
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
window.removeEventListener('identity-runtime-changed', runtimeChanged);
|
||||||
};
|
};
|
||||||
}, [oidcCallbackError]);
|
}, [oidcCallbackError]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1354,7 +1359,7 @@ export function App() {
|
|||||||
onSubmitExternalToken={submitExternalToken}
|
onSubmitExternalToken={submitExternalToken}
|
||||||
onSubmitLogin={submitLogin}
|
onSubmitLogin={submitLogin}
|
||||||
onSubmitRegister={submitRegister}
|
onSubmitRegister={submitRegister}
|
||||||
oidcEnabled={oidcLoginEnabled()}
|
oidcEnabled={oidcEnabled}
|
||||||
onOIDCLogin={loginWithOIDC}
|
onOIDCLogin={loginWithOIDC}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1362,6 +1367,7 @@ export function App() {
|
|||||||
{activePage === 'admin' && (
|
{activePage === 'admin' && (
|
||||||
isAuthenticated ? (
|
isAuthenticated ? (
|
||||||
<AdminPage
|
<AdminPage
|
||||||
|
token={token}
|
||||||
data={data}
|
data={data}
|
||||||
operationMessage={coreMessage}
|
operationMessage={coreMessage}
|
||||||
section={adminSection}
|
section={adminSection}
|
||||||
@@ -1419,7 +1425,7 @@ export function App() {
|
|||||||
onSubmitExternalToken={submitExternalToken}
|
onSubmitExternalToken={submitExternalToken}
|
||||||
onSubmitLogin={submitLogin}
|
onSubmitLogin={submitLogin}
|
||||||
onSubmitRegister={submitRegister}
|
onSubmitRegister={submitRegister}
|
||||||
oidcEnabled={oidcLoginEnabled()}
|
oidcEnabled={oidcEnabled}
|
||||||
onOIDCLogin={loginWithOIDC}
|
onOIDCLogin={loginWithOIDC}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
gatewayErrorMessage,
|
gatewayErrorMessage,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||||
|
startIdentityPairing,
|
||||||
|
validateIdentityRevision,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
describe('Gateway provisioning errors', () => {
|
describe('Gateway provisioning errors', () => {
|
||||||
@@ -35,6 +37,51 @@ describe('Gateway provisioning errors', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('identity configuration transport', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the one-time onboarding code in the request body and enforces write headers', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'pairing', version: 1 }), {
|
||||||
|
status: 202,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||||
|
|
||||||
|
await startIdentityPairing('manager-token', {
|
||||||
|
authCenterUrl: 'https://auth.example.com',
|
||||||
|
onboardingCode: 'one-time-code-must-stay-in-body',
|
||||||
|
publicBaseUrl: 'https://api.gateway.example.com',
|
||||||
|
webBaseUrl: 'https://gateway.example.com',
|
||||||
|
localTenantKey: 'default',
|
||||||
|
legacyJwtEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).not.toContain('one-time-code-must-stay-in-body');
|
||||||
|
expect(JSON.parse(String(init.body)).onboardingCode).toBe('one-time-code-must-stay-in-body');
|
||||||
|
expect(new Headers(init.headers).get('If-Match')).toBe('W/"0"');
|
||||||
|
expect(new Headers(init.headers).get('Idempotency-Key')).toContain('identity-pair-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the revision version when validating a draft or active runtime', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id: 'revision', version: 8 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-4000-8000-000000000000' });
|
||||||
|
|
||||||
|
await validateIdentityRevision('manager-token', 'revision', 7);
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(new Headers(init.headers).get('If-Match')).toBe('W/"7"');
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('security event connection transport', () => {
|
describe('security event connection transport', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
|||||||
+66
-4
@@ -20,6 +20,11 @@ import type {
|
|||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
GatewayRunnerPolicy,
|
GatewayRunnerPolicy,
|
||||||
GatewayRunnerPolicyUpsertRequest,
|
GatewayRunnerPolicyUpsertRequest,
|
||||||
|
IdentityConfigurationRevision,
|
||||||
|
IdentityConfigurationView,
|
||||||
|
IdentityPairingExchange,
|
||||||
|
IdentityPairingInput,
|
||||||
|
IdentityRevisionPolicyUpdate,
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
GatewayNetworkProxyConfig,
|
GatewayNetworkProxyConfig,
|
||||||
@@ -52,7 +57,6 @@ import type {
|
|||||||
WalletSummaryResponse,
|
WalletSummaryResponse,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
||||||
import { oidcBrowserSessionEnabled } from './lib/oidc';
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
||||||
export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__';
|
export const OIDC_BROWSER_SESSION_CREDENTIAL = '__easyai_gateway_oidc_browser_session__';
|
||||||
@@ -644,7 +648,7 @@ export async function* streamChatCompletionText(
|
|||||||
...authorizationHeader(token),
|
...authorizationHeader(token),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@@ -845,7 +849,7 @@ export async function uploadFileToStorage(
|
|||||||
|
|
||||||
const response = await fetch(`${API_BASE}/v1/files/upload`, {
|
const response = await fetch(`${API_BASE}/v1/files/upload`, {
|
||||||
body: form,
|
body: form,
|
||||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
credentials: 'include',
|
||||||
headers: authorizationHeader(token),
|
headers: authorizationHeader(token),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
@@ -1001,6 +1005,64 @@ export async function updateClientCustomizationSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const identityConfigurationPath = '/api/admin/system/identity';
|
||||||
|
|
||||||
|
export async function getIdentityConfiguration(token: string): Promise<IdentityConfigurationView> {
|
||||||
|
return request<IdentityConfigurationView>(`${identityConfigurationPath}/configuration`, { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startIdentityPairing(token: string, input: IdentityPairingInput): Promise<IdentityPairingExchange> {
|
||||||
|
return identityConfigurationWrite<IdentityPairingExchange>(token, `${identityConfigurationPath}/pairings`, 'POST', 0, input, 'pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityPairing(token: string, pairingId: string): Promise<IdentityPairingExchange> {
|
||||||
|
return request<IdentityPairingExchange>(`${identityConfigurationPath}/pairings/${pairingId}`, { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateIdentityRevisionPolicy(
|
||||||
|
token: string,
|
||||||
|
revisionId: string,
|
||||||
|
version: number,
|
||||||
|
input: IdentityRevisionPolicyUpdate,
|
||||||
|
): Promise<IdentityConfigurationRevision> {
|
||||||
|
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}`, 'PATCH', version, input, 'policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
|
||||||
|
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/validate`, 'POST', version, undefined, 'validate');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
|
||||||
|
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/activate`, 'POST', version, undefined, 'activate');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackIdentityRevision(token: string, revisionId: string, version: number): Promise<IdentityConfigurationRevision> {
|
||||||
|
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/revisions/${revisionId}/rollback`, 'POST', version, undefined, 'rollback');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableIdentityConfiguration(token: string, version: number): Promise<IdentityConfigurationRevision> {
|
||||||
|
return identityConfigurationWrite<IdentityConfigurationRevision>(token, `${identityConfigurationPath}/disable`, 'POST', version, undefined, 'disable');
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityConfigurationWrite<T>(
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
version: number,
|
||||||
|
body: unknown,
|
||||||
|
action: string,
|
||||||
|
): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
body,
|
||||||
|
method,
|
||||||
|
token,
|
||||||
|
headers: {
|
||||||
|
'Idempotency-Key': `identity-${action}-${crypto.randomUUID()}`,
|
||||||
|
'If-Match': `W/"${version}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
|
const securityEventConnectionPath = '/api/admin/system/identity/security-events/connection';
|
||||||
|
|
||||||
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
|
export async function getSecurityEventConnection(token: string): Promise<SecurityEventConnectionResponse> {
|
||||||
@@ -1096,7 +1158,7 @@ async function request<T>(
|
|||||||
method: options.method ?? 'GET',
|
method: options.method ?? 'GET',
|
||||||
headers,
|
headers,
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
credentials: oidcBrowserSessionEnabled() ? 'include' : 'same-origin',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ describe('OIDC BFF navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends only returnTo to the Gateway login endpoint', async () => {
|
it('sends only returnTo to the Gateway login endpoint', async () => {
|
||||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
|
||||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
|
||||||
|
}));
|
||||||
const assign = vi.fn();
|
const assign = vi.fn();
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
|
location: { pathname: '/workspace', search: '?tab=wallet', hash: '#balance', assign },
|
||||||
@@ -24,8 +27,11 @@ describe('OIDC BFF navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('submits logout as a top-level POST without exposing tokens', async () => {
|
it('submits logout as a top-level POST without exposing tokens', async () => {
|
||||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
|
||||||
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
vi.stubEnv('VITE_GATEWAY_API_BASE_URL', 'https://gateway.example.com/gateway-api');
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: true, oidcLogin: true, loginUrl: '/api/v1/auth/oidc/login', logoutUrl: '/api/v1/auth/oidc/logout', status: 'active' }),
|
||||||
|
}));
|
||||||
const submit = vi.fn();
|
const submit = vi.fn();
|
||||||
const form = { method: '', action: '', style: { display: '' }, submit };
|
const form = { method: '', action: '', style: { display: '' }, submit };
|
||||||
const appendChild = vi.fn();
|
const appendChild = vi.fn();
|
||||||
@@ -39,7 +45,6 @@ describe('OIDC BFF navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('turns an invalid login transaction callback into an explicit retry action', async () => {
|
it('turns an invalid login transaction callback into an explicit retry action', async () => {
|
||||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
|
||||||
const replaceState = vi.fn();
|
const replaceState = vi.fn();
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
|
location: { pathname: '/', search: '?oidcError=OIDC_LOGIN_INVALID', hash: '#top' },
|
||||||
@@ -56,7 +61,6 @@ describe('OIDC BFF navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
|
it('explains a missing transaction cookie without retaining diagnostic query parameters', async () => {
|
||||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
|
||||||
const replaceState = vi.fn();
|
const replaceState = vi.fn();
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: {
|
location: {
|
||||||
@@ -79,7 +83,6 @@ describe('OIDC BFF navigation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not offer transaction retry for a token exchange failure', async () => {
|
it('does not offer transaction retry for a token exchange failure', async () => {
|
||||||
vi.stubEnv('VITE_OIDC_ENABLED', 'true');
|
|
||||||
vi.stubGlobal('window', {
|
vi.stubGlobal('window', {
|
||||||
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
|
location: { pathname: '/', search: '?oidcError=OIDC_TOKEN_EXCHANGE_FAILED', hash: '' },
|
||||||
history: { replaceState: vi.fn() },
|
history: { replaceState: vi.fn() },
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
const enabled = import.meta.env.VITE_OIDC_ENABLED === 'true';
|
|
||||||
const browserSessionEnabled = import.meta.env.VITE_OIDC_BROWSER_SESSION_ENABLED !== 'false';
|
|
||||||
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
|
const gatewayAPIBase = (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088').replace(/\/$/, '');
|
||||||
|
|
||||||
|
export type OIDCRuntimeConfiguration = {
|
||||||
|
enabled: boolean;
|
||||||
|
oidcLogin: boolean;
|
||||||
|
loginUrl?: string;
|
||||||
|
logoutUrl?: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabledRuntime: OIDCRuntimeConfiguration = { enabled: false, oidcLogin: false, status: 'disabled' };
|
||||||
|
let runtimeConfiguration = disabledRuntime;
|
||||||
|
let runtimeLoaded = false;
|
||||||
|
let runtimeRequest: Promise<OIDCRuntimeConfiguration> | null = null;
|
||||||
|
|
||||||
export type OIDCCallbackError = {
|
export type OIDCCallbackError = {
|
||||||
code: string;
|
code: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -27,32 +38,67 @@ const loginTransactionFailureMessages: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function oidcLoginEnabled() {
|
export function oidcLoginEnabled() {
|
||||||
return enabled;
|
return runtimeConfiguration.enabled && runtimeConfiguration.oidcLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function oidcBrowserSessionEnabled() {
|
export function oidcBrowserSessionEnabled() {
|
||||||
return browserSessionEnabled;
|
return oidcLoginEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOIDCRuntimeConfiguration(force = false): Promise<OIDCRuntimeConfiguration> {
|
||||||
|
if (!force && runtimeLoaded) return runtimeConfiguration;
|
||||||
|
if (!force && runtimeRequest) return runtimeRequest;
|
||||||
|
runtimeRequest = fetch(`${gatewayAPIBase}/api/v1/public/identity`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}).then(async (response) => {
|
||||||
|
if (!response.ok) return disabledRuntime;
|
||||||
|
const value = await response.json() as Partial<OIDCRuntimeConfiguration>;
|
||||||
|
if (typeof value.enabled !== 'boolean' || typeof value.oidcLogin !== 'boolean' || typeof value.status !== 'string') {
|
||||||
|
return disabledRuntime;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: value.enabled,
|
||||||
|
oidcLogin: value.oidcLogin,
|
||||||
|
status: value.status,
|
||||||
|
...(typeof value.loginUrl === 'string' ? { loginUrl: value.loginUrl } : {}),
|
||||||
|
...(typeof value.logoutUrl === 'string' ? { logoutUrl: value.logoutUrl } : {}),
|
||||||
|
};
|
||||||
|
}).catch(() => disabledRuntime).then((configuration) => {
|
||||||
|
runtimeConfiguration = configuration;
|
||||||
|
runtimeLoaded = true;
|
||||||
|
runtimeRequest = null;
|
||||||
|
return configuration;
|
||||||
|
});
|
||||||
|
return runtimeRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOIDCLogin() {
|
export async function startOIDCLogin() {
|
||||||
if (!oidcLoginEnabled()) throw new Error('统一认证未配置');
|
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||||
|
if (!configuration.enabled || !configuration.oidcLogin || !configuration.loginUrl) throw new Error('统一认证未配置');
|
||||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||||
const loginURL = new URL(`${gatewayAPIBase}/api/v1/auth/oidc/login`);
|
const loginURL = new URL(identityEndpointURL(configuration.loginUrl));
|
||||||
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
loginURL.searchParams.set('returnTo', returnTo.startsWith('/') ? returnTo : '/');
|
||||||
window.location.assign(loginURL.toString());
|
window.location.assign(loginURL.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startOIDCLogout() {
|
export async function startOIDCLogout() {
|
||||||
if (!oidcLoginEnabled()) return false;
|
const configuration = await loadOIDCRuntimeConfiguration(true);
|
||||||
|
if (!configuration.enabled || !configuration.oidcLogin || !configuration.logoutUrl) return false;
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = `${gatewayAPIBase}/api/v1/auth/oidc/logout`;
|
form.action = identityEndpointURL(configuration.logoutUrl);
|
||||||
form.style.display = 'none';
|
form.style.display = 'none';
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function identityEndpointURL(path: string) {
|
||||||
|
if (/^https:\/\//i.test(path) || /^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/i.test(path)) return path;
|
||||||
|
return `${gatewayAPIBase}/${path.replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function consumeOIDCCallbackError() {
|
export function consumeOIDCCallbackError() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const code = params.get('oidcError') ?? '';
|
const code = params.get('oidcError') ?? '';
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const tabs = [
|
|||||||
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
||||||
|
|
||||||
export function AdminPage(props: {
|
export function AdminPage(props: {
|
||||||
|
token: string;
|
||||||
data: ConsoleData;
|
data: ConsoleData;
|
||||||
operationMessage: string;
|
operationMessage: string;
|
||||||
section: AdminSection;
|
section: AdminSection;
|
||||||
@@ -191,21 +192,16 @@ export function AdminPage(props: {
|
|||||||
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
|
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
|
||||||
{props.section === 'systemSettings' && (
|
{props.section === 'systemSettings' && (
|
||||||
<SystemSettingsPanel
|
<SystemSettingsPanel
|
||||||
|
token={props.token}
|
||||||
channels={props.data.fileStorageChannels}
|
channels={props.data.fileStorageChannels}
|
||||||
clientCustomizationSettings={props.data.clientCustomizationSettings}
|
clientCustomizationSettings={props.data.clientCustomizationSettings}
|
||||||
settings={props.data.fileStorageSettings}
|
settings={props.data.fileStorageSettings}
|
||||||
securityEventConnection={props.data.securityEventConnection}
|
|
||||||
message={props.operationMessage}
|
message={props.operationMessage}
|
||||||
state={props.state}
|
state={props.state}
|
||||||
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
|
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
|
||||||
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
|
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
|
||||||
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
|
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
|
||||||
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
|
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
|
||||||
onConnectSecurityEvents={props.onConnectSecurityEvents}
|
|
||||||
onDisconnectSecurityEvents={props.onDisconnectSecurityEvents}
|
|
||||||
onRefreshSecurityEvents={props.onRefreshSecurityEvents}
|
|
||||||
onRotateSecurityEventsCredential={props.onRotateSecurityEventsCredential}
|
|
||||||
onVerifySecurityEvents={props.onVerifySecurityEvents}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
import { Database, Link2, Pencil, Plus, RefreshCw, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2, Unplug } from 'lucide-react';
|
import { Database, Pencil, Plus, RotateCcw, Save, ServerCog, Settings2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest, SecurityEventConnectionResponse } from '@easyai-ai-gateway/contracts';
|
import type { ClientCustomizationSettings, ClientCustomizationSettingsUpdateRequest, FileStorageChannel, FileStorageChannelUpsertRequest, FileStorageSettings, FileStorageSettingsUpdateRequest } from '@easyai-ai-gateway/contracts';
|
||||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
|
||||||
import type { LoadState } from '../../types';
|
import type { LoadState } from '../../types';
|
||||||
|
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
|
||||||
|
|
||||||
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'securityEvents';
|
type SystemSettingsTab = 'fileStorage' | 'clientCustomization' | 'identity';
|
||||||
|
|
||||||
type ClientCustomizationForm = {
|
type ClientCustomizationForm = {
|
||||||
clientEnglishName: string;
|
clientEnglishName: string;
|
||||||
@@ -54,21 +55,16 @@ const resultUploadPolicyOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SystemSettingsPanel(props: {
|
export function SystemSettingsPanel(props: {
|
||||||
|
token: string;
|
||||||
channels: FileStorageChannel[];
|
channels: FileStorageChannel[];
|
||||||
clientCustomizationSettings: ClientCustomizationSettings | null;
|
clientCustomizationSettings: ClientCustomizationSettings | null;
|
||||||
message: string;
|
message: string;
|
||||||
settings: FileStorageSettings | null;
|
settings: FileStorageSettings | null;
|
||||||
securityEventConnection: SecurityEventConnectionResponse | null;
|
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
onDeleteFileStorageChannel: (channelId: string) => Promise<void>;
|
||||||
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
|
||||||
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
|
||||||
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
|
||||||
onConnectSecurityEvents: (transmitterIssuer: string, managementClientId?: string, managementClientSecret?: string) => Promise<void>;
|
|
||||||
onDisconnectSecurityEvents: () => Promise<void>;
|
|
||||||
onRefreshSecurityEvents: () => Promise<void>;
|
|
||||||
onRotateSecurityEventsCredential: () => Promise<void>;
|
|
||||||
onVerifySecurityEvents: () => Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
|
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -78,8 +74,6 @@ export function SystemSettingsPanel(props: {
|
|||||||
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||||
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
const [transmitterIssuer, setTransmitterIssuer] = useState('https://auth.51easyai.com/ssf');
|
|
||||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
|
||||||
@@ -89,13 +83,6 @@ export function SystemSettingsPanel(props: {
|
|||||||
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
setClientCustomizationForm(clientCustomizationSettingsToForm(props.clientCustomizationSettings));
|
||||||
}, [props.clientCustomizationSettings]);
|
}, [props.clientCustomizationSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const lifecycle = props.securityEventConnection?.connection?.lifecycleStatus;
|
|
||||||
if (!['connecting', 'verifying', 'bootstrap', 'rotating', 'disconnect_pending', 'retiring'].includes(lifecycle ?? '')) return undefined;
|
|
||||||
const timer = window.setInterval(() => { void props.onRefreshSecurityEvents(); }, 2000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [props.securityEventConnection?.connection?.lifecycleStatus, props.onRefreshSecurityEvents]);
|
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
setEditingChannel(null);
|
setEditingChannel(null);
|
||||||
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
|
setForm(defaultChannelForm(`server-main-${Date.now().toString(36)}`));
|
||||||
@@ -177,7 +164,7 @@ export function SystemSettingsPanel(props: {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
|
{ value: 'fileStorage', label: '文件存储', icon: <Database size={15} /> },
|
||||||
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
|
{ value: 'clientCustomization', label: '客户端自定义', icon: <Settings2 size={15} /> },
|
||||||
{ value: 'securityEvents', label: '认证中心安全事件', icon: <ShieldCheck size={15} /> },
|
{ value: 'identity', label: '统一认证', icon: <ShieldCheck size={15} /> },
|
||||||
]}
|
]}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
/>
|
/>
|
||||||
@@ -291,19 +278,7 @@ export function SystemSettingsPanel(props: {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'securityEvents' && (
|
{activeTab === 'identity' && <UnifiedIdentityPanel token={props.token} />}
|
||||||
<SecurityEventConnectionPanel
|
|
||||||
connection={props.securityEventConnection}
|
|
||||||
issuer={transmitterIssuer}
|
|
||||||
loading={props.state === 'loading'}
|
|
||||||
onIssuerChange={setTransmitterIssuer}
|
|
||||||
onConnect={props.onConnectSecurityEvents}
|
|
||||||
onDisconnect={() => setDisconnectOpen(true)}
|
|
||||||
onRefresh={props.onRefreshSecurityEvents}
|
|
||||||
onRotate={props.onRotateSecurityEventsCredential}
|
|
||||||
onVerify={props.onVerifySecurityEvents}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
ariaLabel={editingChannel ? '编辑文件存储渠道' : '新增文件存储渠道'}
|
||||||
@@ -393,137 +368,10 @@ export function SystemSettingsPanel(props: {
|
|||||||
onCancel={() => setPendingDeleteChannel(null)}
|
onCancel={() => setPendingDeleteChannel(null)}
|
||||||
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
|
onConfirm={() => pendingDeleteChannel ? deleteChannel(pendingDeleteChannel) : undefined}
|
||||||
/>
|
/>
|
||||||
<ConfirmDialog
|
|
||||||
confirmLabel="断开连接"
|
|
||||||
description="系统会先删除远端 Stream,并继续使用水位和 RFC 7662 至少 360 秒;不会让旧 Token 因断开而重新有效。"
|
|
||||||
loading={props.state === 'loading'}
|
|
||||||
open={disconnectOpen}
|
|
||||||
title="确认断开认证中心安全事件连接?"
|
|
||||||
onCancel={() => setDisconnectOpen(false)}
|
|
||||||
onConfirm={async () => { await props.onDisconnectSecurityEvents(); setDisconnectOpen(false); }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SecurityEventConnectionPanel(props: {
|
|
||||||
connection: SecurityEventConnectionResponse | null;
|
|
||||||
issuer: string;
|
|
||||||
loading: boolean;
|
|
||||||
onIssuerChange(value: string): void;
|
|
||||||
onConnect(issuer: string, managementClientId?: string, managementClientSecret?: string): Promise<void>;
|
|
||||||
onDisconnect(): void;
|
|
||||||
onRefresh(): Promise<void>;
|
|
||||||
onRotate(): Promise<void>;
|
|
||||||
onVerify(): Promise<void>;
|
|
||||||
}) {
|
|
||||||
const connection = props.connection?.connection;
|
|
||||||
const prerequisites = props.connection?.prerequisites;
|
|
||||||
const [managementClientId, setManagementClientId] = useState('');
|
|
||||||
const [managementClientSecret, setManagementClientSecret] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
if (!managementClientId && prerequisites?.managementClientId) setManagementClientId(prerequisites.managementClientId);
|
|
||||||
}, [managementClientId, prerequisites?.managementClientId]);
|
|
||||||
const missing = [
|
|
||||||
!prerequisites?.oidcConfigured ? '先完成 OIDC Issuer、Tenant 和 Audience 配置' : '',
|
|
||||||
!prerequisites?.publicBaseUrlConfigured ? '配置 AI_GATEWAY_PUBLIC_BASE_URL' : '',
|
|
||||||
].filter(Boolean);
|
|
||||||
return (
|
|
||||||
<section className="fileStoragePanel">
|
|
||||||
<div className="fileStorageSettingsCard">
|
|
||||||
<div>
|
|
||||||
<strong>SSF / CAEP 实时会话撤销</strong>
|
|
||||||
<span>只需连接认证中心。Gateway 后端自动生成并托管 Push Bearer,复用现有 RFC 7662 机器 Client,不需要重启。</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant={connection?.lifecycleStatus === 'enabled' ? 'success' : connection ? 'secondary' : 'outline'}>
|
|
||||||
{securityEventLifecycleLabel(connection?.lifecycleStatus)}
|
|
||||||
</Badge>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void props.onRefresh()} disabled={props.loading}>
|
|
||||||
<RefreshCw size={14} />刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{!connection && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pageStack">
|
|
||||||
<div>
|
|
||||||
<strong>连接认证中心</strong>
|
|
||||||
<p className="mutedText">请先在认证中心 Application 的“安全事件流”中完成“准备 Gateway 接入”。</p>
|
|
||||||
</div>
|
|
||||||
{missing.length > 0 && <div className="formMessage">{missing.join(';')}</div>}
|
|
||||||
<Label>
|
|
||||||
SSF Transmitter Issuer
|
|
||||||
<Input value={props.issuer} onChange={(event) => props.onIssuerChange(event.target.value)} placeholder="https://auth.51easyai.com/ssf" />
|
|
||||||
<small>Endpoint、Scope、Push Bearer 和 Stream 由后端自动处理。</small>
|
|
||||||
</Label>
|
|
||||||
<Label>
|
|
||||||
Machine Client ID
|
|
||||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
|
||||||
</Label>
|
|
||||||
<Label>
|
|
||||||
Machine Client Secret
|
|
||||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
|
||||||
<small>浏览器只负责本次传递;Gateway 后端会立即写入 SecretStore,数据库、响应和日志均不保存明文。</small>
|
|
||||||
</Label>
|
|
||||||
<Button type="button" disabled={props.loading || missing.length > 0 || !props.issuer.trim() || !managementClientId.trim() || !managementClientSecret}
|
|
||||||
onClick={() => void props.onConnect(props.issuer.trim(), managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
|
||||||
<Link2 size={15} />连接认证中心
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{connection && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pageStack">
|
|
||||||
<div className="fileStorageMeta">
|
|
||||||
<span>机器 Client: {connection.managementClientId}</span>
|
|
||||||
<span>Receiver Endpoint: {connection.receiverEndpoint}</span>
|
|
||||||
<span>Issuer: {connection.transmitterIssuer}</span>
|
|
||||||
<span>Audience: {connection.audience ?? '正在获取'}</span>
|
|
||||||
<span>Stream ID: {connection.streamId ?? '正在创建'}</span>
|
|
||||||
<span>健康模式: {securityEventHealthLabel(connection.healthMode)}</span>
|
|
||||||
<span>最近 Verification: {connection.lastVerificationAt ? new Date(connection.lastVerificationAt).toLocaleString() : '尚未成功'}</span>
|
|
||||||
{props.connection?.traceId && <span>最近操作 Trace ID: {props.connection.traceId}</span>}
|
|
||||||
{props.connection?.auditId && <span>最近操作 Audit ID: {props.connection.auditId}</span>}
|
|
||||||
{connection.lastErrorCategory && <span>最近错误: {connection.lastErrorCategory}</span>}
|
|
||||||
</div>
|
|
||||||
{(connection.lastErrorCategory === 'credential_unavailable' || connection.lastErrorCategory === 'management_token_failed') && <div className="pageStack">
|
|
||||||
<div className="formMessage">认证中心机器凭据不可用。请在认证中心重新生成一次性连接凭据,并在这里更新;无需断开 Stream。</div>
|
|
||||||
<Label>
|
|
||||||
Machine Client ID
|
|
||||||
<Input value={managementClientId} onChange={(event) => setManagementClientId(event.target.value)} placeholder="从认证中心一次性连接凭据复制" autoComplete="off" />
|
|
||||||
</Label>
|
|
||||||
<Label>
|
|
||||||
Machine Client Secret
|
|
||||||
<Input type="password" value={managementClientSecret} onChange={(event) => setManagementClientSecret(event.target.value)} placeholder="仅本次提交,保存后不再显示" autoComplete="new-password" />
|
|
||||||
</Label>
|
|
||||||
<Button type="button" disabled={props.loading || !managementClientId.trim() || !managementClientSecret}
|
|
||||||
onClick={() => void props.onConnect(connection.transmitterIssuer, managementClientId.trim(), managementClientSecret).then(() => setManagementClientSecret(''))}>
|
|
||||||
<Link2 size={15} />保存凭据并恢复连接
|
|
||||||
</Button>
|
|
||||||
</div>}
|
|
||||||
<div className="fileStorageToolbar">
|
|
||||||
{connection.lifecycleStatus === 'error' && (
|
|
||||||
<Button type="button" size="sm" disabled={props.loading} onClick={() => void props.onConnect(connection.transmitterIssuer)}><Link2 size={14} />重试连接</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={() => void props.onVerify()}><RefreshCw size={14} />重新验证</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" disabled={props.loading || connection.lifecycleStatus === 'rotating'} onClick={() => void props.onRotate()}><RotateCcw size={14} />轮换接收凭据</Button>
|
|
||||||
<Button type="button" variant="destructive" size="sm" disabled={props.loading || connection.lifecycleStatus === 'retiring'} onClick={props.onDisconnect}><Unplug size={14} />断开连接</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function securityEventLifecycleLabel(status?: string) {
|
|
||||||
return ({ connecting: '连接中', verifying: '验证中', bootstrap: '内省保护期', enabled: '推送健康', degraded: '已降级', rotating: '轮换中', disconnect_pending: '等待断开', retiring: '安全退役中', error: '连接异常' } as Record<string, string>)[status ?? ''] ?? '未连接';
|
|
||||||
}
|
|
||||||
|
|
||||||
function securityEventHealthLabel(mode: string) {
|
|
||||||
return ({ disabled: '未启用', bootstrap: 'RFC 7662 启动保护', push_healthy: 'Push 健康', introspection_fallback: 'RFC 7662 降级' } as Record<string, string>)[mode] ?? mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultClientCustomizationForm(): ClientCustomizationForm {
|
function defaultClientCustomizationForm(): ClientCustomizationForm {
|
||||||
return {
|
return {
|
||||||
clientEnglishName: 'EasyAI AI Gateway',
|
clientEnglishName: 'EasyAI AI Gateway',
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { UnifiedIdentityPanel } from './UnifiedIdentityPanel';
|
||||||
|
|
||||||
|
describe('UnifiedIdentityPanel', () => {
|
||||||
|
it('asks only for the standard pairing inputs and does not expose a machine secret field', () => {
|
||||||
|
const html = renderToStaticMarkup(<UnifiedIdentityPanel token="manager-token" />);
|
||||||
|
|
||||||
|
expect(html).toContain('Auth Center 地址');
|
||||||
|
expect(html).toContain('一次性接入码');
|
||||||
|
expect(html).toContain('Gateway API 公网地址');
|
||||||
|
expect(html).toContain('Gateway Web 地址');
|
||||||
|
expect(html).toContain('Gateway 本地租户映射');
|
||||||
|
expect(html).not.toContain('Machine Client Secret');
|
||||||
|
expect(html).not.toContain('SSF Transmitter Issuer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react';
|
||||||
|
import { CheckCircle2, Link2, RefreshCw, RotateCcw, ShieldCheck, Unplug } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
IdentityConfigurationRevision,
|
||||||
|
IdentityConfigurationView,
|
||||||
|
IdentityPairingInput,
|
||||||
|
} from '@easyai-ai-gateway/contracts';
|
||||||
|
import {
|
||||||
|
activateIdentityRevision,
|
||||||
|
disableIdentityConfiguration,
|
||||||
|
getIdentityConfiguration,
|
||||||
|
rollbackIdentityRevision,
|
||||||
|
startIdentityPairing,
|
||||||
|
updateIdentityRevisionPolicy,
|
||||||
|
validateIdentityRevision,
|
||||||
|
} from '../../api';
|
||||||
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Input, Label } from '../../components/ui';
|
||||||
|
|
||||||
|
const terminalPairingStatuses = new Set(['completed', 'failed', 'expired']);
|
||||||
|
|
||||||
|
export function UnifiedIdentityPanel(props: { token: string }) {
|
||||||
|
const [configuration, setConfiguration] = useState<IdentityConfigurationView | null>(null);
|
||||||
|
const [form, setForm] = useState<IdentityPairingInput>(defaultPairingInput);
|
||||||
|
const [showPairingForm, setShowPairingForm] = useState(false);
|
||||||
|
const [localTenantKey, setLocalTenantKey] = useState('default');
|
||||||
|
const [legacyJwtEnabled, setLegacyJwtEnabled] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [confirmAction, setConfirmAction] = useState<'disable' | 'rollback' | null>(null);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const next = await getIdentityConfiguration(props.token);
|
||||||
|
setConfiguration(next);
|
||||||
|
if (next.draft) {
|
||||||
|
setLocalTenantKey(next.draft.localTenantKey);
|
||||||
|
setLegacyJwtEnabled(next.draft.legacyJwtEnabled);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh().catch((caught) => setError(errorMessage(caught, '统一认证配置加载失败')));
|
||||||
|
}, [props.token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const status = configuration?.pairing?.status;
|
||||||
|
if (!status || terminalPairingStatuses.has(status)) return undefined;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void refresh().catch(() => undefined);
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [configuration?.pairing?.status, props.token]);
|
||||||
|
|
||||||
|
async function run(action: () => Promise<unknown>, success: string, runtimeChanged = false) {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await refresh();
|
||||||
|
setMessage(success);
|
||||||
|
if (runtimeChanged) window.dispatchEvent(new Event('identity-runtime-changed'));
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setError(errorMessage(caught, '统一认证操作失败'));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPairing(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = { ...form };
|
||||||
|
setForm((current) => ({ ...current, onboardingCode: '' }));
|
||||||
|
await run(async () => {
|
||||||
|
await startIdentityPairing(props.token, input);
|
||||||
|
setShowPairingForm(false);
|
||||||
|
}, '接入码已领取,正在由 Gateway 后端准备并保存配置。');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraftPolicy() {
|
||||||
|
const draft = configuration?.draft;
|
||||||
|
if (!draft) return;
|
||||||
|
await run(() => updateIdentityRevisionPolicy(props.token, draft.id, draft.version, {
|
||||||
|
localTenantKey: localTenantKey.trim(),
|
||||||
|
legacyJwtEnabled,
|
||||||
|
}), 'Gateway 本地认证策略已保存。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = configuration?.active;
|
||||||
|
const draft = configuration?.draft;
|
||||||
|
const previous = configuration?.previous;
|
||||||
|
const pairing = configuration?.pairing;
|
||||||
|
const runtime = configuration?.runtime;
|
||||||
|
const shouldShowPairing = showPairingForm || (!active && !pairing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="pageStack">
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<div>
|
||||||
|
<strong>统一认证</strong>
|
||||||
|
<span>使用认证中心生成的一次性接入码完成标准应用配对;Gateway 自动配置 OIDC、Introspection 与可选 SSF。</span>
|
||||||
|
</div>
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<Badge variant={active ? 'success' : 'outline'}>{active ? '已启用' : '未启用'}</Badge>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => void refresh()}><RefreshCw size={14} />刷新</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(message || error) && <div className="formMessage">{error || message}</div>}
|
||||||
|
|
||||||
|
{active && runtime && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div>
|
||||||
|
<CardTitle>当前生效配置</CardTitle>
|
||||||
|
<p className="mutedText">Revision {shortID(active.id)} · {active.issuer}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="success"><CheckCircle2 size={13} />Active</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pageStack">
|
||||||
|
<div className="identityHealthGrid">
|
||||||
|
<IdentityHealth label="OIDC 登录" value={runtime.login} />
|
||||||
|
<IdentityHealth label="JIT 用户" value={runtime.jit} />
|
||||||
|
<IdentityHealth label="Token Introspection" value={runtime.tokenIntrospection} />
|
||||||
|
<IdentityHealth label="SSF 会话撤销" value={runtime.sessionRevocation} />
|
||||||
|
</div>
|
||||||
|
<RevisionDetails revision={active} />
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={loading}
|
||||||
|
onClick={() => void run(() => validateIdentityRevision(props.token, active.id, active.version), '当前配置重新验证成功。', true)}>
|
||||||
|
<RefreshCw size={14} />重新验证
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setShowPairingForm(true)}>
|
||||||
|
<RotateCcw size={14} />重新配对 / 轮换机器凭据
|
||||||
|
</Button>
|
||||||
|
{previous && (
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setConfirmAction('rollback')}>
|
||||||
|
<RotateCcw size={14} />回滚上一版本
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="destructive" size="sm" disabled={loading} onClick={() => setConfirmAction('disable')}>
|
||||||
|
<Unplug size={14} />禁用统一认证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pairing && !terminalPairingStatuses.has(pairing.status) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pageStack">
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<div><strong>正在配对认证中心</strong><span>{pairingStatusLabel(pairing.status)}</span></div>
|
||||||
|
<Badge variant="secondary">{pairing.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="fileStorageMeta">
|
||||||
|
<span>Exchange: {shortID(pairing.remoteExchangeId)}</span>
|
||||||
|
<span>有效期至: {new Date(pairing.expiresAt).toLocaleString()}</span>
|
||||||
|
{pairing.lastTraceId && <span>Trace ID: {pairing.lastTraceId}</span>}
|
||||||
|
{pairing.authCenterAuditId && <span>Auth Center Audit ID: {pairing.authCenterAuditId}</span>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft && pairing?.status === 'completed' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div><CardTitle>待启用配置</CardTitle><p className="mutedText">机器凭据已写入 SecretStore,页面不会显示或保存明文。</p></div>
|
||||||
|
<Badge variant={draft.state === 'failed' ? 'destructive' : 'secondary'}>{draft.state}</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pageStack">
|
||||||
|
<RevisionDetails revision={draft} />
|
||||||
|
{draft.state === 'draft' && (
|
||||||
|
<div className="formGrid two">
|
||||||
|
<Label>Gateway 本地租户映射<Input value={localTenantKey} onChange={(event) => setLocalTenantKey(event.target.value)} /></Label>
|
||||||
|
<label className="identityCheckbox">
|
||||||
|
<input type="checkbox" checked={legacyJwtEnabled} onChange={(event) => setLegacyJwtEnabled(event.target.checked)} />
|
||||||
|
<span><strong>继续兼容 Legacy JWT</strong><small>仅在迁移期确有旧调用方时开启。</small></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
{draft.state === 'draft' && <>
|
||||||
|
<Button type="button" variant="outline" disabled={loading || !localTenantKey.trim()} onClick={() => void saveDraftPolicy()}>保存本地策略</Button>
|
||||||
|
<Button type="button" disabled={loading || !localTenantKey.trim()}
|
||||||
|
onClick={() => void run(() => validateIdentityRevision(props.token, draft.id, draft.version), '统一认证配置验证成功,可以激活。')}>
|
||||||
|
<ShieldCheck size={14} />验证配置
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
|
{draft.state === 'validated' && (
|
||||||
|
<Button type="button" disabled={loading}
|
||||||
|
onClick={() => void run(() => activateIdentityRevision(props.token, draft.id, draft.version), '统一认证已热切换启用,无需重启。', true)}>
|
||||||
|
<CheckCircle2 size={14} />激活配置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{draft.state === 'failed' && <span className="formMessage">验证失败:{draft.lastErrorCategory || 'validation_failed'}。请检查地址和租户后使用新接入码重新配对。</span>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pairing && terminalPairingStatuses.has(pairing.status) && pairing.status !== 'completed' && (
|
||||||
|
<div className="fileStorageToolbar">
|
||||||
|
<span className="formMessage">配对{pairing.status === 'expired' ? '已过期' : '失败'}:{pairing.lastErrorCategory || pairing.status}。请在认证中心生成新的接入码。</span>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowPairingForm(true)}>使用新接入码</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldShowPairing && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div><CardTitle>{active ? '重新配对认证中心' : '接入认证中心'}</CardTitle><p className="mutedText">认证中心保持通用 Application 模型;这里仅消费标准 Manifest 和一次性机器凭据。</p></div>
|
||||||
|
<Badge variant="outline">接入码 10 分钟有效</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="formGrid two" onSubmit={(event) => void submitPairing(event)}>
|
||||||
|
<Label>Auth Center 地址<Input required value={form.authCenterUrl} onChange={(event) => setForm({ ...form, authCenterUrl: event.target.value })} placeholder="https://auth.example.com" /></Label>
|
||||||
|
<Label>一次性接入码<Input required type="password" autoComplete="one-time-code" value={form.onboardingCode} onChange={(event) => setForm({ ...form, onboardingCode: event.target.value })} placeholder="仅本次提交,不写入日志或数据库" /></Label>
|
||||||
|
<Label>Gateway API 公网地址<Input required value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://api.gateway.example.com" /></Label>
|
||||||
|
<Label>Gateway Web 地址<Input required value={form.webBaseUrl} onChange={(event) => setForm({ ...form, webBaseUrl: event.target.value })} placeholder="https://gateway.example.com" /></Label>
|
||||||
|
<Label>Gateway 本地租户映射<Input required value={form.localTenantKey} onChange={(event) => setForm({ ...form, localTenantKey: event.target.value })} placeholder="default" /></Label>
|
||||||
|
<label className="identityCheckbox">
|
||||||
|
<input type="checkbox" checked={form.legacyJwtEnabled} onChange={(event) => setForm({ ...form, legacyJwtEnabled: event.target.checked })} />
|
||||||
|
<span><strong>继续兼容 Legacy JWT</strong><small>默认关闭;本地应急管理员登录始终保留。</small></span>
|
||||||
|
</label>
|
||||||
|
<div className="fileStorageToolbar spanTwo">
|
||||||
|
<Button type="submit" disabled={loading || !form.onboardingCode.trim()}><Link2 size={15} />开始安全配对</Button>
|
||||||
|
{active && <Button type="button" variant="outline" onClick={() => setShowPairingForm(false)}>取消</Button>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
confirmLabel={confirmAction === 'rollback' ? '确认回滚' : '确认禁用'}
|
||||||
|
description={confirmAction === 'rollback'
|
||||||
|
? '系统会重新验证上一版本并热切换;现有统一认证会话将被清理,用户需要重新登录。'
|
||||||
|
: '统一认证将关闭并清理现有 BFF Session;本地应急管理员登录继续可用。'}
|
||||||
|
loading={loading}
|
||||||
|
open={confirmAction !== null}
|
||||||
|
title={confirmAction === 'rollback' ? '回滚统一认证配置?' : '禁用统一认证?'}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
let succeeded = false;
|
||||||
|
if (confirmAction === 'rollback' && previous) {
|
||||||
|
succeeded = await run(() => rollbackIdentityRevision(props.token, previous.id, previous.version), '统一认证已回滚,用户需要重新登录。', true);
|
||||||
|
} else if (confirmAction === 'disable' && active) {
|
||||||
|
succeeded = await run(() => disableIdentityConfiguration(props.token, active.version), '统一认证已禁用,本地管理登录继续可用。', true);
|
||||||
|
}
|
||||||
|
if (succeeded) setConfirmAction(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevisionDetails({ revision }: { revision: IdentityConfigurationRevision }) {
|
||||||
|
return (
|
||||||
|
<div className="fileStorageMeta">
|
||||||
|
<span>Issuer: {revision.issuer || '等待 Manifest'}</span>
|
||||||
|
<span>Audience: {revision.audience || '等待 Manifest'}</span>
|
||||||
|
<span>Tenant: {revision.tenantId || '等待 Manifest'}</span>
|
||||||
|
<span>本地租户: {revision.localTenantKey}</span>
|
||||||
|
<span>登录 Client: {revision.browserClientId || '未启用'}</span>
|
||||||
|
<span>服务 Client: {revision.machineClientId || '未启用'}</span>
|
||||||
|
<span>能力: {revision.capabilities.map(capabilityLabel).join('、') || '等待 Manifest'}</span>
|
||||||
|
<span>Scope: {revision.scopes.join(', ') || '无'}</span>
|
||||||
|
{revision.lastTraceId && <span>Trace ID: {revision.lastTraceId}</span>}
|
||||||
|
{revision.lastAuditId && <span>Audit ID: {revision.lastAuditId}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IdentityHealth({ label, value }: { label: string; value: string }) {
|
||||||
|
const healthy = value === 'healthy' || value === 'push_healthy';
|
||||||
|
return <div className="identityHealthItem"><span>{label}</span><Badge variant={healthy ? 'success' : value === 'disabled' ? 'outline' : 'secondary'}>{healthLabel(value)}</Badge></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPairingInput(): IdentityPairingInput {
|
||||||
|
const webBaseUrl = typeof window === 'undefined' ? '' : window.location.origin;
|
||||||
|
return {
|
||||||
|
authCenterUrl: 'https://auth.51easyai.com',
|
||||||
|
onboardingCode: '',
|
||||||
|
publicBaseUrl: (import.meta.env.VITE_GATEWAY_API_BASE_URL ?? '').replace(/\/$/, ''),
|
||||||
|
webBaseUrl,
|
||||||
|
localTenantKey: 'default',
|
||||||
|
legacyJwtEnabled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairingStatusLabel(status: string) {
|
||||||
|
return ({ metadata_pending: '正在提交回调与 Receiver 地址', preparing: '认证中心正在创建或复用标准资源', ready: '正在领取并保存一次性机器凭据', credentials_saved: '正在建立安全事件能力并确认完成' } as Record<string, string>)[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityLabel(value: string) {
|
||||||
|
return ({ oidc_login: 'OIDC 登录', api_access: 'API 验证', token_introspection: 'Token Introspection', machine_to_machine: '机器调用', session_revocation: 'SSF 会话撤销' } as Record<string, string>)[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthLabel(value: string) {
|
||||||
|
return ({ healthy: '健康', disabled: '未启用', push_healthy: 'Push 健康', introspection_fallback: 'Introspection 降级', bootstrap: '启动保护' } as Record<string, string>)[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortID(value: string) { return value ? `${value.slice(0, 8)}…` : '-'; }
|
||||||
|
|
||||||
|
function errorMessage(caught: unknown, fallback: string) { return caught instanceof Error ? caught.message : fallback; }
|
||||||
@@ -1995,6 +1995,44 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identityHealthGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityHealthItem {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-subtle, #f8fafc);
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityCheckbox {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 66px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identityCheckbox span,
|
||||||
|
.identityCheckbox small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.identityHealthGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fileStorageGrid {
|
.fileStorageGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -988,6 +988,117 @@ export interface SecurityEventConnectionResponse {
|
|||||||
prerequisites?: SecurityEventConnectionPrerequisites;
|
prerequisites?: SecurityEventConnectionPrerequisites;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IdentityRevisionState = 'draft' | 'validated' | 'active' | 'superseded' | 'failed';
|
||||||
|
|
||||||
|
export interface IdentityConfigurationRevision {
|
||||||
|
id: string;
|
||||||
|
state: IdentityRevisionState;
|
||||||
|
schemaVersion: number;
|
||||||
|
authCenterUrl: string;
|
||||||
|
issuer?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
audience?: string;
|
||||||
|
browserClientId?: string;
|
||||||
|
machineClientId?: string;
|
||||||
|
scopes: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
rolePrefix: string;
|
||||||
|
localTenantKey: string;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
webBaseUrl: string;
|
||||||
|
jitEnabled: boolean;
|
||||||
|
legacyJwtEnabled: boolean;
|
||||||
|
tokenIntrospection: boolean;
|
||||||
|
sessionRevocation: boolean;
|
||||||
|
securityEventIssuer?: string;
|
||||||
|
securityEventConfigurationUrl?: string;
|
||||||
|
securityEventAudience?: string;
|
||||||
|
sessionIdleSeconds: number;
|
||||||
|
sessionAbsoluteSeconds: number;
|
||||||
|
sessionRefreshSeconds: number;
|
||||||
|
version: number;
|
||||||
|
lastErrorCategory?: string;
|
||||||
|
lastTraceId?: string;
|
||||||
|
lastAuditId?: string;
|
||||||
|
validatedAt?: string;
|
||||||
|
activatedAt?: string;
|
||||||
|
supersededAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IdentityPairingStatus =
|
||||||
|
| 'metadata_pending'
|
||||||
|
| 'preparing'
|
||||||
|
| 'ready'
|
||||||
|
| 'credentials_saved'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'expired';
|
||||||
|
|
||||||
|
export interface IdentityPairingExchange {
|
||||||
|
id: string;
|
||||||
|
revisionId: string;
|
||||||
|
remoteExchangeId: string;
|
||||||
|
status: IdentityPairingStatus;
|
||||||
|
remoteVersion: number;
|
||||||
|
expiresAt: string;
|
||||||
|
version: number;
|
||||||
|
lastErrorCategory?: string;
|
||||||
|
authCenterAuditId?: string;
|
||||||
|
lastTraceId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityRuntimeStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
revisionId?: string;
|
||||||
|
login: string;
|
||||||
|
jit: string;
|
||||||
|
tokenIntrospection: string;
|
||||||
|
sessionRevocation: string;
|
||||||
|
lastTraceId?: string;
|
||||||
|
lastAuditId?: string;
|
||||||
|
lastErrorCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityConfigurationView {
|
||||||
|
active: IdentityConfigurationRevision | null;
|
||||||
|
draft: IdentityConfigurationRevision | null;
|
||||||
|
previous: IdentityConfigurationRevision | null;
|
||||||
|
pairing?: IdentityPairingExchange;
|
||||||
|
runtime: IdentityRuntimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicIdentityConfiguration {
|
||||||
|
enabled: boolean;
|
||||||
|
oidcLogin: boolean;
|
||||||
|
loginUrl?: string;
|
||||||
|
logoutUrl?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityPairingInput {
|
||||||
|
authCenterUrl: string;
|
||||||
|
onboardingCode: string;
|
||||||
|
publicBaseUrl: string;
|
||||||
|
webBaseUrl: string;
|
||||||
|
localTenantKey: string;
|
||||||
|
legacyJwtEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityRevisionPolicyUpdate {
|
||||||
|
localTenantKey?: string;
|
||||||
|
rolePrefix?: string;
|
||||||
|
jitEnabled?: boolean;
|
||||||
|
legacyJwtEnabled?: boolean;
|
||||||
|
sessionIdleSeconds?: number;
|
||||||
|
sessionAbsoluteSeconds?: number;
|
||||||
|
sessionRefreshSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTask {
|
export interface GatewayTask {
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user