This commit is contained in:
allanice001
2026-01-06 17:05:52 +00:00
parent 810218124e
commit 0f562ac5f4
24 changed files with 4518 additions and 4258 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

4186
internal/web/dist/assets/index-GteqH5KT.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,9 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title> <title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-CyGsiYei.js"></script> <script type="module" crossorigin src="/assets/index-GteqH5KT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-Dt2M6tWj.js"> <link rel="modulepreload" crossorigin href="/assets/react-v1TLhXpT.js">
<link rel="stylesheet" crossorigin href="/assets/index-VHZG0dIU.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cdjh6IZW.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

Binary file not shown.

View File

@@ -1,2 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -53,7 +53,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-hook-form": "^7.69.0", "react-hook-form": "^7.70.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",

View File

@@ -68,6 +68,10 @@ const AWS_ALLOWED_SERVICES = ["route53", "s3", "ec2", "iam", "rds", "dynamodb"]
type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number] type AwsSvc = (typeof AWS_ALLOWED_SERVICES)[number]
// -------------------- Schemas -------------------- // -------------------- 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 const createCredentialSchema = z
.object({ .object({
@@ -91,6 +95,16 @@ const createCredentialSchema = z
secret: z.any(), secret: z.any(),
}) })
.superRefine((val, ctx) => { .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.credential_provider === "aws") {
if (val.scope_kind === "service") { if (val.scope_kind === "service") {
const svc = (val.scope as any)?.service const svc = (val.scope as any)?.service
@@ -112,6 +126,9 @@ const createCredentialSchema = z
}) })
} }
} }
}
// secret requiredness by kind (create always validates)
if (val.kind === "aws_access_key") { if (val.kind === "aws_access_key") {
const sk = val.secret ?? {} const sk = val.secret ?? {}
const id = sk.access_key_id const id = sk.access_key_id
@@ -130,6 +147,121 @@ const createCredentialSchema = z
}) })
} }
} }
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 CreateCredentialValues = z.input<typeof createCredentialSchema>
// 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") { if (val.kind === "api_token") {
@@ -142,6 +274,7 @@ const createCredentialSchema = z
}) })
} }
} }
if (val.kind === "basic_auth") { if (val.kind === "basic_auth") {
const s = val.secret ?? {} const s = val.secret ?? {}
if (!s.username || !s.password) { if (!s.username || !s.password) {
@@ -152,6 +285,7 @@ const createCredentialSchema = z
}) })
} }
} }
if (val.kind === "oauth2") { if (val.kind === "oauth2") {
const s = val.secret ?? {} const s = val.secret ?? {}
if (!s.client_id || !s.client_secret || !s.refresh_token) { if (!s.client_id || !s.client_secret || !s.refresh_token) {
@@ -162,30 +296,28 @@ 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> type UpdateCredentialValues = z.input<typeof updateCredentialSchema>
const updateCredentialSchema = createCredentialSchema.partial().extend({
name: z.string().min(1, "Name is required").max(100).optional(),
})
// -------------------- Helpers -------------------- // -------------------- Helpers --------------------
function pretty(obj: unknown) { function pretty(obj: unknown) {
try { 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 { } catch {
return "" return ""
} }
} }
function extractErr(e: any): string { function extractErr(e: any): string {
const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message const raw = (e as any)?.body ?? (e as any)?.response ?? (e as any)?.message
if (typeof raw === "string") return raw if (typeof raw === "string") return raw
@@ -252,9 +384,9 @@ function buildCreateBody(v: CreateCredentialValues) {
} }
// Build exact PATCH body (only provided fields) // Build exact PATCH body (only provided fields)
function buildUpdateBody(v: z.infer<typeof updateCredentialSchema>) { function buildUpdateBody(v: UpdateCredentialValues) {
const body: any = {} const body: any = {}
const keys: (keyof typeof v)[] = [ const keys: (keyof UpdateCredentialValues)[] = [
"name", "name",
"account_id", "account_id",
"region", "region",
@@ -316,7 +448,7 @@ export const CredentialPage = () => {
// Update // Update
const updateMutation = useMutation({ 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)), credentialsApi.updateCredential(payload.id, buildUpdateBody(payload.body)),
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["credentials"] }) await qc.invalidateQueries({ queryKey: ["credentials"] })
@@ -362,7 +494,7 @@ export const CredentialPage = () => {
mode: "onBlur", mode: "onBlur",
}) })
const editForm = useForm<z.input<typeof updateCredentialSchema>>({ const editForm = useForm<UpdateCredentialValues>({
resolver: zodResolver(updateCredentialSchema), resolver: zodResolver(updateCredentialSchema),
defaultValues: {}, defaultValues: {},
mode: "onBlur", mode: "onBlur",
@@ -371,7 +503,8 @@ export const CredentialPage = () => {
function openEdit(row: any) { function openEdit(row: any) {
setEditingId(row.id) setEditingId(row.id)
editForm.reset({ editForm.reset({
provider: row.provider, // FIX: correct key (was "provider" in your original)
credential_provider: row.credential_provider,
kind: row.kind, kind: row.kind,
schema_version: row.schema_version ?? 1, schema_version: row.schema_version ?? 1,
name: row.name, name: row.name,
@@ -380,7 +513,7 @@ export const CredentialPage = () => {
account_id: row.account_id ?? "", account_id: row.account_id ?? "",
region: row.region ?? "", region: row.region ?? "",
scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined), scope: row.scope ?? (row.scope_kind === "provider" ? {} : undefined),
secret: undefined, secret: undefined, // keep existing unless user rotates
} as any) } as any)
setUseRawEditSecretJSON(false) setUseRawEditSecretJSON(false)
setEditOpen(true) setEditOpen(true)
@@ -394,7 +527,7 @@ export const CredentialPage = () => {
return items.filter((c: any) => return items.filter((c: any) =>
[ [
c.name, c.name,
c.provider, c.credential_provider,
c.kind, c.kind,
c.scope_kind, c.scope_kind,
c.account_id, c.account_id,
@@ -436,6 +569,7 @@ export const CredentialPage = () => {
function ensureCreateDefaultsForSecret() { function ensureCreateDefaultsForSecret() {
if (useRawSecretJSON) return if (useRawSecretJSON) return
if (credential_provider === "aws" && kind === "aws_access_key") { if (credential_provider === "aws" && kind === "aws_access_key") {
const s = createForm.getValues("secret") ?? {} const s = createForm.getValues("secret") ?? {}
setCreateSecret({ setCreateSecret({
@@ -459,7 +593,7 @@ export const CredentialPage = () => {
} }
function onChangeCreateScopeKind(next: "provider" | "service" | "resource") { 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 === "provider") setCreateScope({})
if (next === "service") setCreateScope({ service: "route53" as AwsSvc }) if (next === "service") setCreateScope({ service: "route53" as AwsSvc })
if (next === "resource") setCreateScope({ arn: "" }) if (next === "resource") setCreateScope({ arn: "" })
@@ -905,6 +1039,7 @@ export const CredentialPage = () => {
client_secret: e.target.value, client_secret: e.target.value,
}) })
} }
placeholder="••••••••••"
/> />
)} )}
/> />
@@ -933,7 +1068,6 @@ export const CredentialPage = () => {
)} )}
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
{/* Preview Create button */}
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -1260,9 +1394,7 @@ export const CredentialPage = () => {
<FormItem> <FormItem>
<FormLabel>Rotate Secret (JSON)</FormLabel> <FormLabel>Rotate Secret (JSON)</FormLabel>
<Textarea <Textarea
value={ value={typeof field.value === "string" ? field.value : pretty(field.value)}
typeof field.value === "string" ? field.value : pretty(field.value ?? {})
}
onChange={(e) => { onChange={(e) => {
try { try {
field.onChange(JSON.parse(e.target.value)) field.onChange(JSON.parse(e.target.value))
@@ -1281,7 +1413,6 @@ export const CredentialPage = () => {
)} )}
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
{/* Preview Update button */}
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"

View File

@@ -128,6 +128,10 @@ const credLabel = (c: DtoCredentialOut) => {
} }
// ---------- zod schemas ---------- // ---------- 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({ const createDomainSchema = z.object({
domain_name: z domain_name: z
@@ -144,8 +148,22 @@ const createDomainSchema = z.object({
}) })
type CreateDomainValues = z.input<typeof createDomainSchema> type CreateDomainValues = z.input<typeof createDomainSchema>
const updateDomainSchema = createDomainSchema.partial() // Update: all optional; replicate the normalization safely
type UpdateDomainValues = z.infer<typeof updateDomainSchema> 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 const ttlSchema = z
.union([ .union([
@@ -182,7 +200,42 @@ const createRecordSchema = z
}) })
type CreateRecordValues = z.input<typeof createRecordSchema> 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> type UpdateRecordValues = z.input<typeof updateRecordSchema>
// ---------- main ---------- // ---------- main ----------
@@ -224,12 +277,9 @@ export const DnsPage = () => {
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data]) const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
useEffect(() => { useEffect(() => {
const setSelectedDns = () => {
if (!selected && domainsQ.data && domainsQ.data.length) { if (!selected && domainsQ.data && domainsQ.data.length) {
setSelected(domainsQ.data[0]!) setSelected(domainsQ.data[0]!)
} }
}
setSelectedDns()
}, [domainsQ.data, selected]) }, [domainsQ.data, selected])
const filteredDomains = useMemo(() => { const filteredDomains = useMemo(() => {
@@ -237,7 +287,7 @@ export const DnsPage = () => {
if (!filter.trim()) return list if (!filter.trim()) return list
const f = filter.toLowerCase() const f = filter.toLowerCase()
return list.filter((d) => 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) .filter(Boolean)
.map((x) => String(x).toLowerCase()) .map((x) => String(x).toLowerCase())
.some((s) => s.includes(f)) .some((s) => s.includes(f))
@@ -271,6 +321,7 @@ export const DnsPage = () => {
const editDomainForm = useForm<UpdateDomainValues>({ const editDomainForm = useForm<UpdateDomainValues>({
resolver: zodResolver(updateDomainSchema), resolver: zodResolver(updateDomainSchema),
defaultValues: {},
}) })
const openEditDomain = (d: DtoDomainResponse) => { const openEditDomain = (d: DtoDomainResponse) => {
@@ -283,10 +334,20 @@ export const DnsPage = () => {
setEditDomOpen(true) setEditDomOpen(true)
} }
// Build PATCH body (dont 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({ const updateDomainMut = useMutation({
mutationFn: (vals: UpdateDomainValues) => { mutationFn: (vals: UpdateDomainValues) => {
if (!selected) throw new Error("No domain selected") 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 () => { onSuccess: async () => {
toast.success("Domain updated") toast.success("Domain updated")
@@ -338,7 +399,6 @@ export const DnsPage = () => {
const body: DtoCreateRecordSetRequest = { const body: DtoCreateRecordSetRequest = {
name: vals.name, name: vals.name,
type: vals.type, type: vals.type,
// omit ttl when empty/undefined
...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}), ...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}),
values: parseCommaList(vals.valuesCsv ?? ""), values: parseCommaList(vals.valuesCsv ?? ""),
} }
@@ -356,6 +416,7 @@ export const DnsPage = () => {
const editRecForm = useForm<UpdateRecordValues>({ const editRecForm = useForm<UpdateRecordValues>({
resolver: zodResolver(updateRecordSchema), resolver: zodResolver(updateRecordSchema),
defaultValues: {},
}) })
const openEditRecord = (r: DtoRecordSetResponse) => { const openEditRecord = (r: DtoRecordSetResponse) => {
@@ -374,15 +435,12 @@ export const DnsPage = () => {
mutationFn: async (vals: UpdateRecordValues) => { mutationFn: async (vals: UpdateRecordValues) => {
if (!editingRecord) throw new Error("No record selected") if (!editingRecord) throw new Error("No record selected")
const body: DtoUpdateRecordSetRequest = {} const body: DtoUpdateRecordSetRequest = {}
if (vals.name !== undefined) body.name = vals.name
if (vals.type !== undefined) body.type = vals.type if (typeof vals.name !== "undefined") body.name = vals.name
if (vals.ttl !== undefined && vals.ttl !== null) { if (typeof vals.type !== "undefined") body.type = vals.type
// if blank string came through it would have been filtered; when undefined, omit if (typeof vals.ttl !== "undefined") body.ttl = vals.ttl as unknown as number | undefined
body.ttl = vals.ttl as unknown as number | undefined if (typeof vals.valuesCsv !== "undefined") body.values = parseCommaList(vals.valuesCsv)
}
if (vals.valuesCsv !== undefined) {
body.values = parseCommaList(vals.valuesCsv)
}
return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body) return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body)
}, },
onSuccess: async () => { onSuccess: async () => {
@@ -556,7 +614,14 @@ export const DnsPage = () => {
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center justify-end gap-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" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<AlertDialog> <AlertDialog>
@@ -650,10 +715,7 @@ export const DnsPage = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Type</FormLabel> <FormLabel>Type</FormLabel>
<Select <Select onValueChange={field.onChange} value={field.value as string}>
onValueChange={field.onChange}
defaultValue={field.value as string}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -969,7 +1031,7 @@ export const DnsPage = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Type</FormLabel> <FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value as string}> <Select onValueChange={field.onChange} value={field.value as string}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />

View File

@@ -53,13 +53,17 @@ type Role = (typeof ROLE_OPTIONS)[number]
const STATUS = ["pending", "provisioning", "ready", "failed"] as const const STATUS = ["pending", "provisioning", "ready", "failed"] as const
type Status = (typeof STATUS)[number] 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 const createServerSchema = z
.object({ .object({
hostname: z.string().trim().max(60, "Max 60 chars"), hostname: z.string().trim().max(60, "Max 60 chars"),
public_ip_address: z.string().trim().optional().or(z.literal("")), public_ip_address: z.string().trim().optional().or(z.literal("")),
private_ip_address: z.string().trim().min(1, "Private IP address required"), private_ip_address: z.string().trim().min(1, "Private IP address required"),
role: z.enum(ROLE_OPTIONS), 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"), ssh_user: z.string().trim().min(1, "SSH user is required"),
status: z.enum(STATUS).default("pending"), status: z.enum(STATUS).default("pending"),
}) })
@@ -69,8 +73,33 @@ const createServerSchema = z
) )
type CreateServerInput = z.input<typeof createServerSchema> type CreateServerInput = z.input<typeof createServerSchema>
const updateServerSchema = createServerSchema.partial() // Patch-friendly update schema:
type UpdateServerValues = z.infer<typeof updateServerSchema> // - 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 }) { function StatusBadge({ status }: { status: Status }) {
const v = 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 = () => { export const ServerPage = () => {
const [filter, setFilter] = useState<string>("") const [filter, setFilter] = useState<string>("")
const [createOpen, setCreateOpen] = useState<boolean>(false) const [createOpen, setCreateOpen] = useState<boolean>(false)
@@ -180,13 +230,12 @@ export const ServerPage = () => {
}) })
const roleIsBastionU = watchedRoleUpdate === "bastion" const roleIsBastionU = watchedRoleUpdate === "bastion"
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? "" const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
const needPubUpdate = roleIsBastionU && pubUpdate === "" const needPubUpdate = roleIsBastionU && pubUpdate === ""
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) => mutationFn: ({ id, values }: { id: string; values: UpdateServerValues }) =>
serversApi.updateServer(id, values as any), serversApi.updateServer(id, buildUpdateBody(values) as any),
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["servers"] }) await qc.invalidateQueries({ queryKey: ["servers"] })
setUpdateOpen(false) setUpdateOpen(false)
@@ -279,7 +328,7 @@ export const ServerPage = () => {
</div> </div>
<Select <Select
value={roleFilter || "all"} // map "" -> "all" for the UI value={roleFilter || "all"}
onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))} onValueChange={(v) => setRoleFilter(v === "all" ? "" : (v as Role))}
> >
<SelectTrigger className="w-36"> <SelectTrigger className="w-36">
@@ -296,14 +345,14 @@ export const ServerPage = () => {
</Select> </Select>
<Select <Select
value={statusFilter || "all"} // map "" -> "all" for the UI value={statusFilter || "all"}
onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))} onValueChange={(v) => setStatusFilter(v === "all" ? "" : (v as Status))}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Status (all)" /> <SelectValue placeholder="Status (all)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All statuses</SelectItem> {/* sentinel */} <SelectItem value="all">All statuses</SelectItem>
{STATUS.map((s) => ( {STATUS.map((s) => (
<SelectItem key={s} value={s}> <SelectItem key={s} value={s}>
{s} {s}

View File

@@ -4721,7 +4721,7 @@ react-dom@^19.2.3:
dependencies: dependencies:
scheduler "^0.27.0" scheduler "^0.27.0"
react-hook-form@^7.69.0: react-hook-form@^7.70.0:
version "7.70.0" version "7.70.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.70.0.tgz#256fa2ca71633c99f16ca2cd6074628aa52001b2" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.70.0.tgz#256fa2ca71633c99f16ca2cd6074628aa52001b2"
integrity sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw== integrity sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==