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

View File

@@ -53,7 +53,7 @@
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
"react-hook-form": "^7.70.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"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]
// -------------------- 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"

View File

@@ -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 (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({
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 />

View File

@@ -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}

View File

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