WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail
(lettre), persisted job runs with log and status, JobRunner with per-kind
concurrency guard, 6-field cron schedules with defaults, scheduler loop.
Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines.

Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:16:56 +02:00
parent 6113af0a19
commit fa9ac9a6bc
31 changed files with 1632 additions and 58 deletions

View File

@ -59,4 +59,5 @@ export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
}

View File

@ -26,3 +26,44 @@ export interface UpdateUserPayload {
role?: Role
is_active?: boolean
}
export type SmtpSecurity = 'none' | 'starttls' | 'tls'
export interface SmtpView {
host: string
port: number
security: SmtpSecurity
username: string
from: string
notify_to: string[]
password_set: boolean
}
export interface SmtpPayload {
host: string
port: number
security: SmtpSecurity
username: string
password: string
from: string
notify_to: string[]
}
export interface ScheduleView {
kind: string
cron: string | null
default_cron: string | null
}
export type JobStatus = 'running' | 'success' | 'failed'
export interface JobRun {
id: string
kind: string
params?: string | null
status: JobStatus
started_at: string
finished_at: string | null
log: string
triggered_by: string
}

View File

@ -9,6 +9,7 @@ const nav = [
{ to: '/updates', label: 'Updates' },
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
{ to: '/backups', label: 'Backups' },
{ to: '/jobs', label: 'Jobs' },
{ to: '/users', label: 'Users', admin: true },
{ to: '/settings', label: 'Settings' },
]

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { SmtpPayload, SmtpSecurity, SmtpView } from '../api/types'
const props = defineProps<{ current?: SmtpView | null; busy?: boolean }>()
const emit = defineEmits<{ submit: [payload: SmtpPayload] }>()
const host = ref(props.current?.host ?? '')
const port = ref(props.current?.port ?? 587)
const security = ref<SmtpSecurity>(props.current?.security ?? 'starttls')
const username = ref(props.current?.username ?? '')
const password = ref('')
const from = ref(props.current?.from ?? '')
const notifyTo = ref(props.current?.notify_to.join(', ') ?? '')
function submit() {
emit('submit', {
host: host.value,
port: Number(port.value),
security: security.value,
username: username.value,
password: password.value,
from: from.value,
notify_to: notifyTo.value
.split(/[,;\s]+/)
.map((s) => s.trim())
.filter(Boolean),
})
}
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<form class="grid max-w-xl gap-4" @submit.prevent="submit">
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">
<label for="smtp-host" class="block text-sm font-medium">SMTP host</label>
<input id="smtp-host" v-model="host" name="host" required :class="input" />
</div>
<div>
<label for="smtp-port" class="block text-sm font-medium">Port</label>
<input
id="smtp-port"
v-model="port"
name="port"
type="number"
min="1"
max="65535"
required
:class="input"
/>
</div>
</div>
<div>
<label for="smtp-security" class="block text-sm font-medium">Security</label>
<select id="smtp-security" v-model="security" name="security" :class="input">
<option value="starttls">STARTTLS</option>
<option value="tls">TLS</option>
<option value="none">None</option>
</select>
</div>
<div>
<label for="smtp-user" class="block text-sm font-medium">Username</label>
<input id="smtp-user" v-model="username" name="username" :class="input" />
</div>
<div>
<label for="smtp-pass" class="block text-sm font-medium">Password</label>
<input
id="smtp-pass"
v-model="password"
name="password"
type="password"
autocomplete="new-password"
:class="input"
/>
<p v-if="current?.password_set" class="mt-1 text-xs text-gray-500">
A password is stored, leave empty to keep it.
</p>
</div>
<div>
<label for="smtp-from" class="block text-sm font-medium">From address</label>
<input id="smtp-from" v-model="from" name="from" type="email" required :class="input" />
</div>
<div>
<label for="smtp-to" class="block text-sm font-medium">Notification recipients</label>
<input
id="smtp-to"
v-model="notifyTo"
name="notify_to"
placeholder="ops@example.com, dev@example.com"
:class="input"
/>
</div>
<div>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save SMTP settings
</button>
</div>
</form>
</template>

View File

@ -5,7 +5,7 @@ import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
vi.mock('../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn() },
ApiError: class extends Error {},
}))

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { JobRun } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import StatusBadge from '../components/StatusBadge.vue'
const auth = useAuthStore()
const toast = useToastStore()
const runs = ref<JobRun[]>([])
const kinds = ref<string[]>([])
const kind = ref('')
const selected = ref<JobRun | null>(null)
let timer: ReturnType<typeof setInterval> | undefined
async function load() {
runs.value = await api.get<JobRun[]>('/api/jobs?limit=50')
if (selected.value)
selected.value = runs.value.find((r) => r.id === selected.value!.id) ?? selected.value
}
onMounted(async () => {
try {
kinds.value = await api.get<string[]>('/api/jobs/kinds')
kind.value = kinds.value[0] ?? ''
await load()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
timer = setInterval(() => load().catch(() => undefined), 3000)
})
onUnmounted(() => clearInterval(timer))
async function runNow() {
try {
const run = await api.post<JobRun>('/api/jobs/run', { kind: kind.value })
toast.success(`${run.kind} started`)
await load()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
}
const badge = (s: JobRun['status']) =>
s === 'success' ? 'ok' : s === 'failed' ? 'error' : 'unknown'
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '–')
</script>
<template>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Jobs</h1>
<div v-if="auth.isAdmin" class="flex items-end gap-2">
<div>
<label for="job-kind" class="block text-sm font-medium">Job</label>
<select
id="job-kind"
v-model="kind"
name="kind"
class="mt-1 rounded-md border border-gray-300 px-3 py-2"
>
<option v-for="k in kinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<button
type="button"
name="run"
:disabled="!kind"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="runNow"
>
Run now
</button>
</div>
</div>
<table class="mt-6 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Job</th>
<th>Status</th>
<th>Started</th>
<th>Finished</th>
<th>Triggered by</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id" class="border-b border-gray-100">
<td class="py-2 font-mono">{{ r.kind }}</td>
<td><StatusBadge :status="badge(r.status)" :label="r.status" /></td>
<td>{{ fmt(r.started_at) }}</td>
<td>{{ fmt(r.finished_at) }}</td>
<td>{{ r.triggered_by }}</td>
<td class="text-right">
<button class="text-blue-600 hover:underline" @click="selected = r">Log</button>
</td>
</tr>
<tr v-if="runs.length === 0">
<td colspan="6" class="py-6 text-center text-gray-500">No job runs yet.</td>
</tr>
</tbody>
</table>
<div v-if="selected" class="mt-6 rounded-md border border-gray-200 bg-white">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2 text-sm">
<span class="font-mono">{{ selected.kind }} · {{ fmt(selected.started_at) }}</span>
<button class="text-blue-600 hover:underline" @click="selected = null">Close</button>
</div>
<pre data-testid="job-log" class="max-h-96 overflow-auto p-4 text-xs">{{
selected.log || '(no output yet)'
}}</pre>
</div>
</template>

View File

@ -0,0 +1,145 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { ScheduleView, SmtpPayload, SmtpView } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import SmtpForm from '../components/SmtpForm.vue'
const auth = useAuthStore()
const toast = useToastStore()
const smtp = ref<SmtpView | null>(null)
const loaded = ref(false)
const busy = ref(false)
const testTo = ref('')
const schedules = ref<ScheduleView[]>([])
const cronInputs = ref<Record<string, string>>({})
const labels: Record<string, string> = {
package_refresh: 'Package refresh',
vulnerability_scan: 'Vulnerability scan',
}
function fail(e: unknown) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
async function load() {
const res = await api.get<{ configured: boolean; smtp: SmtpView | null }>('/api/settings/smtp')
smtp.value = res.smtp
schedules.value = await api.get<ScheduleView[]>('/api/settings/schedules')
cronInputs.value = Object.fromEntries(schedules.value.map((s) => [s.kind, s.cron ?? '']))
loaded.value = true
}
onMounted(() => load().catch(fail))
async function saveSmtp(payload: SmtpPayload) {
busy.value = true
try {
await api.put('/api/settings/smtp', payload)
toast.success('SMTP settings saved')
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function sendTest() {
busy.value = true
try {
await api.post('/api/settings/smtp/test', { to: testTo.value })
toast.success('Test mail sent')
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function saveSchedules() {
busy.value = true
try {
for (const s of schedules.value) {
const cron = cronInputs.value[s.kind].trim()
await api.put(`/api/settings/schedules/${s.kind}`, { cron: cron || null })
}
toast.success('Schedules saved')
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
</script>
<template>
<h1 class="text-2xl font-semibold">Settings</h1>
<section class="mt-8">
<h2 class="text-lg font-medium">E-mail (SMTP)</h2>
<p class="mb-4 text-sm text-gray-600">Used for vulnerability and backup notifications.</p>
<SmtpForm
v-if="loaded"
:key="smtp?.host ?? 'new'"
:current="smtp"
:busy="busy || !auth.isAdmin"
@submit="saveSmtp"
/>
<form
v-if="smtp && auth.isAdmin"
class="mt-4 flex max-w-xl items-end gap-2"
@submit.prevent="sendTest"
>
<div class="flex-1">
<label for="test-to" class="block text-sm font-medium">Send test mail to</label>
<input
id="test-to"
v-model="testTo"
type="email"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
:disabled="busy"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
>
Send test mail
</button>
</form>
</section>
<section class="mt-10">
<h2 class="text-lg font-medium">Schedules</h2>
<p class="mb-4 text-sm text-gray-600">
Cron expressions with seconds (sec min hour day month weekday). Empty disables the job.
</p>
<form class="grid max-w-xl gap-4" @submit.prevent="saveSchedules">
<div v-for="s in schedules" :key="s.kind">
<label :for="`cron-${s.kind}`" class="block text-sm font-medium"
>{{ labels[s.kind] ?? s.kind }} schedule</label
>
<input
:id="`cron-${s.kind}`"
v-model="cronInputs[s.kind]"
:placeholder="s.default_cron ?? ''"
:disabled="!auth.isAdmin"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
</div>
<div v-if="auth.isAdmin">
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save schedules
</button>
</div>
</form>
</section>
</template>

View File

@ -6,6 +6,8 @@ import LoginPage from './pages/LoginPage.vue'
import DashboardPage from './pages/DashboardPage.vue'
import UsersPage from './pages/UsersPage.vue'
import PlaceholderPage from './pages/PlaceholderPage.vue'
import SettingsPage from './pages/SettingsPage.vue'
import JobsPage from './pages/JobsPage.vue'
export const router = createRouter({
history: createWebHistory(),
@ -24,7 +26,8 @@ export const router = createRouter({
},
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
{ path: 'users', component: UsersPage, meta: { admin: true } },
{ path: 'settings', component: PlaceholderPage, props: { title: 'Settings' } },
{ path: 'jobs', component: JobsPage },
{ path: 'settings', component: SettingsPage },
],
},
],