АРХ - NODEDC LAUNCHER: BFF OIDC и app access
This commit is contained in:
+177
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user