fix(dashboard): allow OAuth provider config in development environments and refresh config after RDE apply

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-07-14 02:11:37 +00:00
parent ce956a0fd2
commit f47d81aeff
7 changed files with 104 additions and 27 deletions

View File

@ -513,6 +513,7 @@ export default function PageClient() {
const [pillShowLabels, setPillShowLabels] = useState(true);
const [pillWithIcons, setPillWithIcons] = useState(true);
const [pillSelected, setPillSelected] = useState("a");
const [pillDisabled, setPillDisabled] = useState(false);
// Selector Dropdown
const [selSize, setSelSize] = useState<Size3>("sm");
@ -1233,6 +1234,7 @@ export default function PageClient() {
onSelect={setPillSelected}
size={pillSize}
glassmorphic={pillGlass}
disabled={pillDisabled}
showLabels={pillShowLabels}
/>
);
@ -2137,6 +2139,9 @@ export default function PageClient() {
<PropField label="Show Labels">
<BoolToggle value={pillShowLabels} onChange={setPillShowLabels} on="Show" off="Hide" />
</PropField>
<PropField label="Disabled">
<BoolToggle value={pillDisabled} onChange={setPillDisabled} on="Yes" off="No" />
</PropField>
</div>
);
}
@ -2493,6 +2498,7 @@ export default function PageClient() {
selected="${pillSelected}"
onSelect={setPillSelected}
size="${pillSize}"${pillGlass === undefined ? "" : `\n glassmorphic={${pillGlass}}`}
disabled={${pillDisabled}}
showLabels={${pillShowLabels}}
/>`;
}

View File

@ -179,9 +179,20 @@ function adminProviderToConfigProvider(
}
}
function adminProviderToBranchConfigProvider(
provider: AdminOAuthProviderConfig,
): Pick<CompleteConfig['auth']['oauth']['providers'][string], 'type' | 'allowSignIn' | 'allowConnectedAccounts'> {
const configProvider = adminProviderToConfigProvider(provider, undefined);
return {
type: configProvider.type,
allowSignIn: configProvider.allowSignIn,
allowConnectedAccounts: configProvider.allowConnectedAccounts,
};
}
// ─── Plan gating ─────────────────────────────────────────────────────────
function AddCustomOidcButton({ onClick }: { onClick: () => void }) {
function AddCustomOidcButton({ onClick, isDevelopmentEnvironment }: { onClick: () => void, isDevelopmentEnvironment: boolean }) {
const project = useAdminApp().useProject();
const user = useDashboardInternalUser();
const teams = user.useTeams();
@ -191,7 +202,7 @@ function AddCustomOidcButton({ onClick }: { onClick: () => void }) {
);
if (ownerTeam == null) {
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />;
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} isDevelopmentEnvironment={isDevelopmentEnvironment} />;
}
// The plan check reads the internal project's owned products from bulldozer.
@ -199,42 +210,45 @@ function AddCustomOidcButton({ onClick }: { onClick: () => void }) {
// read failure (or while it loads) we report it and fall back to the locked
// (non-Team) state, which is the same safe default as having no owner team.
return (
<ErrorBoundary errorComponent={({ error }) => <AddCustomOidcButtonPlanReadFailed onClick={onClick} error={error} />}>
<Suspense fallback={<AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />}>
<AddCustomOidcButtonInner team={ownerTeam} onClick={onClick} />
<ErrorBoundary errorComponent={({ error }) => <AddCustomOidcButtonPlanReadFailed onClick={onClick} error={error} isDevelopmentEnvironment={isDevelopmentEnvironment} />}>
<Suspense fallback={<AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} isDevelopmentEnvironment={isDevelopmentEnvironment} />}>
<AddCustomOidcButtonInner team={ownerTeam} onClick={onClick} isDevelopmentEnvironment={isDevelopmentEnvironment} />
</Suspense>
</ErrorBoundary>
);
}
function AddCustomOidcButtonPlanReadFailed({ onClick, error }: { onClick: () => void, error: unknown }) {
function AddCustomOidcButtonPlanReadFailed({ onClick, error, isDevelopmentEnvironment }: { onClick: () => void, error: unknown, isDevelopmentEnvironment: boolean }) {
useEffect(() => {
captureError("auth-methods:custom-oidc-plan-gate", error);
}, [error]);
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />;
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} isDevelopmentEnvironment={isDevelopmentEnvironment} />;
}
function AddCustomOidcButtonInner({
team,
onClick,
isDevelopmentEnvironment,
}: {
team: { useProducts: () => Array<{ id: string | null, type?: string }> },
onClick: () => void,
isDevelopmentEnvironment: boolean,
}) {
const products = team.useProducts();
const planId = resolvePlanId(products);
const isTeamPlanOrAbove = planId === "team" || planId === "growth";
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={isTeamPlanOrAbove} />;
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={isTeamPlanOrAbove} isDevelopmentEnvironment={isDevelopmentEnvironment} />;
}
function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove }: { onClick: () => void, isTeamPlanOrAbove: boolean }) {
function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove, isDevelopmentEnvironment }: { onClick: () => void, isTeamPlanOrAbove: boolean, isDevelopmentEnvironment: boolean }) {
const isEnabled = isTeamPlanOrAbove && !isDevelopmentEnvironment;
return (
<SimpleTooltip tooltip={!isTeamPlanOrAbove ? "Custom OIDC providers require a Team plan or above." : undefined}>
<SimpleTooltip tooltip={isDevelopmentEnvironment ? "Custom OIDC providers are environment-specific and read-only in a development environment." : !isTeamPlanOrAbove ? "Custom OIDC providers require a Team plan or above." : undefined}>
<DesignButton
onClick={onClick}
variant="secondary"
disabled={!isTeamPlanOrAbove}
disabled={!isEnabled}
>
<GlobeIcon size={16} className="mr-1.5" />
Add Custom OIDC
@ -307,7 +321,8 @@ function CustomOidcProviderDialog({
existing?: CustomOidcConfigEntry,
}) {
const hexclaveAdminApp = useAdminApp();
const config = hexclaveAdminApp.useProject().useConfig();
const project = hexclaveAdminApp.useProject();
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const defaultValues: CustomOidcFormValues = {
@ -364,9 +379,19 @@ function CustomOidcProviderDialog({
onClose={onClose}
title={isEditing ? `Edit ${existing.displayName || "Custom OIDC Provider"}` : "Add Custom OIDC Provider"}
cancelButton
okButton={{ label: isEditing ? "Save" : "Add Provider" }}
okButton={{
label: isEditing ? "Save" : "Add Provider",
props: { disabled: project.isDevelopmentEnvironment },
}}
render={(form) => (
<div className="flex flex-col gap-4 w-full">
{project.isDevelopmentEnvironment && (
<DesignAlert
variant="info"
title="Custom OIDC is read-only in a development environment"
description="Custom OIDC credentials are environment-specific and cannot be changed here. Configure them in your production deployment."
/>
)}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-9 h-9 rounded-xl ring-1 ring-black/[0.08] dark:ring-white/[0.08] shadow-sm bg-foreground/[0.04]">
<GlobeIcon size={18} className="text-foreground/70" />
@ -485,6 +510,7 @@ function CustomOidcProviderDialog({
function CustomOidcProviderInlineRow({ provider }: { provider: CustomOidcConfigEntry }) {
const hexclaveAdminApp = useAdminApp();
const project = hexclaveAdminApp.useProject();
const updateConfig = useUpdateConfig();
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [turnOffDialogOpen, setTurnOffDialogOpen] = useState(false);
@ -495,7 +521,7 @@ function CustomOidcProviderInlineRow({ provider }: { provider: CustomOidcConfigE
configUpdate: {
[`auth.oauth.providers.${provider.id}`]: null,
},
pushable: false,
pushable: project.isDevelopmentEnvironment,
});
};
@ -597,13 +623,16 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
key={id}
id={id}
provider={provider}
isDevelopmentEnvironment={project.isDevelopmentEnvironment}
updateProvider={async (provider) => {
await updateConfig({
adminApp: hexclaveAdminApp,
configUpdate: {
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, config.auth.oauth.providers[provider.id]),
[`auth.oauth.providers.${provider.id}`]: project.isDevelopmentEnvironment
? adminProviderToBranchConfigProvider(provider)
: adminProviderToConfigProvider(provider, config.auth.oauth.providers[provider.id]),
},
pushable: false,
pushable: project.isDevelopmentEnvironment,
});
}}
deleteProvider={async (id) => {
@ -612,7 +641,7 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
configUpdate: {
[`auth.oauth.providers.${id}`]: null,
},
pushable: false,
pushable: project.isDevelopmentEnvironment,
});
}}
/>;
@ -630,7 +659,8 @@ function DisabledProvidersDialog({ open, onOpenChange }: { open?: boolean, onOpe
function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
const hexclaveAdminApp = useAdminApp();
const completeConfig = hexclaveAdminApp.useProject().useConfig();
const project = hexclaveAdminApp.useProject();
const completeConfig = project.useConfig();
const updateConfig = useUpdateConfig();
const [turnOffProviderDialogOpen, setTurnOffProviderDialogOpen] = useState(false);
const [providerSettingDialogOpen, setProviderSettingDialogOpen] = useState(false);
@ -639,9 +669,11 @@ function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
await updateConfig({
adminApp: hexclaveAdminApp,
configUpdate: {
[`auth.oauth.providers.${provider.id}`]: adminProviderToConfigProvider(provider, completeConfig.auth.oauth.providers[provider.id]),
[`auth.oauth.providers.${provider.id}`]: project.isDevelopmentEnvironment
? adminProviderToBranchConfigProvider(provider)
: adminProviderToConfigProvider(provider, completeConfig.auth.oauth.providers[provider.id]),
},
pushable: false,
pushable: project.isDevelopmentEnvironment,
});
};
@ -651,7 +683,7 @@ function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
configUpdate: {
[`auth.oauth.providers.${id}`]: null,
},
pushable: false,
pushable: project.isDevelopmentEnvironment,
});
};
@ -688,6 +720,7 @@ function OAuthActionCell({ config }: { config: AdminOAuthProviderConfig }) {
provider={config}
updateProvider={updateProvider}
deleteProvider={deleteProvider}
isDevelopmentEnvironment={project.isDevelopmentEnvironment}
/>
<DesignMenu
@ -1154,7 +1187,10 @@ export default function PageClient() {
<PlusCircleIcon size={16} className="mr-1.5" />
Add SSO providers
</DesignButton>
<AddCustomOidcButton onClick={() => setCustomOidcDialogOpen(true)} />
<AddCustomOidcButton
onClick={() => setCustomOidcDialogOpen(true)}
isDevelopmentEnvironment={project.isDevelopmentEnvironment}
/>
</div>
</DesignCard>
<DesignCard

View File

@ -41,6 +41,7 @@ type Props = {
provider?: AdminProject['config']['oauthProviders'][number],
updateProvider: (provider: AdminProject['config']['oauthProviders'][number]) => Promise<void>,
deleteProvider: (id: string) => Promise<void>,
isDevelopmentEnvironment?: boolean,
};
function toTitle(id: string) {
@ -99,9 +100,11 @@ function ProviderHeader({ providerId }: { providerId: string }) {
function PillToggleControl({
form,
hasSharedKeys,
isDevelopmentEnvironment,
}: {
form: UseFormReturn<ProviderFormValues>,
hasSharedKeys: boolean,
isDevelopmentEnvironment: boolean,
}) {
if (!hasSharedKeys) {
return (
@ -119,11 +122,16 @@ function PillToggleControl({
<FormControl>
<DesignPillToggle
selected={field.value ? "shared" : "custom"}
onSelect={(id) => field.onChange(id === "shared")}
onSelect={(id) => {
if (!isDevelopmentEnvironment) {
field.onChange(id === "shared");
}
}}
options={[
{ id: "shared", label: "Shared keys" },
{ id: "custom", label: "Own credentials" },
]}
disabled={isDevelopmentEnvironment}
size="sm"
gradient="default"
glassmorphic
@ -262,6 +270,7 @@ function OAuthProviderSettingsForm(props: {
form: UseFormReturn<ProviderFormValues>,
providerId: string,
hasSharedKeys: boolean,
isDevelopmentEnvironment: boolean,
}) {
const shared = useWatch({ control: props.form.control, name: "shared" });
@ -270,8 +279,15 @@ function OAuthProviderSettingsForm(props: {
<ProviderHeader providerId={props.providerId} />
<div className="flex flex-col gap-2">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Credential source</span>
<PillToggleControl form={props.form} hasSharedKeys={props.hasSharedKeys} />
<PillToggleControl form={props.form} hasSharedKeys={props.hasSharedKeys} isDevelopmentEnvironment={props.isDevelopmentEnvironment} />
</div>
{props.isDevelopmentEnvironment && (
<DesignAlert
variant="info"
title="Own credentials unavailable in a development environment"
description="Own OAuth credentials and provider-specific settings are environment-specific. Configure them in your production deployment."
/>
)}
{shared && <WarningInline />}
{!shared && (
<>
@ -292,10 +308,13 @@ function OAuthProviderSettingsForm(props: {
export function ProviderSettingDialog(props: Props & { open: boolean, onClose: () => void }) {
const hasSharedKeys = sharedProviders.includes(props.id as any);
// Development environments lock the credential-source toggle, so the
// effective mode must also control whether the form can be submitted.
const effectiveShared = props.provider ? props.provider.type === 'shared' : hasSharedKeys;
const bundleIdsArray = (props.provider as any)?.appleBundleIds ?? [];
const defaultValues = {
shared: props.provider ? (props.provider.type === 'shared') : hasSharedKeys,
shared: effectiveShared,
clientId: (props.provider as any)?.clientId ?? "",
clientSecret: (props.provider as any)?.clientSecret ?? "",
facebookConfigId: (props.provider as any)?.facebookConfigId ?? "",
@ -328,12 +347,16 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: (
onClose={props.onClose}
title={`${toTitle(props.id)} OAuth provider`}
cancelButton
okButton={{ label: 'Save' }}
okButton={{
label: 'Save',
props: { disabled: props.isDevelopmentEnvironment && !effectiveShared },
}}
render={(form) => (
<OAuthProviderSettingsForm
form={form}
providerId={props.id}
hasSharedKeys={hasSharedKeys}
isDevelopmentEnvironment={props.isDevelopmentEnvironment ?? false}
/>
)}
/>

View File

@ -64,6 +64,10 @@ export function RdeApplyDialog({ open, adminApp, configUpdate, onSettle }: RdeAp
return;
}
// "redirecting": the browser secret flow took over; treat as not-applied.
if (result === "updated") {
const project = await adminApp.getProject();
await project.refreshConfig();
}
onSettle(result === "updated");
} catch (error) {
if (controller.signal.aborted) {

View File

@ -21,6 +21,7 @@ export type DesignPillToggleProps = {
size?: DesignPillToggleSize,
glassmorphic?: boolean,
gradient?: DesignPillToggleGradient,
disabled?: boolean,
/** When false, hides labels and shows a tooltip on hover instead. Defaults to true. */
showLabels?: boolean,
className?: string,
@ -69,6 +70,7 @@ export function DesignPillToggle({
size = "md",
glassmorphic: glassmorphicProp,
gradient = "default",
disabled = false,
showLabels = true,
className,
}: DesignPillToggleProps) {
@ -83,6 +85,7 @@ export function DesignPillToggle({
const optionRefs = useRef(new Map<string, HTMLButtonElement>());
const handleClick = (optionId: string) => {
if (disabled) return;
const result = onSelect(optionId);
if (result && typeof (result as Promise<void>).then === "function") {
setLoadingOptionId(optionId);
@ -171,12 +174,13 @@ export function DesignPillToggle({
}
}}
onClick={() => handleClick(option.id)}
disabled={loadingOptionId !== null}
disabled={disabled || loadingOptionId !== null}
aria-label={showLabels ? undefined : option.label}
className={cn(
"relative z-10 flex items-center gap-2 font-medium rounded-lg transition-all duration-150 hover:transition-none",
showLabels ? sizeClass.button : sizeClass.iconOnlyButton,
!showLabels && "justify-center p-0",
disabled && "cursor-not-allowed opacity-60",
isActive
? cn("text-foreground", glassmorphic && "dark:text-[hsl(240,71%,90%)]")
: cn(

View File

@ -254,6 +254,9 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
async getConfig() {
return app._adminConfigFromCrud(await app._interface.getConfig());
},
async refreshConfig() {
await app._refreshProjectConfig();
},
// IF_PLATFORM react-like
useConfig() {
const config = useAsyncCache(app._configOverridesCache, [], "project.useConfig()");

View File

@ -52,6 +52,7 @@ export type AdminProject = {
delete(this: AdminProject): Promise<void>,
getConfig(this: AdminProject): Promise<CompleteConfig>,
refreshConfig(this: AdminProject): Promise<void>,
// NEXT_LINE_PLATFORM react-like
useConfig(this: AdminProject): CompleteConfig,