626 lines
23 KiB
TypeScript
626 lines
23 KiB
TypeScript
import { designToken } from "@ekklesia/design-tokens";
|
|
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
|
import { Button } from "@ekklesia/ui";
|
|
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
HostAuthError,
|
|
type HostAuthLanding,
|
|
type HostSignInCredentials,
|
|
requestPasswordReset,
|
|
resendVerificationEmail,
|
|
resetPassword,
|
|
signInWithEmail,
|
|
signOutHost,
|
|
} from "../auth-protocol.js";
|
|
import {
|
|
bootstrapHost,
|
|
type DashboardNavigationEntry,
|
|
type EkklesiaHostConfig,
|
|
type HostBootstrap,
|
|
HostProtocolError,
|
|
loadHostPrincipal,
|
|
} from "../host-protocol.js";
|
|
import { DevicePanel } from "./DevicePanel.js";
|
|
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
|
import { LoginForm } from "./LoginForm.js";
|
|
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
|
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
|
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
|
|
|
|
type AuthenticatedPrincipal = Extract<
|
|
HostBootstrap["principal"],
|
|
{ readonly kind: "authenticated" }
|
|
>;
|
|
|
|
type BootstrapState =
|
|
| { readonly status: "loading" }
|
|
| { readonly status: "ready"; readonly bootstrap: HostBootstrap }
|
|
| { readonly status: "anonymous" }
|
|
| { readonly status: "access-denied"; readonly principal: AuthenticatedPrincipal }
|
|
| { readonly status: "failed" }
|
|
| { readonly status: "signing-out" }
|
|
| { readonly status: "sign-out-failed" };
|
|
|
|
type ResolvedBootstrapState = Extract<
|
|
BootstrapState,
|
|
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" }
|
|
>;
|
|
|
|
/**
|
|
* Which host-owned screen an unauthenticated visitor sees.
|
|
*
|
|
* A small explicit union rather than a client-side router: these four screens are the entirety of
|
|
* the platform's anonymous surface, and a routing library would be a dependency and an extra URL
|
|
* authority for a state machine that fits on a page. Only `reset-password` carries anything, and
|
|
* what it carries came from an emailed link the shell has already stripped from the address bar.
|
|
*
|
|
* None of these states is authenticated, and none can become authenticated: the shell renders the
|
|
* Dashboard only after `/api/v1/principal` says so, exactly as before.
|
|
*/
|
|
type AuthView =
|
|
| { readonly kind: "sign-in" }
|
|
| { readonly kind: "forgot-password" }
|
|
| { readonly kind: "verification-required"; readonly email: string }
|
|
| { readonly kind: "verification-result"; readonly outcome: VerificationOutcome }
|
|
| { readonly kind: "reset-password"; readonly token: string }
|
|
| { readonly kind: "reset-link-invalid" };
|
|
|
|
type VerificationOutcome =
|
|
| { readonly kind: "verified" }
|
|
| { readonly kind: "failed"; readonly expired: boolean };
|
|
|
|
function authViewFromLanding(landing: HostAuthLanding | undefined): AuthView {
|
|
if (!landing) {
|
|
return { kind: "sign-in" };
|
|
}
|
|
if (landing.kind === "verified") {
|
|
return { kind: "verification-result", outcome: { kind: "verified" } };
|
|
}
|
|
if (landing.kind === "verification-failed") {
|
|
return {
|
|
kind: "verification-result",
|
|
outcome: { expired: landing.expired, kind: "failed" },
|
|
};
|
|
}
|
|
if (landing.kind === "reset-password") {
|
|
return { kind: "reset-password", token: landing.token };
|
|
}
|
|
return { kind: "reset-link-invalid" };
|
|
}
|
|
|
|
export interface HostAppProps {
|
|
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
|
|
* built during render would re-fetch forever. The browser entry point builds it once at module
|
|
* scope. */
|
|
readonly config: EkklesiaHostConfig;
|
|
/**
|
|
* What an emailed verification or reset link asked this browser to show, already parsed.
|
|
*
|
|
* Supplied by the entry point rather than read here, so this component still touches no browser
|
|
* global. It only ever selects among the host's own anonymous screens: it can name no destination,
|
|
* so there is nothing an open redirect could use.
|
|
*/
|
|
readonly authLanding?: HostAuthLanding;
|
|
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
|
readonly importModule?: ModuleUiImporter;
|
|
}
|
|
|
|
/**
|
|
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
|
|
* colours (ADR 0039). Nothing here is a brand decision any more: changing
|
|
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
|
|
* artifact together, because custom properties inherit through the artifacts' isolated roots.
|
|
*/
|
|
const shellStyles = {
|
|
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
|
|
aside: {
|
|
background: designToken("background.canvas"),
|
|
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
|
boxSizing: "border-box",
|
|
minWidth: "15rem",
|
|
overflowY: "auto",
|
|
padding: designToken("spacing.xl"),
|
|
width: "15rem",
|
|
},
|
|
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
|
|
brandMark: {
|
|
background: designToken("accent.base"),
|
|
borderRadius: designToken("radius.md"),
|
|
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
|
|
display: "block",
|
|
height: designToken("spacing.xl"),
|
|
width: designToken("spacing.xl"),
|
|
},
|
|
groupLabel: {
|
|
color: designToken("text.muted"),
|
|
fontSize: designToken("typography.size-xs"),
|
|
letterSpacing: "0.05em",
|
|
margin: `0 0 ${designToken("spacing.xs")}`,
|
|
textTransform: "uppercase",
|
|
},
|
|
header: {
|
|
alignItems: "center",
|
|
background: designToken("background.surface"),
|
|
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
|
display: "flex",
|
|
fontWeight: designToken("typography.weight-bold"),
|
|
gridColumn: "1 / -1",
|
|
justifyContent: "space-between",
|
|
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
|
|
},
|
|
heading: {
|
|
fontSize: designToken("typography.size-lg"),
|
|
fontWeight: designToken("typography.weight-bold"),
|
|
margin: 0,
|
|
},
|
|
layout: {
|
|
background: designToken("background.page"),
|
|
color: designToken("text.primary"),
|
|
display: "grid",
|
|
fontFamily: designToken("typography.family-sans"),
|
|
gridTemplateColumns: "15rem minmax(0, 1fr)",
|
|
gridTemplateRows: "auto minmax(0, 1fr)",
|
|
minHeight: "100vh",
|
|
minWidth: 0,
|
|
width: "100%",
|
|
},
|
|
main: {
|
|
boxSizing: "border-box",
|
|
minWidth: 0,
|
|
overflowX: "auto",
|
|
padding: designToken("spacing.xl"),
|
|
},
|
|
message: {
|
|
color: designToken("text.secondary"),
|
|
display: "grid",
|
|
fontFamily: designToken("typography.family-sans"),
|
|
gap: designToken("spacing.md"),
|
|
justifyItems: "center",
|
|
margin: 0,
|
|
padding: designToken("spacing.lg"),
|
|
textAlign: "center",
|
|
},
|
|
} as const;
|
|
|
|
/** Identity, not placement: moving a module between groups does not reset what is open. */
|
|
function moduleKey(entry: DashboardNavigationEntry): string {
|
|
return `${entry.descriptor.id}@${entry.descriptor.version}`;
|
|
}
|
|
|
|
function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry[] {
|
|
return [
|
|
...bootstrap.navigation.groups.flatMap((group) => group.modules),
|
|
...bootstrap.navigation.ungrouped,
|
|
];
|
|
}
|
|
|
|
function ModuleButton(props: {
|
|
readonly entry: DashboardNavigationEntry;
|
|
readonly selected: boolean;
|
|
readonly onSelect: () => void;
|
|
}): ReactElement {
|
|
return (
|
|
<Button
|
|
aria-current={props.selected ? "page" : undefined}
|
|
aria-pressed={props.selected}
|
|
data-testid={`select-${props.entry.descriptor.id}`}
|
|
onClick={props.onSelect}
|
|
style={{ justifyContent: "flex-start", width: "100%" }}
|
|
variant="quiet"
|
|
>
|
|
{props.entry.name}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function HostMessage(props: {
|
|
readonly children: string;
|
|
readonly testId: string;
|
|
readonly actions?: ReactElement;
|
|
}): ReactElement {
|
|
return (
|
|
<main aria-live="polite" data-testid={props.testId} style={shellStyles.message}>
|
|
<p style={{ margin: 0 }}>{props.children}</p>
|
|
{props.actions}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Principal first, navigation second. A 403 after an authenticated principal is authorization, not
|
|
* evidence that the session vanished. Re-read the principal once only to close the narrow race where
|
|
* a session expires between those two sequential requests.
|
|
*/
|
|
async function resolveBootstrapState(
|
|
config: EkklesiaHostConfig,
|
|
signal: AbortSignal,
|
|
): Promise<ResolvedBootstrapState> {
|
|
try {
|
|
const bootstrap = await bootstrapHost(config, signal);
|
|
// Only a person signs into the Dashboard. The test is `=== "authenticated"` and never "not
|
|
// anonymous", so a principal class added later cannot quietly become a Dashboard login — which
|
|
// is precisely how a paired display device would otherwise have walked in (ADR 0036). In
|
|
// practice `/api/v1/principal` never reports a device at all; this is the second lock.
|
|
return bootstrap.principal.kind === "authenticated"
|
|
? { bootstrap: { ...bootstrap, principal: bootstrap.principal }, status: "ready" }
|
|
: { status: "anonymous" };
|
|
} catch (error) {
|
|
if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") {
|
|
return { status: "anonymous" };
|
|
}
|
|
if (error instanceof HostProtocolError && error.code === "HOST_ACCESS_DENIED") {
|
|
try {
|
|
const principal = await loadHostPrincipal(config, signal);
|
|
return principal.kind === "authenticated"
|
|
? { principal, status: "access-denied" }
|
|
: { status: "anonymous" };
|
|
} catch {
|
|
return { status: "failed" };
|
|
}
|
|
}
|
|
return { status: "failed" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Ekklesia Dashboard shell and its host-owned authentication boundary.
|
|
*
|
|
* The login form replaces the protected application in place. There is no redirect parameter, URL
|
|
* parser, location assignment, or client router in this shell, so the current internal Dashboard URL
|
|
* simply remains the current URL through sign-in and no external/protocol-relative return value can
|
|
* become a navigation sink. Modules receive the same five-field context after authentication; they
|
|
* neither render platform login nor receive sign-in/sign-out controls.
|
|
*/
|
|
export function HostApp(props: HostAppProps): ReactElement {
|
|
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
|
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
|
|
const [bootstrapAttempt, setBootstrapAttempt] = useState(0);
|
|
const activeTransitionRef = useRef<AbortController | undefined>(undefined);
|
|
const revalidationRef = useRef<AbortController | undefined>(undefined);
|
|
const generationRef = useRef(0);
|
|
const mountedRef = useRef(true);
|
|
const { config, importModule } = props;
|
|
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
|
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
|
// cannot resurrect a screen the person has left.
|
|
const [authView, setAuthView] = useState<AuthView>(() => authViewFromLanding(props.authLanding));
|
|
|
|
const beginTransition = useCallback(() => {
|
|
activeTransitionRef.current?.abort();
|
|
revalidationRef.current?.abort();
|
|
revalidationRef.current = undefined;
|
|
const controller = new AbortController();
|
|
activeTransitionRef.current = controller;
|
|
generationRef.current += 1;
|
|
return { controller, generation: generationRef.current };
|
|
}, []);
|
|
|
|
const isCurrent = useCallback(
|
|
(controller: AbortController, generation: number): boolean =>
|
|
mountedRef.current &&
|
|
!controller.signal.aborted &&
|
|
activeTransitionRef.current === controller &&
|
|
generationRef.current === generation,
|
|
[],
|
|
);
|
|
|
|
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
|
setState(resolved);
|
|
if (resolved.status === "ready") {
|
|
const first = allEntries(resolved.bootstrap)[0];
|
|
setSelectedKey(first ? moduleKey(first) : undefined);
|
|
} else {
|
|
setSelectedKey(undefined);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
activeTransitionRef.current?.abort();
|
|
revalidationRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// The value is an explicit manual-retry generation; reading it is what makes Retry re-run this
|
|
// otherwise config-driven bootstrap effect.
|
|
void bootstrapAttempt;
|
|
const { controller, generation } = beginTransition();
|
|
setState({ status: "loading" });
|
|
setSelectedKey(undefined);
|
|
void resolveBootstrapState(config, controller.signal).then((resolved) => {
|
|
if (!isCurrent(controller, generation)) {
|
|
return;
|
|
}
|
|
if (resolved.status === "failed") {
|
|
console.error("[ekklesia] host bootstrap failed");
|
|
}
|
|
commitResolvedState(resolved);
|
|
activeTransitionRef.current = undefined;
|
|
});
|
|
return () => {
|
|
if (activeTransitionRef.current === controller) {
|
|
controller.abort();
|
|
activeTransitionRef.current = undefined;
|
|
}
|
|
};
|
|
}, [beginTransition, bootstrapAttempt, commitResolvedState, config, isCurrent]);
|
|
|
|
const authenticate = useCallback(
|
|
async (credentials: HostSignInCredentials, formSignal: AbortSignal): Promise<void> => {
|
|
const { controller, generation } = beginTransition();
|
|
const abortFromForm = (): void => controller.abort();
|
|
formSignal.addEventListener("abort", abortFromForm, { once: true });
|
|
try {
|
|
await signInWithEmail(config, credentials, controller.signal);
|
|
const resolved = await resolveBootstrapState(config, controller.signal);
|
|
if (!isCurrent(controller, generation)) {
|
|
throw new HostAuthError("AUTH_REQUEST_FAILED");
|
|
}
|
|
if (resolved.status === "failed") {
|
|
console.error("[ekklesia] post-sign-in principal resolution failed");
|
|
}
|
|
commitResolvedState(resolved);
|
|
activeTransitionRef.current = undefined;
|
|
if (resolved.status === "anonymous" || resolved.status === "failed") {
|
|
throw new HostAuthError("AUTH_REQUEST_FAILED");
|
|
}
|
|
} finally {
|
|
formSignal.removeEventListener("abort", abortFromForm);
|
|
if (activeTransitionRef.current === controller) {
|
|
activeTransitionRef.current = undefined;
|
|
}
|
|
}
|
|
},
|
|
[beginTransition, commitResolvedState, config, isCurrent],
|
|
);
|
|
|
|
const signOut = useCallback(async (): Promise<void> => {
|
|
const { controller, generation } = beginTransition();
|
|
// The authenticated subtree disappears before the request starts. That synchronously schedules
|
|
// ModuleUiFrame cleanup, which invokes the module's unmount and closes its realtime subscriptions.
|
|
setSelectedKey(undefined);
|
|
setState({ status: "signing-out" });
|
|
try {
|
|
await signOutHost(config, controller.signal);
|
|
const principal = await loadHostPrincipal(config, controller.signal);
|
|
if (!isCurrent(controller, generation)) {
|
|
return;
|
|
}
|
|
// Returning to the anonymous shell always returns to the sign-in form. Without this, signing
|
|
// out while a verification or reset screen was the last anonymous view would show that screen
|
|
// again, describing an emailed link the person has long since finished with.
|
|
setAuthView({ kind: "sign-in" });
|
|
setState(
|
|
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
|
|
);
|
|
} catch {
|
|
if (isCurrent(controller, generation)) {
|
|
console.error("[ekklesia] sign out failed");
|
|
setState({ status: "sign-out-failed" });
|
|
}
|
|
} finally {
|
|
if (activeTransitionRef.current === controller) {
|
|
activeTransitionRef.current = undefined;
|
|
}
|
|
}
|
|
}, [beginTransition, config, isCurrent]);
|
|
|
|
const onAuthorizationResponse = useCallback(
|
|
(status: 401 | 403): void => {
|
|
// Module handlers are not the platform auth authority: one may emit an ordinary local 401,
|
|
// while Ekklesia authorization uses 403 for both anonymous and authenticated denials. Re-read
|
|
// principal once, de-duplicated across a burst of either status. Only an anonymous answer
|
|
// expires the shell; an authenticated answer leaves the module's original response alone. A
|
|
// principal failure is an unavailable platform, never a fabricated logout.
|
|
void status;
|
|
if (revalidationRef.current) {
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
const observedGeneration = generationRef.current;
|
|
revalidationRef.current = controller;
|
|
void loadHostPrincipal(config, controller.signal)
|
|
.then((principal) => {
|
|
if (
|
|
controller.signal.aborted ||
|
|
!mountedRef.current ||
|
|
generationRef.current !== observedGeneration
|
|
) {
|
|
return;
|
|
}
|
|
if (principal.kind === "anonymous") {
|
|
const transition = beginTransition();
|
|
transition.controller.abort();
|
|
activeTransitionRef.current = undefined;
|
|
setSelectedKey(undefined);
|
|
setAuthView({ kind: "sign-in" });
|
|
setState({ status: "anonymous" });
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (
|
|
controller.signal.aborted ||
|
|
!mountedRef.current ||
|
|
generationRef.current !== observedGeneration
|
|
) {
|
|
return;
|
|
}
|
|
const transition = beginTransition();
|
|
transition.controller.abort();
|
|
activeTransitionRef.current = undefined;
|
|
setSelectedKey(undefined);
|
|
console.error("[ekklesia] session revalidation failed");
|
|
setState({ status: "failed" });
|
|
})
|
|
.finally(() => {
|
|
if (revalidationRef.current === controller) {
|
|
revalidationRef.current = undefined;
|
|
}
|
|
});
|
|
},
|
|
[beginTransition, config],
|
|
);
|
|
|
|
const retryBootstrap = (): void => setBootstrapAttempt((current) => current + 1);
|
|
|
|
if (state.status === "loading") {
|
|
return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
|
|
}
|
|
if (state.status === "anonymous") {
|
|
const returnToSignIn = (): void => setAuthView({ kind: "sign-in" });
|
|
if (authView.kind === "forgot-password") {
|
|
return (
|
|
<ForgotPasswordForm
|
|
onRequestReset={(email, signal) => requestPasswordReset(config, email, signal)}
|
|
onReturnToSignIn={returnToSignIn}
|
|
/>
|
|
);
|
|
}
|
|
if (authView.kind === "verification-required") {
|
|
return (
|
|
<VerificationNotice
|
|
email={authView.email}
|
|
onResend={(email, signal) => resendVerificationEmail(config, email, signal)}
|
|
onReturnToSignIn={returnToSignIn}
|
|
/>
|
|
);
|
|
}
|
|
if (authView.kind === "verification-result") {
|
|
return <VerificationResult onReturnToSignIn={returnToSignIn} outcome={authView.outcome} />;
|
|
}
|
|
if (authView.kind === "reset-password") {
|
|
return (
|
|
<ResetPasswordForm
|
|
onResetPassword={(input, signal) => resetPassword(config, input, signal)}
|
|
onReturnToSignIn={returnToSignIn}
|
|
token={authView.token}
|
|
/>
|
|
);
|
|
}
|
|
if (authView.kind === "reset-link-invalid") {
|
|
return <ResetLinkInvalid onReturnToSignIn={returnToSignIn} />;
|
|
}
|
|
return (
|
|
<LoginForm
|
|
onAuthenticate={authenticate}
|
|
onEmailNotVerified={(email) => setAuthView({ email, kind: "verification-required" })}
|
|
onForgotPassword={() => setAuthView({ kind: "forgot-password" })}
|
|
/>
|
|
);
|
|
}
|
|
if (state.status === "failed") {
|
|
return (
|
|
<HostMessage actions={<Button onClick={retryBootstrap}>Retry</Button>} testId="host-error">
|
|
Ekklesia could not be reached.
|
|
</HostMessage>
|
|
);
|
|
}
|
|
if (state.status === "signing-out") {
|
|
return <HostMessage testId="host-signing-out">Signing out…</HostMessage>;
|
|
}
|
|
if (state.status === "sign-out-failed") {
|
|
return (
|
|
<HostMessage
|
|
actions={
|
|
<div style={shellStyles.actions}>
|
|
<Button onClick={() => void signOut()}>Retry sign out</Button>
|
|
<Button onClick={retryBootstrap}>Return to Ekklesia</Button>
|
|
</div>
|
|
}
|
|
testId="host-sign-out-error"
|
|
>
|
|
Ekklesia could not sign you out. Try again.
|
|
</HostMessage>
|
|
);
|
|
}
|
|
if (state.status === "access-denied") {
|
|
return (
|
|
<HostMessage
|
|
actions={
|
|
<Button data-testid="host-logout" onClick={() => void signOut()}>
|
|
Sign out
|
|
</Button>
|
|
}
|
|
testId="host-access-denied"
|
|
>
|
|
You are signed in, but do not have access to this Dashboard.
|
|
</HostMessage>
|
|
);
|
|
}
|
|
|
|
const { bootstrap } = state;
|
|
const entries = allEntries(bootstrap);
|
|
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
|
|
const { groups, ungrouped } = bootstrap.navigation;
|
|
|
|
return (
|
|
<div data-testid="host-shell" style={shellStyles.layout}>
|
|
<header style={shellStyles.header}>
|
|
<div style={shellStyles.brand}>
|
|
<span aria-hidden="true" style={shellStyles.brandMark} />
|
|
<h1 style={shellStyles.heading}>Ekklesia</h1>
|
|
</div>
|
|
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
|
|
Sign out
|
|
</Button>
|
|
</header>
|
|
<aside style={shellStyles.aside}>
|
|
<DevicePanel config={config} />
|
|
{entries.length === 0 && groups.length === 0 ? (
|
|
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
|
No installed module provides a user interface.
|
|
</p>
|
|
) : (
|
|
<nav aria-label="Modules" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
|
{groups.map((group) => (
|
|
<section data-testid={`group-${group.id}`} key={group.id}>
|
|
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
|
{group.modules.map((entry) => (
|
|
<ModuleButton
|
|
entry={entry}
|
|
key={moduleKey(entry)}
|
|
onSelect={() => setSelectedKey(moduleKey(entry))}
|
|
selected={moduleKey(entry) === selectedKey}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
{ungrouped.length > 0 ? (
|
|
<section data-testid="group-ungrouped">
|
|
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
|
<div style={{ display: "grid", gap: "0.125rem" }}>
|
|
{ungrouped.map((entry) => (
|
|
<ModuleButton
|
|
entry={entry}
|
|
key={moduleKey(entry)}
|
|
onSelect={() => setSelectedKey(moduleKey(entry))}
|
|
selected={moduleKey(entry) === selectedKey}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : undefined}
|
|
</nav>
|
|
)}
|
|
</aside>
|
|
<main style={shellStyles.main}>
|
|
{selected ? (
|
|
<ModuleUiFrame
|
|
config={config}
|
|
descriptor={selected.descriptor}
|
|
{...(importModule ? { importModule } : {})}
|
|
key={moduleKey(selected)}
|
|
onAuthorizationResponse={onAuthorizationResponse}
|
|
principal={bootstrap.principal}
|
|
/>
|
|
) : undefined}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|