Skip to content
Merged
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
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
240 changes: 240 additions & 0 deletions apps/admin/src/pages/organization/org-sso.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { useEffect, useRef, 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 { isSystemAdmin, orgRole, isLoading: isLoadingPermissions } = usePermissions();
const canManageSso = isSystemAdmin || orgRole === 'admin' || orgRole === 'owner';

if (isLoadingPermissions) {
return null;
}
if (!canManageSso) {
// Fails closed without an API round trip, and without even mounting
// useSsoConfig()'s query: OrgSsoForm - the only thing that calls that
// hook - is simply never rendered for a non-admin. useSsoConfig()
// (#407 / PR #419, already merged) takes no arguments and has no
// `enabled` option, so it can't be told not to fire; keeping it out of
// this component entirely is what actually keeps the request from
// going out for a member. orgRole/isSystemAdmin come from the
// already-resolved usePermissions() query.
return null;
}

return <OrgSsoForm />;
}

function OrgSsoForm() {
const { t } = useTranslation();
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);

// useSsoConfig() rebuilds `config` via `stripClientSecret`'s object
// spread on every call (use-sso-config.ts), so it's a new object
// identity on every render even when the underlying data hasn't
// changed. A plain `[config]` dependency would therefore re-fire this
// effect - and stomp any in-progress edit back to the loaded values -
// on every keystroke, since each keystroke's setFormValues triggers a
// re-render that calls the hook again. Guard with a ref so the form is
// hydrated from the loaded config exactly once per mount, not once per
// render.
const hasHydratedRef = useRef(false);

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

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" aria-hidden="true" />
{t('common.save')}
</button>
</form>
)}
</div>
);
}
Loading
Loading