From 681d46cbac800a735a2120513277897b7e26dfeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 09:19:25 +0000 Subject: [PATCH 1/5] feat(auth): add extra Apple OAuth clients configuration - Add AppleExtraClient type with id, clientId, name, redirect_uri, privateKey, teamId, keyId - Implement useFieldArray in AppleConfigForm for dynamic client management - Add Zod validation for unique nicknames and all-or-nothing extra credentials - Require default keyId when Apple authentication is enabled - Fix lodash merge issue by replacing apple config wholesale on save - Add ScrollArea to StrategySettings dialog for overflow handling - Trim id and clientId fields on form submission - Support inheriting default credentials or providing second Apple team credentials Matches backend contract from Conduit #1547 --- .../strategies/StrategySettings.tsx | 52 +++-- .../settingsConfig/oAuth/appleConfig.tsx | 221 +++++++++++++++++- src/lib/models/authentication/apple.config.ts | 11 + 3 files changed, 256 insertions(+), 28 deletions(-) diff --git a/src/components/authentication/strategies/StrategySettings.tsx b/src/components/authentication/strategies/StrategySettings.tsx index e377645f9..ef3b59fdf 100644 --- a/src/components/authentication/strategies/StrategySettings.tsx +++ b/src/components/authentication/strategies/StrategySettings.tsx @@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation'; import { toast } from '@/lib/hooks/use-toast'; import { CheckIcon, LucideX } from 'lucide-react'; import { ErrorPre } from '@/components/ui/error-pre'; +import { ScrollArea } from '@/components/ui/scroll-area'; export interface StrategySettingsProps { strategy: StrategyInterface; @@ -29,9 +30,20 @@ export const StrategySettings: React.FC = ({ const onSubmit = async (data: unknown) => { try { - await patchAuthenticationSettingsMerged({ - [strategy.key as string]: data as Record, - }); + if (strategy.key === 'apple') { + const { getAuthenticationSettings, patchAuthenticationSettings } = + await import('@/lib/api/authentication'); + const { config } = await getAuthenticationSettings(); + const updatedConfig = { + ...config, + apple: data as typeof config.apple, + }; + await patchAuthenticationSettings(updatedConfig); + } else { + await patchAuthenticationSettingsMerged({ + [strategy.key as string]: data as Record, + }); + } toast({ title: strategy.name, description: ( @@ -67,7 +79,7 @@ export const StrategySettings: React.FC = ({ - + {strategy.name} settings configuration @@ -79,20 +91,24 @@ export const StrategySettings: React.FC = ({ Documentation - -
- {strategy.form ? ( - { - setOpen(false); - }} - /> - ) : ( - 'No settings available' - )} + +
+
+ + {strategy.form ? ( + { + setOpen(false); + }} + /> + ) : ( + 'No settings available' + )} + +
diff --git a/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx b/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx index 415bf94af..9e61f0e96 100644 --- a/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx +++ b/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx @@ -1,6 +1,6 @@ 'use client'; import { z } from 'zod'; -import { useForm } from 'react-hook-form'; +import { useFieldArray, useForm } from 'react-hook-form'; import { rhfZodResolver } from '@/lib/zod-form'; import { oauthDefaultConfig } from '@/components/authentication/strategies/settingsConfig/oAuth/oauthDefaultConfig'; import SwitchField from '@/components/ui/form-inputs/SwitchField'; @@ -14,30 +14,118 @@ import { FormField, FormItem, FormLabel, + FormMessage, } from '@/components/ui/form'; import { SecretTextarea } from '@/components/ui/secret-textarea'; +import { Plus, Trash2 } from 'lucide-react'; -type authStrategyFormType = z.infer; -const authStrategySchema = oauthDefaultConfig.merge( - z.object({ - privateKey: z.string().default(''), - teamId: z.string().default(''), - keyId: z.string().default(''), +const extraClientSchema = z + .object({ + id: z.string().trim().min(1, 'Nickname is required'), + clientId: z.string().trim().min(1, 'Apple app ID is required'), + name: z.string().optional(), + redirect_uri: z.string().optional(), + privateKey: z.string().optional(), + teamId: z.string().optional(), + keyId: z.string().optional(), }) -); + .refine( + data => { + const hasPrivateKey = + data.privateKey && data.privateKey.trim().length > 0; + const hasTeamId = data.teamId && data.teamId.trim().length > 0; + const hasKeyId = data.keyId && data.keyId.trim().length > 0; + + const credCount = [hasPrivateKey, hasTeamId, hasKeyId].filter( + Boolean + ).length; + + return credCount === 0 || credCount === 3; + }, + { + message: + 'Either leave all three empty to reuse the default key, or provide all three (private key, team ID, and key ID) for a second Apple team', + path: ['privateKey'], + } + ); + +type authStrategyFormType = z.infer; +const authStrategySchema = oauthDefaultConfig + .merge( + z.object({ + privateKey: z.string().default(''), + teamId: z.string().default(''), + keyId: z.string().default(''), + clients: z.array(extraClientSchema).default([]), + }) + ) + .refine( + data => { + if (data.enabled) { + if (!data.clientId || data.clientId.trim().length === 0) { + return false; + } + if (!data.privateKey || data.privateKey.trim().length === 0) { + return false; + } + if (!data.teamId || data.teamId.trim().length === 0) { + return false; + } + if (!data.keyId || data.keyId.trim().length === 0) { + return false; + } + } + return true; + }, + { + message: + 'When enabled, default client ID, private key, team ID, and key ID are all required', + path: ['enabled'], + } + ) + .refine( + data => { + const ids = data.clients.map(c => c.id.trim().toLowerCase()); + const uniqueIds = new Set(ids); + return ids.length === uniqueIds.size; + }, + { + message: 'All nicknames must be unique', + path: ['clients'], + } + ); export const AppleConfigForm: React.FC< StrategyFormProps > = ({ data, onSubmit, onCancel }) => { const form = useForm({ resolver: rhfZodResolver(authStrategySchema), - defaultValues: data ? { ...data } : {}, + defaultValues: data + ? { ...data, clients: data.clients || [] } + : { clients: [] }, }); const { isSubmitting } = form.formState; + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'clients', + }); + + const handleFormSubmit = (formData: authStrategyFormType) => { + const processedData = { + ...formData, + clients: formData.clients.map(client => ({ + ...client, + id: client.id.trim(), + clientId: client.clientId.trim(), + })), + }; + onSubmit(processedData); + }; + return (
- +
@@ -46,6 +134,11 @@ export const AppleConfigForm: React.FC< label={'Account Linking'} />
+ +

+ Default Apple Configuration +

+
@@ -63,12 +156,120 @@ export const AppleConfigForm: React.FC< + )} />
+ +
+
+

Extra Apple Clients

+ +
+ + {fields.length > 0 && ( +
+ {fields.map((field, index) => ( +
+
+ + Client {index + 1} + + +
+ +
+ + +
+ + + + + +

+ Leave all three fields below empty to reuse the default + key, or provide all three for a second Apple team: +

+ + ( + + + Private Key (optional) + + + + + + + )} + /> + +
+ + +
+
+ ))} +
+ )} +
+
+
+
+

Default client

+

+ Primary Apple credentials for authentication. +

+ +
+ + +
+

Credentials

+
+
+ +
+ +
+
+ + +
+ + ( + + + Private Key + + + + + + + )} + /> + +
+
+
+
- {fields.length > 0 && ( -
- {fields.map((field, index) => ( -
-
- - Client {index + 1} - - -
- -
- - -
- - - - +
+
+

Extra clients

+

+ Additional credential sets for multi-app support. +

+
-

- Leave all three fields below empty to reuse the default - key, or provide all three for a second Apple team: -

+ {fields.length === 0 ? ( +
+ No extra clients configured. +
+ ) : ( +
+ {fields.map((field, index) => { + const nickname = (form.watch(`clients.${index}.id` as any) ?? + '') as string; + const appleAppId = (form.watch( + `clients.${index}.clientId` as any + ) ?? '') as string; + const displayName = nickname.trim() || `Client ${index + 1}`; - ( - - - Private Key (optional) - - - - - - - )} - /> + const extraFields: CredentialFields = { + clientId: (form.watch(`clients.${index}.clientId` as any) ?? + '') as string, + teamId: (form.watch(`clients.${index}.teamId` as any) ?? + '') as string, + keyId: (form.watch(`clients.${index}.keyId` as any) ?? + '') as string, + privateKey: (form.watch( + `clients.${index}.privateKey` as any + ) ?? '') as string, + }; -
- - -
-
- ))} + return ( + +
+ + +
+

+ {displayName} +

+ {appleAppId.trim() ? ( +

+ {appleAppId} +

+ ) : null} +
+
+ + +
+ +
+
+ + +
+ + +

+ Leave all three fields below empty to reuse the + default key, or provide all three for a second Apple + team: +

+ ( + + + Private Key (optional) + + + + + + + )} + /> +
+ + +
+
+
+
+ ); + })}
)} -
-
- + + +
+ -
From 277a355b144387a4c7687e76f10c149eb111e4b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 09:58:29 +0000 Subject: [PATCH 4/5] fix(auth): correct extra-client badge logic and auto-open sections with errors 1. Extra-client credential badge now ONLY counts the three creds (teamId, keyId, privateKey): - 0 filled = 'Reuses default' (inherits default credentials) - 3 filled = 'Second team' (has its own credentials) - 1-2 filled = 'Incomplete' (mixed credentials, blocked by validation) Do not include clientId/id/name in extra-client badge count since clientId is always required. Default client badge still uses all four fields. 2. After validation failure, auto-open collapsible sections that contain errors: - Default client section opens if it has validation errors - Extra client rows with errors (nickname/mixed creds) open automatically - Prevents hiding validation errors inside collapsed sections - Uses controlled Collapsible state with React state + useEffect Keep all #311 fixes: wholesale apple replace, inherit/mixed/unique/keyId validation, whitespace normalize. --- .../settingsConfig/oAuth/appleConfig.tsx | 82 ++++++++++++++++--- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx b/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx index 339aa1ca1..04edacde7 100644 --- a/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx +++ b/src/components/authentication/strategies/settingsConfig/oAuth/appleConfig.tsx @@ -32,7 +32,7 @@ type CredentialFields = { privateKey?: string; }; -function credentialLabel(fields: CredentialFields): string { +function defaultCredentialLabel(fields: CredentialFields): string { const filled = [ fields.clientId, fields.teamId, @@ -46,13 +46,33 @@ function credentialLabel(fields: CredentialFields): string { return 'Incomplete'; } -function CredentialBadge({ fields }: { fields: CredentialFields }) { - const label = credentialLabel(fields); +function extraCredentialLabel(fields: CredentialFields): string { + const filled = [fields.teamId, fields.keyId, fields.privateKey] + .map(value => value?.trim()) + .filter(Boolean).length; + if (filled === 0) return 'Reuses default'; + if (filled === 3) return 'Second team'; + return 'Incomplete'; +} + +function CredentialBadge({ + fields, + isExtra = false, +}: { + fields: CredentialFields; + isExtra?: boolean; +}) { + const label = isExtra + ? extraCredentialLabel(fields) + : defaultCredentialLabel(fields); + const isComplete = isExtra + ? label === 'Second team' || label === 'Reuses default' + : label === 'Credentials complete'; return ( >(new Set()); + + React.useEffect(() => { + if (Object.keys(errors).length > 0) { + const hasDefaultErrors = + errors.enabled || + errors.clientId || + errors.teamId || + errors.keyId || + errors.privateKey || + errors.redirect_uri; + + if (hasDefaultErrors) { + setDefaultOpen(true); + } + + if (errors.clients && Array.isArray(errors.clients)) { + const newOpenClients = new Set(); + errors.clients.forEach((clientError, index) => { + if (clientError) { + newOpenClients.add(index); + } + }); + setOpenClients(newOpenClients); + } + } + }, [errors]); + const handleFormSubmit = (formData: authStrategyFormType) => { const normalizeWhitespace = (value: string | undefined): string => { if (!value || value.trim().length === 0) { @@ -215,7 +264,8 @@ export const AppleConfigForm: React.FC<

@@ -225,7 +275,7 @@ export const AppleConfigForm: React.FC<

Credentials

- +
@@ -289,10 +339,22 @@ export const AppleConfigForm: React.FC< ) ?? '') as string, }; + const isOpen = openClients.has(index); + const toggleOpen = (open: boolean) => { + const newOpenClients = new Set(openClients); + if (open) { + newOpenClients.add(index); + } else { + newOpenClients.delete(index); + } + setOpenClients(newOpenClients); + }; + return (
@@ -309,7 +371,7 @@ export const AppleConfigForm: React.FC< ) : null}
- + - + {strategy.name} settings configuration @@ -94,20 +98,33 @@ export const StrategySettings: React.FC = ({

- - {strategy.form ? ( - { - setOpen(false); - }} - /> - ) : ( - 'No settings available' - )} - + {strategy.key === 'apple' ? ( + + {strategy.form ? ( + { + setOpen(false); + }} + /> + ) : ( + 'No settings available' + )} + + ) : strategy.form ? ( + { + setOpen(false); + }} + /> + ) : ( + 'No settings available' + )}
diff --git a/src/components/authentication/strategies/settingsConfig/localConfig.tsx b/src/components/authentication/strategies/settingsConfig/localConfig.tsx index d1523f0ff..849552ebc 100644 --- a/src/components/authentication/strategies/settingsConfig/localConfig.tsx +++ b/src/components/authentication/strategies/settingsConfig/localConfig.tsx @@ -82,15 +82,17 @@ export const LocalConfigForm: React.FC< /> )} -
- -
+
- +