АРХ - NODEDC LAUNCHER: BFF OIDC и app access

This commit is contained in:
DCCONSTRUCTIONS
2026-05-04 13:01:26 +03:00
parent 5e86047a02
commit de0a0d2948
9 changed files with 1734 additions and 12 deletions
+177 -5
View File
@@ -12,6 +12,7 @@ import {
profileOptions,
type LauncherData,
} from "../shared/api/mockApi";
import { fetchAuthSession, fetchAvailableApps, type AuthSession, type LauncherAuthApp } from "../shared/api/authApi";
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
import { AdminOverlay, type SetUserServiceAccessCommand } from "../widgets/admin-overlay/AdminOverlay";
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
@@ -25,12 +26,78 @@ export function LauncherApp() {
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
const [adminOpen, setAdminOpen] = useState(false);
const [storageHydrated, setStorageHydrated] = useState(false);
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
const runtimeMe = useMemo(() => {
if (!authSession?.authenticated) return me;
return {
...me,
user: {
...me.user,
authentikUserId: authSession.user.sub,
email: authSession.user.email || me.user.email,
name: authSession.user.name || me.user.name,
},
mockAuthentikClaims: {
...me.mockAuthentikClaims,
sub: authSession.user.sub,
email: authSession.user.email || me.mockAuthentikClaims.email,
name: authSession.user.name || me.mockAuthentikClaims.name,
groups: authSession.groups,
},
};
}, [authSession, me]);
const resolvedClientId = me.activeClientId;
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
const launcherServices = useMemo(
() => buildLauncherServices(data, activeProfileId, resolvedClientId),
[data, activeProfileId, resolvedClientId]
() => {
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
if (!authSession?.authenticated || authApps === null) {
return services;
}
return services.map((service) => {
const app = authAppsBySlug.get(service.slug);
if (!app) {
return {
...service,
userAccess: "denied" as const,
openUrl: null,
effectiveAccess: {
...service.effectiveAccess,
allowed: false,
visible: true,
openEnabled: false,
reason: "Нет доступа",
},
};
}
const openEnabled = app.hasAccess && app.status === "active";
return {
...service,
title: app.title || service.title,
description: app.description || service.description,
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const),
effectiveAccess: {
...service.effectiveAccess,
allowed: app.hasAccess,
visible: true,
openEnabled,
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"),
},
};
});
},
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId]
);
useEffect(() => {
@@ -46,6 +113,55 @@ export function LauncherApp() {
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
useEffect(() => {
let isMounted = true;
fetchAuthSession()
.then(async (session) => {
if (!isMounted) return;
setAuthSession(session);
setAuthError(null);
if (!session.authenticated) {
setAuthApps([]);
return;
}
const apps = await fetchAvailableApps();
if (isMounted) {
setAuthApps(apps);
}
})
.catch((error: unknown) => {
if (!isMounted) return;
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
setAuthApps([]);
setAuthError(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!authSession?.authenticated) return;
const nextProfileId = authSession.isSuperAdmin ? "user_root" : "user_vasya";
const nextProfile = profileOptions.find((profile) => profile.userId === nextProfileId);
if (activeProfileId !== nextProfileId) {
setActiveProfileId(nextProfileId);
}
if (nextProfile && activeClientId !== nextProfile.defaultClientId) {
setActiveClientId(nextProfile.defaultClientId);
}
}, [activeClientId, activeProfileId, authSession]);
useEffect(() => {
let isMounted = true;
@@ -178,7 +294,7 @@ export function LauncherApp() {
{
...invite,
id: `invite_mock_${Date.now()}`,
invitedByUserId: me.user.id,
invitedByUserId: runtimeMe.user.id,
token: `mock-${Date.now()}`,
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
status: "created",
@@ -476,10 +592,18 @@ export function LauncherApp() {
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
}
if (!authSession) {
return <AuthStateScreen title="Проверяем сессию NODE.DC" description="Платформа подготавливает рабочую область и список приложений." />;
}
if (!authSession.authenticated) {
return <AuthStateScreen title="Вход на платформу NODE.DC" description="Войдите, чтобы открыть рабочую область и доступы." error={authError} loginUrl={authSession.loginUrl} />;
}
return (
<div className="launcher-app">
<TopBar
me={me}
me={runtimeMe}
clients={data.clients}
profileOptions={profileOptions}
activeProfileId={activeProfileId}
@@ -489,6 +613,7 @@ export function LauncherApp() {
onClientChange={setActiveClientId}
onToggleAdmin={() => setAdminOpen((current) => !current)}
onOpenShowcase={() => setAdminOpen(false)}
onLogout={() => window.location.assign(authSession.logoutUrl)}
/>
<main className="launcher-main">
@@ -502,7 +627,7 @@ export function LauncherApp() {
{adminOpen && me.permissions.canOpenAdmin ? (
<AdminOverlay
data={data}
me={me}
me={runtimeMe}
activeClientId={resolvedClientId}
onClose={() => setAdminOpen(false)}
onSetUserServiceAccess={handleSetUserServiceAccess}
@@ -537,3 +662,50 @@ function syncLauncherServiceLinks(data: LauncherData): LauncherData {
services: data.services.map(syncServiceLaunchLink),
};
}
function AuthStateScreen({
title,
description,
error,
loginUrl,
}: {
title: string;
description: string;
error?: string | null;
loginUrl?: string;
}) {
return (
<div className="launcher-app">
<main
style={{
display: "grid",
minHeight: "100vh",
placeItems: "center",
padding: "2rem",
}}
>
<section
style={{
display: "grid",
width: "min(34rem, 100%)",
gap: "1rem",
padding: "2rem",
borderRadius: "1.75rem",
background: "rgba(255, 255, 255, 0.08)",
textAlign: "center",
}}
>
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
<h1 style={{ margin: 0 }}>{title}</h1>
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
{error ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{error}</p> : null}
{loginUrl ? (
<button className="button button--primary" type="button" onClick={() => window.location.assign(loginUrl)}>
Войти
</button>
) : null}
</section>
</main>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
export interface AuthUser {
sub: string;
email: string;
name: string;
preferredUsername: string | null;
groups: string[];
}
export interface AuthenticatedSession {
authenticated: true;
user: AuthUser;
groups: string[];
isSuperAdmin: boolean;
logoutUrl: string;
}
export interface UnauthenticatedSession {
authenticated: false;
loginUrl: string;
}
export type AuthSession = AuthenticatedSession | UnauthenticatedSession;
export interface LauncherAuthApp {
id: string;
slug: string;
title: string;
description: string;
url: string;
openUrl: string;
status: string;
provider: string;
requiredGroups: string[];
matchedGroups: string[];
hasAccess: boolean;
accessReason: string;
media?: {
icon?: string | null;
coverImage?: string | null;
accentColor?: string | null;
};
}
export async function fetchAuthSession(): Promise<AuthSession> {
const response = await fetch("/api/me", { cache: "no-store" });
if (response.status === 401) {
return (await response.json()) as UnauthenticatedSession;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, "Не удалось получить сессию платформы"));
}
return (await response.json()) as AuthenticatedSession;
}
export async function fetchAvailableApps(): Promise<LauncherAuthApp[]> {
const response = await fetch("/api/apps", { cache: "no-store" });
if (!response.ok) {
throw new Error(await readErrorMessage(response, "Не удалось получить список приложений"));
}
const payload = (await response.json()) as { apps?: LauncherAuthApp[] };
return payload.apps ?? [];
}
async function readErrorMessage(response: Response, fallback: string) {
try {
const payload = (await response.json()) as { error?: string };
return payload.error ?? fallback;
} catch {
return response.statusText || fallback;
}
}
+5 -3
View File
@@ -15,9 +15,11 @@ interface NodeDcProfileMenuProps {
coverUrl?: string;
trigger: (api: { open: boolean; toggle: () => void; setTriggerRef: (node: HTMLElement | null) => void }) => ReactNode;
className?: string;
onLogout?: () => void;
onSettings?: () => void;
}
export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", trigger, className }: NodeDcProfileMenuProps) {
export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", trigger, className, onLogout, onSettings }: NodeDcProfileMenuProps) {
return (
<NodeDcDropdown
className={className}
@@ -32,11 +34,11 @@ export function NodeDcProfileMenu({ user, coverUrl = "/storage/default.gif", tri
<strong>{user.name}</strong>
<span>{user.email}</span>
</div>
<button className="nodedc-ui-profile-card__row" type="button">
<button className="nodedc-ui-profile-card__row" type="button" onClick={onSettings}>
<Settings size={15} strokeWidth={1.7} />
<span>Настройки</span>
</button>
<button className="nodedc-ui-profile-card__row" type="button">
<button className="nodedc-ui-profile-card__row" type="button" onClick={onLogout}>
<LogOut size={15} strokeWidth={1.7} />
<span>Выйти</span>
</button>
+3
View File
@@ -15,6 +15,7 @@ export function TopBar({
onClientChange,
onToggleAdmin,
onOpenShowcase,
onLogout,
}: {
me: MeResponse;
clients: Client[];
@@ -26,6 +27,7 @@ export function TopBar({
onClientChange: (clientId: string) => void;
onToggleAdmin: () => void;
onOpenShowcase: () => void;
onLogout?: () => void;
}) {
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
@@ -112,6 +114,7 @@ export function TopBar({
<div className="nodedc-expanded-toolbar-right">
<NodeDcProfileMenu
user={me.user}
onLogout={onLogout}
trigger={({ open, toggle, setTriggerRef }) => (
<div
ref={setTriggerRef}