diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index 209ae95a0..deb6cb90c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -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("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() { + + + ); } @@ -2493,6 +2498,7 @@ export default function PageClient() { selected="${pillSelected}" onSelect={setPillSelected} size="${pillSize}"${pillGlass === undefined ? "" : `\n glassmorphic={${pillGlass}}`} + disabled={${pillDisabled}} showLabels={${pillShowLabels}} />`; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 26362cd17..f0e9d6743 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -179,9 +179,20 @@ function adminProviderToConfigProvider( } } +function adminProviderToBranchConfigProvider( + provider: AdminOAuthProviderConfig, +): Pick { + 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 ; + return ; } // 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 ( - }> - }> - + }> + }> + ); } -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 ; + return ; } 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 ; + return ; } -function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove }: { onClick: () => void, isTeamPlanOrAbove: boolean }) { +function AddCustomOidcButtonDisabled({ onClick, isTeamPlanOrAbove, isDevelopmentEnvironment }: { onClick: () => void, isTeamPlanOrAbove: boolean, isDevelopmentEnvironment: boolean }) { + const isEnabled = isTeamPlanOrAbove && !isDevelopmentEnvironment; return ( - + 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) => (
+ {project.isDevelopmentEnvironment && ( + + )}
@@ -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} /> Add SSO providers - setCustomOidcDialogOpen(true)} /> + setCustomOidcDialogOpen(true)} + isDevelopmentEnvironment={project.isDevelopmentEnvironment} + />
Promise, deleteProvider: (id: string) => Promise, + isDevelopmentEnvironment?: boolean, }; function toTitle(id: string) { @@ -99,9 +100,11 @@ function ProviderHeader({ providerId }: { providerId: string }) { function PillToggleControl({ form, hasSharedKeys, + isDevelopmentEnvironment, }: { form: UseFormReturn, hasSharedKeys: boolean, + isDevelopmentEnvironment: boolean, }) { if (!hasSharedKeys) { return ( @@ -119,11 +122,16 @@ function PillToggleControl({ 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, providerId: string, hasSharedKeys: boolean, + isDevelopmentEnvironment: boolean, }) { const shared = useWatch({ control: props.form.control, name: "shared" }); @@ -270,8 +279,15 @@ function OAuthProviderSettingsForm(props: {
Credential source - +
+ {props.isDevelopmentEnvironment && ( + + )} {shared && } {!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) => ( )} /> diff --git a/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx b/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx index e974193ff..6b8547510 100644 --- a/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx +++ b/apps/dashboard/src/components/config-update/rde-apply-dialog.tsx @@ -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) { diff --git a/packages/dashboard-ui-components/src/components/pill-toggle.tsx b/packages/dashboard-ui-components/src/components/pill-toggle.tsx index 1e9742b09..08c433e69 100644 --- a/packages/dashboard-ui-components/src/components/pill-toggle.tsx +++ b/packages/dashboard-ui-components/src/components/pill-toggle.tsx @@ -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()); const handleClick = (optionId: string) => { + if (disabled) return; const result = onSelect(optionId); if (result && typeof (result as Promise).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( diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index be2426139..9e0b1165d 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -254,6 +254,9 @@ export class _HexclaveAdminAppImplIncomplete, getConfig(this: AdminProject): Promise, + refreshConfig(this: AdminProject): Promise, // NEXT_LINE_PLATFORM react-like useConfig(this: AdminProject): CompleteConfig,