mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
ui fixes
This commit is contained in:
@@ -68,6 +68,10 @@ const AWS_ALLOWED_SERVICES = ["route53", "s3", "ec2", "iam", "rds", "dynamodb"]
|
||||
type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number]
|
||||
|
||||
// -------------------- Schemas --------------------
|
||||
// Zod v4 gotchas you hit:
|
||||
// - .partial() cannot be used if the object contains refinements/effects (often true once you have transforms/refines).
|
||||
// - .extend() cannot overwrite keys after refinements (requires .safeExtend()).
|
||||
// Easiest fix: define CREATE and UPDATE schemas separately (no .partial(), no post-refinement .extend()).
|
||||
|
||||
const createCredentialSchema = z
|
||||
.object({
|
||||
@@ -91,6 +95,16 @@ const createCredentialSchema = z
|
||||
secret: z.any(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
// scope required unless provider scope
|
||||
if (val.scope_kind !== "provider" && !val.scope) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scope"],
|
||||
message: `scope is required`,
|
||||
})
|
||||
}
|
||||
|
||||
// AWS scope checks
|
||||
if (val.credential_provider === "aws") {
|
||||
if (val.scope_kind === "service") {
|
||||
const svc = (val.scope as any)?.service
|
||||
@@ -112,23 +126,25 @@ const createCredentialSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
if (val.kind === "aws_access_key") {
|
||||
const sk = val.secret ?? {}
|
||||
const id = sk.access_key_id
|
||||
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
||||
})
|
||||
}
|
||||
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `secret_access_key is required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// secret requiredness by kind (create always validates)
|
||||
if (val.kind === "aws_access_key") {
|
||||
const sk = val.secret ?? {}
|
||||
const id = sk.access_key_id
|
||||
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
||||
})
|
||||
}
|
||||
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `secret_access_key is required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +158,7 @@ const createCredentialSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.kind === "basic_auth") {
|
||||
const s = val.secret ?? {}
|
||||
if (!s.username || !s.password) {
|
||||
@@ -152,6 +169,7 @@ const createCredentialSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.kind === "oauth2") {
|
||||
const s = val.secret ?? {}
|
||||
if (!s.client_id || !s.client_secret || !s.refresh_token) {
|
||||
@@ -162,30 +180,144 @@ const createCredentialSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.scope_kind !== "provider" && !val.scope) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scope"],
|
||||
message: `scope is required`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type CreateCredentialValues = z.input<typeof createCredentialSchema>
|
||||
const updateCredentialSchema = createCredentialSchema.partial().extend({
|
||||
name: z.string().min(1, "Name is required").max(100).optional(),
|
||||
})
|
||||
|
||||
// UPDATE schema: all fields optional, and validations are "patch-friendly".
|
||||
const updateCredentialSchema = z
|
||||
.object({
|
||||
credential_provider: z
|
||||
.enum(["aws", "cloudflare", "hetzner", "digitalocean", "generic"])
|
||||
.optional(),
|
||||
kind: z.enum(["aws_access_key", "api_token", "basic_auth", "oauth2"]).optional(),
|
||||
schema_version: z.number().optional(),
|
||||
name: z.string().min(1, "Name is required").max(100).optional(),
|
||||
scope_kind: z.enum(["provider", "service", "resource"]).optional(),
|
||||
scope_version: z.number().optional(),
|
||||
scope: z.any().optional(),
|
||||
// allow "" so your form can keep empty strings; buildUpdateBody will drop them
|
||||
account_id: z.string().optional().or(z.literal("")),
|
||||
region: z.string().optional().or(z.literal("")),
|
||||
secret: z.any().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
// If scope_kind is being changed to non-provider, require scope in the patch
|
||||
if (typeof val.scope_kind !== "undefined") {
|
||||
if (val.scope_kind !== "provider" && !val.scope) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scope"],
|
||||
message: `scope is required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AWS scope checks only if we have enough info
|
||||
if (val.credential_provider === "aws") {
|
||||
if (val.scope_kind === "service" && typeof val.scope !== "undefined") {
|
||||
const svc = (val.scope as any)?.service
|
||||
if (!AWS_ALLOWED_SERVICES.includes(svc)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scope"],
|
||||
message: `For AWS service scope, "service" must be one of: ${AWS_ALLOWED_SERVICES.join(", ")}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (val.scope_kind === "resource" && typeof val.scope !== "undefined") {
|
||||
const arn = (val.scope as any)?.arn
|
||||
if (typeof arn !== "string" || !arn.startsWith("arn:")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scope"],
|
||||
message: `For AWS resource scope, "arn" must start with "arn:"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secret validation on update:
|
||||
// - only validate if rotating secret OR changing kind
|
||||
// - if rotating secret but kind is NOT provided, skip kind-specific checks (backend can validate)
|
||||
const rotatingSecret = typeof val.secret !== "undefined"
|
||||
const changingKind = typeof val.kind !== "undefined"
|
||||
if (!rotatingSecret && !changingKind) return
|
||||
if (!val.kind) return
|
||||
|
||||
if (val.kind === "aws_access_key") {
|
||||
const sk = val.secret ?? {}
|
||||
const id = sk.access_key_id
|
||||
if (typeof id !== "string" || !/^[A-Z0-9]{20}$/.test(id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `access_key_id must be 20 chars (A-Z0-9)`,
|
||||
})
|
||||
}
|
||||
if (typeof sk.secret_access_key !== "string" || sk.secret_access_key.length < 10) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `secret_access_key is required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.kind === "api_token") {
|
||||
const token = (val.secret ?? {}).token
|
||||
if (!token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `token is required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.kind === "basic_auth") {
|
||||
const s = val.secret ?? {}
|
||||
if (!s.username || !s.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `username and password are required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (val.kind === "oauth2") {
|
||||
const s = val.secret ?? {}
|
||||
if (!s.client_id || !s.client_secret || !s.refresh_token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["secret"],
|
||||
message: `client_id, client_secret, and refresh_token are required`,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type UpdateCredentialValues = z.input<typeof updateCredentialSchema>
|
||||
|
||||
// -------------------- Helpers --------------------
|
||||
|
||||
function pretty(obj: unknown) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(obj as string), null, 2)
|
||||
if (obj == null) return ""
|
||||
if (typeof obj === "string") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(obj), null, 2)
|
||||
} catch {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
return JSON.stringify(obj, null, 2)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function extractErr(e: any): string {
|
||||
const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message
|
||||
if (typeof raw === "string") return raw
|
||||
@@ -252,9 +384,9 @@ function buildCreateBody(v: CreateCredentialValues) {
|
||||
}
|
||||
|
||||
// Build exact PATCH body (only provided fields)
|
||||
function buildUpdateBody(v: z.infer<typeof updateCredentialSchema>) {
|
||||
function buildUpdateBody(v: UpdateCredentialValues) {
|
||||
const body: any = {}
|
||||
const keys: (keyof typeof v)[] = [
|
||||
const keys: (keyof UpdateCredentialValues)[] = [
|
||||
"name",
|
||||
"account_id",
|
||||
"region",
|
||||
@@ -316,7 +448,7 @@ export const CredentialPage = () => {
|
||||
|
||||
// Update
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; body: z.infer<typeof updateCredentialSchema> }) =>
|
||||
mutationFn: (payload: { id: string; body: UpdateCredentialValues }) =>
|
||||
credentialsApi.updateCredential(payload.id, buildUpdateBody(payload.body)),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["credentials"] })
|
||||
@@ -362,7 +494,7 @@ export const CredentialPage = () => {
|
||||
mode: "onBlur",
|
||||
})
|
||||
|
||||
const editForm = useForm<z.input<typeof updateCredentialSchema>>({
|
||||
const editForm = useForm<UpdateCredentialValues>({
|
||||
resolver: zodResolver(updateCredentialSchema),
|
||||
defaultValues: {},
|
||||
mode: "onBlur",
|
||||
@@ -371,7 +503,8 @@ export const CredentialPage = () => {
|
||||
function openEdit(row: any) {
|
||||
setEditingId(row.id)
|
||||
editForm.reset({
|
||||
provider: row.provider,
|
||||
// FIX: correct key (was "provider" in your original)
|
||||
credential_provider: row.credential_provider,
|
||||
kind: row.kind,
|
||||
schema_version: row.schema_version ?? 1,
|
||||
name: row.name,
|
||||
@@ -380,7 +513,7 @@ export const CredentialPage = () => {
|
||||
account_id: row.account_id ?? "",
|
||||
region: row.region ?? "",
|
||||
scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined),
|
||||
secret: undefined,
|
||||
secret: undefined, // keep existing unless user rotates
|
||||
} as any)
|
||||
setUseRawEditSecretJSON(false)
|
||||
setEditOpen(true)
|
||||
@@ -394,7 +527,7 @@ export const CredentialPage = () => {
|
||||
return items.filter((c: any) =>
|
||||
[
|
||||
c.name,
|
||||
c.provider,
|
||||
c.credential_provider,
|
||||
c.kind,
|
||||
c.scope_kind,
|
||||
c.account_id,
|
||||
@@ -436,6 +569,7 @@ export const CredentialPage = () => {
|
||||
|
||||
function ensureCreateDefaultsForSecret() {
|
||||
if (useRawSecretJSON) return
|
||||
|
||||
if (credential_provider === "aws" && kind === "aws_access_key") {
|
||||
const s = createForm.getValues("secret") ?? {}
|
||||
setCreateSecret({
|
||||
@@ -459,7 +593,7 @@ export const CredentialPage = () => {
|
||||
}
|
||||
|
||||
function onChangeCreateScopeKind(next: "provider" | "service" | "resource") {
|
||||
createForm.setValue("scope_kind", next)
|
||||
createForm.setValue("scope_kind", next, { shouldDirty: true, shouldValidate: true })
|
||||
if (next === "provider") setCreateScope({})
|
||||
if (next === "service") setCreateScope({ service: "route53" as AwsSvc })
|
||||
if (next === "resource") setCreateScope({ arn: "" })
|
||||
@@ -905,6 +1039,7 @@ export const CredentialPage = () => {
|
||||
client_secret: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -933,7 +1068,6 @@ export const CredentialPage = () => {
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{/* Preview Create button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -1260,9 +1394,7 @@ export const CredentialPage = () => {
|
||||
<FormItem>
|
||||
<FormLabel>Rotate Secret (JSON)</FormLabel>
|
||||
<Textarea
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : pretty(field.value ?? {})
|
||||
}
|
||||
value={typeof field.value === "string" ? field.value : pretty(field.value)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
field.onChange(JSON.parse(e.target.value))
|
||||
@@ -1281,7 +1413,6 @@ export const CredentialPage = () => {
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{/* Preview Update button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -128,6 +128,10 @@ const credLabel = (c: DtoCredentialOut) => {
|
||||
}
|
||||
|
||||
// ---------- zod schemas ----------
|
||||
// IMPORTANT (Zod v4):
|
||||
// - `.partial()` cannot be used on object schemas containing refinements/effects.
|
||||
// Your schemas contain effects via `.transform(...)` and refinements via `.superRefine(...)` / `.refine(...)`,
|
||||
// so we define UPDATE schemas explicitly instead of using `.partial()`.
|
||||
|
||||
const createDomainSchema = z.object({
|
||||
domain_name: z
|
||||
@@ -144,8 +148,22 @@ const createDomainSchema = z.object({
|
||||
})
|
||||
type CreateDomainValues = z.input<typeof createDomainSchema>
|
||||
|
||||
const updateDomainSchema = createDomainSchema.partial()
|
||||
type UpdateDomainValues = z.infer<typeof updateDomainSchema>
|
||||
// Update: all optional; replicate the normalization safely
|
||||
const updateDomainSchema = z.object({
|
||||
domain_name: z
|
||||
.string()
|
||||
.min(1, "Domain is required")
|
||||
.max(253)
|
||||
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
|
||||
.optional(),
|
||||
credential_id: z.string().uuid("Pick a credential").optional(),
|
||||
zone_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.transform((v) => (v ? v.trim() : undefined)),
|
||||
})
|
||||
type UpdateDomainValues = z.input<typeof updateDomainSchema>
|
||||
|
||||
const ttlSchema = z
|
||||
.union([
|
||||
@@ -182,7 +200,42 @@ const createRecordSchema = z
|
||||
})
|
||||
type CreateRecordValues = z.input<typeof createRecordSchema>
|
||||
|
||||
const updateRecordSchema = createRecordSchema.partial()
|
||||
// Update: all optional. Only enforce "values required"/"CNAME exactly one" if valuesCsv is present.
|
||||
// Only validate ttl if present (ttlSchema already optional).
|
||||
const updateRecordSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name required")
|
||||
.max(253)
|
||||
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
|
||||
.optional(),
|
||||
type: z.enum(rrtypes as [string, ...string[]]).optional(),
|
||||
ttl: ttlSchema,
|
||||
valuesCsv: z.string().optional(),
|
||||
})
|
||||
.superRefine((vals, ctx) => {
|
||||
const hasValues = typeof vals.valuesCsv !== "undefined"
|
||||
if (!hasValues) return
|
||||
|
||||
const arr = parseCommaList(vals.valuesCsv ?? "")
|
||||
if (arr.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["valuesCsv"],
|
||||
message: "At least one value is required",
|
||||
})
|
||||
}
|
||||
|
||||
// We can only enforce CNAME rule if `type` is provided in patch (or you can enforce always if you want).
|
||||
if (vals.type === "CNAME" && arr.length !== 1) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["valuesCsv"],
|
||||
message: "CNAME requires exactly one value",
|
||||
})
|
||||
}
|
||||
})
|
||||
type UpdateRecordValues = z.input<typeof updateRecordSchema>
|
||||
|
||||
// ---------- main ----------
|
||||
@@ -224,12 +277,9 @@ export const DnsPage = () => {
|
||||
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
|
||||
|
||||
useEffect(() => {
|
||||
const setSelectedDns = () => {
|
||||
if (!selected && domainsQ.data && domainsQ.data.length) {
|
||||
setSelected(domainsQ.data[0]!)
|
||||
}
|
||||
if (!selected && domainsQ.data && domainsQ.data.length) {
|
||||
setSelected(domainsQ.data[0]!)
|
||||
}
|
||||
setSelectedDns()
|
||||
}, [domainsQ.data, selected])
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
@@ -237,7 +287,7 @@ export const DnsPage = () => {
|
||||
if (!filter.trim()) return list
|
||||
const f = filter.toLowerCase()
|
||||
return list.filter((d) =>
|
||||
[d.domain_name, d.zone_id, d.status, d.domain_name]
|
||||
[d.domain_name, d.zone_id, d.status]
|
||||
.filter(Boolean)
|
||||
.map((x) => String(x).toLowerCase())
|
||||
.some((s) => s.includes(f))
|
||||
@@ -271,6 +321,7 @@ export const DnsPage = () => {
|
||||
|
||||
const editDomainForm = useForm<UpdateDomainValues>({
|
||||
resolver: zodResolver(updateDomainSchema),
|
||||
defaultValues: {},
|
||||
})
|
||||
|
||||
const openEditDomain = (d: DtoDomainResponse) => {
|
||||
@@ -283,10 +334,20 @@ export const DnsPage = () => {
|
||||
setEditDomOpen(true)
|
||||
}
|
||||
|
||||
// Build PATCH body (don’t send empty strings)
|
||||
const buildUpdateDomainBody = (vals: UpdateDomainValues): DtoUpdateDomainRequest => {
|
||||
const body: any = {}
|
||||
if (typeof vals.domain_name !== "undefined") body.domain_name = vals.domain_name
|
||||
if (typeof vals.credential_id !== "undefined" && vals.credential_id !== "")
|
||||
body.credential_id = vals.credential_id
|
||||
if (typeof vals.zone_id !== "undefined" && vals.zone_id !== "") body.zone_id = vals.zone_id
|
||||
return body as DtoUpdateDomainRequest
|
||||
}
|
||||
|
||||
const updateDomainMut = useMutation({
|
||||
mutationFn: (vals: UpdateDomainValues) => {
|
||||
if (!selected) throw new Error("No domain selected")
|
||||
return dnsApi.updateDomain(selected.id!, vals as unknown as DtoUpdateDomainRequest)
|
||||
return dnsApi.updateDomain(selected.id!, buildUpdateDomainBody(vals))
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Domain updated")
|
||||
@@ -338,7 +399,6 @@ export const DnsPage = () => {
|
||||
const body: DtoCreateRecordSetRequest = {
|
||||
name: vals.name,
|
||||
type: vals.type,
|
||||
// omit ttl when empty/undefined
|
||||
...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}),
|
||||
values: parseCommaList(vals.valuesCsv ?? ""),
|
||||
}
|
||||
@@ -356,6 +416,7 @@ export const DnsPage = () => {
|
||||
|
||||
const editRecForm = useForm<UpdateRecordValues>({
|
||||
resolver: zodResolver(updateRecordSchema),
|
||||
defaultValues: {},
|
||||
})
|
||||
|
||||
const openEditRecord = (r: DtoRecordSetResponse) => {
|
||||
@@ -374,15 +435,12 @@ export const DnsPage = () => {
|
||||
mutationFn: async (vals: UpdateRecordValues) => {
|
||||
if (!editingRecord) throw new Error("No record selected")
|
||||
const body: DtoUpdateRecordSetRequest = {}
|
||||
if (vals.name !== undefined) body.name = vals.name
|
||||
if (vals.type !== undefined) body.type = vals.type
|
||||
if (vals.ttl !== undefined && vals.ttl !== null) {
|
||||
// if blank string came through it would have been filtered; when undefined, omit
|
||||
body.ttl = vals.ttl as unknown as number | undefined
|
||||
}
|
||||
if (vals.valuesCsv !== undefined) {
|
||||
body.values = parseCommaList(vals.valuesCsv)
|
||||
}
|
||||
|
||||
if (typeof vals.name !== "undefined") body.name = vals.name
|
||||
if (typeof vals.type !== "undefined") body.type = vals.type
|
||||
if (typeof vals.ttl !== "undefined") body.ttl = vals.ttl as unknown as number | undefined
|
||||
if (typeof vals.valuesCsv !== "undefined") body.values = parseCommaList(vals.valuesCsv)
|
||||
|
||||
return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -556,7 +614,14 @@ export const DnsPage = () => {
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={() => openEditDomain(d)}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEditDomain(d)
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
@@ -650,10 +715,7 @@ export const DnsPage = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value as string}
|
||||
>
|
||||
<Select onValueChange={field.onChange} value={field.value as string}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -969,7 +1031,7 @@ export const DnsPage = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value as string}>
|
||||
<Select onValueChange={field.onChange} value={field.value as string}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
|
||||
@@ -53,13 +53,17 @@ type Role = (typeof ROLE_OPTIONS)[number]
|
||||
const STATUS = ["pending", "provisioning", "ready", "failed"] as const
|
||||
type Status = (typeof STATUS)[number]
|
||||
|
||||
// ---------- Zod schemas ----------
|
||||
// Zod v4: `.partial()` cannot be used on schemas with refinements/effects.
|
||||
// createServerSchema has a refinement, so define updateServerSchema explicitly.
|
||||
|
||||
const createServerSchema = z
|
||||
.object({
|
||||
hostname: z.string().trim().max(60, "Max 60 chars"),
|
||||
public_ip_address: z.string().trim().optional().or(z.literal("")),
|
||||
private_ip_address: z.string().trim().min(1, "Private IP address required"),
|
||||
role: z.enum(ROLE_OPTIONS),
|
||||
ssh_key_id: z.uuid("Pick a valid SSH key"),
|
||||
ssh_key_id: z.string().uuid("Pick a valid SSH key"),
|
||||
ssh_user: z.string().trim().min(1, "SSH user is required"),
|
||||
status: z.enum(STATUS).default("pending"),
|
||||
})
|
||||
@@ -69,8 +73,33 @@ const createServerSchema = z
|
||||
)
|
||||
type CreateServerInput = z.input<typeof createServerSchema>
|
||||
|
||||
const updateServerSchema = createServerSchema.partial()
|
||||
type UpdateServerValues = z.infer<typeof updateServerSchema>
|
||||
// Patch-friendly update schema:
|
||||
// - all fields optional
|
||||
// - only enforce "public ip required" if role is being set to bastion in the patch
|
||||
const updateServerSchema = z
|
||||
.object({
|
||||
hostname: z.string().trim().max(60, "Max 60 chars").optional(),
|
||||
public_ip_address: z.string().trim().optional().or(z.literal("")),
|
||||
private_ip_address: z.string().trim().min(1, "Private IP address required").optional(),
|
||||
role: z.enum(ROLE_OPTIONS).optional(),
|
||||
ssh_key_id: z.string().uuid("Pick a valid SSH key").optional(),
|
||||
ssh_user: z.string().trim().min(1, "SSH user is required").optional(),
|
||||
status: z.enum(STATUS).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
// If updating role to bastion, require public_ip_address in the patch
|
||||
if (v.role === "bastion") {
|
||||
const pub = typeof v.public_ip_address === "string" ? v.public_ip_address.trim() : ""
|
||||
if (!pub) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["public_ip_address"],
|
||||
message: "Public IP required for bastion",
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
type UpdateServerValues = z.input<typeof updateServerSchema>
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
const v =
|
||||
@@ -88,6 +117,27 @@ function StatusBadge({ status }: { status: Status }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Build PATCH body: omit undefined and empty strings (so optional inputs can be cleared in UI without sending "")
|
||||
function buildUpdateBody(values: UpdateServerValues) {
|
||||
const body: any = {}
|
||||
const keys: (keyof UpdateServerValues)[] = [
|
||||
"hostname",
|
||||
"public_ip_address",
|
||||
"private_ip_address",
|
||||
"role",
|
||||
"ssh_key_id",
|
||||
"ssh_user",
|
||||
"status",
|
||||
]
|
||||
for (const k of keys) {
|
||||
const v = values[k]
|
||||
if (typeof v === "undefined") continue
|
||||
if (v === "") continue
|
||||
body[k] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export const ServerPage = () => {
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const [createOpen, setCreateOpen] = useState<boolean>(false)
|
||||
@@ -180,13 +230,12 @@ export const ServerPage = () => {
|
||||
})
|
||||
|
||||
const roleIsBastionU = watchedRoleUpdate === "bastion"
|
||||
|
||||
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
|
||||
const needPubUpdate = roleIsBastionU && pubUpdate === ""
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) =>
|
||||
serversApi.updateServer(id, values as any),
|
||||
serversApi.updateServer(id, buildUpdateBody(values) as any),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["servers"] })
|
||||
setUpdateOpen(false)
|
||||
@@ -279,7 +328,7 @@ export const ServerPage = () => {
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={roleFilter || "all"} // map "" -> "all" for the UI
|
||||
value={roleFilter || "all"}
|
||||
onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
@@ -296,14 +345,14 @@ export const ServerPage = () => {
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={statusFilter || "all"} // map "" -> "all" for the UI
|
||||
value={statusFilter || "all"}
|
||||
onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Status (all)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem> {/* sentinel */}
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
|
||||
Reference in New Issue
Block a user