Files
gh-christianlouis-docuelevate/frontend/templates/notifications_dashboard.html
T
copilot-swe-agent[bot] fcefd0978f feat(notifications): add per-user notification system with inbox, email, and webhook targets
- Add UserNotificationTarget, UserNotificationPreference, InAppNotification models
- Add migration 025_add_user_notifications (tables + indexes)
- Add app/utils/user_notification.py dispatch service
- Add app/api/notifications.py REST endpoints (inbox, targets, preferences)
- Add app/views/notifications.py view route
- Add frontend/templates/notifications_dashboard.html Alpine.js dashboard
- Add bell icon with unread badge in base.html nav (desktop + mobile)
- Register routers in app/api/__init__.py and app/views/__init__.py
- Add 32 unit tests in tests/test_notifications_api.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 21:34:25 +00:00

980 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Notifications DocuElevate{% endblock %}
{% block content %}
<div
class="container mx-auto px-4 py-8"
x-data="notificationsDashboard()"
x-init="init()"
>
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-bell text-blue-500" aria-hidden="true"></i>
Notifications
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Manage your notification inbox, targets, and event preferences.
</p>
</div>
<!-- ── Tab nav ────────────────────────────────────────────────────────── -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6" role="tablist" aria-label="Notification sections">
<nav class="-mb-px flex space-x-6">
<button
type="button"
role="tab"
:aria-selected="activeTab === 'inbox'"
:tabindex="activeTab === 'inbox' ? 0 : -1"
@click="activeTab = 'inbox'"
:class="activeTab === 'inbox'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-controls="panel-inbox"
id="tab-inbox"
style="min-height:44px;"
>
<i class="fas fa-inbox mr-1" aria-hidden="true"></i>
Inbox
<template x-if="unreadCount > 0">
<span
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700"
aria-label="unread notifications"
x-text="unreadCount > 99 ? '99+' : unreadCount"
></span>
</template>
</button>
<button
type="button"
role="tab"
:aria-selected="activeTab === 'settings'"
:tabindex="activeTab === 'settings' ? 0 : -1"
@click="activeTab = 'settings'"
:class="activeTab === 'settings'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-controls="panel-settings"
id="tab-settings"
style="min-height:44px;"
>
<i class="fas fa-sliders-h mr-1" aria-hidden="true"></i>
Settings
</button>
</nav>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
INBOX PANEL
══════════════════════════════════════════════════════════════════════ -->
<div
role="tabpanel"
id="panel-inbox"
aria-labelledby="tab-inbox"
x-show="activeTab === 'inbox'"
>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<label for="inboxFilter" class="text-sm font-medium text-gray-700 dark:text-gray-300">Show:</label>
<select
id="inboxFilter"
x-model="inboxFilter"
@change="loadInbox()"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Filter notifications"
>
<option value="all">All</option>
<option value="unread">Unread only</option>
<option value="read">Read only</option>
</select>
</div>
<button
type="button"
@click="markAllRead()"
:disabled="unreadCount === 0"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:44px;"
aria-label="Mark all notifications as read"
>
<i class="fas fa-check-double mr-1" aria-hidden="true"></i> Mark all read
</button>
</div>
<!-- Loading state -->
<template x-if="inboxLoading">
<div class="text-center py-12 text-gray-400" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm">Loading notifications…</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!inboxLoading && filteredNotifications().length === 0">
<div class="text-center py-12 text-gray-400" role="status">
<i class="fas fa-bell-slash text-4xl mb-3" aria-hidden="true"></i>
<p class="text-sm">No notifications found.</p>
</div>
</template>
<!-- Notifications list -->
<template x-if="!inboxLoading && filteredNotifications().length > 0">
<ul class="space-y-2" role="list" aria-label="Notification items">
<template x-for="notif in filteredNotifications()" :key="notif.id">
<li
class="flex items-start gap-3 p-4 rounded-lg border transition-colors"
:class="notif.is_read
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'"
>
<!-- Event icon -->
<div
class="flex-shrink-0 h-9 w-9 rounded-full flex items-center justify-center"
:class="notif.event_type === 'document.failed'
? 'bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'
: 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'"
aria-hidden="true"
>
<i :class="notif.event_type === 'document.failed' ? 'fas fa-times-circle' : 'fas fa-check-circle'"></i>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p
class="text-sm font-medium text-gray-900 dark:text-white"
:class="notif.is_read ? '' : 'font-semibold'"
x-text="notif.title"
></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="notif.message"></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1" x-text="formatDate(notif.created_at)"></p>
</div>
<!-- Actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<template x-if="notif.file_id">
<a
:href="`/files/${notif.file_id}`"
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
:aria-label="`View file for: ${notif.title}`"
>
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
</a>
</template>
<template x-if="!notif.is_read">
<button
type="button"
@click="markRead(notif)"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
:aria-label="`Mark as read: ${notif.title}`"
style="min-height:44px;min-width:44px;"
title="Mark as read"
>
<i class="fas fa-check" aria-hidden="true"></i>
</button>
</template>
</div>
</li>
</template>
</ul>
</template>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
SETTINGS PANEL
══════════════════════════════════════════════════════════════════════ -->
<div
role="tabpanel"
id="panel-settings"
aria-labelledby="tab-settings"
x-show="activeTab === 'settings'"
>
<!-- ── Notification Targets ──────────────────────────────────────────── -->
<section aria-labelledby="targets-heading" class="mb-10">
<div class="flex items-center justify-between mb-4">
<h2 id="targets-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-satellite-dish mr-2 text-indigo-500" aria-hidden="true"></i>Notification Targets
</h2>
<button
type="button"
@click="openAddTarget()"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
aria-label="Add notification target"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Target
</button>
</div>
<template x-if="targetsLoading">
<div class="text-center py-8 text-gray-400" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="sr-only">Loading targets…</span>
</div>
</template>
<template x-if="!targetsLoading && targets.length === 0">
<div class="text-center py-8 text-gray-400 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
<i class="fas fa-satellite-dish text-3xl mb-2" aria-hidden="true"></i>
<p class="text-sm">No notification targets configured.</p>
<p class="text-xs mt-1">Add an email or webhook target to receive notifications outside the app.</p>
</div>
</template>
<template x-if="!targetsLoading && targets.length > 0">
<div class="space-y-3" role="list" aria-label="Notification targets">
<template x-for="target in targets" :key="target.id">
<div
class="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
role="listitem"
>
<!-- Type icon -->
<div
class="flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center"
:class="target.channel_type === 'email'
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
aria-hidden="true"
>
<i :class="target.channel_type === 'email' ? 'fas fa-envelope' : 'fas fa-globe'"></i>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="target.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 capitalize" x-text="target.channel_type"></p>
</div>
<!-- Active badge -->
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="target.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="target.is_active ? 'Active' : 'Inactive'"
></span>
<!-- Actions -->
<div class="flex items-center gap-2">
<button
type="button"
@click="testTarget(target)"
class="p-2 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
:aria-label="`Test target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Send test notification"
>
<i class="fas fa-paper-plane" aria-hidden="true"></i>
</button>
<button
type="button"
@click="openEditTarget(target)"
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
:aria-label="`Edit target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Edit target"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
</button>
<button
type="button"
@click="deleteTarget(target)"
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
:aria-label="`Delete target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Delete target"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</template>
</div>
</template>
</section>
<!-- ── Event Preferences ─────────────────────────────────────────────── -->
<section aria-labelledby="prefs-heading">
<div class="flex items-center justify-between mb-4">
<h2 id="prefs-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-sliders-h mr-2 text-green-500" aria-hidden="true"></i>Event Preferences
</h2>
<button
type="button"
@click="savePreferences()"
:disabled="prefsSaving"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-green-500"
style="min-height:44px;"
aria-label="Save notification preferences"
>
<template x-if="prefsSaving">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<template x-if="!prefsSaving">
<i class="fas fa-save mr-1" aria-hidden="true"></i>
</template>
Save Preferences
</button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Notification event preferences">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Event
</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
In-App
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Webhook
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="eventType in eventTypes" :key="eventType">
<tr>
<!-- Event label -->
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white" x-text="eventLabels[eventType] || eventType"></td>
<!-- In-App (always enabled, non-editable) -->
<td class="px-4 py-3 text-center">
<input
type="checkbox"
checked
disabled
class="h-4 w-4 text-blue-600 rounded opacity-60 cursor-not-allowed"
:aria-label="`In-app notification for ${eventLabels[eventType] || eventType} (always enabled)`"
title="In-app notifications are always enabled"
/>
</td>
<!-- Email channel -->
<td class="px-4 py-3">
<div class="flex flex-col gap-1">
<template x-for="target in emailTargets()" :key="target.id">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="isPrefEnabled(eventType, 'email', target.id)"
@change="togglePref(eventType, 'email', target.id, $event.target.checked)"
class="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
:aria-label="`Email via ${target.name} for ${eventLabels[eventType] || eventType}`"
/>
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
</label>
</template>
<template x-if="emailTargets().length === 0">
<span class="text-xs text-gray-400 italic">No email targets</span>
</template>
</div>
</td>
<!-- Webhook channel -->
<td class="px-4 py-3">
<div class="flex flex-col gap-1">
<template x-for="target in webhookTargets()" :key="target.id">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="isPrefEnabled(eventType, 'webhook', target.id)"
@change="togglePref(eventType, 'webhook', target.id, $event.target.checked)"
class="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
:aria-label="`Webhook via ${target.name} for ${eventLabels[eventType] || eventType}`"
/>
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
</label>
</template>
<template x-if="webhookTargets().length === 0">
<span class="text-xs text-gray-400 italic">No webhook targets</span>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</section>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
TARGET MODAL (Add / Edit)
══════════════════════════════════════════════════════════════════════ -->
<div
x-show="targetModalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
@keydown.escape.window="targetModalOpen = false"
>
<div
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg"
@click.stop
>
<!-- Modal header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3
:id="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
class="text-lg font-semibold text-gray-900 dark:text-white"
x-text="targetModalMode === 'edit' ? 'Edit Notification Target' : 'Add Notification Target'"
></h3>
<button
type="button"
@click="targetModalOpen = false"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Close modal"
style="min-height:44px;min-width:44px;"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Modal body -->
<form @submit.prevent="submitTargetForm()" class="px-6 py-5 space-y-4">
<!-- Channel type (only for new targets) -->
<template x-if="targetModalMode === 'add'">
<div>
<label for="modal-channel-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Channel Type <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="modal-channel-type"
x-model="targetForm.channel_type"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
aria-required="true"
>
<option value="email">Email (SMTP)</option>
<option value="webhook">Webhook (HTTP POST)</option>
</select>
</div>
</template>
<!-- Name -->
<div>
<label for="modal-target-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
type="text"
id="modal-target-name"
x-model="targetForm.name"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g. My Gmail, Production Webhook"
required
aria-required="true"
maxlength="255"
/>
</div>
<!-- Email-specific fields -->
<template x-if="targetForm.channel_type === 'email'">
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label for="modal-smtp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<input
type="text"
id="modal-smtp-host"
x-model="targetForm.config.smtp_host"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label for="modal-smtp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<input
type="number"
id="modal-smtp-port"
x-model.number="targetForm.config.smtp_port"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="587"
min="1"
max="65535"
/>
</div>
</div>
<div>
<label for="modal-smtp-username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
<input
type="text"
id="modal-smtp-username"
x-model="targetForm.config.smtp_username"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="user@example.com"
autocomplete="off"
/>
</div>
<div>
<label for="modal-smtp-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
<input
type="password"
id="modal-smtp-password"
x-model="targetForm.config.smtp_password"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Leave blank to keep existing"
autocomplete="new-password"
/>
</div>
<div>
<label for="modal-recipient-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient Email</label>
<input
type="email"
id="modal-recipient-email"
x-model="targetForm.config.recipient_email"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="you@example.com"
/>
</div>
<div>
<label for="modal-sender-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Sender Email (optional)</label>
<input
type="email"
id="modal-sender-email"
x-model="targetForm.config.sender_email"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="noreply@docuelevate.local"
/>
</div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="targetForm.config.smtp_use_tls"
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
aria-label="Use STARTTLS"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
</label>
</div>
</template>
<!-- Webhook-specific fields -->
<template x-if="targetForm.channel_type === 'webhook'">
<div class="space-y-3">
<div>
<label for="modal-webhook-url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL</label>
<input
type="url"
id="modal-webhook-url"
x-model="targetForm.config.url"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="https://hooks.example.com/..."
/>
</div>
<div>
<label for="modal-webhook-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Secret (optional)
</label>
<input
type="password"
id="modal-webhook-secret"
x-model="targetForm.config.secret"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Sent as X-DocuElevate-Secret header"
autocomplete="new-password"
/>
</div>
</div>
</template>
<!-- Active toggle (edit mode) -->
<template x-if="targetModalMode === 'edit'">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="targetForm.is_active"
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
aria-label="Target is active"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
</template>
<!-- Error -->
<template x-if="targetFormError">
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="targetFormError"></p>
</template>
<!-- Footer -->
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="targetModalOpen = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
>
Cancel
</button>
<button
type="submit"
:disabled="targetFormSaving"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
>
<template x-if="targetFormSaving">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<span x-text="targetModalMode === 'edit' ? 'Save Changes' : 'Add Target'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Global toast ───────────────────────────────────────────────────── -->
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg shadow-lg px-4 py-3 text-white text-sm font-medium"
:class="toast.type === 'error' ? 'bg-red-600' : 'bg-green-600'"
role="status"
aria-live="polite"
x-text="toast.message"
></div>
</div>
{% endblock %}
{% block scripts %}
<script>
function notificationsDashboard() {
return {
// Tab state
activeTab: 'inbox',
// Inbox
notifications: [],
inboxLoading: false,
inboxFilter: 'all',
unreadCount: 0,
// Targets
targets: [],
targetsLoading: false,
// Preferences
eventTypes: [],
eventLabels: {},
prefs: {}, // event_type -> channel_type -> {is_enabled, target_id}
prefsSaving: false,
// Target modal
targetModalOpen: false,
targetModalMode: 'add',
editingTargetId: null,
targetForm: {
channel_type: 'email',
name: '',
is_active: true,
config: { smtp_use_tls: true, smtp_port: 587 },
},
targetFormSaving: false,
targetFormError: '',
// Toast
toast: { visible: false, message: '', type: 'success' },
async init() {
await Promise.all([this.loadInbox(), this.loadTargets(), this.loadPreferences()]);
},
// ── Inbox ────────────────────────────────────────────────────────────
async loadInbox() {
this.inboxLoading = true;
try {
const resp = await fetch('/api/user-notifications/inbox?limit=100');
if (!resp.ok) throw new Error('Failed to load');
this.notifications = await resp.json();
this.unreadCount = this.notifications.filter(n => !n.is_read).length;
} catch (e) {
this.showToast('Failed to load notifications', 'error');
} finally {
this.inboxLoading = false;
}
},
filteredNotifications() {
if (this.inboxFilter === 'unread') return this.notifications.filter(n => !n.is_read);
if (this.inboxFilter === 'read') return this.notifications.filter(n => n.is_read);
return this.notifications;
},
async markRead(notif) {
try {
const resp = await fetch(`/api/user-notifications/inbox/${notif.id}/read`, { method: 'POST' });
if (!resp.ok) throw new Error('Failed');
notif.is_read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
} catch (e) {
this.showToast('Failed to mark as read', 'error');
}
},
async markAllRead() {
try {
const resp = await fetch('/api/user-notifications/inbox/read-all', { method: 'POST' });
if (!resp.ok) throw new Error('Failed');
this.notifications.forEach(n => { n.is_read = true; });
this.unreadCount = 0;
this.updateBadge();
this.showToast('All notifications marked as read');
} catch (e) {
this.showToast('Failed to mark all as read', 'error');
}
},
updateBadge() {
const badge = document.getElementById('notificationBadge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.classList.remove('hidden');
badge.setAttribute('aria-label', this.unreadCount + ' unread notifications');
} else {
badge.classList.add('hidden');
}
},
formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
},
// ── Targets ──────────────────────────────────────────────────────────
async loadTargets() {
this.targetsLoading = true;
try {
const resp = await fetch('/api/user-notifications/targets');
if (!resp.ok) throw new Error('Failed to load targets');
this.targets = await resp.json();
} catch (e) {
this.showToast('Failed to load notification targets', 'error');
} finally {
this.targetsLoading = false;
}
},
emailTargets() {
return this.targets.filter(t => t.channel_type === 'email' && t.is_active);
},
webhookTargets() {
return this.targets.filter(t => t.channel_type === 'webhook' && t.is_active);
},
openAddTarget() {
this.targetModalMode = 'add';
this.editingTargetId = null;
this.targetForm = {
channel_type: 'email',
name: '',
is_active: true,
config: { smtp_use_tls: true, smtp_port: 587 },
};
this.targetFormError = '';
this.targetModalOpen = true;
},
openEditTarget(target) {
this.targetModalMode = 'edit';
this.editingTargetId = target.id;
this.targetForm = {
channel_type: target.channel_type,
name: target.name,
is_active: target.is_active,
config: Object.assign({}, target.config),
};
this.targetFormError = '';
this.targetModalOpen = true;
},
async submitTargetForm() {
this.targetFormSaving = true;
this.targetFormError = '';
try {
let url, method, body;
if (this.targetModalMode === 'add') {
url = '/api/user-notifications/targets';
method = 'POST';
body = {
channel_type: this.targetForm.channel_type,
name: this.targetForm.name,
is_active: this.targetForm.is_active,
config: this.targetForm.config,
};
} else {
url = `/api/user-notifications/targets/${this.editingTargetId}`;
method = 'PUT';
body = {
name: this.targetForm.name,
is_active: this.targetForm.is_active,
config: this.targetForm.config,
};
}
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save target');
}
await this.loadTargets();
this.targetModalOpen = false;
this.showToast(this.targetModalMode === 'edit' ? 'Target updated' : 'Target created');
} catch (e) {
this.targetFormError = e.message;
} finally {
this.targetFormSaving = false;
}
},
async deleteTarget(target) {
if (!confirm(`Delete notification target "${target.name}"? Associated preferences will also be removed.`)) return;
try {
const resp = await fetch(`/api/user-notifications/targets/${target.id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to delete');
await this.loadTargets();
await this.loadPreferences();
this.showToast('Target deleted');
} catch (e) {
this.showToast('Failed to delete target', 'error');
}
},
async testTarget(target) {
try {
const resp = await fetch(`/api/user-notifications/targets/${target.id}/test`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Test failed');
}
this.showToast('Test notification sent!');
} catch (e) {
this.showToast(`Test failed: ${e.message}`, 'error');
}
},
// ── Preferences ──────────────────────────────────────────────────────
async loadPreferences() {
try {
const resp = await fetch('/api/user-notifications/preferences');
if (!resp.ok) throw new Error('Failed to load preferences');
const data = await resp.json();
this.eventTypes = data.event_types || [];
this.eventLabels = data.event_labels || {};
this.prefs = data.preferences || {};
// Build flat list for isPrefEnabled lookups
this._prefsList = [];
for (const [et, channelMap] of Object.entries(this.prefs)) {
for (const [ct, item] of Object.entries(channelMap)) {
this._prefsList.push({
event_type: et,
channel_type: ct,
target_id: item.target_id,
is_enabled: item.is_enabled,
});
}
}
} catch (e) {
this.showToast('Failed to load preferences', 'error');
}
},
isPrefEnabled(eventType, channelType, targetId) {
const entry = this._prefKey(eventType, channelType, targetId);
return entry ? entry.is_enabled : false;
},
_prefKey(eventType, channelType, targetId) {
if (!this._prefsList) return null;
return this._prefsList.find(
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
) || null;
},
// Store pending pref changes as a dict: `${eventType}|${channelType}|${targetId}` -> is_enabled
_pendingPrefs: {},
_prefsList: [],
togglePref(eventType, channelType, targetId, enabled) {
const k = `${eventType}|${channelType}|${targetId}`;
this._pendingPrefs[k] = { event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled };
// Update in _prefsList for immediate UI feedback
const idx = this._prefsList.findIndex(
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
);
if (idx >= 0) {
this._prefsList[idx].is_enabled = enabled;
} else {
this._prefsList.push({ event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled });
}
},
async savePreferences() {
this.prefsSaving = true;
try {
// Build flat list from all pending changes
const prefsList = Object.values(this._pendingPrefs).map(entry => ({
event_type: entry.event_type,
channel_type: entry.channel_type,
target_id: entry.target_id,
is_enabled: entry.is_enabled,
}));
const resp = await fetch('/api/user-notifications/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferences: prefsList }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save');
}
this._pendingPrefs = {};
await this.loadPreferences();
this.showToast('Preferences saved');
} catch (e) {
this.showToast(`Failed to save preferences: ${e.message}`, 'error');
} finally {
this.prefsSaving = false;
}
},
// ── Toast ─────────────────────────────────────────────────────────────
showToast(message, type = 'success') {
this.toast = { visible: true, message, type };
setTimeout(() => { this.toast.visible = false; }, 3500);
},
};
}
</script>
{% endblock %}