diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx index 4dd9893a4bc..b50f8d01360 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/add-partner-form.client.tsx @@ -1,33 +1,17 @@ -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import type { z } from "zod"; -import type { Ecosystem } from "../../../../../types"; -import { partnerFormSchema } from "../../constants"; +import type { Ecosystem, Partner } from "../../../../../types"; import { useAddPartner } from "../../hooks/use-add-partner"; +import { PartnerForm, type PartnerFormValues } from "./partner-form.client"; export function AddPartnerForm({ ecosystem, onPartnerAdded, authToken, -}: { authToken: string; ecosystem: Ecosystem; onPartnerAdded: () => void }) { - const form = useForm>({ - resolver: zodResolver(partnerFormSchema), - }); - +}: { + authToken: string; + ecosystem: Ecosystem; + onPartnerAdded: () => void; +}) { const { mutateAsync: addPartner, isPending } = useAddPartner( { authToken, @@ -46,95 +30,28 @@ export function AddPartnerForm({ }, ); - return ( -
- { - addPartner({ - ecosystem, - name: values.name, - allowlistedDomains: values.domains - .split(/,| /) - .filter((d) => d.length > 0), - allowlistedBundleIds: values.bundleIds - .split(/,| /) - .filter((d) => d.length > 0), - }); - })} - className="flex flex-col gap-6" - > -
- ( - - App Name - - - - - - )} - /> - ( - - Domains - - - - - - Space or comma-separated list of regex domains (e.g. - *.example.com) - - - )} - /> - ( - - Bundle ID - - - + const handleSubmit = ( + values: PartnerFormValues, + finalAccessControl: Partner["accessControl"] | null, + ) => { + addPartner({ + ecosystem, + name: values.name, + allowlistedDomains: values.domains + .split(/,| /) + .filter((d) => d.length > 0), + allowlistedBundleIds: values.bundleIds + .split(/,| /) + .filter((d) => d.length > 0), + accessControl: finalAccessControl, + }); + }; - - Space or comma-separated list of bundle IDs - - - - - )} - /> -
- - -
- + return ( + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx new file mode 100644 index 00000000000..2811dad4fff --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/partner-form.client.tsx @@ -0,0 +1,340 @@ +"use client"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PlusIcon, Trash2Icon } from "lucide-react"; +import { useFieldArray, useForm } from "react-hook-form"; +import type { z } from "zod"; +import type { Partner } from "../../../../../types"; +import { partnerFormSchema } from "../../constants"; + +export type PartnerFormValues = z.infer; + +type PartnerFormProps = { + partner?: Partner; // Optional for add form + onSubmit: ( + values: PartnerFormValues, + finalAccessControl: Partner["accessControl"] | null, + ) => void; + isSubmitting: boolean; + submitLabel: string; +}; + +export function PartnerForm({ + partner, + onSubmit, + isSubmitting, + submitLabel, +}: PartnerFormProps) { + // Check if partner has accessControl and serverVerifier + const hasAccessControl = partner ? !!partner.accessControl : false; + const hasServerVerifier = + hasAccessControl && !!partner?.accessControl?.serverVerifier; + + const form = useForm({ + resolver: zodResolver(partnerFormSchema), + defaultValues: { + name: partner?.name || "", + domains: partner?.allowlistedDomains.join(",") || "", + bundleIds: partner?.allowlistedBundleIds.join(",") || "", + // Set the actual accessControl data if it exists + accessControl: partner?.accessControl, + // Set the UI control properties based on existing data + accessControlEnabled: hasAccessControl, + serverVerifierEnabled: hasServerVerifier, + }, + }); + + // Watch the boolean flags for UI state + const accessControlEnabled = form.watch("accessControlEnabled"); + const serverVerifierEnabled = form.watch("serverVerifierEnabled"); + + // Setup field array for headers + const customHeaderFields = useFieldArray({ + control: form.control, + name: "accessControl.serverVerifier.headers", + }); + + const handleSubmit = form.handleSubmit( + (values) => { + // Construct the final accessControl object based on the enabled flags + let finalAccessControl: Partner["accessControl"] | null = null; + + if (values.accessControlEnabled) { + finalAccessControl = {} as Partner["accessControl"]; + + if (finalAccessControl && values.serverVerifierEnabled) { + finalAccessControl.serverVerifier = { + url: values.accessControl?.serverVerifier?.url || "", + headers: values.accessControl?.serverVerifier?.headers || [], + }; + } + + // TODO add signature policies here + + // if no values have been set, remove the accessControl object + if ( + finalAccessControl && + Object.keys(finalAccessControl).length === 0 + ) { + finalAccessControl = null; + } + } + + onSubmit(values, finalAccessControl); + }, + (errors) => { + // Log validation errors for debugging + console.error("Form validation errors:", errors); + }, + ); + + return ( +
+ +
+ ( + + Name + + + + + {form.formState.errors.name?.message} + + + )} + /> + ( + + Domains + + + + + {form.formState.errors.domains?.message ?? + "Space or comma-separated list of regex domains (e.g. *.example.com)"} + + + )} + /> + ( + + Bundle ID + + + + + {form.formState.errors.bundleIds?.message ?? + "Space or comma-separated list of bundle IDs"} + + + + )} + /> + + {/* Access Control Section */} +
+
+ +

+ Enable access control for this partner +

+
+ { + form.setValue("accessControlEnabled", checked); + // If disabling access control, also disable server verifier + if (!checked) { + form.setValue("serverVerifierEnabled", false); + } + }} + /> +
+ + {accessControlEnabled && ( +
+
+
+ +

+ Configure a server verifier for access control +

+
+ { + form.setValue("serverVerifierEnabled", checked); + + // Initialize serverVerifier fields if enabling + if ( + checked && + !form.getValues("accessControl.serverVerifier") + ) { + form.setValue("accessControl.serverVerifier", { + url: "", + headers: [], + }); + } + }} + /> +
+ + {serverVerifierEnabled && ( +
+ ( + + Server Verifier URL + + + + + {form.formState.errors.accessControl?.serverVerifier + ?.url?.message || + "Enter the URL of your server where verification requests will be sent"} + + + )} + /> + +
+ +
+ {customHeaderFields.fields.map((field, headerIdx) => { + return ( +
+ + + +
+ ); + })} + + +
+ +

+ Set custom headers to be sent along with verification + requests +

+
+
+ )} +
+ )} +
+ + +
+ + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx index 2f1e383ac4b..15027b09443 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/components/client/update-partner-form.client.tsx @@ -1,24 +1,8 @@ "use client"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import type { z } from "zod"; import type { Ecosystem, Partner } from "../../../../../types"; -import { partnerFormSchema } from "../../constants"; import { useUpdatePartner } from "../../hooks/use-update-partner"; +import { PartnerForm, type PartnerFormValues } from "./partner-form.client"; export function UpdatePartnerForm({ ecosystem, @@ -31,138 +15,48 @@ export function UpdatePartnerForm({ onSuccess: () => void; authToken: string; }) { - const form = useForm>({ - resolver: zodResolver(partnerFormSchema), - defaultValues: { - name: partner.name, - domains: partner.allowlistedDomains.join(","), - bundleIds: partner.allowlistedBundleIds.join(","), - }, - }); - const { mutateAsync: updatePartner, isPending } = useUpdatePartner( { authToken, }, { onSuccess: () => { - form.reset(); onSuccess(); }, onError: (error) => { const message = error instanceof Error ? error.message - : "Failed to add ecosystem partner"; + : "Failed to update ecosystem partner"; toast.error(message); }, }, ); - return ( -
- { - updatePartner({ - ecosystem, - partnerId: partner.id, - name: values.name, - allowlistedDomains: values.domains - .split(/,| /) - .filter((d) => d.length > 0), - allowlistedBundleIds: values.bundleIds - .split(/,| /) - .filter((d) => d.length > 0), - }); - })} - className="flex flex-col gap-4" - > -
- ( - - Name - - - - - {form.formState.errors.name?.message} - - - )} - /> - ( - - Domains - - - - - {form.formState.errors.domains?.message ?? - "Space or comma-separated list of regex domains (e.g. *.example.com)"} - - - )} - /> - ( - - Bundle ID - - - - - {form.formState.errors.bundleIds?.message ?? - "Space or comma-separated list of bundle IDs"} - - - - )} - /> -
+ const handleSubmit = ( + values: PartnerFormValues, + finalAccessControl: Partner["accessControl"] | null, + ) => { + updatePartner({ + ecosystem, + partnerId: partner.id, + name: values.name, + allowlistedDomains: values.domains + .split(/,| /) + .filter((d) => d.length > 0), + allowlistedBundleIds: values.bundleIds + .split(/,| /) + .filter((d) => d.length > 0), + accessControl: finalAccessControl, + }); + }; - -
- + return ( + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts index 04dae439fd4..acda5ec85c4 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/constants.ts @@ -3,31 +3,92 @@ import z from "zod"; const isDomainRegex = /^(?:\*|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*(?:\*(?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9](?:\.[a-z0-9][a-z0-9-]{0,61}[a-z0-9])*)?)|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]|localhost(?::\d{1,5})?)$/; -export const partnerFormSchema = z.object({ - name: z - .string() - .trim() - .min(3, { - message: "Name must be at least 3 characters", - }) - .refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), { - message: "Name can only contain letters, numbers and spaces", - }), - domains: z - .string() - .trim() - .transform((s) => s.split(/,| /).filter((d) => d.length > 0)) - .refine((domains) => domains.every((d) => isDomainRegex.test(d)), { - message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state - }) - .transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types - bundleIds: z - .string() - .trim() - .transform((s) => - s - .split(/,| /) - .filter((d) => d.length > 0) - .join(","), - ), -}); +export const partnerFormSchema = z + .object({ + name: z + .string() + .trim() + .min(3, { + message: "Name must be at least 3 characters", + }) + .refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), { + message: "Name can only contain letters, numbers and spaces", + }), + domains: z + .string() + .trim() + .transform((s) => s.split(/,| /).filter((d) => d.length > 0)) + .refine((domains) => domains.every((d) => isDomainRegex.test(d)), { + message: "Invalid domain format", // This error message CANNOT be within the array iteration, or the FormMessage won't be able to find it in the form state + }) + .transform((s) => s.join(",")), // This is rejoined to return a string (and later split again) since react-hook-form's typings can't handle different input vs output types + bundleIds: z + .string() + .trim() + .transform((s) => + s + .split(/,| /) + .filter((d) => d.length > 0) + .join(","), + ), + accessControlEnabled: z.boolean().default(false), + serverVerifierEnabled: z.boolean().default(false), + accessControl: z + .object({ + serverVerifier: z + .object({ + url: z.string().optional(), + headers: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .optional(), + }) + .optional(), + }) + .optional(), + }) + .refine( + (data) => { + // If serverVerifier is enabled, validate the URL + if (data.accessControlEnabled && data.serverVerifierEnabled) { + return ( + data.accessControl?.serverVerifier?.url && + data.accessControl.serverVerifier.url.trim() !== "" + ); + } + // Otherwise, no validation needed + return true; + }, + { + message: + "Server Verifier URL is required when Server Verifier is enabled", + path: ["accessControl", "serverVerifier", "url"], + }, + ) + .refine( + (data) => { + // If serverVerifier is enabled, validate the URL format + if ( + data.accessControlEnabled && + data.serverVerifierEnabled && + data.accessControl?.serverVerifier?.url + ) { + try { + new URL(data.accessControl.serverVerifier.url); + return true; + } catch { + return false; + } + } + // Otherwise, no validation needed + return true; + }, + { + message: "Please enter a valid URL", + path: ["accessControl", "serverVerifier", "url"], + }, + ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts index 950a943d496..b1e43a50c70 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-add-partner.ts @@ -10,6 +10,7 @@ type AddPartnerParams = { name: string; allowlistedDomains: string[]; allowlistedBundleIds: string[]; + accessControl?: Partner["accessControl"] | null; }; export function useAddPartner( @@ -41,6 +42,7 @@ export function useAddPartner( name: params.name, allowlistedDomains: params.allowlistedDomains, allowlistedBundleIds: params.allowlistedBundleIds, + accessControl: params.accessControl ?? undefined, // TODO - remove the requirement for permissions in API endpoint permissions: ["FULL_CONTROL_V1"], }), diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts index 8fd4d59a8ac..92a7ee58e30 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/configuration/hooks/use-update-partner.ts @@ -11,6 +11,12 @@ type UpdatePartnerParams = { name: string; allowlistedDomains: string[]; allowlistedBundleIds: string[]; + accessControl?: { + serverVerifier?: { + url: string; + headers?: { key: string; value: string }[]; + } | null; + } | null; }; export function useUpdatePartner( @@ -42,6 +48,7 @@ export function useUpdatePartner( name: params.name, allowlistedDomains: params.allowlistedDomains, allowlistedBundleIds: params.allowlistedBundleIds, + accessControl: params.accessControl, }), }, ); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts index 7c295290af8..50f6372f045 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/[slug]/(active)/hooks/use-partners.ts @@ -22,7 +22,11 @@ export function usePartners({ ); } - return (await res.json()) as Partner[]; + const partners = (await res.json()) as Partner[]; + return partners.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); }, retry: false, }); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/types.ts b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/types.ts index 71cd5363d54..610494417b6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/types.ts +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/ecosystem/types.ts @@ -55,4 +55,10 @@ export type Partner = { permissions: [PartnerPermission]; createdAt: string; updatedAt: string; + accessControl?: { + serverVerifier?: { + url: string; + headers?: { key: string; value: string }[]; + }; + }; };