WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
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:
@ -19,6 +19,8 @@ export default defineConfig({
|
||||
env: {
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
JWT_SECRET: 'e2e-test-secret-do-not-use-in-production',
|
||||
MASTER_KEY: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
|
||||
FAKE_HOST: 'true',
|
||||
BOOTSTRAP_ADMIN_EMAIL: 'admin@example.com',
|
||||
BOOTSTRAP_ADMIN_PASSWORD: 'admin-password-123',
|
||||
BIND: '127.0.0.1:8080',
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
104
frontend/src/components/SmtpForm.vue
Normal file
104
frontend/src/components/SmtpForm.vue
Normal 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>
|
||||
@ -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 {},
|
||||
}))
|
||||
|
||||
|
||||
114
frontend/src/pages/JobsPage.vue
Normal file
114
frontend/src/pages/JobsPage.vue
Normal 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>
|
||||
145
frontend/src/pages/SettingsPage.vue
Normal file
145
frontend/src/pages/SettingsPage.vue
Normal 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>
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user