mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
ce956a0fd2
commit
f47d81aeff
@ -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}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()");
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user