fcefd0978f
- 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>
980 lines
42 KiB
HTML
980 lines
42 KiB
HTML
{% 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 %}
|