Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions apps/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import OrgBillingPage from './pages/organization/org-billing';
import OrgInvoicesPage from './pages/organization/org-invoices';
import OrgInvoiceDetailPage from './pages/organization/org-invoice-detail';
import OrgLegalDetailsPage from './pages/organization/org-legal-details';
import OrgSsoPage from './pages/organization/org-sso';
import OrgIntelligencePage from './pages/organization/org-intelligence';
import OrgObservabilityPage from './pages/organization/org-observability';
import AcceptInvitationPage from './pages/invitations/accept';
Expand Down Expand Up @@ -308,6 +309,14 @@ function App() {
</OrgRoute>
}
/>
<Route
path="my-organization/sso"
element={
<OrgRoute>
<OrgSsoPage />
</OrgRoute>
}
/>
<Route
path="my-organization/intelligence"
element={
Expand Down
226 changes: 226 additions & 0 deletions apps/admin/src/pages/organization/org-sso.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Save, AlertCircle } from 'lucide-react';
import { usePermissions } from '../../hooks/use-permissions';
import { useSsoConfig } from '../../hooks/use-sso-config';

interface SsoFormValues {
issuerUrl: string;
clientId: string;
allowedDomains: string;
enforceSso: boolean;
}

const DEFAULT_FORM_VALUES: SsoFormValues = {
issuerUrl: '',
clientId: '',
allowedDomains: '',
enforceSso: false,
};

const INPUT_CLASSES =
'w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500';

export default function OrgSsoPage() {
const { t } = useTranslation();
const { isSystemAdmin, orgRole, isLoading: isLoadingPermissions } = usePermissions();
const canManageSso = isSystemAdmin || orgRole === 'admin' || orgRole === 'owner';

// useSsoConfig() (#407 / PR #419, already merged) takes no arguments and
// always fires whenever the current org is resolved - it does not expose
// an `enabled` option, unlike what spec 0409 originally called for. This
// page can't suppress the query itself for a non-admin as a result; it
// still fails closed on rendering below (never showing the form or a
Comment thread
alex-budanov marked this conversation as resolved.
Outdated
// client-secret indicator to an unauthorized user), which is what the
// acceptance criteria actually require. The authoritative access check
// has to live on the backend endpoint per spec 0409 constraint 1 anyway.
const { config, isLoading, error, updateConfig } = useSsoConfig();

const [formValues, setFormValues] = useState<SsoFormValues>(DEFAULT_FORM_VALUES);
const [clientSecretInput, setClientSecretInput] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);

useEffect(() => {
if (!config) {
return;
}
setFormValues({
issuerUrl: config.issuerUrl ?? '',
clientId: config.clientId ?? '',
allowedDomains: (config.allowedDomains ?? []).join(', '),
enforceSso: config.enforceSso ?? false,
});
}, [config]);
Comment thread
Like2Read marked this conversation as resolved.

if (isLoadingPermissions) {
return null;
}
if (!canManageSso) {
// Fails closed without an API round trip: orgRole/isSystemAdmin come
// from the already-resolved usePermissions() query.
return null;
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
setIsSaving(true);
try {
await updateConfig({
issuerUrl: formValues.issuerUrl,
clientId: formValues.clientId,
allowedDomains: formValues.allowedDomains
.split(',')
.map((domain) => domain.trim())
.filter(Boolean),
enforceSso: formValues.enforceSso,
// Omit clientSecret entirely when the user didn't type a new one -
// never coerce to an empty string, which would defeat the
// optional-field contract on the backend.
...(clientSecretInput ? { clientSecret: clientSecretInput } : {}),
});
setClientSecretInput('');
} catch {
setSubmitError(t('errors.failedToSaveConfiguration'));
} finally {
setIsSaving(false);
}
}
Comment thread
alex-budanov marked this conversation as resolved.

return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">
<KeyRound className="inline-block h-6 w-6 mr-2" aria-hidden="true" />
{t('sso.title')}
</h1>
<p className="mt-1 text-sm text-gray-500">{t('sso.description')}</p>
</div>

{isLoading ? (
<div className="text-center py-12 text-gray-500">{t('common.loading')}</div>
) : (
<form className="space-y-4 max-w-xl" onSubmit={handleSubmit}>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600" role="alert">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{error.message}
</div>
)}

<div>
<label
htmlFor="sso-issuer-url"
className="block text-sm font-medium text-gray-700 mb-1"
Comment thread
alex-budanov marked this conversation as resolved.
>
{t('sso.settings.issuerUrl')}
</label>
<input
id="sso-issuer-url"
type="text"
value={formValues.issuerUrl}
onChange={(event) =>
setFormValues((prev) => ({ ...prev, issuerUrl: event.target.value }))
}
className={INPUT_CLASSES}
/>
</div>

<div>
<label htmlFor="sso-client-id" className="block text-sm font-medium text-gray-700 mb-1">
{t('sso.settings.clientId')}
</label>
<input
id="sso-client-id"
type="text"
value={formValues.clientId}
onChange={(event) =>
setFormValues((prev) => ({ ...prev, clientId: event.target.value }))
}
className={INPUT_CLASSES}
/>
</div>

<div>
<label
htmlFor="sso-client-secret"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('sso.settings.clientSecret')}
{config?.hasClientSecret && (
<span className="ml-2 text-xs font-normal text-gray-500">
({t('sso.settings.clientSecretConfigured')})
</span>
)}
</label>
{/* Never populate value/defaultValue with a real secret - only the
boolean hasClientSecret drives the "currently set" indicator. */}
<input
id="sso-client-secret"
type="password"
value={clientSecretInput}
placeholder={config?.hasClientSecret ? '••••••••' : ''}
onChange={(event) => setClientSecretInput(event.target.value)}
className={INPUT_CLASSES}
/>
<p className="mt-1 text-xs text-gray-400">
{t('sso.settings.clientSecretPlaceholder')}
</p>
</div>

<div>
<label
htmlFor="sso-allowed-domains"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('sso.settings.allowedDomains')}
</label>
<input
id="sso-allowed-domains"
type="text"
value={formValues.allowedDomains}
onChange={(event) =>
setFormValues((prev) => ({ ...prev, allowedDomains: event.target.value }))
}
className={INPUT_CLASSES}
/>
</div>

<div className="flex items-start gap-2">
<input
id="sso-enforce"
type="checkbox"
checked={formValues.enforceSso}
onChange={(event) =>
setFormValues((prev) => ({ ...prev, enforceSso: event.target.checked }))
}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="sso-enforce" className="text-sm text-gray-700">
<span className="font-medium">{t('sso.settings.enforceSso')}</span>
<p className="text-xs text-gray-500">{t('sso.settings.enforceSsoDescription')}</p>
</label>
</div>

{submitError && (
<div className="flex items-center gap-2 text-sm text-red-600" role="alert">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{submitError}
</div>
)}

<button
type="submit"
disabled={isSaving}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
<Save className="h-4 w-4" />
Comment thread
Copilot marked this conversation as resolved.
Outdated
{t('common.save')}
</button>
</form>
)}
</div>
);
}
86 changes: 86 additions & 0 deletions apps/admin/src/tests/pages/org-sso.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import OrgSsoPage from '../../pages/organization/org-sso';
import { usePermissions } from '../../hooks/use-permissions';
import { useSsoConfig } from '../../hooks/use-sso-config';

vi.mock('../../hooks/use-permissions', () => ({
usePermissions: vi.fn(),
}));

// useSsoConfig is a @tanstack/react-query hook; mocking the module
// directly avoids needing a QueryClientProvider ancestor, same fix as
// dedup-rules-back-nav.test.tsx uses for useDedupRules().
vi.mock('../../hooks/use-sso-config', () => ({
useSsoConfig: vi.fn(),
}));

describe('OrgSsoPage', () => {
it('renders the SSO config form for an org admin', () => {
vi.mocked(usePermissions).mockReturnValue({
isSystemAdmin: false,
orgRole: 'admin',
isLoading: false,
} as ReturnType<typeof usePermissions>);
vi.mocked(useSsoConfig).mockReturnValue({
config: undefined,
isLoading: true,
error: null,
updateConfig: vi.fn(),
isSaving: false,
} as ReturnType<typeof useSsoConfig>);

render(<OrgSsoPage />);

// useSsoConfig() (#407 / PR #419) takes no arguments - it always
// fires whenever the org is resolved, regardless of role. See the
// comment on the page component for why gating it isn't possible
// without changing that already-merged hook.
expect(useSsoConfig).toHaveBeenCalledWith();
expect(screen.getByRole('heading', { name: /sso/i })).toBeInTheDocument();
});
Comment thread
alex-budanov marked this conversation as resolved.

it('does not render the SSO config form for a regular member', () => {
vi.mocked(usePermissions).mockReturnValue({
isSystemAdmin: false,
orgRole: 'member',
isLoading: false,
} as ReturnType<typeof usePermissions>);
vi.mocked(useSsoConfig).mockReturnValue({
config: undefined,
isLoading: false,
error: null,
updateConfig: vi.fn(),
isSaving: false,
} as ReturnType<typeof useSsoConfig>);

render(<OrgSsoPage />);

expect(screen.queryByRole('heading', { name: /sso/i })).not.toBeInTheDocument();
});

it('surfaces a save error instead of an unhandled rejection when updateConfig fails', async () => {
const user = userEvent.setup();
const updateConfig = vi.fn().mockRejectedValue(new Error('network error'));
vi.mocked(usePermissions).mockReturnValue({
isSystemAdmin: false,
orgRole: 'admin',
isLoading: false,
} as ReturnType<typeof usePermissions>);
vi.mocked(useSsoConfig).mockReturnValue({
config: undefined,
isLoading: false,
error: null,
updateConfig,
isSaving: false,
} as ReturnType<typeof useSsoConfig>);

render(<OrgSsoPage />);

await user.click(screen.getByRole('button', { name: /save/i }));

expect(updateConfig).toHaveBeenCalledTimes(1);
expect(await screen.findByRole('alert')).toHaveTextContent(/failed to save configuration/i);
});
});
Loading