Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@
"integrations": [
{
"name": "github",
"operations": [
"get_issue",
"create_pull_request"
]
"operations": ["get_issue", "create_pull_request"]
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function TwoFactorGraceBanner({
return (
<div className="px-4 pt-2">
<Row
role="alert"
role="status"
gap={2}
wrap
className="bg-warning/10 border-warning/30 rounded-lg border p-3 text-sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function TwoFactorLowBackupCodesBanner({
return (
<div className="px-4 pt-2">
<Row
role="alert"
role="status"
gap={2}
wrap
className="bg-warning/10 border-warning/30 rounded-lg border p-3 text-sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export function usePasswordExpiryGate(organizationId: string): void {

useEffect(() => {
if (!data || !data.expired) return;
if (location.pathname.endsWith(FORCED_CHANGE_PATH)) return;
// The route is `/forced-change-password/$id`, so the pathname ends with the
// id, never the literal segment — match by inclusion so the gate actually
// short-circuits on that page instead of re-navigating to it (#2085[06]).
if (location.pathname.includes(`/${FORCED_CHANGE_PATH}`)) return;
const id = (params as { id?: string }).id ?? organizationId;
void navigate({
to: '/forced-change-password/$id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function TwoFactorSection() {
if (!status || !status.authenticated || !status.hasCredential) return null;

return status.twoFactorEnabled ? (
<EnrolledState />
<EnrolledState enforced={status.enforced} />
) : (
<NotEnrolledState enforced={status.enforced} />
);
Expand Down Expand Up @@ -152,7 +152,7 @@ function NotEnrolledState({ enforced }: { enforced: boolean }) {
);
}

function EnrolledState() {
function EnrolledState({ enforced }: { enforced: boolean }) {
const { t } = useT('twoFactor');
const { toast } = useToast();
const showBackupCodes = useShowBackupCodes();
Expand Down Expand Up @@ -224,6 +224,7 @@ function EnrolledState() {
open={disableOpen}
title={t('enrollment.disableButton')}
description={t('enrollment.disablePromptDescription')}
warning={enforced ? t('enrollment.disableEnforcedWarning') : undefined}
submitting={submitting}
onCancel={() => {
setDisableOpen(false);
Expand Down Expand Up @@ -253,6 +254,8 @@ interface PasswordPromptProps {
open: boolean;
title: string;
description: string;
/** Optional standing warning shown above the password field. */
warning?: string;
submitting: boolean;
error: string | null;
onCancel: () => void;
Expand All @@ -263,6 +266,7 @@ function PasswordPromptDialog({
open,
title,
description,
warning,
submitting,
error,
onCancel,
Expand Down Expand Up @@ -291,6 +295,14 @@ function PasswordPromptDialog({
if (!submitting && password) onSubmit(password);
}}
>
{warning && (
<Text
role="status"
className="bg-warning/10 border-warning/30 rounded-lg border p-3 text-sm"
>
{warning}
</Text>
)}
<Input
id="two-factor-password"
type="password"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,73 @@ describe('EnterpriseSsoForm validation + save', () => {
const errors = await screen.findAllByText(/this field is required/i);
expect(errors.length).toBeGreaterThan(0);
});

it('requires a client secret when switching a SAML connection to OIDC (#2057)', async () => {
upsertOidcMock.mockClear();
const { user } = renderForm(samlConfig);

// Switch the protocol SAML → Microsoft Entra ID.
await user.click(screen.getByRole('combobox', { name: /protocol/i }));
await user.click(
await screen.findByRole('option', { name: /microsoft entra id/i }),
);

// A SAML-only connection has no stored OIDC secret to reuse, so a blank
// secret must keep Save blocked even though issuer + client id are filled.
await user.type(
screen.getByLabelText(/issuer url/i),
'https://login.example.com',
);
await user.type(screen.getByLabelText(/^client id$/i), 'client-123');

const saveButton = await screen.findByRole('button', { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeDisabled());
expect(upsertOidcMock).not.toHaveBeenCalled();
});

it('mounts controls defined so no uncontrolled→controlled warning fires as config loads (#2095)', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { rerender } = render(
<Bare>
<EnterpriseSsoForm organizationId="org-1" config={undefined} />
</Bare>,
);
rerender(
<Bare>
<EnterpriseSsoForm organizationId="org-1" config={connectedOidc} />
</Bare>,
);
const warned = errorSpy.mock.calls.some((call) =>
/uncontrolled to controlled|controlled to uncontrolled|changing an uncontrolled|changing a controlled/i.test(
String(call[0]),
),
);
errorSpy.mockRestore();
expect(warned).toBe(false);
});

it('adds a role-mapping rule and saves it (#2085[12])', async () => {
upsertOidcMock.mockClear();
revealClientIdMock.mockResolvedValueOnce('client-xyz');
const { user } = renderForm(connectedOidc);

// Wait for the stored client id to be revealed so the OIDC form is valid.
await waitFor(() =>
expect(screen.getByLabelText(/^client id$/i)).toHaveValue('client-xyz'),
);

// The editor is visible (the connection auto-provisions roles). Add a rule
// mapping the IdP group "Engineering" to the default member role.
await user.click(screen.getByRole('button', { name: /add rule/i }));
await user.type(screen.getByLabelText(/matches value/i), 'Engineering');

const saveButton = await screen.findByRole('button', { name: /^save$/i });
await waitFor(() => expect(saveButton).toBeEnabled());
await user.click(saveButton);

await waitFor(() => expect(upsertOidcMock).toHaveBeenCalledTimes(1));
expect(upsertOidcMock.mock.calls[0][0].roleMappingRules).toEqual([
{ source: 'group', pattern: 'Engineering', targetRole: 'member' },
]);
});
});
Loading
Loading