diff --git a/src/components/authentication/strategies/StrategySettings.tsx b/src/components/authentication/strategies/StrategySettings.tsx index e377645f9..23c6aea76 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,11 @@ export const StrategySettings: React.FC = ({ - + {strategy.name} settings configuration @@ -79,20 +95,37 @@ export const StrategySettings: React.FC = ({ Documentation - -
- {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< /> )} -
- -
+
; -const authStrategySchema = oauthDefaultConfig.merge( - z.object({ - privateKey: z.string().default(''), - teamId: z.string().default(''), - keyId: z.string().default(''), +type CredentialFields = { + clientId?: string; + teamId?: string; + keyId?: string; + privateKey?: string; +}; + +function defaultCredentialLabel(fields: CredentialFields): string { + const filled = [ + fields.clientId, + fields.teamId, + fields.keyId, + fields.privateKey, + ] + .map(value => value?.trim()) + .filter(Boolean).length; + if (filled === 0) return 'Not configured'; + if (filled === 4) return 'Credentials complete'; + return 'Incomplete'; +} + +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 ( + + {label} + + ); +} + +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'], + } + ) + .superRefine((data, ctx) => { + const ids = data.clients.map((c, idx) => ({ + id: c.id.trim().toLowerCase(), + index: idx, + })); + const seen = new Map(); + + for (const { id, index } of ids) { + if (seen.has(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'This nickname is already used', + path: ['clients', index, 'id'], + }); + const firstIndex = seen.get(id)!; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'This nickname is already used', + path: ['clients', firstIndex, 'id'], + }); + } else { + seen.set(id, index); + } + } + }); 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, errors } = form.formState; + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'clients', }); - const { isSubmitting } = form.formState; + + const [defaultOpen, setDefaultOpen] = React.useState(false); + const [openClients, setOpenClients] = React.useState>(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) { + return ''; + } + return value; + }; + + const processedData = { + ...formData, + clients: formData.clients.map(client => ({ + ...client, + id: client.id.trim(), + clientId: client.clientId.trim(), + redirect_uri: normalizeWhitespace(client.redirect_uri), + privateKey: normalizeWhitespace(client.privateKey), + teamId: normalizeWhitespace(client.teamId), + keyId: normalizeWhitespace(client.keyId), + })), + }; + onSubmit(processedData); + }; + + const defaultFields: CredentialFields = { + clientId: form.watch('clientId'), + teamId: form.watch('teamId'), + keyId: form.watch('keyId'), + privateKey: form.watch('privateKey'), + }; return (
- -
-
- - -
-
- - + +
+
+ +
- - ( - - - Private Key - - - - - +
+
+

Default client

+

+ Primary Apple credentials for authentication. +

+
+ +
+ + +
+

Credentials

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

Extra clients

+

+ Additional credential sets for multi-app support. +

+
+ + {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}`; + + 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, + }; + + 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 ( + +
+ + +
+

+ {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) + + + + + + + )} + /> +
+ + +
+
+
+
+ ); + })} +
)} - /> -
- -
-
- +
+ +
+ -
diff --git a/src/lib/models/authentication/apple.config.ts b/src/lib/models/authentication/apple.config.ts index 00874d014..0542b59a3 100644 --- a/src/lib/models/authentication/apple.config.ts +++ b/src/lib/models/authentication/apple.config.ts @@ -1,9 +1,20 @@ import { Oauth2BaseConfig } from '@/lib/models/authentication/oauth2Base.config'; +export type AppleExtraClient = { + id: string; + clientId: string; + name?: string; + redirect_uri?: string; + privateKey?: string; + teamId?: string; + keyId?: string; +}; + export type AppleConfig = { apple: Oauth2BaseConfig & { privateKey: string; teamId: string; keyId: string; + clients?: AppleExtraClient[]; }; };