963 lines
56 KiB
HTML
963 lines
56 KiB
HTML
{% extends "layouts/base.html" %}
|
|
{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
|
|
{% from "components/ui/button.html" import button %}
|
|
{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
|
|
{% from "components/ui/input.html" import input, label, form_group %}
|
|
|
|
{% block title %}Settings - DMARQ{% endblock %}
|
|
|
|
{% block page_title %}Settings{% endblock %}
|
|
|
|
{% block content %}
|
|
<div
|
|
x-data="settingsApp()"
|
|
x-init="loadSettings()"
|
|
class="space-y-6 py-4"
|
|
>
|
|
|
|
<!-- Flash message -->
|
|
<template x-if="flashMsg">
|
|
<div :class="flashOk ? 'alert alert-success' : 'alert alert-error'" class="shadow-sm">
|
|
<span x-text="flashMsg"></span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── General ──────────────────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}General{% endcall %}
|
|
{% call card_description() %}Basic application settings{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('general')" class="space-y-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Application Name</span></label>
|
|
<input type="text" x-model="s['general.app_name']"
|
|
class="input input-bordered w-full"
|
|
placeholder="DMARQ" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">Display name shown in the navigation bar and page titles</span></label>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Base URL</span></label>
|
|
<input type="url" x-model="s['general.base_url']"
|
|
class="input input-bordered w-full"
|
|
placeholder="https://dmarc.example.com" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">Public URL used in OAuth2 redirect URIs and email links</span></label>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Reports Per Page</span></label>
|
|
<input type="number" x-model.number="s['general.reports_per_page']"
|
|
class="input input-bordered w-full" min="5" max="200" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">How many reports are shown per page</span></label>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Session Lifetime (minutes)</span></label>
|
|
<input type="number" x-model.number="s['general.session_lifetime_minutes']"
|
|
class="input input-bordered w-full" min="5" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">How long login sessions remain valid</span></label>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save General Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── DMARC Policy Defaults ─────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}DMARC Policy Defaults{% endcall %}
|
|
{% call card_description() %}Default values applied when adding a new domain{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('dmarc')" class="space-y-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Default Policy</span></label>
|
|
<select x-model="s['dmarc.default_policy']" class="input input-bordered w-full">
|
|
<option value="none">None (monitoring only)</option>
|
|
<option value="quarantine">Quarantine (send to spam)</option>
|
|
<option value="reject">Reject (block delivery)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label">
|
|
<span class="label-text font-medium">Default Percentage</span>
|
|
<span class="label-text-alt" x-text="(s['dmarc.default_percentage'] || 100) + '%'"></span>
|
|
</label>
|
|
<input type="range" x-model.number="s['dmarc.default_percentage']"
|
|
min="0" max="100"
|
|
class="range range-primary w-full" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">Percentage of messages to which the policy is applied (pct tag)</span></label>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">DKIM Alignment (adkim)</span></label>
|
|
<select x-model="s['dmarc.default_adkim']" class="input input-bordered w-full">
|
|
<option value="r">Relaxed</option>
|
|
<option value="s">Strict</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">SPF Alignment (aspf)</span></label>
|
|
<select x-model="s['dmarc.default_aspf']" class="input input-bordered w-full">
|
|
<option value="r">Relaxed</option>
|
|
<option value="s">Strict</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save DMARC Defaults
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── DNS Resolver ──────────────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}DNS Resolver{% endcall %}
|
|
{% call card_description() %}Choose how DMARQ resolves DNS records for domain lookups{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('dns')" class="space-y-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">DNS Resolver</span></label>
|
|
<select x-model="s['dns.resolver']" class="input input-bordered w-full">
|
|
<option value="system">System (OS default)</option>
|
|
<option value="cloudflare">Cloudflare DoH (1.1.1.1)</option>
|
|
</select>
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">System resolver uses the OS-configured DNS server; Cloudflare uses DNS-over-HTTPS</span></label>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save DNS Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── Cloudflare Integration ─────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Cloudflare Integration{% endcall %}
|
|
{% call card_description() %}
|
|
Provide a Cloudflare API token and Zone ID to enable automated DNS record management and DoH lookups.
|
|
Obtain your token from <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="underline">Cloudflare API Tokens</a>.
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('cloudflare')" class="space-y-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">API Token</span></label>
|
|
<div class="relative">
|
|
<input :type="showCfToken ? 'text' : 'password'"
|
|
x-model="s['cloudflare.api_token']"
|
|
class="input input-bordered w-full pr-10"
|
|
placeholder="Your Cloudflare API token" />
|
|
<button type="button"
|
|
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
|
|
@click="showCfToken = !showCfToken">
|
|
<svg x-show="!showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
|
<svg x-show="showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
|
</button>
|
|
</div>
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">Stored securely; leave as-is to keep existing token</span></label>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Zone ID</span></label>
|
|
<input type="text" x-model="s['cloudflare.zone_id']"
|
|
class="input input-bordered w-full"
|
|
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">Optional. Leave blank to discover all zones available to the token.</span></label>
|
|
</div>
|
|
<div class="border-t border-border pt-4 space-y-4">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 class="text-sm font-semibold">Domain Discovery</h3>
|
|
<p class="text-sm text-muted-foreground">Find Cloudflare zones and import them into DMARQ.</p>
|
|
</div>
|
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
<button type="button" class="btn btn-outline btn-sm" :disabled="saving || loadingCfZones" @click="discoverCloudflareZones()">
|
|
<template x-if="!loadingCfZones">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
|
<path d="M3 3v5h5"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="loadingCfZones"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Discover
|
|
</button>
|
|
<button type="button" class="btn btn-default btn-sm" :disabled="saving || importingCfZones || cfZones.length === 0" @click="importCloudflareZones()">
|
|
<template x-if="!importingCfZones">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M12 5v14"></path>
|
|
<path d="m19 12-7 7-7-7"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="importingCfZones"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Import New
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<template x-if="cfZones.length > 0">
|
|
<div class="overflow-x-auto rounded-md border border-border">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Domain</th>
|
|
<th>Status</th>
|
|
<th>Account</th>
|
|
<th>Imported</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="zone in cfZones" :key="zone.id">
|
|
<tr>
|
|
<td class="font-medium" x-text="zone.name"></td>
|
|
<td x-text="zone.status || 'unknown'"></td>
|
|
<td x-text="zone.account_name || ''"></td>
|
|
<td>
|
|
<span class="badge" :class="zone.imported ? 'badge-success' : 'badge-outline'" x-text="zone.imported ? 'Yes' : 'No'"></span>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save Cloudflare Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── Forensics ──────────────────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Forensic Reports{% endcall %}
|
|
{% call card_description() %}Control privacy for forensic report metadata{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('forensics')" class="space-y-4">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Email Redaction</span></label>
|
|
<select x-model="s['forensics.redaction_mode']"
|
|
class="select select-bordered w-full">
|
|
<option value="balanced">Balanced</option>
|
|
<option value="domain_only">Domain only</option>
|
|
<option value="strict">Strict</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['forensics.redact_long_tokens_enabled'] === 'true'"
|
|
@change="s['forensics.redact_long_tokens_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Redact long tokens</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save Forensic Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── Notifications ──────────────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Notifications{% endcall %}
|
|
{% call card_description() %}Send alerts through Apprise-compatible targets{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.apprise_enabled'] === 'true'"
|
|
@change="s['notifications.apprise_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Enable notifications</span>
|
|
</label>
|
|
</div>
|
|
|
|
<template x-if="s['notifications.apprise_enabled'] === 'true'">
|
|
<div class="space-y-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Apprise Target URLs</span></label>
|
|
<textarea x-model="s['notifications.apprise_urls']"
|
|
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
|
|
placeholder="mailto://user:password@example.com slack://token-a/token-b/token-c/channel"></textarea>
|
|
<label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are encrypted at rest and redacted after saving.</span></label>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Minimum Send Interval</span></label>
|
|
<input type="number" x-model.number="s['notifications.min_send_interval_minutes']"
|
|
class="input input-bordered w-full" min="0" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.redact_pii_enabled'] === 'true'"
|
|
@change="s['notifications.redact_pii_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Redact email addresses in messages</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-border pt-4 space-y-4">
|
|
<div>
|
|
<h3 class="text-sm font-semibold">Alert Rules</h3>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.alert_new_sources_enabled'] === 'true'"
|
|
@change="s['notifications.alert_new_sources_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">New sending sources</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.alert_missing_reports_enabled'] === 'true'"
|
|
@change="s['notifications.alert_missing_reports_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Missing reports</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.alert_failure_threshold_enabled'] === 'true'"
|
|
@change="s['notifications.alert_failure_threshold_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">High DMARC failures</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.alert_compliance_drop_enabled'] === 'true'"
|
|
@change="s['notifications.alert_compliance_drop_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Compliance drops</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Failure Threshold</span></label>
|
|
<input type="number" x-model.number="s['notifications.alert_failure_threshold_count']"
|
|
class="input input-bordered w-full" min="1" />
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Missing After Days</span></label>
|
|
<input type="number" x-model.number="s['notifications.alert_missing_reports_days']"
|
|
class="input input-bordered w-full" min="1" />
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Drop Points</span></label>
|
|
<input type="number" x-model.number="s['notifications.alert_compliance_drop_points']"
|
|
class="input input-bordered w-full" min="1" max="100" />
|
|
</div>
|
|
</div>
|
|
|
|
<template x-if="alertPreview !== null">
|
|
<div class="alert" :class="alertPreview > 0 ? 'alert-warning' : 'alert-success'">
|
|
<span x-text="alertPreview > 0 ? `${alertPreview} active alert${alertPreview === 1 ? '' : 's'} found.` : 'No active alerts found.'"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="border-t border-border pt-4 space-y-4">
|
|
<div>
|
|
<h3 class="text-sm font-semibold">Summary Notifications</h3>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.summary_daily_enabled'] === 'true'"
|
|
@change="s['notifications.summary_daily_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Daily summary</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label cursor-pointer justify-start gap-3">
|
|
<input type="checkbox"
|
|
:checked="s['notifications.summary_weekly_enabled'] === 'true'"
|
|
@change="s['notifications.summary_weekly_enabled'] = $event.target.checked ? 'true' : 'false'"
|
|
class="checkbox checkbox-primary" />
|
|
<span class="label-text font-medium">Weekly summary</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Send Hour (UTC)</span></label>
|
|
<input type="number" x-model.number="s['notifications.summary_send_hour_utc']"
|
|
class="input input-bordered w-full" min="0" max="23" />
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Weekly Day</span></label>
|
|
<select x-model="s['notifications.summary_weekday_utc']" class="input input-bordered w-full">
|
|
<option value="0">Monday</option>
|
|
<option value="1">Tuesday</option>
|
|
<option value="2">Wednesday</option>
|
|
<option value="3">Thursday</option>
|
|
<option value="4">Friday</option>
|
|
<option value="5">Saturday</option>
|
|
<option value="6">Sunday</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control w-full">
|
|
<label class="label"><span class="label-text font-medium">Preview Period</span></label>
|
|
<select x-model="summaryPeriod" class="input input-bordered w-full">
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<template x-if="summaryPreview">
|
|
<div class="alert alert-info">
|
|
<span x-text="`${summaryPreview.total_messages} messages, ${summaryPreview.reports_processed} reports, ${summaryPreview.alerts.length} active alerts.`"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="border-t border-border pt-4 space-y-4">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h3 class="text-sm font-semibold">Alert History</h3>
|
|
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingAlertHistory" @click="loadAlertHistory()">
|
|
<template x-if="!loadingAlertHistory">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
|
<path d="M3 3v5h5"></path>
|
|
<path d="M12 7v5l4 2"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="loadingAlertHistory"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<template x-if="alertHistory.length === 0">
|
|
<div class="alert alert-info">
|
|
<span>No alert history yet.</span>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-2" x-show="alertHistory.length > 0">
|
|
<template x-for="item in alertHistory" :key="item.id">
|
|
<div class="rounded-md border border-border p-3">
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<div class="text-sm font-semibold" x-text="item.title"></div>
|
|
<div class="text-sm text-muted-foreground" x-text="item.detail"></div>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="badge" :class="item.is_active ? 'badge-warning' : 'badge-ghost'" x-text="item.is_active ? 'Active' : 'Resolved'"></span>
|
|
<span class="badge badge-outline" x-text="item.observed_count + 'x'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-border pt-4 space-y-4">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h3 class="text-sm font-semibold">Configuration Audit</h3>
|
|
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingConfigAudit" @click="loadConfigAudit()">
|
|
<template x-if="!loadingConfigAudit">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
|
<path d="M3 3v5h5"></path>
|
|
<path d="M12 7v5l4 2"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="loadingConfigAudit"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<template x-if="configAudit.length === 0">
|
|
<div class="alert alert-info">
|
|
<span>No configuration changes recorded yet.</span>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-2" x-show="configAudit.length > 0">
|
|
<template x-for="item in configAudit" :key="item.id">
|
|
<div class="rounded-md border border-border p-3">
|
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<div class="text-sm font-semibold" x-text="item.key"></div>
|
|
<div class="text-sm text-muted-foreground" x-text="`${item.old_value || '(empty)'} -> ${item.new_value || '(empty)'}`"></div>
|
|
</div>
|
|
<div class="text-xs text-muted-foreground" x-text="new Date(item.changed_at).toLocaleString()"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row justify-end gap-2">
|
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || previewingSummary" @click="previewSummary()">
|
|
<template x-if="!previewingSummary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M3 3v18h18"></path>
|
|
<path d="M18 17V9"></path>
|
|
<path d="M13 17V5"></path>
|
|
<path d="M8 17v-3"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="previewingSummary"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Preview Summary
|
|
</button>
|
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingSummary" @click="sendSummaryNow()">
|
|
<template x-if="!sendingSummary">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M3 3v18h18"></path>
|
|
<path d="m19 9-5 5-4-4-3 3"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="sendingSummary"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Send Summary Now
|
|
</button>
|
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || checkingAlerts" @click="checkAlerts()">
|
|
<template x-if="!checkingAlerts">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="checkingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Check Alerts
|
|
</button>
|
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingAlerts" @click="sendAlertSummary()">
|
|
<template x-if="!sendingAlerts">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M10.268 21a2 2 0 0 0 3.464 0"></path>
|
|
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="sendingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Send Alerts Now
|
|
</button>
|
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || testingNotification" @click="sendTestNotification()">
|
|
<template x-if="!testingNotification">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="m22 2-7 20-4-9-9-4Z"></path>
|
|
<path d="M22 2 11 13"></path>
|
|
</svg>
|
|
</template>
|
|
<template x-if="testingNotification"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Send Test
|
|
</button>
|
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
|
<template x-if="!saving">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
</template>
|
|
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
|
Save Notification Settings
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
<!-- ── Mail Sources shortcut ──────────────────────────────────────────── -->
|
|
{% call card() %}
|
|
{% call card_header() %}
|
|
{% call card_title() %}Mail Sources{% endcall %}
|
|
{% call card_description() %}
|
|
IMAP, POP3 and Gmail API inbox credentials are managed on the dedicated
|
|
<strong>Mail Sources</strong> page.
|
|
{% endcall %}
|
|
{% endcall %}
|
|
{% call card_content() %}
|
|
<a href="/mail-sources" class="btn btn-default btn-md">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
|
<polyline points="22,6 12,13 2,6"></polyline>
|
|
</svg>
|
|
Manage Mail Sources
|
|
</a>
|
|
{% endcall %}
|
|
{% endcall %}
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function settingsApp() {
|
|
return {
|
|
s: {}, // flat map of key → value (strings)
|
|
saving: false,
|
|
flashMsg: '',
|
|
flashOk: true,
|
|
testingNotification: false,
|
|
checkingAlerts: false,
|
|
sendingAlerts: false,
|
|
alertPreview: null,
|
|
previewingSummary: false,
|
|
sendingSummary: false,
|
|
summaryPeriod: 'daily',
|
|
summaryPreview: null,
|
|
loadingAlertHistory: false,
|
|
alertHistory: [],
|
|
loadingConfigAudit: false,
|
|
configAudit: [],
|
|
loadingCfZones: false,
|
|
importingCfZones: false,
|
|
cfZones: [],
|
|
showCfToken: false,
|
|
|
|
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
|
// No manual auth header needed for API calls from the UI.
|
|
apiHeaders() {
|
|
return { 'Content-Type': 'application/json' };
|
|
},
|
|
|
|
async loadSettings() {
|
|
try {
|
|
const res = await fetch('/api/v1/settings', { headers: this.apiHeaders() });
|
|
if (res.status === 401 || res.status === 403) {
|
|
window.location.href = '/login?next=/settings';
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
this.showFlash('Failed to load settings: ' + res.statusText, false);
|
|
return;
|
|
}
|
|
const rows = await res.json();
|
|
const map = {};
|
|
rows.forEach(r => { map[r.key] = r.value ?? ''; });
|
|
this.s = map;
|
|
await this.loadAlertHistory(false);
|
|
await this.loadConfigAudit(false);
|
|
} catch (err) {
|
|
this.showFlash('Error loading settings: ' + err.message, false);
|
|
}
|
|
},
|
|
|
|
async saveCategory(category) {
|
|
this.saving = true;
|
|
// Collect all keys that belong to this category
|
|
const categoryKeys = Object.keys(this.s).filter(k => k.startsWith(category + '.'));
|
|
const settings = {};
|
|
categoryKeys.forEach(k => { settings[k] = String(this.s[k] ?? ''); });
|
|
try {
|
|
const res = await fetch('/api/v1/settings/bulk', {
|
|
method: 'POST',
|
|
headers: this.apiHeaders(),
|
|
body: JSON.stringify({ settings }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
this.showFlash('Save failed: ' + (data.detail || res.statusText), false);
|
|
} else {
|
|
const rows = await res.json();
|
|
rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
|
|
if (category === 'notifications') {
|
|
await this.loadConfigAudit(false);
|
|
}
|
|
this.showFlash('Settings saved successfully.', true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error saving settings: ' + err.message, false);
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
async sendTestNotification() {
|
|
this.testingNotification = true;
|
|
try {
|
|
await this.saveCategory('notifications');
|
|
const res = await fetch('/api/v1/settings/notifications/test', {
|
|
method: 'POST',
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const detail = data.detail || {};
|
|
this.showFlash('Test failed: ' + (detail.message || data.detail || res.statusText), false);
|
|
} else {
|
|
this.showFlash('Test notification sent.', true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error sending test notification: ' + err.message, false);
|
|
} finally {
|
|
this.testingNotification = false;
|
|
}
|
|
},
|
|
|
|
async checkAlerts() {
|
|
this.checkingAlerts = true;
|
|
try {
|
|
await this.saveCategory('notifications');
|
|
const res = await fetch('/api/v1/settings/notifications/alerts', {
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
this.showFlash('Alert check failed: ' + (data.detail || res.statusText), false);
|
|
} else {
|
|
this.alertPreview = (data.alerts || []).length;
|
|
await this.loadAlertHistory(false);
|
|
this.showFlash(`${this.alertPreview} active alert${this.alertPreview === 1 ? '' : 's'} found.`, true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error checking alerts: ' + err.message, false);
|
|
} finally {
|
|
this.checkingAlerts = false;
|
|
}
|
|
},
|
|
|
|
async sendAlertSummary() {
|
|
this.sendingAlerts = true;
|
|
try {
|
|
await this.saveCategory('notifications');
|
|
const res = await fetch('/api/v1/settings/notifications/alerts/send', {
|
|
method: 'POST',
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const detail = data.detail || {};
|
|
const notification = detail.notification || {};
|
|
this.showFlash('Alert send failed: ' + (notification.message || data.detail || res.statusText), false);
|
|
} else {
|
|
this.alertPreview = (data.alerts || []).length;
|
|
await this.loadAlertHistory(false);
|
|
this.showFlash(data.notification.skipped ? 'No active alerts to send.' : 'Alert summary sent.', true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error sending alerts: ' + err.message, false);
|
|
} finally {
|
|
this.sendingAlerts = false;
|
|
}
|
|
},
|
|
|
|
async previewSummary() {
|
|
this.previewingSummary = true;
|
|
try {
|
|
await this.saveCategory('notifications');
|
|
const res = await fetch(`/api/v1/settings/notifications/summary?period=${this.summaryPeriod}`, {
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
this.showFlash('Summary preview failed: ' + (data.detail || res.statusText), false);
|
|
} else {
|
|
this.summaryPreview = data.summary;
|
|
this.showFlash('Summary preview updated.', true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error previewing summary: ' + err.message, false);
|
|
} finally {
|
|
this.previewingSummary = false;
|
|
}
|
|
},
|
|
|
|
async sendSummaryNow() {
|
|
this.sendingSummary = true;
|
|
try {
|
|
await this.saveCategory('notifications');
|
|
const res = await fetch(`/api/v1/settings/notifications/summary/send?period=${this.summaryPeriod}`, {
|
|
method: 'POST',
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const detail = data.detail || {};
|
|
const notification = detail.notification || {};
|
|
this.showFlash('Summary send failed: ' + (notification.message || data.detail || res.statusText), false);
|
|
} else {
|
|
this.summaryPreview = data.summary;
|
|
await this.loadAlertHistory(false);
|
|
this.showFlash('Summary notification sent.', true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error sending summary: ' + err.message, false);
|
|
} finally {
|
|
this.sendingSummary = false;
|
|
}
|
|
},
|
|
|
|
async loadAlertHistory(showMessage = true) {
|
|
this.loadingAlertHistory = true;
|
|
try {
|
|
const res = await fetch('/api/v1/settings/notifications/alerts/history?limit=10', {
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
if (showMessage) {
|
|
this.showFlash('Alert history failed: ' + (data.detail || res.statusText), false);
|
|
}
|
|
} else {
|
|
this.alertHistory = data.history || [];
|
|
if (showMessage) {
|
|
this.showFlash('Alert history refreshed.', true);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (showMessage) {
|
|
this.showFlash('Error loading alert history: ' + err.message, false);
|
|
}
|
|
} finally {
|
|
this.loadingAlertHistory = false;
|
|
}
|
|
},
|
|
|
|
async loadConfigAudit(showMessage = true) {
|
|
this.loadingConfigAudit = true;
|
|
try {
|
|
const res = await fetch('/api/v1/settings/notifications/config-audit?limit=10', {
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
if (showMessage) {
|
|
this.showFlash('Configuration audit failed: ' + (data.detail || res.statusText), false);
|
|
}
|
|
} else {
|
|
this.configAudit = data.audit || [];
|
|
if (showMessage) {
|
|
this.showFlash('Configuration audit refreshed.', true);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (showMessage) {
|
|
this.showFlash('Error loading configuration audit: ' + err.message, false);
|
|
}
|
|
} finally {
|
|
this.loadingConfigAudit = false;
|
|
}
|
|
},
|
|
|
|
async discoverCloudflareZones() {
|
|
this.loadingCfZones = true;
|
|
try {
|
|
await this.saveCategory('cloudflare');
|
|
const res = await fetch('/api/v1/domains/cloudflare/discover', {
|
|
headers: this.apiHeaders(),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
this.showFlash('Cloudflare discovery failed: ' + (data.detail || res.statusText), false);
|
|
} else {
|
|
this.cfZones = data || [];
|
|
this.showFlash(`${this.cfZones.length} Cloudflare zone${this.cfZones.length === 1 ? '' : 's'} found.`, true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error discovering Cloudflare zones: ' + err.message, false);
|
|
} finally {
|
|
this.loadingCfZones = false;
|
|
}
|
|
},
|
|
|
|
async importCloudflareZones() {
|
|
this.importingCfZones = true;
|
|
try {
|
|
const domains = this.cfZones.filter(z => !z.imported).map(z => z.name);
|
|
const res = await fetch('/api/v1/domains/cloudflare/import', {
|
|
method: 'POST',
|
|
headers: this.apiHeaders(),
|
|
body: JSON.stringify({ domains }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
this.showFlash('Cloudflare import failed: ' + (data.detail || res.statusText), false);
|
|
} else {
|
|
await this.discoverCloudflareZones();
|
|
this.showFlash(`${data.imported.length} domain${data.imported.length === 1 ? '' : 's'} imported.`, true);
|
|
}
|
|
} catch (err) {
|
|
this.showFlash('Error importing Cloudflare zones: ' + err.message, false);
|
|
} finally {
|
|
this.importingCfZones = false;
|
|
}
|
|
},
|
|
|
|
showFlash(msg, ok) {
|
|
this.flashMsg = msg;
|
|
this.flashOk = ok;
|
|
setTimeout(() => { this.flashMsg = ''; }, 4000);
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|