Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
parent
5ee347571f
commit
5de4e911e3
3 changed files with 7585 additions and 595 deletions
|
|
@ -0,0 +1,77 @@
|
|||
import { AppShell } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import {
|
||||
ClientSettingsModal,
|
||||
type ClientSettingsModalProps,
|
||||
} from "./ClientSettingsModal";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
const configuredSettings: ClientSettingsFormValues = {
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "inspector-idp-client",
|
||||
clientSecret: "super-secret-idp-value",
|
||||
};
|
||||
|
||||
function InteractiveRender(args: ClientSettingsModalProps) {
|
||||
const [, updateArgs] = useArgs<ClientSettingsModalProps>();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<ClientSettingsModal
|
||||
{...args}
|
||||
onSettingsChange={(settings) => {
|
||||
args.onSettingsChange(settings);
|
||||
updateArgs({ settings });
|
||||
}}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ClientSettingsModal> = {
|
||||
title: "Groups/ClientSettingsModal",
|
||||
component: ClientSettingsModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: fn(),
|
||||
onSettingsChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ClientSettingsModal>;
|
||||
|
||||
export const Configured: Story = {
|
||||
args: {
|
||||
settings: configuredSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EnabledEmptyFields: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ClientSettingsModal } from "./ClientSettingsModal";
|
||||
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsModal", () => {
|
||||
it("renders the title when opened", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Client Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClose when the CloseButton is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when toggling EMA enable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enable enterprise IdP configuration",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows IdP fields when EMA is enabled", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client ID")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Secret")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when typing issuer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.type(screen.getByLabelText("Issuer"), "h");
|
||||
expect(onSettingsChange).toHaveBeenCalled();
|
||||
const call =
|
||||
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
|
||||
expect(call.issuer).toBe("h");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
ClientSettingsForm,
|
||||
type ClientSettingsSection,
|
||||
} from "../ClientSettingsForm/ClientSettingsForm";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
|
||||
|
||||
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
|
||||
|
||||
export interface ClientSettingsModalProps {
|
||||
opened: boolean;
|
||||
settings: ClientSettingsFormValues;
|
||||
onClose: () => void;
|
||||
onSettingsChange: (settings: ClientSettingsFormValues) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
}
|
||||
|
||||
export function ClientSettingsModal({
|
||||
opened,
|
||||
settings,
|
||||
onClose,
|
||||
onSettingsChange,
|
||||
emaIdpLoginState,
|
||||
onEmaIdpLogout,
|
||||
}: ClientSettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
>(["ema"]);
|
||||
|
||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||
|
||||
function handleToggleAll() {
|
||||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
<Title order={4} ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Title>
|
||||
<CloseButton onClick={onClose} />
|
||||
</Group>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue