Add user notification inbox and delivery controls
This commit is contained in:
+11
File diff suppressed because one or more lines are too long
@@ -4,9 +4,10 @@
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { user, isacAccess, children }: {
|
||||
let { user, isacAccess, notificationSummary, children }: {
|
||||
user: { username: string; displayName: string; role: string } | null;
|
||||
isacAccess: boolean;
|
||||
notificationSummary: { unread: number };
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -19,9 +20,32 @@
|
||||
let showCookieNotice = $state(false);
|
||||
let mobileOpen = $state(false);
|
||||
let mobilePanel = $state<HTMLElement | null>(null);
|
||||
let unreadNotifications = $state(0);
|
||||
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
||||
|
||||
onMount(() => {
|
||||
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
|
||||
if (!user) return;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications/unread');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
unreadNotifications = Number(result.count ?? 0);
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
const shown = new Set<string>(JSON.parse(localStorage.getItem('shd-browser-notifications') ?? '[]'));
|
||||
for (const item of result.notifications as Array<{ id: string; title: string; body: string; link?: string }>) {
|
||||
if (shown.has(item.id)) continue;
|
||||
const alert = new Notification(item.title, { body: item.body, icon: '/favicon.svg', tag: `shd-${item.id}` });
|
||||
alert.onclick = () => { window.focus(); location.href = item.link || '/account/notifications'; alert.close(); };
|
||||
shown.add(item.id);
|
||||
}
|
||||
localStorage.setItem('shd-browser-notifications', JSON.stringify([...shown].slice(-100)));
|
||||
} catch { /* A transient poll failure must not interrupt navigation. */ }
|
||||
};
|
||||
void poll();
|
||||
const interval = window.setInterval(poll, 30_000);
|
||||
return () => window.clearInterval(interval);
|
||||
});
|
||||
|
||||
function dismissCookieNotice() {
|
||||
@@ -63,7 +87,7 @@
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
||||
{/if}
|
||||
{#if isacAccess}
|
||||
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a>
|
||||
@@ -71,6 +95,7 @@
|
||||
</nav>
|
||||
{#if user}
|
||||
<div class="account">
|
||||
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
||||
</div>
|
||||
@@ -90,11 +115,12 @@
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account')} href="/account"><small>04</small><span>My Profile</span><b>›</b></a>
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>04</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>05</small><span>My Profile</span><b>›</b></a>
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
|
||||
{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>05</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if user}
|
||||
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
let { items }: { items: BreadcrumbItem[] } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="eyebrow page-breadcrumb" aria-label="Breadcrumb">
|
||||
<span class="breadcrumb-line" aria-hidden="true"></span>
|
||||
<ol>
|
||||
{#each items as item, index}
|
||||
<li>
|
||||
{#if item.href}<a href={item.href}>{item.label}</a>{:else}<span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
let { body, expanded = false }: { body: string; expanded?: boolean } = $props();
|
||||
|
||||
const parsed = $derived.by(() => {
|
||||
const detailMatch = body.match(/^(.*?)(?:\s+)(Oversight reason|Reason|Required action|Details):\s*(.+)$/i);
|
||||
if (!detailMatch) return { summary: body, detailLabel: null, detail: null };
|
||||
return {
|
||||
summary: detailMatch[1].trim(),
|
||||
detailLabel: detailMatch[2].toUpperCase(),
|
||||
detail: detailMatch[3].trim()
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="system-message-body" class:expanded>
|
||||
<div class="system-message-kicker"><span aria-hidden="true">◇</span><b>ISAC TRANSMISSION</b></div>
|
||||
<p>{parsed.summary}</p>
|
||||
{#if parsed.detail}
|
||||
<div class="system-message-detail">
|
||||
<span>{parsed.detailLabel}</span>
|
||||
<p>{parsed.detail}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -41,3 +41,18 @@ export async function sendAuthenticationLink(email: string, token: string, purpo
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNotificationEmail(email: string, title: string, body: string, link: string | null): Promise<void> {
|
||||
const url = link ? `${applicationOrigin()}${link.startsWith('/') ? link : `/${link}`}` : `${applicationOrigin()}/account/notifications`;
|
||||
await mailer().sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to: email,
|
||||
subject: `${title} — SHD.build`,
|
||||
text: `${title}\n\n${body}\n\nOpen SHD.build: ${url}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD NOTIFICATION NETWORK</p><h1 style="font-size:24px">${escapeHtml(title)}</h1><p style="color:#a5aaab;line-height:1.6">${escapeHtml(body)}</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">OPEN SHD INBOX</a></p></div>`
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { sendNotificationEmail } from '$lib/server/auth/mail';
|
||||
|
||||
export const NOTIFICATION_CATEGORIES = ['isac', 'contributions', 'comments', 'builds', 'guides', 'system'] as const;
|
||||
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
|
||||
|
||||
export async function createNotification(input: {
|
||||
userId: string;
|
||||
category: NotificationCategory;
|
||||
source: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string | null;
|
||||
mandatory?: boolean;
|
||||
}) {
|
||||
const database = await getDatabase();
|
||||
const preference = await (await database.prepare(
|
||||
'SELECT in_app_enabled,browser_enabled,email_frequency FROM notification_preferences WHERE user_id=? AND category=?'
|
||||
)).get(input.userId, input.category);
|
||||
const mandatory = Boolean(input.mandatory || input.category === 'isac');
|
||||
const inApp = mandatory || preference?.in_app_enabled === undefined || Boolean(preference.in_app_enabled);
|
||||
const browser = Boolean(preference?.browser_enabled);
|
||||
const emailFrequency = String(preference?.email_frequency ?? 'none');
|
||||
if (!inApp && !browser && emailFrequency === 'none') return null;
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(
|
||||
id, input.userId, input.category, input.source, input.title.slice(0, 140),
|
||||
input.body.slice(0, 2000), input.link ?? null, mandatory ? 1 : 0, now
|
||||
);
|
||||
if (emailFrequency === 'immediate') {
|
||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(input.userId);
|
||||
if (typeof identity?.email === 'string') {
|
||||
try {
|
||||
await sendNotificationEmail(identity.email, input.title, input.body, input.link ?? null);
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
} catch (error) {
|
||||
console.error('Notification email delivery failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function notificationSummary(userId: string) {
|
||||
const database = await getDatabase();
|
||||
const row = await (await database.prepare(`SELECT COUNT(*) AS unread FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId);
|
||||
return { unread: Number(row?.unread ?? 0) };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { notificationSummary } from '$lib/server/notifications';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
return { user, isacAccess: await hasPermission(user, 'isac.access') };
|
||||
return { user, isacAccess: await hasPermission(user, 'isac.access'), notifications: user ? await notificationSummary(user.id) : { unread: 0 } };
|
||||
};
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</svelte:head>
|
||||
|
||||
<AppShell user={data.user} isacAccess={data.isacAccess}>{@render children()}</AppShell>
|
||||
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
|
||||
|
||||
@@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
|
||||
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] = await Promise.all([
|
||||
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
|
||||
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
|
||||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
|
||||
@@ -24,7 +24,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
|
||||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
|
||||
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
|
||||
getAuthenticationMethods(database, user.id)
|
||||
getAuthenticationMethods(database, user.id),
|
||||
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,n.created_at,n.read_at
|
||||
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
|
||||
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id)
|
||||
]);
|
||||
const oauthErrorCode = url.searchParams.get('oauth_error');
|
||||
const oauthErrors: Record<string, { title: string; message: string }> = {
|
||||
@@ -48,7 +55,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
account: { builds, guides, favorites, ratings },
|
||||
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
|
||||
discordConnection: connection?.discord_id ? {
|
||||
id: String(connection.discord_id),
|
||||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
||||
@@ -24,6 +25,19 @@
|
||||
function initialAvatarPanel(): AvatarPanel {
|
||||
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
|
||||
}
|
||||
const notificationIsIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
|
||||
const notificationTone = (notification: any) => {
|
||||
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
|
||||
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
|
||||
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
const notificationSeverity = (notification: any) => {
|
||||
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
|
||||
if (/(error|failed|could not)/.test(text)) return 'ERROR';
|
||||
if (/(warning|violation|needs revision)/.test(text)) return 'WARNING';
|
||||
return null;
|
||||
};
|
||||
let accountPanel = $state<AccountPanel>(initialAccountPanel());
|
||||
let accessPanel = $state<AccessPanel>(initialAccessPanel());
|
||||
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
|
||||
@@ -65,8 +79,8 @@
|
||||
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
|
||||
<main class="page profile-page">
|
||||
<section class="profile-command">
|
||||
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
|
||||
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div></div>
|
||||
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
|
||||
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
|
||||
</section>
|
||||
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
|
||||
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
|
||||
@@ -74,6 +88,14 @@
|
||||
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
|
||||
<div class="profile-dashboard">
|
||||
<section class="profile-feed">
|
||||
<div class="profile-module profile-notifications">
|
||||
<div class="module-head"><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a class="secondary" href="/account/notifications">OPEN INBOX{data.account.notificationUnread ? ` · ${data.account.notificationUnread} UNREAD` : ''} →</a></div>
|
||||
<div class="profile-notification-list">
|
||||
{#each data.account.notifications as notification}
|
||||
<a class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={notificationIsIsac(notification)} class:positive={notificationTone(notification) === 'positive'} class:negative={notificationTone(notification) === 'negative'} href="/account/notifications"><div><img src={notificationIsIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp'} alt=""/><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {new Date(String(notification.created_at)).toLocaleDateString()}</small></span></div><span><strong>{notification.title}</strong><small>{notification.body}</small></span><em>{notificationSeverity(notification) ?? 'OPEN'} ›</em></a>
|
||||
{:else}<div class="profile-empty compact"><span>◇</span><b>NO NOTIFICATIONS</b><p>ISAC and community messages will appear here.</p></div>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
|
||||
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><button>OPEN →</button></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
|
||||
</div>
|
||||
@@ -89,7 +111,7 @@
|
||||
|
||||
{#if accountPanel === 'identity'}
|
||||
<div class="account-panel" id="identity-panel" role="tabpanel" aria-labelledby="identity-tab">
|
||||
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
|
||||
<div id="avatar" class="profile-anchor" aria-hidden="true"></div><p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
|
||||
<div class="account-subtabs" role="tablist" aria-label="Avatar source">
|
||||
<button class:active={avatarPanel === 'presets'} role="tab" aria-selected={avatarPanel === 'presets'} onclick={() => avatarPanel = 'presets'}>SHD PRESETS</button>
|
||||
<button class:active={avatarPanel === 'upload'} role="tab" aria-selected={avatarPanel === 'upload'} onclick={() => avatarPanel = 'upload'}>CUSTOM UPLOAD</button>
|
||||
@@ -190,3 +212,15 @@
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.profile-notification-list a > div { grid-template-columns: 30px minmax(0, 1fr); align-items: center; }
|
||||
.profile-notification-list a > div > img { width: 30px; height: 30px; object-fit: cover; border: 1px solid #3b4446; border-radius: 50%; background: #111; }
|
||||
.profile-notification-list a > div > span { min-width: 0; display: grid; gap: 3px; }
|
||||
.profile-notification-list a.isac-message b { color: #8ecce5; }
|
||||
.profile-notification-list a.positive { border-left: 2px solid var(--shd-success); background: #101713; }
|
||||
.profile-notification-list a.negative { border-left: 2px solid var(--shd-danger); background: #191111; }
|
||||
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
|
||||
.profile-notification-list a.positive em { color: var(--shd-success); }
|
||||
.profile-notification-list a.negative em { color: var(--shd-danger); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const category = NOTIFICATION_CATEGORIES.includes(url.searchParams.get('category') as any) ? String(url.searchParams.get('category')) : '';
|
||||
const unreadOnly = url.searchParams.get('state') === 'unread';
|
||||
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
||||
const args: unknown[] = [user.id];
|
||||
if (category) { conditions.push('n.category=?'); args.push(category); }
|
||||
if (unreadOnly) conditions.push('n.read_at IS NULL');
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
|
||||
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
|
||||
const stored = preferenceRows.find((row) => row.category === name);
|
||||
return {
|
||||
category: name,
|
||||
inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled),
|
||||
browser: Boolean(stored?.browser_enabled),
|
||||
emailFrequency: String(stored?.email_frequency ?? 'none')
|
||||
};
|
||||
});
|
||||
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
|
||||
return { notifications, preferences, pagination, filters: { category, unreadOnly }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
markRead: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?')).run(new Date().toISOString(), id, user.id);
|
||||
return { notificationUpdated: true };
|
||||
},
|
||||
markAllRead: async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL')).run(new Date().toISOString(), user.id);
|
||||
return { notificationsCleared: true };
|
||||
},
|
||||
savePreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const hasEmail = Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id));
|
||||
for (const category of NOTIFICATION_CATEGORIES) {
|
||||
const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on';
|
||||
const browser = form.get(`browser:${category}`) === 'on';
|
||||
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
|
||||
if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.' });
|
||||
if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.' });
|
||||
await (await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET
|
||||
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
|
||||
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
|
||||
}
|
||||
return { notificationPreferencesSaved: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
|
||||
let { data, form } = $props();
|
||||
let settingsOpen = $state(false);
|
||||
let selected = $state<any>(null);
|
||||
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
|
||||
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
|
||||
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)));
|
||||
const isIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
|
||||
const tone = (notification: any) => {
|
||||
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
|
||||
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
|
||||
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
|
||||
return 'neutral';
|
||||
};
|
||||
const severity = (notification: any) => {
|
||||
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
|
||||
if (/(error|failed|could not)/.test(text)) return 'error';
|
||||
if (/(warning|violation|needs revision)/.test(text)) return 'warning';
|
||||
return null;
|
||||
};
|
||||
const avatar = (notification: any) => isIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp';
|
||||
const action = (notification: any) => {
|
||||
if (!notification.link) return null;
|
||||
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
|
||||
if (text.includes('avatar') && /(removed|rejected|not approved)/.test(text)) return { label: 'UPLOAD NEW AVATAR', href: '/account#avatar' };
|
||||
if (notification.category === 'comments' && text.includes('guide')) return { label: 'VIEW GUIDE & REPLY', href: notification.link };
|
||||
if (notification.category === 'comments') return { label: 'VIEW BUILD & REPLY', href: notification.link };
|
||||
if (notification.category === 'contributions' && /(needs revision|not approved|could not)/.test(text)) return { label: 'REVISE CONTRIBUTION', href: notification.link };
|
||||
if (notification.category === 'contributions' && /(approved|deployed)/.test(text)) return { label: 'VIEW PUBLISHED DATA', href: notification.link };
|
||||
if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link };
|
||||
if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link };
|
||||
if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
|
||||
return { label: 'OPEN RELATED PAGE', href: notification.link };
|
||||
};
|
||||
async function enableBrowser() {
|
||||
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
|
||||
browserPermission = await Notification.requestPermission();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
|
||||
<main class="page profile-page notification-page">
|
||||
<section class="notification-command" aria-labelledby="notification-page-title">
|
||||
<div>
|
||||
<PageBreadcrumb items={[{ label: 'AGENT PROFILE', href: '/account' }, { label: 'NOTIFICATIONS' }]} />
|
||||
<h1 id="notification-page-title">INBOX</h1>
|
||||
<p>ISAC directives, contribution decisions, and community activity.</p>
|
||||
</div>
|
||||
<div class="notification-command-actions">
|
||||
<div class="notification-unread-summary"><strong>{data.unread}</strong><span>UNREAD</span></div>
|
||||
<button class="secondary" type="button" aria-expanded={settingsOpen} onclick={() => settingsOpen = !settingsOpen}>PREFERENCES <span aria-hidden="true">⚙</span></button>
|
||||
</div>
|
||||
</section>
|
||||
{#if settingsOpen}
|
||||
<section class="notification-settings">
|
||||
<div class="module-head"><div><p class="eyebrow">DELIVERY CONTROL</p><h2>NOTIFICATION PREFERENCES</h2></div><span>BROWSER: {browserPermission.toUpperCase()}</span></div>
|
||||
<p class="notification-rule"><b>ISAC INBOX DELIVERY IS MANDATORY.</b> Browser and email delivery remain optional. Email is disabled by default.</p>
|
||||
{#if browserPermission !== 'granted'}<button class="secondary browser-permission" type="button" onclick={enableBrowser}>ENABLE BROWSER PERMISSION →</button>{/if}
|
||||
<PendingForm action="?/savePreferences" class="notification-preference-form">
|
||||
<div class="notification-preference-head"><span>CHANNEL</span><span>INBOX</span><span>BROWSER</span><span>EMAIL</span></div>
|
||||
{#each data.preferences as preference}
|
||||
<div class="notification-preference-row"><div><b>{labels[preference.category]}</b><small>{preference.category === 'isac' ? 'MANDATORY OPERATIONS MESSAGES' : preference.category === 'contributions' ? 'ISAC DECISIONS REMAIN REQUIRED' : 'OPTIONAL ACTIVITY CHANNEL'}</small></div><label><input type="checkbox" name={`inApp:${preference.category}`} checked={preference.inApp} disabled={preference.category === 'isac'}/><span>{preference.category === 'isac' ? 'REQUIRED' : 'ENABLED'}</span></label><label><input type="checkbox" name={`browser:${preference.category}`} checked={preference.browser} disabled={browserPermission !== 'granted'}/><span>ALERT</span></label><select name={`email:${preference.category}`} disabled={!data.hasVerifiedEmail}><option value="none" selected={preference.emailFrequency === 'none'}>OFF</option><option value="immediate" selected={preference.emailFrequency === 'immediate'}>IMMEDIATE</option><option value="daily" selected={preference.emailFrequency === 'daily'}>DAILY DIGEST</option></select></div>
|
||||
{/each}
|
||||
{#if !data.hasVerifiedEmail}<p class="notification-rule">Add a verified magic-link email from Agent Profile → Access to enable email delivery.</p>{/if}
|
||||
{#if form?.notificationPreferenceError}<p class="error">{form.notificationPreferenceError}</p>{/if}
|
||||
{#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if}
|
||||
<div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div>
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="notification-toolbar"><form><select name="category"><option value="">ALL CHANNELS</option>{#each Object.entries(labels) as [value,label]}<option {value} selected={data.filters.category === value}>{label}</option>{/each}</select><select name="state"><option value="">ALL MESSAGES</option><option value="unread" selected={data.filters.unreadOnly}>UNREAD ONLY</option></select><button class="secondary">APPLY FILTER →</button></form>{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}</div>
|
||||
<RegistryPagination pagination={data.pagination}/>
|
||||
<section class="notification-list">
|
||||
{#each data.notifications as notification}
|
||||
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
|
||||
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
|
||||
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
|
||||
</article>
|
||||
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{#if selected}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title">
|
||||
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
|
||||
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
|
||||
<h2 id="notification-detail-title">{selected.title}</h2>
|
||||
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
|
||||
<div class="form-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notification-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
height: auto;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 6px 9px 7px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--shd-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.notification-message-header h2 {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-message-header time {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
|
||||
|
||||
@@ -77,6 +78,7 @@ export const actions: Actions = {
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
|
||||
.run(now, reviewer.id, note, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
|
||||
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { deserializeProposal, refreshProposalDependencies } from '$lib/server/da
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['stats', 'talents', 'brands', 'gear', 'weapons', 'protocols'];
|
||||
@@ -110,10 +111,12 @@ export const actions: Actions = {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/admin/${proposal.module}`, mandatory: true });
|
||||
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
|
||||
}
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/admin/${proposal.module}`, mandatory: true });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
@@ -128,6 +131,7 @@ export const actions: Actions = {
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/admin/${proposal.module}`, mandatory: true });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/se
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
@@ -92,8 +93,10 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
removeCustomAvatar: async ({ cookies, params }) => {
|
||||
removeCustomAvatar: async ({ cookies, params, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim().slice(0, 500);
|
||||
if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.' });
|
||||
const database = await getDatabase();
|
||||
const avatar = await (await database.prepare(
|
||||
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
|
||||
@@ -102,17 +105,18 @@ export const actions: Actions = {
|
||||
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
|
||||
}
|
||||
await deleteUploadedAvatar(database, params.id);
|
||||
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note='Removed from agent operations'
|
||||
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
|
||||
.run(new Date().toISOString(), operator.id, params.id, avatar.updated_at);
|
||||
.run(new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: params.id,
|
||||
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
|
||||
after: { source: 'default', reviewSource: 'agent_operations' }
|
||||
after: { source: 'default', reviewSource: 'agent_operations', reason }
|
||||
});
|
||||
await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarRemoved: true };
|
||||
},
|
||||
setAccess: async ({ cookies, request, params }) => {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<img src={data.profile.avatar_url} alt={`${data.profile.display_name}'s avatar`}/>
|
||||
<div><b>{data.profile.avatar_source === 'upload' ? 'CUSTOM UPLOAD' : 'SHD PRESET'}</b><small>{data.profile.avatar_source === 'upload' ? `${Math.ceil(Number(data.profile.avatar_byte_size) / 1024)} KB · NORMALIZED WEBP` : 'APPROVED PORTRAIT LIBRARY'}</small></div>
|
||||
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}<button type="button" class="danger moderation-action" onclick={() => removalTarget = 'avatar'}>REMOVE</button>{:else}<span class:custom-avatar={data.profile.avatar_source === 'upload'}>{data.profile.avatar_source === 'upload' ? 'REVIEW' : 'APPROVED'}</span>{/if}
|
||||
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><PendingForm action="?/removeCustomAvatar"><button class="primary">CONFIRM REMOVE</button></PendingForm></div></div>{/if}
|
||||
{#if removalTarget === 'avatar'}<div class="isac-method-confirm avatar-remove-confirm avatar-remove-reason"><div><b>REMOVE CUSTOM AVATAR?</b><small>The uploaded file will be deleted and replaced by the default SHD portrait.</small></div><PendingForm action="?/removeCustomAvatar"><input name="reason" required minlength="3" maxlength="500" placeholder="REQUIRED REMOVAL REASON"/><div><button type="button" class="secondary" onclick={() => removalTarget = null}>CANCEL</button><button class="primary">CONFIRM REMOVE</button></div></PendingForm></div>{/if}
|
||||
</div>
|
||||
{#if data.profile.avatar_source === 'upload' && can('avatars.review')}
|
||||
<p class="console-note avatar-review-note">Removing this image deletes the uploaded file and resets the agent to the default SHD portrait.</p>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const database = await getDatabase();
|
||||
const rows = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
|
||||
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND COALESCE(p.browser_enabled,0)=1
|
||||
ORDER BY n.created_at DESC LIMIT 20`)).all(user.id);
|
||||
const count = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
|
||||
return json({ count, notifications: rows });
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
redirect(308, `/account/notifications${url.search}`);
|
||||
};
|
||||
@@ -23,7 +23,7 @@
|
||||
<section id="accounts"><span>02</span><div><h2>ACCOUNTS & SECURITY</h2><p>You are responsible for activity performed through your account and for maintaining control of your authentication methods. You must provide accurate information, may not impersonate another person, and may not attempt to access another account. We may restrict accounts to protect users, enforce these terms, or comply with law.</p><p>Available authentication methods may include passkeys, verified email magic links, local username and password credentials, Discord, and other providers added later. Third-party authentication remains subject to that provider’s terms and privacy practices.</p></div></section>
|
||||
<section id="content"><span>03</span><div><h2>USER CONTENT</h2><p>You retain ownership of original content you submit. You grant SHD.build a non-exclusive, worldwide, royalty-free license to host, reproduce, format, display, and distribute that content as necessary to operate the service. You represent that you have the rights required to post it.</p><p>Builds, guides, and comments may remain available after account deletion under a “Deleted Agent” attribution so community references are not broken. You may not upload unlawful, infringing, malicious, or privacy-invasive content.</p></div></section>
|
||||
<section id="conduct"><span>04</span><div><h2>COMMUNITY CONDUCT</h2><p>Do not harass others; publish illegal or infringing material; distribute malware; evade access restrictions; scrape or overload the service; manipulate ratings; spam; or exploit security vulnerabilities. Community Oversight may remove content, restrict access, or preserve relevant records when reasonably necessary for safety, support, abuse prevention, or legal obligations.</p></div></section>
|
||||
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, and comments. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>OAuth and email providers process information under their own policies.</p></div></section>
|
||||
<section id="privacy"><span>05</span><div><h2>INFORMATION WE PROCESS</h2><p>Account data may include internal account identifiers, private login username, public display name, password hash, verified email, linked-provider identifiers and profile details, passkey public credentials, avatar, roles, permissions, builds, guides, favorites, ratings, comments, notification messages, read state, and delivery preferences. Passwords are stored only as salted Argon2id hashes. Private passkey material remains on your authenticator.</p><p>For login security and support, events may include the time, success status, authentication provider, IP address, user-agent string, normalized browser, operating system and device type, approximate country/region/city when supplied by the hosting proxy, failure reason, automated risk flags, provider or passkey credential reference, associated session identifier, and session end time or reason. We do not sell this information. It is used for authentication, abuse prevention, troubleshooting, moderation, and account support.</p><p>Browser and email notification delivery is optional except that operational ISAC notices remain available in the in-app inbox. Email frequency is configurable by notification category and defaults to off. OAuth and email providers process information under their own policies.</p></div></section>
|
||||
<section id="cookies"><span>06</span><div><h2>ESSENTIAL COOKIES</h2><p>SHD.build currently uses only first-party cookies that are strictly necessary to provide authentication and protect sign-in flows. We do not currently use analytics, advertising, behavioral-tracking, or personalization cookies. These essential cookies cannot be disabled through a preference control. You may prevent cookies through your browser, but account sign-in will not function correctly.</p><div class="legal-table-wrap"><table class="legal-table"><thead><tr><th>COOKIE</th><th>PURPOSE</th><th>DURATION</th><th>PROTECTION</th></tr></thead><tbody><tr><td><code>shd_session</code></td><td>Keeps you securely signed in and associates requests with your server-side session.</td><td>Up to 30 days, or until sign-out or session invalidation.</td><td>HTTP-only, SameSite=Lax, Secure in production.</td></tr><tr><td><code>shd_discord_oauth_state</code></td><td>Validates a Discord sign-in or linking response and helps prevent request forgery.</td><td>Up to 10 minutes; removed when the callback is processed.</td><td>HTTP-only, SameSite=Lax, Secure in production; limited to the Discord authentication path.</td></tr></tbody></table></div><p>The informational notice remembers its dismissal in browser local storage under <code>shd-essential-cookie-notice</code>. It is not sent with web requests and can be removed through your browser’s site-data controls. OAuth state remains in a short-lived HTTP-only cookie because it must be securely validated by the server and unavailable to browser scripts.</p><p>If non-essential technologies are introduced later, this policy and the site’s consent controls will be updated before those technologies are enabled where consent is required.</p></div></section>
|
||||
<section id="retention"><span>07</span><div><h2>RETENTION & DELETION</h2><p>IP addresses, user-agent strings, normalized device information, and credential references in login history are retained for no more than 30 days, then automatically removed from the event. Non-sensitive facts such as event time, provider, outcome, failure category, and whether a session was created may remain for security and support history.</p><p>Deleting your account removes active sessions, credentials, verified email, provider profiles, passkeys, permissions, and uploaded avatar. You may also select immediate purging of retained login-event history during self-deletion. Public contributions may remain attributed to “Deleted Agent.” Backups and records required for fraud prevention, dispute resolution, or law may persist for a limited period where applicable.</p></div></section>
|
||||
<section id="third-parties"><span>08</span><div><h2>THIRD-PARTY SERVICES</h2><p>SHD.build may connect to Discord, email delivery services, and future identity providers. Their services are governed by their own terms. Links to other sites do not imply endorsement. You authorize the minimum provider data requested when choosing a linked sign-in method.</p></div></section>
|
||||
|
||||
Reference in New Issue
Block a user