Files
web-app-launcher/src/lib/components/notifications/NotificationHistory.svelte
T
alexei.dolgolyov f1cfb61d13
Lint & Test / lint-and-check (push) Failing after 5m5s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped
feat: production hardening + password reset, metrics, signed webhooks
Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
  refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
  RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
  Manual redirect handling re-validates each 3xx Location hop. Applied to
  healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
  discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
  switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
  of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
  before pattern match, drops forbidden at-rules incl. @import without
  whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
  uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
  HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
  for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
  emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
  branches) + rate limiting on login/register/onboarding/refresh/invite/
  password-reset.
- OAuth callback sanitizes IdP error_description before echoing.

New features:
- Custom +error.svelte pages (root + boards + admin) via shared
  ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
  page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
  auth. Counters for login/healthcheck/notification/integration; gauges
  for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
  shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
  at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
  OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
  APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.

Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
  in board query; widgets lazy-loaded via dynamic imports (marked,
  DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.

Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
  IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
  CSS sanitizer, SVG sanitizer, rate limiter.

CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
  uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
  prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
  startup, log rotation, resource limits.
- README documents breaking-change upgrade path.

Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
  nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
  replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.

Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.

Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
2026-05-26 19:51:21 +03:00

170 lines
4.9 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
interface NotificationItem {
readonly id: string;
readonly appId: string | null;
readonly event: string;
readonly message: string;
readonly sentAt: string;
readonly readAt: string | null;
readonly app?: {
readonly name: string;
} | null;
}
let allNotifications = $state<NotificationItem[]>([]);
let loading = $state(true);
let currentPage = $state(1);
let hasMore = $state(false);
let filterEvent = $state('');
let filterAppId = $state('');
const PAGE_SIZE = 20;
async function loadNotifications(page: number = 1) {
loading = true;
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
});
if (filterEvent) params.set('event', filterEvent);
if (filterAppId) params.set('appId', filterAppId);
const res = await fetch(`/api/notifications?${params.toString()}`);
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
allNotifications = json.data;
hasMore = json.data.length === PAGE_SIZE;
}
}
} catch {
// Silently fail
} finally {
loading = false;
}
}
onMount(() => {
loadNotifications();
});
function changePage(delta: number) {
currentPage = Math.max(1, currentPage + delta);
loadNotifications(currentPage);
}
function applyFilters() {
currentPage = 1;
loadNotifications(1);
}
function eventLabel(event: string): string {
switch (event) {
case 'app_online': return 'Online';
case 'app_offline': return 'Offline';
case 'app_degraded': return 'Degraded';
default: return event;
}
}
function eventBadgeClass(event: string): string {
switch (event) {
case 'app_online': return 'bg-green-500/10 text-green-500';
case 'app_offline': return 'bg-red-500/10 text-red-500';
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
default: return 'bg-muted text-muted-foreground';
}
}
</script>
<div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<select
bind:value={filterEvent}
onchange={applyFilters}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
<option value="">All Events</option>
<option value="app_online">Online</option>
<option value="app_offline">Offline</option>
<option value="app_degraded">Degraded</option>
</select>
</div>
<!-- Table -->
{#if loading}
<div class="py-12 text-center text-muted-foreground">Loading...</div>
{:else if allNotifications.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">No notifications found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Time</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Event</th>
<th class="px-4 py-3 font-medium text-muted-foreground">App</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Message</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{#each allNotifications as notification (notification.id)}
<tr class="border-b border-border last:border-0">
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted-foreground">
{new Date(notification.sentAt).toLocaleString()}
</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {eventBadgeClass(notification.event)}">
{eventLabel(notification.event)}
</span>
</td>
<td class="px-4 py-3 text-sm text-foreground">
{notification.app?.name ?? '—'}
</td>
<td class="max-w-xs truncate px-4 py-3 text-sm text-foreground">
{notification.message}
</td>
<td class="px-4 py-3">
{#if notification.readAt}
<span class="text-xs text-muted-foreground">Read</span>
{:else}
<span class="text-xs font-medium text-primary">Unread</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between">
<button
type="button"
disabled={currentPage === 1}
onclick={() => changePage(-1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
<button
type="button"
disabled={!hasMore}
onclick={() => changePage(1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
{/if}
</div>