diff --git a/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json b/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json
index 7137aa6c8..4324ffb97 100644
--- a/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json
+++ b/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json
@@ -21,10 +21,7 @@
"integrations": [
{
"name": "github",
- "operations": [
- "get_issue",
- "create_pull_request"
- ]
+ "operations": ["get_issue", "create_pull_request"]
}
]
},
diff --git a/services/platform/app/features/auth/components/two-factor-grace-banner.tsx b/services/platform/app/features/auth/components/two-factor-grace-banner.tsx
index e9475cb2e..5f11c2627 100644
--- a/services/platform/app/features/auth/components/two-factor-grace-banner.tsx
+++ b/services/platform/app/features/auth/components/two-factor-grace-banner.tsx
@@ -40,7 +40,7 @@ export function TwoFactorGraceBanner({
return (
{
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',
diff --git a/services/platform/app/features/settings/account/components/two-factor-section.tsx b/services/platform/app/features/settings/account/components/two-factor-section.tsx
index e9675ad3e..efe11ac39 100644
--- a/services/platform/app/features/settings/account/components/two-factor-section.tsx
+++ b/services/platform/app/features/settings/account/components/two-factor-section.tsx
@@ -48,7 +48,7 @@ export function TwoFactorSection() {
if (!status || !status.authenticated || !status.hasCredential) return null;
return status.twoFactorEnabled ? (
-
+
) : (
);
@@ -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();
@@ -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);
@@ -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;
@@ -263,6 +266,7 @@ function PasswordPromptDialog({
open,
title,
description,
+ warning,
submitting,
error,
onCancel,
@@ -291,6 +295,14 @@ function PasswordPromptDialog({
if (!submitting && password) onSubmit(password);
}}
>
+ {warning && (
+
+ {warning}
+
+ )}
{
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(
+
+
+ ,
+ );
+ rerender(
+
+
+ ,
+ );
+ 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' },
+ ]);
+ });
});
diff --git a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx
index 67155cbc0..ab96c5e3e 100644
--- a/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx
+++ b/services/platform/app/features/settings/enterprise-sso/components/enterprise-sso-form.tsx
@@ -2,7 +2,7 @@
import { Badge } from '@tale/ui/badge';
import { Button } from '@tale/ui/button';
-import { HStack, Stack } from '@tale/ui/layout';
+import { HStack, Row, Stack } from '@tale/ui/layout';
import { StatusIndicator } from '@tale/ui/status-indicator';
import { Text } from '@tale/ui/text';
import { Check, Copy, Loader2 } from 'lucide-react';
@@ -14,7 +14,12 @@ import {
useRef,
useState,
} from 'react';
-import { Controller } from 'react-hook-form';
+import {
+ Controller,
+ type Control,
+ useFieldArray,
+ useWatch,
+} from 'react-hook-form';
import { z } from 'zod';
import {
@@ -30,8 +35,10 @@ import { useToast } from '@/app/hooks/use-toast';
import { useT } from '@/lib/i18n/client';
import type {
PlatformRole,
+ RoleMappingRule,
SsoConnectionView,
} from '@/lib/shared/schemas/enterprise_sso';
+import { convexErrorCode, convexErrorMessage } from '@/lib/utils/convex-error';
import { narrowStringUnion } from '@/lib/utils/type-utils';
import {
@@ -82,7 +89,7 @@ interface Props {
const DEFAULT_SCOPES: Record = {
'entra-id':
- 'openid email profile offline_access https://graph.microsoft.com/GroupMember.Read.All',
+ 'openid email profile offline_access https://graph.microsoft.com/User.Read https://graph.microsoft.com/GroupMember.Read.All',
'generic-oidc': 'openid email profile',
oauth2: 'email profile',
saml: '',
@@ -110,10 +117,26 @@ interface SsoFormData {
// Provisioning
defaultRole: PlatformRole;
autoRole: boolean;
+ roleMappingRules: RoleMappingRule[];
autoTeam: boolean;
excludeGroups: string;
}
+const ROLE_RULE_SOURCES = [
+ 'group',
+ 'appRole',
+ 'jobTitle',
+ 'claim',
+] as const satisfies readonly RoleMappingRule['source'][];
+type RoleRuleSource = (typeof ROLE_RULE_SOURCES)[number];
+
+const ROLE_RULE_TARGETS = [
+ 'admin',
+ 'developer',
+ 'editor',
+ 'member',
+] as const satisfies readonly PlatformRole[];
+
const isOidcProtocol = (p: UiProtocol): p is Exclude =>
p !== 'saml';
@@ -171,12 +194,13 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
const canEdit = !cannotManage;
// -------------------------------------------------------------------------
- // Validation schema (memoized on `t` and `config?.configured`). The protocol
- // Select drives which fields are required; `clientSecret` is required only
- // for a brand-new connection (an existing connection keeps its stored secret
- // when the field is left blank).
+ // Validation schema (memoized on `t` and whether an OIDC secret is stored).
+ // The protocol Select drives which fields are required; `clientSecret` is
+ // required whenever no OIDC client secret is already stored — i.e. a new
+ // connection OR a switch to OIDC from a SAML-only connection. An existing
+ // OIDC connection keeps its stored secret when the field is left blank.
// -------------------------------------------------------------------------
- const isConfigured = !!config?.configured;
+ const hasStoredOidcSecret = !!config?.oidc;
const schema = useMemo(() => {
const requiredMsg = t('integrations.enterpriseSso.validation.required');
const urlMsg = t('integrations.enterpriseSso.validation.url');
@@ -206,6 +230,20 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
'disabled',
]),
autoRole: z.boolean(),
+ roleMappingRules: z.array(
+ z.object({
+ source: z.enum(ROLE_RULE_SOURCES),
+ pattern: z.string(),
+ targetRole: z.enum([
+ 'admin',
+ 'developer',
+ 'editor',
+ 'member',
+ 'disabled',
+ ]),
+ claim: z.string().optional(),
+ }),
+ ),
autoTeam: z.boolean(),
excludeGroups: z.string(),
})
@@ -232,9 +270,12 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
if (!data.issuer.trim()) req('issuer');
else if (!looksLikeUrl(data.issuer.trim())) url('issuer');
if (!data.clientId.trim()) req('clientId');
- // Secret is required only when configuring a NEW connection. On an
- // existing connection a blank secret means "keep the stored one".
- if (!isConfigured && !data.clientSecret.trim()) req('clientSecret');
+ // Required unless an OIDC secret is already stored. A blank secret on
+ // an existing OIDC connection means "keep the stored one"; switching
+ // to OIDC from a SAML-only connection has no stored secret to reuse.
+ if (!hasStoredOidcSecret && !data.clientSecret.trim()) {
+ req('clientSecret');
+ }
if (data.protocol === 'oauth2') {
for (const field of [
@@ -255,7 +296,7 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
if (!data.idpCertificate.trim()) req('idpCertificate');
}
});
- }, [t, isConfigured]);
+ }, [t, hasStoredOidcSecret]);
// -------------------------------------------------------------------------
// Seed the form once the stored connection loads. `data` is `undefined`
@@ -320,6 +361,7 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
idpCertificate,
defaultRole: config.provisioning.defaultRole,
autoRole: config.provisioning.autoProvisionRole,
+ roleMappingRules: config.provisioning.roleMappingRules,
autoTeam: config.provisioning.autoProvisionTeam,
excludeGroups: config.provisioning.excludeGroups.join(', '),
};
@@ -330,7 +372,9 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
const provisioning = {
autoProvisionRole: values.autoRole,
defaultRole: values.defaultRole,
- roleMappingRules: config?.provisioning.roleMappingRules ?? [],
+ roleMappingRules: values.roleMappingRules.filter((r) =>
+ r.pattern.trim(),
+ ),
autoProvisionTeam: values.autoTeam,
excludeGroups: values.excludeGroups
.split(',')
@@ -391,11 +435,12 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
variant: 'success',
});
} catch (error) {
+ const fallback = t('integrations.enterpriseSso.saveFailed');
toast({
title:
- error instanceof Error
- ? error.message
- : t('integrations.enterpriseSso.saveFailed'),
+ convexErrorCode(error) === 'sso_client_secret_required'
+ ? t('integrations.enterpriseSso.validation.clientSecretRequired')
+ : convexErrorMessage(error, fallback),
variant: 'destructive',
});
throw error;
@@ -419,6 +464,7 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
const protocol = watch('protocol') ?? 'entra-id';
const isOidcLike = isOidcProtocol(protocol);
+ const autoRole = watch('autoRole') ?? false;
// -------------------------------------------------------------------------
// Prefill clientId once for an existing OIDC connection (the read view no
@@ -559,7 +605,7 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {
id="sso-protocol"
label={t('integrations.enterpriseSso.protocolLabel')}
description={t('integrations.enterpriseSso.protocolHelp')}
- value={field.value}
+ value={field.value ?? 'entra-id'}
onValueChange={(value) => {
const next = narrowStringUnion(
value,
@@ -749,7 +795,7 @@ export function EnterpriseSsoForm({ organizationId, config }: Props) {