docs/superpowers/plans/2026-05-23-self-signed-enhancements.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: (1) Extract the Custom Domains row-list editor into a shared StringListInput component and reuse it for the self-signed Domains / IP Addresses fields. (2) Show a renewal-policy hint in the self-signed form. (3) Require a non-empty Name for self-signed certificates on both client and server.
Architecture: New StringListInput lives in app/src/components/. SelfSignedCertFields gains a hideRenewalNote prop and required Name. Payload factories in three locations seed an empty editable row; three submit/save paths trim and filter before calling the API and now reject empty Name. Backend gains binding:"required" on SelfSignedCertRequest.Name and one new Go test.
Tech Stack: Vue 3 <script setup lang="ts">, Ant Design Vue, UnoCSS, project's $gettext helper. Go / Gin / Cosy for the backend; existing gin.New + httptest test pattern.
Spec: docs/superpowers/specs/2026-05-23-self-signed-enhancements-design.md
Prior context: Builds on commits 605c6fed1 and 19776d442 on the feature/self-signed-certificate branch.
app/: pnpm lint, pnpm lint:fix, pnpm typecheck. The perfectionist rule may reorder imports during lint:fix — accept it.ref, computed, watch, App.useApp, $gettext, useTemplateRef, storeToRefs. Match surrounding style.gofmt/goimports clean, go test ./... -race -cover for full sweep but only the touched packages need to pass.Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>feature/self-signed-certificate. Commit directly; do not branch.New:
app/src/components/StringListInput/StringListInput.vueapp/src/components/StringListInput/index.tsModified:
app/src/views/certificate/components/SelfSignedCertFields.vueapp/src/views/certificate/components/SelfSignedCertForm.vueapp/src/views/certificate/components/SelfSignedCertManagement.vueapp/src/views/certificate/components/DNSIssueCertificate.vueapp/src/views/certificate/CertificateEditor.vueapp/src/api/cert.tsapi/certificate/self_signed.goapi/certificate/self_signed_test.goFiles:
Create: app/src/components/StringListInput/StringListInput.vue
Create: app/src/components/StringListInput/index.ts
Step 1: Create the component file
Write app/src/components/StringListInput/StringListInput.vue exactly:
<script setup lang="ts">
defineProps<{
placeholder?: string
addButtonText?: string
}>()
const items = defineModel<string[]>({ required: true })
function addItem() {
items.value = [...items.value, '']
}
function removeItem(index: number) {
if (items.value.length <= 1)
return
const next = [...items.value]
next.splice(index, 1)
items.value = next
}
function updateItem(index: number, value: string) {
const next = [...items.value]
next[index] = value
items.value = next
}
</script>
<template>
<div class="space-y-2">
<div
v-for="(item, index) in items"
:key="index"
class="flex items-center gap-2"
>
<AInput
:value="item"
:placeholder="placeholder"
class="flex-1"
@update:value="(value: string) => updateItem(index, value)"
/>
<AButton
v-if="items.length > 1"
type="link"
danger
@click="removeItem(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addItem"
>
{{ addButtonText ?? $gettext('Add Item') }}
</AButton>
</div>
</template>
Notes:
Uses defineModel<string[]>({ required: true }) — Vue 3.4+ idiom, already used elsewhere in this project.
updateItem swaps via spread to keep the reactive identity stable (mirrors how the original Custom Domains template mutates customDomains[index] via two-way binding; we go through an explicit setter so the model emits cleanly).
Step 2: Create the re-export
Write app/src/components/StringListInput/index.ts:
import StringListInput from './StringListInput.vue'
export default StringListInput
cd app && pnpm lint && pnpm typecheck
Expected: both exit 0. If lint flags ordering, run pnpm lint:fix then re-check.
git add app/src/components/StringListInput/
git commit -m "$(cat <<'EOF'
feat(ui): add StringListInput component
Reusable multi-row text input with Add/Remove buttons. Used in the
upcoming refactor of Custom Domains and self-signed Domains / IP
Addresses editors so all three share a single editor pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Doing the backend first means the frontend changes in later tasks are validated against the new server contract end-to-end.
Files:
Modify: api/certificate/self_signed.go
Modify: api/certificate/self_signed_test.go
Step 1: Add binding:"required" to Name
In api/certificate/self_signed.go, change the struct definition:
type SelfSignedCertRequest struct {
Name string `json:"name"`
to:
type SelfSignedCertRequest struct {
Name string `json:"name" binding:"required"`
(Only the Name field's tag changes; the other fields stay as-is.)
Append a new test to api/certificate/self_signed_test.go. Use the same pattern as the existing rollback test (which sets up setupSelfSignedAPITest, creates a gin router, marshals a SelfSignedCertRequest, POSTs via httptest).
func TestGenerateSelfSignedCertRejectsEmptyName(t *testing.T) {
setupSelfSignedAPITest(t)
router := gin.New()
router.POST("/self_signed_cert", GenerateSelfSignedCert)
body, err := json.Marshal(SelfSignedCertRequest{
Domains: []string{"named.example"},
KeyType: string(certcrypto.EC256),
ValidityDays: 30,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/self_signed_cert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code < 400 || rec.Code >= 500 {
t.Fatalf("status = %d, want a 4xx for missing name", rec.Code)
}
if !strings.Contains(strings.ToLower(rec.Body.String()), "name") {
t.Fatalf("response body %q did not mention the missing name field", rec.Body.String())
}
}
Check existing imports in the test file. If bytes, http, httptest, strings are not already imported, add them. The rollback test already imports bytes, encoding/json, net/http, net/http/httptest, and gin, so most should be present — only strings may need adding.
go test ./api/certificate/ -run TestGenerateSelfSignedCertRejectsEmptyName -race
Expected: PASS.
go test ./api/certificate/ ./internal/cert/ -race
Expected: all PASS. The existing TestGenerateSelfSignedCertRollsBackDBOnFileWriteFailure already sends Name: "rollback-test" so it's unaffected; buildSelfSignedOptions unit tests don't go through binding validation.
gofmt -w api/certificate/self_signed.go api/certificate/self_signed_test.go
goimports -w api/certificate/self_signed.go api/certificate/self_signed_test.go 2>/dev/null || true
(If goimports isn't installed locally that's fine; gofmt is the hard gate.)
git add api/certificate/self_signed.go api/certificate/self_signed_test.go
git commit -m "$(cat <<'EOF'
feat(cert): require Name when generating self-signed certificates
Adds binding:"required" to SelfSignedCertRequest.Name so an empty name
is rejected at the request boundary, and covers the contract with a
new API-level test.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
Modify: app/src/views/certificate/components/SelfSignedCertFields.vue
Step 1: Replace the component file
Replace the entire contents of app/src/views/certificate/components/SelfSignedCertFields.vue with:
<script setup lang="ts">
import type { SelfSignedCertPayload } from '@/api/cert'
import NodeSelector from '@/components/NodeSelector'
import StringListInput from '@/components/StringListInput'
import { PrivateKeyTypeList } from '@/constants'
const props = defineProps<{
isKeyTypeReadonly?: boolean
hideRenewalNote?: boolean
}>()
const data = defineModel<SelfSignedCertPayload>({ required: true })
</script>
<template>
<AForm layout="vertical">
<AAlert
v-if="!props.hideRenewalNote"
class="mb-4"
type="info"
show-icon
:message="$gettext('Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate\'s validity period.')"
/>
<AFormItem
:label="$gettext('Name')"
required
>
<AInput
v-model:value="data.name"
:placeholder="$gettext('Enter certificate name')"
/>
</AFormItem>
<AFormItem :label="$gettext('Domains')">
<StringListInput
v-model="data.domains"
:placeholder="$gettext('Enter domain name')"
:add-button-text="$gettext('Add Domain')"
/>
</AFormItem>
<AFormItem :label="$gettext('IP Addresses')">
<StringListInput
v-model="data.ip_addresses"
:placeholder="$gettext('Enter IP address')"
:add-button-text="$gettext('Add IP Address')"
/>
</AFormItem>
<AFormItem :label="$gettext('Key Type')">
<ASelect
v-model:value="data.key_type"
:disabled="props.isKeyTypeReadonly"
>
<ASelectOption
v-for="t in PrivateKeyTypeList"
:key="t.key"
:value="t.key"
>
{{ t.name }}
</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem :label="$gettext('Valid For (days)')">
<AInputNumber
v-model:value="data.validity_days"
:min="1"
:max="3650"
class="w-full"
/>
<template #help>
{{ $gettext('Some browsers reject TLS certificates valid for more than 398 days.') }}
</template>
</AFormItem>
<AFormItem :label="$gettext('Sync to')">
<NodeSelector
v-model:target="data.sync_node_ids"
hidden-local
/>
</AFormItem>
</AForm>
</template>
Substantive changes vs. the original file:
Added StringListInput import.
Added hideRenewalNote?: boolean prop alongside isKeyTypeReadonly.
Removed the previous Name placeholder text Optional and added the required attribute on the <AFormItem> for the asterisk.
Replaced the Domains and IP Addresses <ASelect mode="tags"> blocks with StringListInput.
Added the renewal AAlert immediately under <AForm>, gated by !hideRenewalNote.
Step 2: Lint and typecheck
cd app && pnpm lint && pnpm typecheck
Expected: both 0. lint:fix if needed.
git add app/src/views/certificate/components/SelfSignedCertFields.vue
git commit -m "$(cat <<'EOF'
feat(cert): unify self-signed editor and surface renewal hint
Switch Domains and IP Addresses to the shared StringListInput so all
self-signed field editors match the Custom Domains pattern. Add an
auto-renewal hint (suppressible via hideRenewalNote) and mark Name as
required to match the new backend contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
app/src/views/certificate/components/SelfSignedCertManagement.vueThis view already shows its own type="success" "managed by Nginx UI and renewed automatically" alert. We don't want both.
hide-renewal-note attributeIn app/src/views/certificate/components/SelfSignedCertManagement.vue, find the <SelfSignedCertFields> element. Replace:
<SelfSignedCertFields
v-model="data"
is-key-type-readonly
/>
with:
<SelfSignedCertFields
v-model="data"
is-key-type-readonly
hide-renewal-note
/>
cd app && pnpm lint && pnpm typecheck
Expected: both 0.
git add app/src/views/certificate/components/SelfSignedCertManagement.vue
git commit -m "$(cat <<'EOF'
chore(cert): suppress duplicate renewal alert in cert editor
SelfSignedCertManagement already has its own renewal-status alert;
pass hide-renewal-note to SelfSignedCertFields to avoid showing two
adjacent alerts saying the same thing.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files:
app/src/api/cert.tsapp/src/views/certificate/components/SelfSignedCertForm.vueapp/src/views/certificate/components/DNSIssueCertificate.vueapp/src/views/certificate/CertificateEditor.vueThe new StringListInput keeps an empty placeholder row in the model array. Each writing path needs to (a) seed [''] for empty arrays so the editor renders an empty row, and (b) trim + filter + validate Name before sending.
toSelfSignedPayload in app/src/api/cert.tsFind:
export function toSelfSignedPayload(c: Cert): SelfSignedCertPayload {
return {
name: c.name ?? '',
domains: [...(c.domains ?? [])],
ip_addresses: [...(c.self_signed_config?.ip_addresses ?? [])],
key_type: c.key_type || PrivateKeyTypeEnum.P256,
validity_days: c.self_signed_config?.validity_days || 365,
sync_node_ids: [...(c.sync_node_ids ?? [])],
}
}
Replace with:
export function toSelfSignedPayload(c: Cert): SelfSignedCertPayload {
const domains = c.domains?.length ? [...c.domains] : ['']
const ipAddresses = c.self_signed_config?.ip_addresses?.length
? [...c.self_signed_config.ip_addresses]
: ['']
return {
name: c.name ?? '',
domains,
ip_addresses: ipAddresses,
key_type: c.key_type || PrivateKeyTypeEnum.P256,
validity_days: c.self_signed_config?.validity_days || 365,
sync_node_ids: [...(c.sync_node_ids ?? [])],
}
}
SelfSignedCertForm.vueFind emptyForm():
function emptyForm(): SelfSignedCertPayload {
return {
name: '',
domains: [...(props.defaultDomains ?? [])],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
Replace with:
function emptyForm(): SelfSignedCertPayload {
const defaultDomains = props.defaultDomains ?? []
return {
name: '',
domains: defaultDomains.length ? [...defaultDomains] : [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
Find submit():
async function submit() {
if (form.value.domains.length === 0 && form.value.ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
loading.value = true
try {
const created = await cert.generate_self_signed(form.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('created', created)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
loading.value = false
}
}
Replace with:
async function submit() {
const name = (form.value.name ?? '').trim()
const domains = form.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = form.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
loading.value = true
try {
const created = await cert.generate_self_signed({
...form.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('created', created)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
loading.value = false
}
}
DNSIssueCertificate.vueFind emptySelfSignedPayload():
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
Replace with:
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
Find submitSelfSigned():
async function submitSelfSigned() {
const { domains, ip_addresses } = selfSignedPayload.value
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed(selfSignedPayload.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
Replace with:
async function submitSelfSigned() {
const name = (selfSignedPayload.value.name ?? '').trim()
const domains = selfSignedPayload.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = selfSignedPayload.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed({
...selfSignedPayload.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
CertificateEditor.vueFind the save() function. The current isSelfSigned branch (around lines 58-66) is:
async function save() {
try {
let savedId = data.value.id
if (isSelfSigned.value && selfSignedPayload.value && data.value.id) {
const currentId = data.value.id
const result = await cert.modify_self_signed(currentId, selfSignedPayload.value)
savedId = result.id || currentId
data.value = { ...result, id: savedId }
}
else {
await certStore.save()
savedId = data.value.id
}
Modify the isSelfSigned branch so it trims and validates before calling the API. Replace lines from if (isSelfSigned.value && ...) through the closing } of that branch with:
if (isSelfSigned.value && selfSignedPayload.value && data.value.id) {
const payload = selfSignedPayload.value
const name = (payload.name ?? '').trim()
const domains = payload.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = payload.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
const currentId = data.value.id
const result = await cert.modify_self_signed(currentId, {
...payload,
name,
domains,
ip_addresses,
})
savedId = result.id || currentId
data.value = { ...result, id: savedId }
}
(Leave the else branch and the rest of save() exactly as it is.)
cd app && pnpm lint && pnpm typecheck
Expected: both 0.
git add app/src/api/cert.ts \
app/src/views/certificate/components/SelfSignedCertForm.vue \
app/src/views/certificate/components/DNSIssueCertificate.vue \
app/src/views/certificate/CertificateEditor.vue
git commit -m "$(cat <<'EOF'
feat(cert): seed and filter self-signed payloads, validate Name
StringListInput preserves empty placeholder rows for editing; seed
arrays with [''] in toSelfSignedPayload / emptySelfSignedPayload /
emptyForm so the editor always renders an empty row to type into.
Each submit/save path trims and filters the arrays before sending and
now rejects an empty Name client-side to match the new server contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
This is a pure refactor — same UX, less duplication.
Files:
Modify: app/src/views/certificate/components/DNSIssueCertificate.vue
Step 1: Add the import
Inside the <script setup> block, add (next to the existing SelfSignedCertFields import):
import StringListInput from '@/components/StringListInput'
In the <template> block, find:
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
<div class="space-y-2">
<div
v-for="(_, index) in customDomains"
:key="index"
class="flex items-center gap-2"
>
<AInput
v-model:value="customDomains[index]"
:placeholder="$gettext('Enter domain name')"
class="flex-1"
/>
<AButton
v-if="customDomains.length > 1"
type="link"
danger
@click="removeCustomDomain(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addCustomDomain"
>
{{ $gettext('Add Domain') }}
</AButton>
</div>
<AAlert
:message="$gettext('All selected subdomains must belong to the same DNS Provider, otherwise the certificate application will fail.')"
type="info"
show-icon
banner
class="mt-3"
/>
</AFormItem>
</template>
Replace with:
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
<StringListInput
v-model="customDomains"
:placeholder="$gettext('Enter domain name')"
:add-button-text="$gettext('Add Domain')"
/>
<AAlert
:message="$gettext('All selected subdomains must belong to the same DNS Provider, otherwise the certificate application will fail.')"
type="info"
show-icon
banner
class="mt-3"
/>
</AFormItem>
</template>
addCustomDomain and removeCustomDomain helpersIn the <script setup> block, find and delete:
function addCustomDomain() {
customDomains.value.push('')
}
function removeCustomDomain(index: number) {
if (customDomains.value.length > 1) {
customDomains.value.splice(index, 1)
}
}
(Keep the customDomains ref and the computedDomains / computedMainDomain references — they're still used by issueCert.)
cd app && pnpm lint && pnpm typecheck
Expected: both 0. pnpm typecheck should catch any orphaned references to addCustomDomain / removeCustomDomain.
git add app/src/views/certificate/components/DNSIssueCertificate.vue
git commit -m "$(cat <<'EOF'
refactor(cert): use StringListInput for Custom Domains
Drop the inline multi-row template + add/remove helpers in favour of
the shared StringListInput component, matching the editor used by the
self-signed branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Files: none modified.
cd app && pnpm lint && pnpm typecheck
Expected: both 0.
cd .. && go test ./api/certificate/... ./internal/cert/... -race
Expected: all PASS, including the new TestGenerateSelfSignedCertRejectsEmptyName.
(Hand off to the human — they boot the app, you don't.)
Confirm:
Enter certificate name.AInput + Add Domain block button.Please enter a name for the certificate.Please enter at least one domain or IP address.No commit produced by this task.
feature/self-signed-certificate.pnpm lint, pnpm typecheck, and the Go test sweep are all green on the final tree.git status is clean.