Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
228 changes: 228 additions & 0 deletions apps/admin/src/pages/organization/org-sso.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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 { 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);

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.

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