Fix admin settings 502 and improve notifications UX
Build and Push Docker Image / build (push) Successful in 1m47s Details

- Use supabaseAdmin for admin settings operations (bypasses RLS completely)
- Add proper error handling to updateSettings action
- Update notifications to be expandable with full message display
- Clicking notifications in dropdown now goes to /notifications?id=X
- Auto-scroll and expand notification when opening from dropdown link

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-26 17:26:17 +01:00
parent 4e3cf89f62
commit 9b119302d3
4 changed files with 210 additions and 128 deletions

View File

@ -90,14 +90,8 @@
markAsRead(notification.id); markAsRead(notification.id);
} }
closeDropdown(); closeDropdown();
if (notification.link) { // Always go to notifications page with the notification ID to expand it
// Use goto for internal links, window.location for external await goto(`/notifications?id=${notification.id}`);
if (notification.link.startsWith('/')) {
await goto(notification.link);
} else if (notification.link.startsWith('http')) {
window.location.href = notification.link;
}
}
} }
function getTypeIcon(type: Notification['type']) { function getTypeIcon(type: Notification['type']) {

View File

@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email'; import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage'; import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
import { supabaseAdmin } from '$lib/server/supabase';
import * as poste from '$lib/server/poste'; import * as poste from '$lib/server/poste';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
@ -205,15 +206,31 @@ export const actions: Actions = {
// Update app settings // Update app settings
updateSettings: async ({ request, locals }) => { updateSettings: async ({ request, locals }) => {
const { member } = await locals.safeGetSession(); const { member } = await locals.safeGetSession();
if (!member || member.role !== 'admin') {
return fail(403, { error: 'Unauthorized' });
}
const formData = await request.formData(); const formData = await request.formData();
const category = formData.get('category') as string; const category = formData.get('category') as string;
if (!category) {
return fail(400, { error: 'Category is required' });
}
try {
// Get existing boolean settings for this category to handle unchecked checkboxes // Get existing boolean settings for this category to handle unchecked checkboxes
const { data: existingSettings } = await locals.supabase // Use supabaseAdmin to bypass RLS
const { data: existingSettings, error: fetchError } = await supabaseAdmin
.from('app_settings') .from('app_settings')
.select('setting_key, setting_type') .select('setting_key, setting_type')
.eq('category', category); .eq('category', category);
if (fetchError) {
console.error('Error fetching existing settings:', fetchError);
return fail(500, { error: 'Failed to load existing settings' });
}
const existingBooleanKeys = new Set( const existingBooleanKeys = new Set(
(existingSettings || []) (existingSettings || [])
.filter(s => s.setting_type === 'boolean') .filter(s => s.setting_type === 'boolean')
@ -250,10 +267,10 @@ export const actions: Actions = {
} }
} }
// Update or insert each setting // Update or insert each setting using supabaseAdmin (bypasses RLS)
for (const setting of settingsToUpdate) { for (const setting of settingsToUpdate) {
// Try to update first // Try to update first
const { data: existing } = await locals.supabase const { data: existing } = await supabaseAdmin
.from('app_settings') .from('app_settings')
.select('id') .select('id')
.eq('category', category) .eq('category', category)
@ -262,18 +279,22 @@ export const actions: Actions = {
if (existing) { if (existing) {
// Update existing // Update existing
await locals.supabase const { error: updateError } = await supabaseAdmin
.from('app_settings') .from('app_settings')
.update({ .update({
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value), setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
updated_by: member?.id updated_by: member.id
}) })
.eq('category', category) .eq('category', category)
.eq('setting_key', setting.key); .eq('setting_key', setting.key);
if (updateError) {
console.error('Error updating setting:', setting.key, updateError);
}
} else { } else {
// Insert new setting // Insert new setting
await locals.supabase const { error: insertError } = await supabaseAdmin
.from('app_settings') .from('app_settings')
.insert({ .insert({
category, category,
@ -282,8 +303,12 @@ export const actions: Actions = {
setting_type: setting.type, setting_type: setting.type,
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
updated_by: member?.id updated_by: member.id
}); });
if (insertError) {
console.error('Error inserting setting:', setting.key, insertError);
}
} }
} }
@ -293,6 +318,10 @@ export const actions: Actions = {
} }
return { success: 'Settings updated successfully!' }; return { success: 'Settings updated successfully!' };
} catch (err) {
console.error('Unexpected error updating settings:', err);
return fail(500, { error: 'An unexpected error occurred while saving settings' });
}
}, },
// Test SMTP connection // Test SMTP connection

View File

@ -1,10 +1,10 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals, url }) => {
const { member } = await locals.safeGetSession(); const { member } = await locals.safeGetSession();
if (!member) { if (!member) {
return { notifications: [] }; return { notifications: [], expandedId: null };
} }
const { data: notifications } = await locals.supabase const { data: notifications } = await locals.supabase
@ -13,7 +13,11 @@ export const load: PageServerLoad = async ({ locals }) => {
.eq('member_id', member.id) .eq('member_id', member.id)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
// Get the ID from query param to auto-expand that notification
const expandedId = url.searchParams.get('id');
return { return {
notifications: notifications || [] notifications: notifications || [],
expandedId
}; };
}; };

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Bell, Calendar, CreditCard, Users, Megaphone, Settings, Check, ChevronRight, Trash2 } from 'lucide-svelte'; import { Bell, Calendar, CreditCard, Users, Megaphone, Settings, Check, ChevronDown, ChevronUp, ExternalLink } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Notification { interface Notification {
id: string; id: string;
@ -15,6 +16,36 @@
let { data } = $props(); let { data } = $props();
let notifications = $state<Notification[]>(data.notifications); let notifications = $state<Notification[]>(data.notifications);
let filter = $state<'all' | 'unread'>('all'); let filter = $state<'all' | 'unread'>('all');
let expandedId = $state<string | null>(data.expandedId);
// Auto-expand and scroll to notification if ID is in URL
onMount(() => {
if (data.expandedId) {
const notification = notifications.find(n => n.id === data.expandedId);
if (notification && !notification.read_at) {
markAsRead(notification.id);
}
// Scroll to the notification
setTimeout(() => {
const element = document.getElementById(`notification-${data.expandedId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
});
function toggleExpand(id: string) {
if (expandedId === id) {
expandedId = null;
} else {
expandedId = id;
const notification = notifications.find(n => n.id === id);
if (notification && !notification.read_at) {
markAsRead(id);
}
}
}
const filteredNotifications = $derived( const filteredNotifications = $derived(
filter === 'unread' filter === 'unread'
@ -107,14 +138,6 @@
} }
} }
async function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
await markAsRead(notification.id);
}
if (notification.link) {
goto(notification.link);
}
}
</script> </script>
<svelte:head> <svelte:head>
@ -179,9 +202,14 @@
{#each filteredNotifications as notification} {#each filteredNotifications as notification}
{@const Icon = getTypeIcon(notification.type)} {@const Icon = getTypeIcon(notification.type)}
{@const badge = getTypeBadge(notification.type)} {@const badge = getTypeBadge(notification.type)}
{@const isExpanded = expandedId === notification.id}
<div
id="notification-{notification.id}"
class="rounded-xl border bg-white transition-all {notification.read_at ? 'border-slate-200' : 'border-monaco-200 bg-monaco-50/30'} {isExpanded ? 'shadow-lg ring-2 ring-monaco-500/20' : 'hover:shadow-md'}"
>
<button <button
onclick={() => handleNotificationClick(notification)} onclick={() => toggleExpand(notification.id)}
class="w-full rounded-xl border bg-white p-4 text-left transition-all hover:shadow-md {notification.read_at ? 'border-slate-200' : 'border-monaco-200 bg-monaco-50/30'}" class="w-full p-4 text-left"
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}"> <div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
@ -198,23 +226,50 @@
<span class="h-2 w-2 rounded-full bg-monaco-600"></span> <span class="h-2 w-2 rounded-full bg-monaco-600"></span>
{/if} {/if}
</div> </div>
<h3 class="text-base font-semibold text-slate-900 {notification.read_at ? '' : 'text-slate-900'}"> <h3 class="text-base font-semibold text-slate-900">
{notification.title} {notification.title}
</h3> </h3>
{#if !isExpanded}
<p class="mt-1 text-sm text-slate-600 line-clamp-2"> <p class="mt-1 text-sm text-slate-600 line-clamp-2">
{notification.message} {notification.message}
</p> </p>
{/if}
<p class="mt-2 text-xs text-slate-400"> <p class="mt-2 text-xs text-slate-400">
{formatDate(notification.created_at)} {formatDate(notification.created_at)}
</p> </p>
</div> </div>
{#if notification.link} <div class="flex-shrink-0 text-slate-400">
<ChevronRight class="h-5 w-5 flex-shrink-0 text-slate-400" /> {#if isExpanded}
<ChevronUp class="h-5 w-5" />
{:else}
<ChevronDown class="h-5 w-5" />
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div>
</button> </button>
<!-- Expanded content -->
{#if isExpanded}
<div class="border-t border-slate-100 px-4 pb-4 pt-3 ml-16">
<div class="prose prose-sm prose-slate max-w-none">
<p class="text-slate-700 whitespace-pre-wrap">{notification.message}</p>
</div>
{#if notification.link}
<div class="mt-4">
<a
href={notification.link}
class="inline-flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 transition-colors"
>
<ExternalLink class="h-4 w-4" />
View Details
</a>
</div>
{/if}
</div>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}