improve form error codes

This commit is contained in:
miloschwartz
2026-08-05 15:56:12 -04:00
parent 790daba796
commit 2e8bd7a8c7
6 changed files with 103 additions and 77 deletions
+5
View File
@@ -1721,6 +1721,11 @@
"aiProviderErrorUpdate": "Failed to update AI provider", "aiProviderErrorUpdate": "Failed to update AI provider",
"aiProviderErrorDelete": "Failed to delete AI provider", "aiProviderErrorDelete": "Failed to delete AI provider",
"aiProviderErrorLoad": "Failed to load AI provider", "aiProviderErrorLoad": "Failed to load AI provider",
"aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL",
"aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider",
"aiProviderErrorAuthTypeRequired": "Auth type is required",
"aiProviderErrorApiKeyRequired": "API key is required",
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
"aiProviderCreated": "AI provider created", "aiProviderCreated": "AI provider created",
"aiProviderUpdated": "AI provider updated", "aiProviderUpdated": "AI provider updated",
"aiProviderDeleted": "AI provider deleted", "aiProviderDeleted": "AI provider deleted",
@@ -29,7 +29,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
aiProviderFormSchema, createAiProviderFormSchema,
toAiProviderAuthPayload, toAiProviderAuthPayload,
type AiProviderFormValues type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema"; } from "@app/lib/aiProviderFormSchema";
@@ -43,7 +43,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
export default function AiProviderAuthenticationPage() { export default function AiProviderAuthenticationPage() {
@@ -54,8 +54,10 @@ export default function AiProviderAuthenticationPage() {
const t = useTranslations(); const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({ const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: provider.name, name: provider.name,
type: provider.type as AiProviderType, type: provider.type as AiProviderType,
@@ -32,17 +32,10 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
const generalSchema = z.object({
name: z.string().trim().min(1),
enabled: z.boolean()
});
type GeneralFormValues = z.infer<typeof generalSchema>;
export default function AiProviderGeneralPage() { export default function AiProviderGeneralPage() {
const { provider, updateProvider } = useAiProviderContext(); const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -51,6 +44,20 @@ export default function AiProviderGeneralPage() {
const t = useTranslations(); const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const generalSchema = useMemo(
() =>
z.object({
name: z
.string()
.trim()
.min(1, { message: t("nameRequired") }),
enabled: z.boolean()
}),
[t]
);
type GeneralFormValues = z.infer<typeof generalSchema>;
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(generalSchema), resolver: zodResolver(generalSchema),
defaultValues: { defaultValues: {
@@ -37,7 +37,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
aiProviderFormSchema, createAiProviderFormSchema,
showsUpstreamUrlField, showsUpstreamUrlField,
toAiProviderNetworkPayload, toAiProviderNetworkPayload,
upstreamUrlRequired, upstreamUrlRequired,
@@ -54,7 +54,7 @@ import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
export default function AiProviderNetworkPage() { export default function AiProviderNetworkPage() {
@@ -68,8 +68,10 @@ export default function AiProviderNetworkPage() {
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null); const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({ const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderFormSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: provider.name, name: provider.name,
type: provider.type as AiProviderType, type: provider.type as AiProviderType,
@@ -38,7 +38,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { import {
aiProviderCreateFormSchema, createAiProviderCreateFormSchema,
defaultAuthTypeForProvider, defaultAuthTypeForProvider,
emptyUpstreamForType, emptyUpstreamForType,
showsUpstreamUrlField, showsUpstreamUrlField,
@@ -52,7 +52,7 @@ import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
export default function CreateAiProviderPage() { export default function CreateAiProviderPage() {
@@ -65,8 +65,10 @@ export default function CreateAiProviderPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const targetsRef = useRef<LocalTarget[]>([]); const targetsRef = useRef<LocalTarget[]>([]);
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({ const form = useForm<AiProviderFormValues>({
resolver: zodResolver(aiProviderCreateFormSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
type: "openai", type: "openai",
+68 -60
View File
@@ -8,6 +8,8 @@ import {
type AiProviderType type AiProviderType
} from "@server/lib/aiProviderDefaults"; } from "@server/lib/aiProviderDefaults";
type TranslateFn = (key: string) => string;
export const aiProviderTypeValues = [ export const aiProviderTypeValues = [
"openai", "openai",
"anthropic", "anthropic",
@@ -20,82 +22,88 @@ export const aiProviderTypeValues = [
"custom" "custom"
] as const satisfies readonly AiProviderType[]; ] as const satisfies readonly AiProviderType[];
export const aiProviderFormSchema = z export function createAiProviderFormSchema(t: TranslateFn) {
.object({ return z
name: z.string().trim().min(1), .object({
type: z.enum(aiProviderTypeValues), name: z
upstreamUrl: z.string().optional().nullable(), .string()
apiKey: z.string().optional(), .trim()
authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(), .min(1, { message: t("nameRequired") }),
routingMode: z.enum(["url", "target"]).optional(), type: z.enum(aiProviderTypeValues),
skipTlsVerification: z.boolean().optional(), upstreamUrl: z.string().optional().nullable(),
enabled: z.boolean().optional() apiKey: z.string().optional(),
}) authType: z.enum(AI_PROVIDER_AUTH_TYPES).optional().nullable(),
.superRefine((data, ctx) => { routingMode: z.enum(["url", "target"]).optional(),
const routingMode = skipTlsVerification: z.boolean().optional(),
data.type === "custom" ? (data.routingMode ?? "url") : "url"; enabled: z.boolean().optional()
})
.superRefine((data, ctx) => {
const routingMode =
data.type === "custom" ? (data.routingMode ?? "url") : "url";
if (data.type !== "custom" && data.routingMode === "target") { if (data.type !== "custom" && data.routingMode === "target") {
ctx.addIssue({
code: "custom",
message:
"routingMode target is only allowed for custom providers",
path: ["routingMode"]
});
}
const upstreamUrl =
data.upstreamUrl && data.upstreamUrl.trim().length > 0
? data.upstreamUrl.trim()
: null;
if (upstreamUrl) {
try {
new URL(upstreamUrl);
} catch {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Invalid URL", message: t("aiProviderErrorRoutingModeTarget"),
path: ["routingMode"]
});
}
const upstreamUrl =
data.upstreamUrl && data.upstreamUrl.trim().length > 0
? data.upstreamUrl.trim()
: null;
if (upstreamUrl) {
try {
new URL(upstreamUrl);
} catch {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorUpstreamUrlInvalid"),
path: ["upstreamUrl"]
});
}
}
if (
providerRequiresUpstreamUrl(data.type, routingMode) &&
!upstreamUrl
) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorUpstreamUrlRequired"),
path: ["upstreamUrl"] path: ["upstreamUrl"]
}); });
} }
}
if ( if (!data.authType) {
providerRequiresUpstreamUrl(data.type, routingMode) && ctx.addIssue({
!upstreamUrl code: "custom",
) { message: t("aiProviderErrorAuthTypeRequired"),
ctx.addIssue({ path: ["authType"]
code: "custom", });
message: `upstreamUrl is required for ${data.type} providers`, }
path: ["upstreamUrl"] });
}); }
}
if (!data.authType) { export function createAiProviderCreateFormSchema(t: TranslateFn) {
ctx.addIssue({ return createAiProviderFormSchema(t).superRefine((data, ctx) => {
code: "custom",
message: "authType is required",
path: ["authType"]
});
}
});
export type AiProviderFormValues = z.infer<typeof aiProviderFormSchema>;
export const aiProviderCreateFormSchema = aiProviderFormSchema.superRefine(
(data, ctx) => {
const authType: AiProviderAuthType = data.authType ?? "bearer"; const authType: AiProviderAuthType = data.authType ?? "bearer";
if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) { if (authTypeRequiresApiKey(authType) && !data.apiKey?.trim()) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "API key is required", message: t("aiProviderErrorApiKeyRequired"),
path: ["apiKey"] path: ["apiKey"]
}); });
} }
} });
); }
export type AiProviderFormValues = z.infer<
ReturnType<typeof createAiProviderFormSchema>
>;
export function defaultAuthTypeForProvider( export function defaultAuthTypeForProvider(
type: AiProviderType type: AiProviderType