Skip to content

Commit b63c22a

Browse files
authored
Merge pull request #3700 from Dokploy/feat/edit-sso-providers
Feat/edit sso providers
2 parents aa57997 + 05ad6d8 commit b63c22a

File tree

9 files changed

+7652
-73
lines changed

9 files changed

+7652
-73
lines changed

apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
import { zodResolver } from "@hookform/resolvers/zod";
44
import { Plus, Trash2 } from "lucide-react";
5-
import { useState } from "react";
5+
import { useEffect, useState } from "react";
66
import type { FieldArrayPath } from "react-hook-form";
7-
import { useFieldArray, useForm } from "react-hook-form";
7+
import { useFieldArray, useForm, useWatch } from "react-hook-form";
88
import { toast } from "sonner";
99
import { z } from "zod";
1010
import { Button } from "@/components/ui/button";
@@ -28,6 +28,7 @@ import {
2828
} from "@/components/ui/form";
2929
import { Input } from "@/components/ui/input";
3030
import { api } from "@/utils/api";
31+
import { useUrl } from "@/utils/hooks/use-url";
3132

3233
const DEFAULT_SCOPES = ["openid", "email", "profile"];
3334

@@ -58,6 +59,7 @@ const oidcProviderSchema = z.object({
5859
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
5960

6061
interface RegisterOidcDialogProps {
62+
providerId?: string;
6163
children: React.ReactNode;
6264
}
6365

@@ -70,16 +72,86 @@ const formDefaultValues = {
7072
scopes: [...DEFAULT_SCOPES],
7173
};
7274

73-
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
75+
function parseOidcConfig(oidcConfig: string | null): {
76+
clientId?: string;
77+
clientSecret?: string;
78+
scopes?: string[];
79+
} | null {
80+
if (!oidcConfig) return null;
81+
try {
82+
const parsed = JSON.parse(oidcConfig) as {
83+
clientId?: string;
84+
clientSecret?: string;
85+
scopes?: string[];
86+
};
87+
return {
88+
clientId: parsed.clientId,
89+
clientSecret: parsed.clientSecret,
90+
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
91+
};
92+
} catch {
93+
return null;
94+
}
95+
}
96+
97+
export function RegisterOidcDialog({
98+
providerId,
99+
children,
100+
}: RegisterOidcDialogProps) {
74101
const utils = api.useUtils();
75102
const [open, setOpen] = useState(false);
76-
const { mutateAsync, isLoading } = api.sso.register.useMutation();
103+
104+
const { data } = api.sso.one.useQuery(
105+
{ providerId: providerId ?? "" },
106+
{ enabled: !!providerId && open },
107+
);
108+
const registerMutation = api.sso.register.useMutation();
109+
const updateMutation = api.sso.update.useMutation();
110+
111+
const isEdit = !!providerId;
112+
const mutateAsync = isEdit
113+
? updateMutation.mutateAsync
114+
: registerMutation.mutateAsync;
115+
const isLoading = isEdit
116+
? updateMutation.isLoading
117+
: registerMutation.isLoading;
77118

78119
const form = useForm<OidcProviderForm>({
79120
resolver: zodResolver(oidcProviderSchema),
80121
defaultValues: formDefaultValues,
81122
});
82123

124+
const watchedProviderId = useWatch({
125+
control: form.control,
126+
name: "providerId",
127+
defaultValue: "",
128+
});
129+
130+
const baseURL = useUrl();
131+
132+
useEffect(() => {
133+
if (!data || !open) return;
134+
const domains = data.domain
135+
? data.domain
136+
.split(",")
137+
.map((d) => d.trim())
138+
.filter(Boolean)
139+
: [""];
140+
if (domains.length === 0) domains.push("");
141+
const oidc = parseOidcConfig(data.oidcConfig);
142+
form.reset({
143+
providerId: data.providerId,
144+
issuer: data.issuer,
145+
domains,
146+
clientId: oidc?.clientId ?? "",
147+
clientSecret: oidc?.clientSecret ?? "",
148+
scopes:
149+
oidc?.scopes && oidc.scopes.length > 0
150+
? oidc.scopes
151+
: [...DEFAULT_SCOPES],
152+
});
153+
}, [data, open, form]);
154+
83155
const { fields, append, remove } = useFieldArray({
84156
control: form.control,
85157
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -130,7 +202,11 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
130202
},
131203
});
132204

133-
toast.success("OIDC provider registered successfully");
205+
toast.success(
206+
isEdit
207+
? "OIDC provider updated successfully"
208+
: "OIDC provider registered successfully",
209+
);
134210
form.reset(formDefaultValues);
135211
setOpen(false);
136212
await utils.sso.listProviders.invalidate();
@@ -146,11 +222,13 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
146222
<DialogTrigger asChild>{children}</DialogTrigger>
147223
<DialogContent className="sm:max-w-[500px]">
148224
<DialogHeader>
149-
<DialogTitle>Register OIDC provider</DialogTitle>
225+
<DialogTitle>
226+
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
227+
</DialogTitle>
150228
<DialogDescription>
151-
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
152-
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
153-
from the issuer URL when possible.
229+
{isEdit
230+
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
231+
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
154232
</DialogDescription>
155233
</DialogHeader>
156234
<Form {...form}>
@@ -162,11 +240,28 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
162240
<FormItem>
163241
<FormLabel>Provider ID</FormLabel>
164242
<FormControl>
165-
<Input placeholder="e.g. okta or my-idp" {...field} />
243+
<Input
244+
placeholder="e.g. okta or my-idp"
245+
{...field}
246+
readOnly={isEdit}
247+
className={isEdit ? "bg-muted" : undefined}
248+
/>
166249
</FormControl>
167250
<FormDescription>
168251
Unique identifier; used in callback URL path.
252+
{isEdit && " Cannot be changed when editing."}
169253
</FormDescription>
254+
{baseURL && (
255+
<div className="rounded-md bg-muted px-3 py-2 text-xs">
256+
<p className="font-medium text-muted-foreground">
257+
Callback URL (configure in your IdP)
258+
</p>
259+
<p className="mt-0.5 break-all font-mono">
260+
{baseURL}/api/auth/sso/callback/
261+
{watchedProviderId?.trim() || "..."}
262+
</p>
263+
</div>
264+
)}
170265
<FormMessage />
171266
</FormItem>
172267
)}
@@ -341,7 +436,7 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
341436
Cancel
342437
</Button>
343438
<Button type="submit" isLoading={isLoading}>
344-
Register provider
439+
{isEdit ? "Update provider" : "Register provider"}
345440
</Button>
346441
</DialogFooter>
347442
</form>

apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
import { zodResolver } from "@hookform/resolvers/zod";
44
import { Plus, Trash2 } from "lucide-react";
55
import { useEffect, useState } from "react";
6-
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
6+
import {
7+
type FieldArrayPath,
8+
useFieldArray,
9+
useForm,
10+
useWatch,
11+
} from "react-hook-form";
712
import { toast } from "sonner";
813
import { z } from "zod";
914
import { Button } from "@/components/ui/button";
@@ -28,6 +33,7 @@ import {
2833
import { Input } from "@/components/ui/input";
2934
import { Textarea } from "@/components/ui/textarea";
3035
import { api } from "@/utils/api";
36+
import { useUrl } from "@/utils/hooks/use-url";
3137

3238
const domainsArraySchema = z
3339
.array(z.string().trim())
@@ -58,6 +64,7 @@ const samlProviderSchema = z.object({
5864
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
5965

6066
interface RegisterSamlDialogProps {
67+
providerId?: string;
6168
children: React.ReactNode;
6269
}
6370

@@ -70,24 +77,83 @@ const formDefaultValues: SamlProviderForm = {
7077
idpMetadataXml: "",
7178
};
7279

73-
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
80+
function parseSamlConfig(samlConfig: string | null): {
81+
entryPoint?: string;
82+
cert?: string;
83+
idpMetadataXml?: string;
84+
} | null {
85+
if (!samlConfig) return null;
86+
try {
87+
const parsed = JSON.parse(samlConfig) as {
88+
entryPoint?: string;
89+
cert?: string;
90+
idpMetadata?: { metadata?: string };
91+
};
92+
return {
93+
entryPoint: parsed.entryPoint,
94+
cert: parsed.cert,
95+
idpMetadataXml: parsed.idpMetadata?.metadata,
96+
};
97+
} catch {
98+
return null;
99+
}
100+
}
101+
102+
export function RegisterSamlDialog({
103+
providerId,
104+
children,
105+
}: RegisterSamlDialogProps) {
74106
const utils = api.useUtils();
75107
const [open, setOpen] = useState(false);
76-
const { mutateAsync, isLoading } = api.sso.register.useMutation();
77108

78-
const [baseURL, setBaseURL] = useState("");
109+
const { data } = api.sso.one.useQuery(
110+
{ providerId: providerId ?? "" },
111+
{ enabled: !!providerId && open },
112+
);
113+
const registerMutation = api.sso.register.useMutation();
114+
const updateMutation = api.sso.update.useMutation();
115+
116+
const isEdit = !!providerId;
117+
const mutateAsync = isEdit
118+
? updateMutation.mutateAsync
119+
: registerMutation.mutateAsync;
120+
const isLoading = isEdit
121+
? updateMutation.isLoading
122+
: registerMutation.isLoading;
79123

80-
useEffect(() => {
81-
if (typeof window !== "undefined") {
82-
setBaseURL(window.location.origin);
83-
}
84-
}, []);
124+
const baseURL = useUrl();
85125

86126
const form = useForm<SamlProviderForm>({
87127
resolver: zodResolver(samlProviderSchema),
88128
defaultValues: formDefaultValues,
89129
});
90130

131+
useEffect(() => {
132+
if (!data || !open) return;
133+
const domains = data.domain
134+
? data.domain
135+
.split(",")
136+
.map((d) => d.trim())
137+
.filter(Boolean)
138+
: [""];
139+
if (domains.length === 0) domains.push("");
140+
const saml = parseSamlConfig(data.samlConfig);
141+
form.reset({
142+
providerId: data.providerId,
143+
issuer: data.issuer,
144+
domains,
145+
entryPoint: saml?.entryPoint ?? "",
146+
cert: saml?.cert ?? "",
147+
idpMetadataXml: saml?.idpMetadataXml ?? "",
148+
});
149+
}, [data, open, form]);
150+
151+
const watchedProviderId = useWatch({
152+
control: form.control,
153+
name: "providerId",
154+
defaultValue: "",
155+
});
156+
91157
const { fields, append, remove } = useFieldArray({
92158
control: form.control,
93159
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -133,7 +199,11 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
133199
},
134200
});
135201

136-
toast.success("SAML provider registered successfully");
202+
toast.success(
203+
isEdit
204+
? "SAML provider updated successfully"
205+
: "SAML provider registered successfully",
206+
);
137207
form.reset(formDefaultValues);
138208
setOpen(false);
139209
await utils.sso.listProviders.invalidate();
@@ -149,10 +219,13 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
149219
<DialogTrigger asChild>{children}</DialogTrigger>
150220
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
151221
<DialogHeader>
152-
<DialogTitle>Register SAML provider</DialogTitle>
222+
<DialogTitle>
223+
{isEdit ? "Update SAML provider" : "Register SAML provider"}
224+
</DialogTitle>
153225
<DialogDescription>
154-
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
155-
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
226+
{isEdit
227+
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
228+
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
156229
</DialogDescription>
157230
</DialogHeader>
158231
<Form {...form}>
@@ -167,8 +240,26 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
167240
<Input
168241
placeholder="e.g. okta-saml or azure-saml"
169242
{...field}
243+
readOnly={isEdit}
244+
className={isEdit ? "bg-muted" : undefined}
170245
/>
171246
</FormControl>
247+
{isEdit && (
248+
<FormDescription>
249+
Cannot be changed when editing.
250+
</FormDescription>
251+
)}
252+
{baseURL && (
253+
<div className="rounded-md bg-muted px-3 py-2 text-xs">
254+
<p className="font-medium text-muted-foreground">
255+
Callback URL (configure in your IdP)
256+
</p>
257+
<p className="mt-0.5 break-all font-mono">
258+
{baseURL}/api/auth/sso/saml2/callback/
259+
{watchedProviderId?.trim() || "..."}
260+
</p>
261+
</div>
262+
)}
172263
<FormMessage />
173264
</FormItem>
174265
)}
@@ -317,7 +408,7 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
317408
Cancel
318409
</Button>
319410
<Button type="submit" isLoading={isLoading}>
320-
Register provider
411+
{isEdit ? "Update provider" : "Register provider"}
321412
</Button>
322413
</DialogFooter>
323414
</form>

0 commit comments

Comments
 (0)