diff --git a/apps/web/api/_lib/report.ts b/apps/web/api/_lib/report.ts index 398ba7e..7371c89 100644 --- a/apps/web/api/_lib/report.ts +++ b/apps/web/api/_lib/report.ts @@ -345,6 +345,29 @@ export async function generateExcel( return Buffer.from(buf) } +// ─── Recipients ────────────────────────────────────────────────────────────── + +// Reads report_recipients from the settings table; falls back to ADMIN_EMAIL. +export async function fetchRecipients(): Promise { + try { + const supabase = makeSupabase() + const { data } = await supabase + .from('admin_settings') + .select('value') + .eq('key', 'report_recipients') + .single() + const dbValue = data?.value?.trim() ?? '' + if (dbValue) { + return dbValue.split(',').map(e => e.trim()).filter(e => e.length > 0) + } + } catch { + // fall through to env-var fallback + } + const adminEmail = process.env.ADMIN_EMAIL + if (!adminEmail) throw new Error('Missing ADMIN_EMAIL and no recipients configured in settings') + return [adminEmail] +} + // ─── Email ──────────────────────────────────────────────────────────────────── export async function sendEmail( @@ -354,11 +377,11 @@ export async function sendEmail( transactions: EnrichedTransaction[], monthLabel: string, reportMonth: string, + recipients: string[], ): Promise { const resendKey = process.env.RESEND_API_KEY - const adminEmail = process.env.ADMIN_EMAIL if (!resendKey) throw new Error('Missing RESEND_API_KEY') - if (!adminEmail) throw new Error('Missing ADMIN_EMAIL') + if (recipients.length === 0) throw new Error('No report recipients configured') const resend = new Resend(resendKey) const totalCents = transactions.reduce((s, t) => s + t.total_cents, 0) @@ -436,7 +459,7 @@ export async function sendEmail( await resend.emails.send({ from: 'Kaffeelisten ', - to: [adminEmail], + to: recipients, subject: `Kaffeelisten – Monatsbericht ${monthName} ${yearStr}`, html, attachments: [ @@ -545,11 +568,12 @@ export async function deactivateInactiveMembers(): Promise { export async function runMonthlyReport(forMonth?: string): Promise { const { transactions, reportMonth, monthLabel } = await fetchAndEnrich(forMonth) const summaries = computeSummary(transactions) - const [pdfBuffer, xlsxBuffer] = await Promise.all([ + const [pdfBuffer, xlsxBuffer, recipients] = await Promise.all([ generatePdf(summaries, transactions, monthLabel, reportMonth), generateExcel(summaries, transactions), + fetchRecipients(), ]) - await sendEmail(pdfBuffer, xlsxBuffer, summaries, transactions, monthLabel, reportMonth) + await sendEmail(pdfBuffer, xlsxBuffer, summaries, transactions, monthLabel, reportMonth, recipients) await archiveTransactions(transactions, reportMonth) await pruneOldTransactions() await deactivateInactiveMembers() diff --git a/apps/web/api/admin/archive.ts b/apps/web/api/admin/archive.ts new file mode 100644 index 0000000..7ac0dbd --- /dev/null +++ b/apps/web/api/admin/archive.ts @@ -0,0 +1,25 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { fetchAndEnrich, archiveTransactions, pruneOldTransactions } from '../_lib/report' + +export const config = { maxDuration: 60 } + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }) + + const adminPin = process.env.ADMIN_PIN + if (!adminPin || req.headers['x-admin-pin'] !== adminPin) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + try { + const { month } = (req.body ?? {}) as { month?: string } + const { transactions, reportMonth } = await fetchAndEnrich(month) + await archiveTransactions(transactions, reportMonth) + await pruneOldTransactions() + return res.status(200).json({ ok: true, archivedCount: transactions.length, reportMonth }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[admin/archive]', message) + return res.status(500).json({ error: message }) + } +} diff --git a/apps/web/api/admin/change-pin.ts b/apps/web/api/admin/change-pin.ts new file mode 100644 index 0000000..f21d582 --- /dev/null +++ b/apps/web/api/admin/change-pin.ts @@ -0,0 +1,50 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +function makeSupabase() { + const url = process.env.VITE_SUPABASE_URL + const key = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!url || !key) throw new Error('Missing Supabase credentials') + return createClient(url, key) +} + +async function getEffectivePin(supabase: SupabaseClient): Promise { + try { + const { data } = await supabase + .from('admin_settings') + .select('value') + .eq('key', 'admin_pin') + .single() + const dbPin = data?.value?.trim() ?? '' + return dbPin || (process.env.ADMIN_PIN ?? null) + } catch { + return process.env.ADMIN_PIN ?? null + } +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') return res.status(405).end() + + const { currentPin, newPin } = (req.body ?? {}) as { currentPin?: string; newPin?: string } + if (typeof currentPin !== 'string' || typeof newPin !== 'string') { + return res.status(400).json({ error: 'currentPin and newPin required' }) + } + if (!/^\d{4}$/.test(newPin)) { + return res.status(400).json({ error: 'PIN must be exactly 4 digits' }) + } + + const supabase = makeSupabase() + const effectivePin = await getEffectivePin(supabase) + + if (!effectivePin || currentPin !== effectivePin) { + return res.status(403).json({ error: 'Aktuelle PIN ist falsch' }) + } + + const { error } = await supabase + .from('admin_settings') + .upsert({ key: 'admin_pin', value: newPin, updated_at: new Date().toISOString() }, { onConflict: 'key' }) + + if (error) return res.status(500).json({ error: error.message }) + + return res.status(200).json({ ok: true }) +} diff --git a/apps/web/api/admin/settings.ts b/apps/web/api/admin/settings.ts new file mode 100644 index 0000000..2431188 --- /dev/null +++ b/apps/web/api/admin/settings.ts @@ -0,0 +1,48 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { createClient } from '@supabase/supabase-js' + +function makeSupabase() { + const url = process.env.VITE_SUPABASE_URL + const key = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!url || !key) throw new Error('Missing VITE_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY') + return createClient(url, key) +} + +function pinOk(req: VercelRequest): boolean { + const adminPin = process.env.ADMIN_PIN + return Boolean(adminPin) && req.headers['x-admin-pin'] === adminPin +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (!pinOk(req)) return res.status(401).json({ error: 'Unauthorized' }) + + const supabase = makeSupabase() + + // GET /api/admin/settings?key= + if (req.method === 'GET') { + const { key } = req.query + if (typeof key !== 'string') return res.status(400).json({ error: 'Missing key' }) + const { data, error } = await supabase + .from('admin_settings') + .select('value, updated_at') + .eq('key', key) + .single() + if (error) return res.status(500).json({ error: error.message }) + return res.status(200).json({ key, value: data?.value ?? '', updated_at: data?.updated_at ?? null }) + } + + // POST /api/admin/settings body: { key, value } + if (req.method === 'POST') { + const { key, value } = (req.body ?? {}) as { key?: string; value?: string } + if (typeof key !== 'string' || typeof value !== 'string') { + return res.status(400).json({ error: 'Missing key or value' }) + } + const { error } = await supabase + .from('admin_settings') + .upsert({ key, value, updated_at: new Date().toISOString() }, { onConflict: 'key' }) + if (error) return res.status(500).json({ error: error.message }) + return res.status(200).json({ ok: true }) + } + + return res.status(405).json({ error: 'Method not allowed' }) +} diff --git a/apps/web/api/admin/verify-pin.ts b/apps/web/api/admin/verify-pin.ts index 94d6bf5..dc1bd66 100644 --- a/apps/web/api/admin/verify-pin.ts +++ b/apps/web/api/admin/verify-pin.ts @@ -1,18 +1,38 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' +import { createClient } from '@supabase/supabase-js' -export default function handler(req: VercelRequest, res: VercelResponse) { - if (req.method !== 'POST') { - return res.status(405).end() +// DB admin_pin takes precedence; falls back to ADMIN_PIN env var so existing +// deployments keep working without any manual migration step. +async function getEffectivePin(): Promise { + const url = process.env.VITE_SUPABASE_URL + const key = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!url || !key) return process.env.ADMIN_PIN ?? null + + try { + const supabase = createClient(url, key) + const { data } = await supabase + .from('admin_settings') + .select('value') + .eq('key', 'admin_pin') + .single() + const dbPin = data?.value?.trim() ?? '' + return dbPin || (process.env.ADMIN_PIN ?? null) + } catch { + return process.env.ADMIN_PIN ?? null } +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'POST') return res.status(405).end() const { pin } = req.body as { pin?: string } - const adminPin = process.env.ADMIN_PIN + const effectivePin = await getEffectivePin() - if (!adminPin) { + if (!effectivePin) { return res.status(500).json({ error: 'ADMIN_PIN not configured' }) } - if (typeof pin === 'string' && pin === adminPin) { + if (typeof pin === 'string' && pin === effectivePin) { return res.status(200).json({ ok: true }) } diff --git a/apps/web/src/lib/database.types.ts b/apps/web/src/lib/database.types.ts index d76ae98..0fac1ff 100644 --- a/apps/web/src/lib/database.types.ts +++ b/apps/web/src/lib/database.types.ts @@ -132,6 +132,27 @@ export type Database = { } ] } + admin_settings: { + Row: { + id: string + key: string + value: string + updated_at: string + } + Insert: { + id?: string + key: string + value?: string + updated_at?: string + } + Update: { + id?: string + key?: string + value?: string + updated_at?: string + } + Relationships: [] + } transactions_archive: { Row: { id: string diff --git a/apps/web/src/pages/AdminDashboard.tsx b/apps/web/src/pages/AdminDashboard.tsx index 0b46bbf..9cd4fca 100644 --- a/apps/web/src/pages/AdminDashboard.tsx +++ b/apps/web/src/pages/AdminDashboard.tsx @@ -15,6 +15,7 @@ import AdminIcon from '../components/admin/AdminIcon' import ItemsPage from './admin/ItemsPage' import CompaniesPage from './admin/CompaniesPage' import MembersPage from './admin/MembersPage' +import SettingsPage from './admin/SettingsPage' type PageId = 'dashboard' | 'log' | 'companies' | 'members' | 'items' | 'settings' @@ -467,16 +468,7 @@ export default function AdminDashboard() { {/* ── Settings ── */} {activePage === 'settings' && ( - <> - setSidebarOpen(true)} /> -
- -
- + setSidebarOpen(true)} /> )} diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx new file mode 100644 index 0000000..d97fed0 --- /dev/null +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -0,0 +1,387 @@ +import { useEffect, useState } from 'react' +import { supabase } from '../../lib/supabase' +import { Topbar } from '../../components/admin/Topbar' +import AdminButton from '../../components/admin/AdminButton' +import AdminIcon from '../../components/admin/AdminIcon' +import Modal from '../../components/admin/Modal' +import SummaryCard from '../../components/admin/SummaryCard' + +interface Props { + onToast: (msg: string) => void + onMenuClick: () => void +} + +interface ArchiveStats { + totalCount: number + distinctMonths: number + oldestMonth: string | null + newestMonth: string | null +} + +interface RecipientsState { + loading: boolean + emails: string[] + saving: boolean +} + +interface PinState { + current: string + next: string + confirm: string + saving: boolean + error: string | null +} + +interface DataState { + liveCount: number | null + archive: ArchiveStats | null + loadingStats: boolean + archiving: boolean +} + +const INPUT_CLASS = + 'h-11 px-3 bg-stone-100 border border-stone-200 rounded text-stone-900 text-base ' + + 'focus:border-amber-600 focus:ring-1 focus:ring-amber-600 focus:bg-white outline-none transition-colors w-full' + +const LABEL_CLASS = 'text-xs font-medium text-stone-500 uppercase tracking-wide' + +function SectionCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function monthRangeLabel(oldest: string | null, newest: string | null): string { + if (!oldest) return 'Kein Archiv' + if (oldest === newest) return oldest + return `${oldest} – ${newest}` +} + +export default function SettingsPage({ onToast, onMenuClick }: Props) { + const [recipients, setRecipients] = useState({ loading: true, emails: [], saving: false }) + const [addEmailInput, setAddEmailInput] = useState('') + const [pinState, setPinState] = useState({ current: '', next: '', confirm: '', saving: false, error: null }) + const [dataState, setDataState] = useState({ liveCount: null, archive: null, loadingStats: true, archiving: false }) + const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false) + + const pin = () => sessionStorage.getItem('adminPin') ?? '' + + const loadStats = async () => { + setDataState(prev => ({ ...prev, loadingStats: true })) + const [liveRes, archiveRes] = await Promise.all([ + supabase.from('transactions').select('*', { count: 'exact', head: true }), + supabase.from('transactions_archive').select('report_month'), + ]) + const months = [...new Set((archiveRes.data ?? []).map(r => r.report_month as string))].sort() + setDataState(prev => ({ + ...prev, + loadingStats: false, + liveCount: liveRes.count ?? 0, + archive: { + totalCount: archiveRes.data?.length ?? 0, + distinctMonths: months.length, + oldestMonth: months[0] ?? null, + newestMonth: months[months.length - 1] ?? null, + }, + })) + } + + useEffect(() => { + // Load recipients + fetch(`/api/admin/settings?key=report_recipients`, { headers: { 'x-admin-pin': pin() } }) + .then(r => r.json()) + .then((data: { value: string }) => { + const emails = data.value ? data.value.split(',').map(e => e.trim()).filter(Boolean) : [] + setRecipients(prev => ({ ...prev, loading: false, emails })) + }) + .catch(() => setRecipients(prev => ({ ...prev, loading: false }))) + + loadStats() + }, []) + + // ── Recipients ────────────────────────────────────────────────────────────── + + const saveRecipients = async (emails: string[]) => { + setRecipients(prev => ({ ...prev, saving: true })) + const res = await fetch('/api/admin/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-admin-pin': pin() }, + body: JSON.stringify({ key: 'report_recipients', value: emails.join(', ') }), + }) + setRecipients(prev => ({ ...prev, saving: false, emails: res.ok ? emails : prev.emails })) + onToast(res.ok ? 'Empfänger gespeichert.' : 'Fehler beim Speichern.') + } + + const handleAddEmail = () => { + const email = addEmailInput.trim().toLowerCase() + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + onToast('Bitte eine gültige E-Mail-Adresse eingeben.') + return + } + if (recipients.emails.includes(email)) { + onToast('Diese Adresse ist bereits eingetragen.') + return + } + const updated = [...recipients.emails, email] + setAddEmailInput('') + saveRecipients(updated) + } + + const handleRemoveEmail = (email: string) => { + saveRecipients(recipients.emails.filter(e => e !== email)) + } + + // ── PIN change ────────────────────────────────────────────────────────────── + + const handleChangePin = async () => { + if (!/^\d{4}$/.test(pinState.next)) { + setPinState(s => ({ ...s, error: 'Die neue PIN muss genau 4 Ziffern enthalten.' })) + return + } + if (pinState.next !== pinState.confirm) { + setPinState(s => ({ ...s, error: 'Die PINs stimmen nicht überein.' })) + return + } + setPinState(s => ({ ...s, saving: true, error: null })) + const res = await fetch('/api/admin/change-pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPin: pinState.current, newPin: pinState.next }), + }) + const data = await res.json() as { error?: string } + if (res.ok) { + sessionStorage.setItem('adminPin', pinState.next) + setPinState({ current: '', next: '', confirm: '', saving: false, error: null }) + onToast('PIN erfolgreich geändert.') + } else { + setPinState(s => ({ ...s, saving: false, error: data.error ?? 'Fehler beim Speichern.' })) + } + } + + // ── Manual archive ────────────────────────────────────────────────────────── + + const handleManualArchive = async () => { + setDataState(prev => ({ ...prev, archiving: true })) + const res = await fetch('/api/admin/archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-admin-pin': pin() }, + body: JSON.stringify({}), + }) + const data = await res.json() as { archivedCount?: number; error?: string } + setDataState(prev => ({ ...prev, archiving: false })) + setArchiveConfirmOpen(false) + if (res.ok) { + onToast(`${data.archivedCount ?? 0} Einträge archiviert.`) + await loadStats() + } else { + onToast('Fehler beim Archivieren.') + } + } + + const pinValid = pinState.current.length > 0 && /^\d{4}$/.test(pinState.next) && pinState.next === pinState.confirm + + return ( + <> + +
+ + {/* ── Berichtsempfänger ── */} + +

+ Diese Adressen erhalten den monatlichen Bericht per E-Mail. +

+ {recipients.loading ? ( +
+ ) : ( +
+ {recipients.emails.length === 0 && ( + Noch keine Empfänger eingetragen. + )} + {recipients.emails.map(email => ( + + {email} + + + ))} +
+ )} +
+ setAddEmailInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddEmail() }} + disabled={recipients.saving} + /> + } + onClick={handleAddEmail} + disabled={!addEmailInput.trim() || recipients.saving} + > + Hinzufügen + +
+ + + {/* ── PIN ändern ── */} + +

+ Die neue PIN muss aus genau 4 Ziffern bestehen. +

+
+
+ + setPinState(s => ({ ...s, current: e.target.value, error: null }))} + /> +
+
+ + setPinState(s => ({ ...s, next: e.target.value, error: null }))} + /> +
+
+ + setPinState(s => ({ ...s, confirm: e.target.value, error: null }))} + /> +
+
+ {pinState.error && ( +

{pinState.error}

+ )} +
+ + {pinState.saving ? 'Speichern…' : 'PIN ändern'} + +
+
+ + {/* ── Datenverwaltung ── */} + + {dataState.loadingStats ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ + + +
+ )} +
+ } + onClick={() => setArchiveConfirmOpen(true)} + disabled={dataState.archiving || (dataState.liveCount ?? 0) === 0} + > + Manuell archivieren + +
+ + + {/* ── Über die App ── */} + +
+
+
Version
+
0.1.0
+
+ +
+
Erstellt für
+
B4Y3RW4LD Hackathon · ITC1 Deggendorf · Mai 2026
+
+
+
+
+ + setArchiveConfirmOpen(false)} + title="Einträge archivieren" + actions={ + <> + setArchiveConfirmOpen(false)}> + Abbrechen + + } + > + {dataState.archiving ? 'Archivieren…' : 'Jetzt archivieren'} + + + } + > + Die aktuellen{' '} + {dataState.liveCount ?? 0} Einträge werden in das Archiv übertragen und aus der + Haupttabelle entfernt. Es wird kein Bericht versendet. + + + ) +} diff --git a/supabase/migrations/008_admin_settings.sql b/supabase/migrations/008_admin_settings.sql new file mode 100644 index 0000000..86e9ccd --- /dev/null +++ b/supabase/migrations/008_admin_settings.sql @@ -0,0 +1,24 @@ +-- Admin key-value settings store (report recipients, PIN override, etc.) +-- All access goes through service-role API endpoints — anon is blocked. + +create table if not exists admin_settings ( + id uuid primary key default gen_random_uuid(), + key text not null unique, + value text not null default '', + updated_at timestamptz not null default now() +); + +alter table admin_settings enable row level security; + +-- Seed default rows so reads always get a value without null-guards +insert into admin_settings (key, value) +values + ('report_recipients', ''), + ('admin_pin', '') +on conflict (key) do nothing; + +-- Allow anon client to read aggregate stats from the archive table +-- (used by SettingsPage to display archive counts without a server round-trip) +create policy "anon_read_archive_stats" on transactions_archive + for select to anon + using (true); diff --git a/supabase/migrations/009_fix_member_rls_update.sql b/supabase/migrations/009_fix_member_rls_update.sql new file mode 100644 index 0000000..062d411 --- /dev/null +++ b/supabase/migrations/009_fix_member_rls_update.sql @@ -0,0 +1,18 @@ +-- Migration 009: fix member active toggle in admin panel +-- +-- Root cause: PostgreSQL automatically applies a SELECT policy's USING clause +-- as an implicit WITH CHECK on any UPDATE that would make the row invisible. +-- The old SELECT policy had USING (active = true), so setting active = false +-- made the row fail its own visibility check → 42501 "new row violates RLS". +-- +-- Fix: drop the restrictive SELECT policy and replace with USING (true). +-- The member flow already applies eq('active', true) in application code, so +-- inactive members being technically readable via the REST API is not a concern. + +DROP POLICY IF EXISTS anon_read_active_members ON public.members; + +CREATE POLICY anon_read_members + ON public.members + FOR SELECT + TO anon + USING (true);