feat(webui): redesign the admin dashboard around admin tasks

A sidebar replaces the flat tabs with seven pages, and one filter bar
sets the time range, comparison period and scope for all of them.

- Home: spend against budget, forecast, request rate, error rate,
  open breakers, top users and models, recent events and warnings.
- Spend: spend over time with comparison, budget burn-down, an
  attribution drill-down from group to user to model, pricing health
  and cost avoided by free models.
- Consumers: a directory with last seen and limit bars, an access
  matrix, and a detail page per user.
- Models: the catalog with billing prices, fleet latency and error
  rates, and provider health.
- Reliability: error, timeout, failover, 429 and 402 trends, events,
  and latency per provider.
- Config: the redacted config and a change helper that emits YAML or
  users.json snippets to paste into git.
- MCP & Agents: callers per target.

Page state lives in the URL hash, and old tab links map to the new
pages. The replaced views are removed. admin_assets_gen.go is
regenerated.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:41:01 +01:00
parent e8abb91dc8
commit ee49467e64
127 changed files with 11226 additions and 3414 deletions
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
/**
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
* access-list snippet (providers/models/mcpServers/agents, YAML) or a
* user's personal grant (providers/models only — GroupConfig has no
* per-user mcpServers/agents grant field, auth.go's own UserConfig shape)
* as a bare JSON fragment for an existing users.json entry.
*
* Every list field is a comma-separated text Input rather than a proper
* multi-select (no such shadcn-vue primitive is installed in this panel) —
* parsed, trimmed, and de-duplicated below.
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const mcpServersRaw = ref('')
const agentsRaw = ref('')
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const mcpServers = computed(() => parseList(mcpServersRaw.value))
const agents = computed(() => parseList(agentsRaw.value))
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
// comment) — a group's own `providers: []` legitimately means "every
// provider" and Go applies it with no such check at group-construction
// time (auth.go). Gated on targetKind so a group grant with an empty
// providers list + a models list (a perfectly valid group config) no
// longer shows a rejection that does not apply to it (P1 item 13).
const grantError = computed(() => (targetKind.value === 'user' ? validatePersonalGrant(providers.value, models.value) : null))
const isEmpty = computed(
() => providers.value.length === 0 && models.value.length === 0 && mcpServers.value.length === 0 && agents.value.length === 0,
)
const snippet = computed<string | null>(() => {
if (!nameValid.value || grantError.value || isEmpty.value) return null
if (targetKind.value === 'group') {
return groupGrantSnippet(targetName.value.trim(), {
providers: providers.value,
models: models.value,
mcpServers: mcpServers.value,
agents: agents.value,
})
}
const fragment = userGrantJsonFragment({ providers: providers.value, models: models.value })
return fragment || null
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="grant-providers" class="text-xs font-medium text-muted-foreground">Providers (comma-separated, empty = all)</label>
<Input id="grant-providers" v-model="providersRaw" placeholder="anthropic, openai" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-models" class="text-xs font-medium text-muted-foreground">Models (comma-separated, empty = all)</label>
<Input id="grant-models" v-model="modelsRaw" placeholder="anthropic/claude-sonnet-5" />
</div>
<template v-if="targetKind === 'group'">
<div class="flex flex-col gap-1">
<label for="grant-mcp" class="text-xs font-medium text-muted-foreground">MCP servers (comma-separated, empty = all)</label>
<Input id="grant-mcp" v-model="mcpServersRaw" placeholder="fetch, weather" />
</div>
<div class="flex flex-col gap-1">
<label for="grant-agents" class="text-xs font-medium text-muted-foreground">Agents (comma-separated, empty = all)</label>
<Input id="grant-agents" v-model="agentsRaw" placeholder="agentkit" />
</div>
</template>
</div>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
import type { LimitsConfig } from '@/types/api'
/**
* LimitsForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* group's or a user's limits snippet — a group's is a full YAML block
* (lib/snippets.ts's groupLimitsSnippet); a user's own limits live inside
* their users.json line, so it is a bare JSON field fragment
* (userLimitsJsonFragment) meant to be merged into that user's existing
* entry, not a whole new line (UserForm.vue owns building a whole new
* line).
*/
const props = defineProps<{
groupNames: string[]
userNames: string[]
}>()
const targetKind = ref<'group' | 'user'>('group')
const targetName = ref('')
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
/** fieldValid applies isValidNonNegativeInt to the four int64 fields and isValidNonNegative (fractions allowed) to the two float64 cost fields — the ONE gate both invalidFields and limits below share, so they can never disagree on which fields are int-only. */
function fieldValid(key: keyof LimitsConfig, value: number): boolean {
return INTEGER_FIELDS.has(key) ? isValidNonNegativeInt(value) : isValidNonNegative(value)
}
const FIELDS: { key: keyof LimitsConfig; label: string }[] = [
{ key: 'requestsPerMinute', label: 'Requests/minute' },
{ key: 'requestsPerDay', label: 'Requests/day' },
{ key: 'tokensPerDay', label: 'Tokens/day (in+out)' },
{ key: 'tokensPerMonth', label: 'Tokens/month (in+out)' },
{ key: 'costPerDayUSD', label: 'Cost/day (USD)' },
{ key: 'costPerMonthUSD', label: 'Cost/month (USD)' },
]
const raw = reactive<Record<keyof LimitsConfig, string>>({
requestsPerMinute: '',
requestsPerDay: '',
tokensPerDay: '',
tokensPerMonth: '',
costPerDayUSD: '',
costPerMonthUSD: '',
})
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
const invalidFields = computed(() =>
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidIntegerFields = computed(() =>
FIELDS.filter(({ key }) => INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const invalidDecimalFields = computed(() =>
FIELDS.filter(({ key }) => !INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
)
const limits = computed<LimitsConfig>(() => {
const out: LimitsConfig = {}
for (const { key } of FIELDS) {
const value = Number(raw[key])
if (raw[key].trim() !== '' && fieldValid(key, value) && value > 0) out[key] = value
}
return out
})
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
// route-path-segment safety rule); a user name only needs to be
// non-empty (auth.go's buildEntry, no character restriction at all —
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
const snippet = computed<string | null>(() => {
if (!nameValid.value || invalidFields.value.length > 0) return null
if (Object.keys(limits.value).length === 0) return null
return targetKind.value === 'group'
? groupLimitsSnippet(targetName.value.trim(), limits.value)
: userLimitsJsonFragment(limits.value)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="group">Group</SelectItem>
<SelectItem value="user">User (existing users.json entry)</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-col gap-1">
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
<label :for="`limits-${field.key}`" class="text-xs font-medium text-muted-foreground">{{ field.label }}</label>
<Input
:id="`limits-${field.key}`"
v-model="raw[field.key]"
type="number"
min="0"
:step="INTEGER_FIELDS.has(field.key) ? '1' : 'any'"
placeholder="unset"
/>
</div>
</div>
<Alert v-if="invalidFields.length" variant="destructive">
<AlertDescription>
<p v-if="invalidIntegerFields.length">Enter a non-negative whole number for: {{ invalidIntegerFields.join(', ') }}.</p>
<p v-if="invalidDecimalFields.length">Enter a non-negative number for: {{ invalidDecimalFields.join(', ') }}.</p>
</AlertDescription>
</Alert>
<SnippetBlock
v-if="snippet"
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
:text="snippet"
/>
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegativeInt, modelMetaSnippet } from '@/lib/snippets'
/**
* ModelMetaForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's contextTokens/free modelMeta entry — independent fields (unlike
* PricingForm.vue's paired input/output price), either or both may be
* set: contextTokens alone documents a window the upstream cannot report;
* free alone marks a self-hosted model billed as 0 regardless of any
* discovered/override price (pricing.go: modelMetaFree short-circuits
* before override/builtin/LiteLLM resolution).
*/
const modelId = ref('')
const contextRaw = ref('')
const free = ref(false)
const modelIdValid = computed(() => modelId.value.trim() !== '')
const contextValue = computed(() => Number(contextRaw.value))
const contextEntered = computed(() => contextRaw.value.trim() !== '')
// ModelMetaConfig.ContextTokens is Go `int` (llmgateway.go) — a fraction
// like "128000.5" fails json.Unmarshal outright, so isValidNonNegativeInt
// (not isValidNonNegative) is the right domain check here (P2 item 12).
const contextValid = computed(() => !contextEntered.value || (isValidNonNegativeInt(contextValue.value) && contextValue.value > 0))
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !contextValid.value) return null
if (!contextEntered.value && !free.value) return null
return modelMetaSnippet(modelId.value.trim(), {
contextTokens: contextEntered.value ? contextValue.value : undefined,
free: free.value || undefined,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="modelmeta-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="modelmeta-model-id" v-model="modelId" placeholder="gx10/current" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<div class="flex flex-col gap-1">
<label for="modelmeta-context" class="text-xs font-medium text-muted-foreground">Context window (tokens)</label>
<Input id="modelmeta-context" v-model="contextRaw" type="number" min="1" step="1" placeholder="131072" />
</div>
<label class="flex items-center gap-2 pb-1.5 text-sm">
<input id="modelmeta-free" v-model="free" type="checkbox" class="size-4 rounded border-input" />
Free (billed as $0 regardless of any resolved price)
</label>
</div>
<Alert v-if="contextEntered && !contextValid" variant="destructive">
<AlertDescription>Context window must be a positive whole number of tokens.</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and set a context window and/or free to build a snippet.</p>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { isValidNonNegative, pricingSnippet } from '@/lib/snippets'
/**
* PricingForm (ChangeHelper, redesign-plan.md section 3.4) builds a
* model's per-token pricing override — inputPerM/outputPerM, the two
* ModelPricing fields (llmgateway.go) that live in the top-level
* Config.Pricing map (`spec.plugin.llmgateway.pricing`) and OVERRIDE the
* built-in/LiteLLM-derived price for one model id (pricing.go's
* billingPriceSource: `override` always wins). This is a DIFFERENT config
* section from ModelMetaForm.vue's own modelMeta entry — ModelMeta's own
* cost fields drive metadata exposure only, never billing (see
* lib/snippets.ts's own ModelMeta doc comment). Both fields are required
* together — a one-sided price makes no billing sense — unlike
* ModelMetaForm.vue's independent contextTokens/free fields.
*/
const modelId = ref('')
const inputRaw = ref('')
const outputRaw = ref('')
const modelIdValid = computed(() => modelId.value.trim() !== '')
const inputValue = computed(() => Number(inputRaw.value))
const outputValue = computed(() => Number(outputRaw.value))
const inputValid = computed(() => inputRaw.value.trim() !== '' && isValidNonNegative(inputValue.value))
const outputValid = computed(() => outputRaw.value.trim() !== '' && isValidNonNegative(outputValue.value))
const errors = computed(() => {
const out: string[] = []
if (inputRaw.value.trim() !== '' && !inputValid.value) out.push('Input price/MTok must be a non-negative number.')
if (outputRaw.value.trim() !== '' && !outputValid.value) out.push('Output price/MTok must be a non-negative number.')
if ((inputValid.value && !outputValid.value) || (!inputValid.value && outputValid.value)) {
out.push('Set both input and output price/MTok — a one-sided override has no billing meaning.')
}
return out
})
const snippet = computed<string | null>(() => {
if (!modelIdValid.value || !inputValid.value || !outputValid.value) return null
return pricingSnippet(modelId.value.trim(), { inputPerM: inputValue.value, outputPerM: outputValue.value })
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="pricing-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
<Input id="pricing-model-id" v-model="modelId" placeholder="openai/gpt-4" class="font-mono" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="pricing-input" class="text-xs font-medium text-muted-foreground">Input price / MTok (USD)</label>
<Input id="pricing-input" v-model="inputRaw" type="number" min="0" step="any" placeholder="0.50" />
</div>
<div class="flex flex-col gap-1">
<label for="pricing-output" class="text-xs font-medium text-muted-foreground">Output price / MTok (USD)</label>
<Input id="pricing-output" v-model="outputRaw" type="number" min="0" step="any" placeholder="1.50" />
</div>
</div>
<Alert v-if="errors.length" variant="destructive">
<AlertDescription>
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Enter a model id and both prices to build a snippet.</p>
</div>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SnippetBlock from '@/components/SnippetBlock.vue'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
/**
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
* users.json line — name, apiKey (placeholder by default, Q10 DECISION;
* "Generate" produces a real client-side random key, never sent anywhere),
* a primary group (production's own single-`group` convention) plus an
* optional comma list of ADDITIONAL groups that switches the emitted field
* to plural `groups` (lib/snippets.ts's userJsonLine), an optional
* personal grant (validated the same way GrantForm.vue's user-target path
* is), and an admin checkbox.
*/
const props = defineProps<{ groupNames: string[] }>()
const name = ref('')
const apiKey = ref(API_KEY_PLACEHOLDER)
const primaryGroup = ref('')
const extraGroupsRaw = ref('')
const providersRaw = ref('')
const modelsRaw = ref('')
const admin = ref(false)
function generate(): void {
apiKey.value = generateApiKey()
}
function parseList(raw: string): string[] {
const seen = new Set<string>()
for (const part of raw.split(',')) {
const trimmed = part.trim()
if (trimmed) seen.add(trimmed)
}
return Array.from(seen)
}
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
const providers = computed(() => parseList(providersRaw.value))
const models = computed(() => parseList(modelsRaw.value))
const nameValid = computed(() => isValidUserName(name.value))
const groupValid = computed(() => primaryGroup.value !== '')
const apiKeyValid = computed(() => apiKey.value.trim() !== '' && apiKey.value !== API_KEY_PLACEHOLDER)
const grantError = computed(() => validatePersonalGrant(providers.value, models.value))
const snippet = computed<string | null>(() => {
if (!nameValid.value || !groupValid.value || !apiKeyValid.value || grantError.value) return null
const allGroups = extraGroups.value.length > 0 ? [primaryGroup.value, ...extraGroups.value] : null
return userJsonLine({
name: name.value.trim(),
apiKey: apiKey.value.trim(),
group: allGroups ? undefined : primaryGroup.value,
groups: allGroups ?? undefined,
providers: providers.value,
models: models.value,
admin: admin.value,
})
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="user-name" v-model="name" placeholder="alice" />
</div>
<div class="flex flex-col gap-1">
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="user-api-key" class="text-xs font-medium text-muted-foreground">API key</label>
<div class="flex gap-2">
<Input id="user-api-key" v-model="apiKey" class="font-mono" />
<Button type="button" variant="outline" size="sm" @click="generate">Generate</Button>
</div>
<p v-if="apiKey === API_KEY_PLACEHOLDER" class="text-xs text-muted-foreground">Still the placeholder — click Generate for a real key.</p>
</div>
<div class="flex flex-col gap-1">
<label for="user-extra-groups" class="text-xs font-medium text-muted-foreground">Additional groups (comma-separated, optional)</label>
<Input id="user-extra-groups" v-model="extraGroupsRaw" placeholder="friends" />
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1">
<label for="user-providers" class="text-xs font-medium text-muted-foreground">Personal grant: providers (optional)</label>
<Input id="user-providers" v-model="providersRaw" placeholder="gx10" />
</div>
<div class="flex flex-col gap-1">
<label for="user-models" class="text-xs font-medium text-muted-foreground">Personal grant: models (optional)</label>
<Input id="user-models" v-model="modelsRaw" placeholder="gx10/current" />
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="admin" type="checkbox" class="size-4 rounded border-input" />
Admin (full access to /admin/api/*)
</label>
<Alert v-if="grantError" variant="destructive">
<AlertDescription>{{ grantError }}</AlertDescription>
</Alert>
<SnippetBlock v-if="snippet" label="Add as a new line in users.json" :text="snippet" />
<p v-else class="text-sm text-muted-foreground">Fill in a name, group, and a real (generated) API key to build a snippet.</p>
</div>
</template>