mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
ui fixes
This commit is contained in:
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
2
internal/web/dist/assets/index-Cdjh6IZW.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4167
internal/web/dist/assets/index-CyGsiYei.js
vendored
4167
internal/web/dist/assets/index-CyGsiYei.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-CyGsiYei.js.br
vendored
BIN
internal/web/dist/assets/index-CyGsiYei.js.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-CyGsiYei.js.gz
vendored
BIN
internal/web/dist/assets/index-CyGsiYei.js.gz
vendored
Binary file not shown.
4186
internal/web/dist/assets/index-GteqH5KT.js
vendored
Normal file
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
2
internal/web/dist/assets/index-VHZG0dIU.css
vendored
2
internal/web/dist/assets/index-VHZG0dIU.css
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/index-VHZG0dIU.css.br
vendored
BIN
internal/web/dist/assets/index-VHZG0dIU.css.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/index-VHZG0dIU.css.gz
vendored
BIN
internal/web/dist/assets/index-VHZG0dIU.css.gz
vendored
Binary file not shown.
4
internal/web/dist/assets/react-Dt2M6tWj.js
vendored
4
internal/web/dist/assets/react-Dt2M6tWj.js
vendored
File diff suppressed because one or more lines are too long
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.br
vendored
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.br
vendored
Binary file not shown.
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.gz
vendored
BIN
internal/web/dist/assets/react-Dt2M6tWj.js.gz
vendored
Binary file not shown.
File diff suppressed because one or more lines are too long
4
internal/web/dist/assets/react-v1TLhXpT.js
vendored
Normal file
4
internal/web/dist/assets/react-v1TLhXpT.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/web/dist/assets/react-v1TLhXpT.js.map
vendored
Normal file
1
internal/web/dist/assets/react-v1TLhXpT.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
internal/web/dist/index.html
vendored
6
internal/web/dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
BIN
internal/web/dist/index.html.br
vendored
BIN
internal/web/dist/index.html.br
vendored
Binary file not shown.
BIN
internal/web/dist/index.html.gz
vendored
BIN
internal/web/dist/index.html.gz
vendored
Binary file not shown.
3
internal/web/dist/vite.svg
vendored
3
internal/web/dist/vite.svg
vendored
@@ -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 |
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 (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({
|
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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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==
|
||||||
|
|||||||
Reference in New Issue
Block a user