Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76af719fac | ||
|
|
ebab5627de | ||
|
|
01bbb3ba9a | ||
|
|
ebdd6c313a | ||
|
|
7d75e4345a | ||
|
|
7d7711eb8d | ||
|
|
a8d933bf5b |
@@ -33,9 +33,9 @@ TZ=Europe/Berlin
|
|||||||
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||||
# REBRICKABLE_API_KEY=
|
# REBRICKABLE_API_KEY=
|
||||||
|
|
||||||
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
|
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
|
||||||
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
|
# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
|
||||||
# funktioniert unveraendert.
|
# Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
|
||||||
# SMTP_HOST=mail.example.de
|
# SMTP_HOST=mail.example.de
|
||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
# SMTP_SECURE=false
|
# SMTP_SECURE=false
|
||||||
|
|||||||
@@ -7,14 +7,31 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
|
||||||
|
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
|
||||||
|
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
|
||||||
|
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
|
||||||
|
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
|
||||||
|
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
||||||
|
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
|
||||||
|
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
|
||||||
|
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
|
||||||
|
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
|
||||||
|
das Bild wird beim Speichern lokal abgelegt.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
|
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
|
||||||
|
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
|
||||||
|
schon beim Containerstart in der `.env` stand.
|
||||||
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
|
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
|
||||||
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
|
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
|
||||||
Lego-Nummer, von Hand).
|
Lego-Nummer, von Hand).
|
||||||
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
|
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
|
||||||
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
|
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
|
||||||
Export/Import als Kacheln in Gruppen, oben rechts hinter den Initialen.
|
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
|
||||||
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
|
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
|
||||||
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
|
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
|
||||||
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
|
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
|
||||||
@@ -25,15 +42,6 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
|
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
|
||||||
„Verwaltung" stand nur im Menü des Administrators.
|
„Verwaltung" stand nur im Menü des Administrators.
|
||||||
|
|
||||||
### Hinzugefügt
|
|
||||||
|
|
||||||
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
|
||||||
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
|
|
||||||
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
|
|
||||||
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
|
|
||||||
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
|
|
||||||
das Bild wird beim Speichern lokal abgelegt.
|
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
|
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
||||||
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
||||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||||
|
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
|
||||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||||
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
|
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
|
||||||
@@ -205,7 +206,7 @@ Session.
|
|||||||
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||||
Rate-Limiting auf den Login-Endpunkten.
|
Rate-Limiting auf den Login-Endpunkten.
|
||||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||||
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
|
||||||
Export.
|
Export.
|
||||||
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||||
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ tut.
|
|||||||
|
|
||||||
## 7. Passkey hinterlegen
|
## 7. Passkey hinterlegen
|
||||||
|
|
||||||
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (das Kästchen mit deinen Initialen oben rechts). Ab dann meldest du dich per
|
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
|
||||||
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
|
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
|
||||||
eigener Passkey – Handy, Tablet, Laptop.
|
eigener Passkey – Handy, Tablet, Laptop.
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Alles, was man nicht täglich braucht, liegt hinter dem Kästchen mit deinen
|
Alles, was man nicht täglich braucht, liegt hinter dem Button
|
||||||
Initialen oben rechts – der Seite **Mehr**: Konto und Passkeys, Design,
|
**„Mehr"** oben rechts: Konto und Passkeys, Design,
|
||||||
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
|
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
|
||||||
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
|
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
|
||||||
nicht erst.
|
nicht erst.
|
||||||
@@ -538,25 +538,27 @@ schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchsten
|
|||||||
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
|
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
|
||||||
Erinnerung ohne Handlungsbedarf ist nur Lärm.
|
Erinnerung ohne Handlungsbedarf ist nur Lärm.
|
||||||
|
|
||||||
Dafür braucht es einen SMTP-Zugang in der `.env`:
|
Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein:
|
||||||
|
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
|
||||||
|
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
|
||||||
|
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
|
||||||
|
nötig.
|
||||||
|
|
||||||
```bash
|
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
|
||||||
SMTP_HOST=mail.example.de
|
Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=bricktrack@example.de
|
|
||||||
SMTP_PASSWORD=…
|
|
||||||
SMTP_FROM=Bricktrack <bricktrack@example.de>
|
|
||||||
|
|
||||||
# Optional, Standard ist 14:
|
> Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
|
||||||
REMINDER_DAYS_BEFORE=14
|
> eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
```
|
> `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
|
||||||
|
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
|
||||||
|
> (Standard 14).
|
||||||
|
|
||||||
Unter **Mehr → Erinnerungen** kannst du die Verbindung prüfen, dir eine
|
Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine
|
||||||
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
||||||
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
||||||
viel dort noch offen ist.
|
viel dort noch offen ist.
|
||||||
|
|
||||||
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
|
Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert
|
||||||
unverändert.
|
unverändert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 497 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 381 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 271 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE `app_setting` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1788765478965,
|
"when": 1788765478965,
|
||||||
"tag": "0003_powerful_sally_floyd",
|
"tag": "0003_powerful_sally_floyd",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792963951232,
|
||||||
|
"tag": "0004_modern_agent_brand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
import {
|
||||||
|
actionData,
|
||||||
|
actionError,
|
||||||
|
actionOk,
|
||||||
|
formToObject,
|
||||||
|
parseForm,
|
||||||
|
type ActionResult,
|
||||||
|
} from '@/lib/action-result';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import {
|
||||||
|
deleteMailSettings,
|
||||||
|
getMailConfig,
|
||||||
|
isMailConfigured,
|
||||||
|
saveMailSettings,
|
||||||
|
} from '@/lib/mail-settings';
|
||||||
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
||||||
|
import { mailSettingsSchema } from '@/lib/validation';
|
||||||
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
||||||
import { requireAdmin } from '@/lib/session';
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
@@ -17,7 +31,7 @@ export async function testMailConnection(): Promise<ActionResult> {
|
|||||||
export async function sendTestMail(): Promise<ActionResult> {
|
export async function sendTestMail(): Promise<ActionResult> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
if (!env.isMailConfigured) {
|
if (!isMailConfigured()) {
|
||||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +55,7 @@ export async function sendTestMail(): Promise<ActionResult> {
|
|||||||
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
if (!env.isMailConfigured) {
|
if (!isMailConfigured()) {
|
||||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
|||||||
revalidatePath('/verwaltung/erinnerungen');
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
return actionData(run);
|
return actionData(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores the SMTP server set up on the reminders page. */
|
||||||
|
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (getMailConfig()?.source === 'env') {
|
||||||
|
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
|
||||||
|
saveMailSettings(parsed.data);
|
||||||
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMailSettingsAction(): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
deleteMailSettings();
|
||||||
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { wishlist } from '@/db/schema';
|
import { wishlist } from '@/db/schema';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
|
||||||
import {
|
import {
|
||||||
ReminderSettings,
|
ReminderSettings,
|
||||||
type ReminderListRow,
|
type ReminderListRow,
|
||||||
} from '@/components/verwaltung/reminder-settings';
|
} from '@/components/verwaltung/reminder-settings';
|
||||||
|
import { getMailSettingsView } from '@/lib/mail-settings';
|
||||||
import { daysUntil } from '@/lib/reminders';
|
import { daysUntil } from '@/lib/reminders';
|
||||||
import { requireAdmin } from '@/lib/session';
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
@@ -41,11 +43,16 @@ export default async function ErinnerungenPage() {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||||
|
|
||||||
|
const mail = getMailSettingsView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
<ReminderSettings
|
<ReminderSettings
|
||||||
configured={env.isMailConfigured}
|
configured={mail.source !== null}
|
||||||
daysBefore={env.REMINDER_DAYS_BEFORE}
|
daysBefore={env.REMINDER_DAYS_BEFORE}
|
||||||
lists={lists}
|
lists={lists}
|
||||||
/>
|
/>
|
||||||
|
<MailSettingsForm view={mail} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
import { BrickLogo } from '@/components/brick/logo';
|
import { BrickLogo } from '@/components/brick/logo';
|
||||||
import { DesktopNav } from './bottom-nav';
|
import { DesktopNav } from './bottom-nav';
|
||||||
import { CollectorSwitch, type CollectorOption } from './collector-switch';
|
import { CollectorSwitch, type CollectorOption } from './collector-switch';
|
||||||
import type { SessionUser } from '@/lib/session';
|
import type { SessionUser } from '@/lib/session';
|
||||||
|
|
||||||
function initials(name: string): string {
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
|
|
||||||
return letters.toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppHeader({
|
export function AppHeader({
|
||||||
user,
|
user,
|
||||||
collectors,
|
collectors,
|
||||||
@@ -35,13 +30,14 @@ export function AppHeader({
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<DesktopNav legoImport={legoImport} />
|
<DesktopNav legoImport={legoImport} />
|
||||||
{/* Account, design and master data: everything that is not daily use. */}
|
{/* Account, design and master data: everything that is not daily use. */}
|
||||||
|
{/* Labelled on purpose: an initials-only box was not recognisable. */}
|
||||||
<Link
|
<Link
|
||||||
href="/mehr"
|
href="/mehr"
|
||||||
aria-label="Mehr: Konto, Design und Verwaltung"
|
title={`Mehr: Konto (${user.name}), Design und Verwaltung`}
|
||||||
title="Mehr"
|
className="flex h-10 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card px-3 text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
||||||
>
|
>
|
||||||
{initials(user.name)}
|
<Menu className="size-5" aria-hidden />
|
||||||
|
Mehr
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { Loader2, Save, Server, Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
deleteMailSettingsAction,
|
||||||
|
saveMailSettingsAction,
|
||||||
|
} from '@/app/(app)/verwaltung/erinnerungen/actions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Field } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
|
|
||||||
|
export type MailSettingsFormView = {
|
||||||
|
source: 'env' | 'app' | null;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: 'starttls' | 'tls' | 'none';
|
||||||
|
user: string;
|
||||||
|
from: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
passwordUnreadable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECURITY_LABELS = {
|
||||||
|
starttls: 'STARTTLS (meist Port 587)',
|
||||||
|
tls: 'SSL/TLS (meist Port 465)',
|
||||||
|
none: 'Unverschlüsselt (nur im eigenen Netz)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The SMTP server for reminders, set up right here instead of in the .env. */
|
||||||
|
export function MailSettingsForm({ view }: { view: MailSettingsFormView }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [security, setSecurity] = useState(view.security);
|
||||||
|
const [port, setPort] = useState(String(view.port));
|
||||||
|
|
||||||
|
if (view.source === 'env') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5 text-primary" aria-hidden />
|
||||||
|
Mailserver
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den
|
||||||
|
Container neu starten.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Server:</span> {view.host}:{view.port} ·{' '}
|
||||||
|
{SECURITY_LABELS[view.security].split(' (')[0]}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Absender:</span> {view.from}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSecurity(next: 'starttls' | 'tls' | 'none') {
|
||||||
|
setSecurity(next);
|
||||||
|
// Follow the usual port while the user has not picked a special one.
|
||||||
|
if (next === 'tls' && port === '587') setPort('465');
|
||||||
|
if (next !== 'tls' && port === '465') setPort('587');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await saveMailSettingsAction(formData);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The password never stays visible, not even as dots.
|
||||||
|
const password = form.elements.namedItem('password');
|
||||||
|
if (password instanceof HTMLInputElement) password.value = '';
|
||||||
|
toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.');
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteMailSettingsAction();
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Mailserver entfernt. Erinnerungen sind aus.');
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5 text-primary" aria-hidden />
|
||||||
|
Mailserver
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem
|
||||||
|
Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt
|
||||||
|
gespeichert und nie wieder angezeigt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<Field label="SMTP-Server" htmlFor="mail-host" required>
|
||||||
|
<Input
|
||||||
|
id="mail-host"
|
||||||
|
name="host"
|
||||||
|
defaultValue={view.host}
|
||||||
|
placeholder="mail.example.de"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Port" htmlFor="mail-port" required>
|
||||||
|
<Input
|
||||||
|
id="mail-port"
|
||||||
|
name="port"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={port}
|
||||||
|
onChange={(event) => setPort(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Verschlüsselung" htmlFor="mail-security">
|
||||||
|
<NativeSelect
|
||||||
|
id="mail-security"
|
||||||
|
name="security"
|
||||||
|
value={security}
|
||||||
|
onChange={(event) => changeSecurity(event.target.value as typeof security)}
|
||||||
|
>
|
||||||
|
{Object.entries(SECURITY_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Benutzername" htmlFor="mail-user">
|
||||||
|
<Input
|
||||||
|
id="mail-user"
|
||||||
|
name="user"
|
||||||
|
defaultValue={view.user}
|
||||||
|
placeholder="bricktrack@example.de"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Passwort"
|
||||||
|
htmlFor="mail-password"
|
||||||
|
|
||||||
|
hint={
|
||||||
|
view.passwordUnreadable
|
||||||
|
? 'Das gespeicherte Passwort ist nicht mehr lesbar (BETTER_AUTH_SECRET wurde geändert). Bitte neu eingeben.'
|
||||||
|
: view.hasPassword
|
||||||
|
? 'Gespeichert. Leer lassen, um es zu behalten.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="mail-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={view.hasPassword ? '••••••••' : ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.hasPassword ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox name="clearPassword" value="on" />
|
||||||
|
Gespeichertes Passwort entfernen
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Absender"
|
||||||
|
htmlFor="mail-from"
|
||||||
|
required
|
||||||
|
|
||||||
|
hint="So steht es im Postfach, z. B. Bricktrack <bricktrack@example.de>."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="mail-from"
|
||||||
|
name="from"
|
||||||
|
defaultValue={view.from}
|
||||||
|
placeholder="Bricktrack <bricktrack@example.de>"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" size="lg" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
{view.source === 'app' ? (
|
||||||
|
<Button type="button" variant="ghost" size="lg" onClick={remove} disabled={pending}>
|
||||||
|
<Trash2 />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,17 +98,10 @@ export function ReminderSettings({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere
|
Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere
|
||||||
funktioniert unverändert.
|
funktioniert unverändert.
|
||||||
</p>
|
</p>
|
||||||
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p>
|
<p>Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.</p>
|
||||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
|
|
||||||
{`SMTP_HOST=mail.example.de
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=bricktrack@example.de
|
|
||||||
SMTP_PASSWORD=…
|
|
||||||
SMTP_FROM=Bricktrack <bricktrack@example.de>`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export * from './collector';
|
|||||||
export * from './prices';
|
export * from './prices';
|
||||||
export * from './collection';
|
export * from './collection';
|
||||||
export * from './wishlist';
|
export * from './wishlist';
|
||||||
|
export * from './settings';
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings made in the app rather than in the .env - for now the SMTP server
|
||||||
|
* for reminders. One row per topic, the value as JSON.
|
||||||
|
*
|
||||||
|
* Never part of the export: it may hold credentials (the SMTP password is
|
||||||
|
* stored encrypted, see src/lib/secret-box.ts).
|
||||||
|
*/
|
||||||
|
export const appSetting = sqliteTable('app_setting', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value', { mode: 'json' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { appSetting } from '@/db/schema';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { openSecret, sealSecret } from '@/lib/secret-box';
|
||||||
|
import type { MailSettingsInput } from '@/lib/validation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the SMTP server comes from.
|
||||||
|
*
|
||||||
|
* The .env wins when it names a server: an admin who configured it there
|
||||||
|
* expects exactly that. Otherwise the settings made on the reminders page
|
||||||
|
* apply. Without either, reminders stay off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MailSecurity = 'starttls' | 'tls' | 'none';
|
||||||
|
|
||||||
|
export type MailConfig = {
|
||||||
|
source: 'env' | 'app';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string | null;
|
||||||
|
password: string | null;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredMail = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string | null;
|
||||||
|
/** Encrypted, see secret-box.ts. */
|
||||||
|
password: string | null;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = 'smtp';
|
||||||
|
|
||||||
|
function readStored(): StoredMail | null {
|
||||||
|
const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get();
|
||||||
|
return (row?.value as StoredMail | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMailConfig(): MailConfig | null {
|
||||||
|
if (env.isMailConfigured) {
|
||||||
|
return {
|
||||||
|
source: 'env',
|
||||||
|
host: env.SMTP_HOST!,
|
||||||
|
port: env.SMTP_PORT,
|
||||||
|
security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls',
|
||||||
|
user: env.SMTP_USER ?? null,
|
||||||
|
password: env.SMTP_PASSWORD ?? null,
|
||||||
|
from: env.SMTP_FROM!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = readStored();
|
||||||
|
if (!stored) return null;
|
||||||
|
return {
|
||||||
|
source: 'app',
|
||||||
|
host: stored.host,
|
||||||
|
port: stored.port,
|
||||||
|
security: stored.security,
|
||||||
|
user: stored.user,
|
||||||
|
password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null,
|
||||||
|
from: stored.from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMailConfigured(): boolean {
|
||||||
|
return getMailConfig() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the settings form may show - never the password itself. */
|
||||||
|
export type MailSettingsView = {
|
||||||
|
source: 'env' | 'app' | null;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string;
|
||||||
|
from: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
/** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */
|
||||||
|
passwordUnreadable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMailSettingsView(): MailSettingsView {
|
||||||
|
const config = getMailConfig();
|
||||||
|
const stored = config?.source === 'env' ? null : readStored();
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: config?.source ?? null,
|
||||||
|
host: config?.host ?? '',
|
||||||
|
port: config?.port ?? 587,
|
||||||
|
security: config?.security ?? 'starttls',
|
||||||
|
user: config?.user ?? '',
|
||||||
|
from: config?.from ?? '',
|
||||||
|
hasPassword: Boolean(config?.password),
|
||||||
|
passwordUnreadable: Boolean(stored?.password && !config?.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMailSettings(input: MailSettingsInput): void {
|
||||||
|
const previous = readStored();
|
||||||
|
|
||||||
|
let password = previous?.password ?? null;
|
||||||
|
if (input.clearPassword) password = null;
|
||||||
|
if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET);
|
||||||
|
|
||||||
|
const value: StoredMail = {
|
||||||
|
host: input.host,
|
||||||
|
port: input.port,
|
||||||
|
security: input.security,
|
||||||
|
user: input.user,
|
||||||
|
password,
|
||||||
|
from: input.from,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.insert(appSetting)
|
||||||
|
.values({ key: KEY, value, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMailSettings(): void {
|
||||||
|
db.delete(appSetting).where(eq(appSetting.key, KEY)).run();
|
||||||
|
}
|
||||||
@@ -1,32 +1,39 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import nodemailer, { type Transporter } from 'nodemailer';
|
import nodemailer, { type Transporter } from 'nodemailer';
|
||||||
import { env } from '@/env';
|
import { getMailConfig, type MailConfig } from '@/lib/mail-settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outgoing mail.
|
* Outgoing mail.
|
||||||
*
|
*
|
||||||
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and
|
* Entirely optional: without a server - in the .env or on the reminders page -
|
||||||
* nothing is sent. The app never depends on mail working.
|
* nothing is sent. The app never depends on mail working.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let transporter: Transporter | null = null;
|
let cached: { key: string; transporter: Transporter; from: string } | null = null;
|
||||||
|
|
||||||
function getTransporter(): Transporter | null {
|
function connectionKey(config: MailConfig): string {
|
||||||
if (!env.isMailConfigured) return null;
|
return JSON.stringify([config.host, config.port, config.security, config.user, config.password]);
|
||||||
if (transporter) return transporter;
|
}
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
function getTransport(): { transporter: Transporter; from: string } | null {
|
||||||
host: env.SMTP_HOST,
|
const config = getMailConfig();
|
||||||
port: env.SMTP_PORT,
|
if (!config) return null;
|
||||||
|
|
||||||
|
// Settings can change at runtime now; a new connection follows them.
|
||||||
|
const key = connectionKey(config);
|
||||||
|
if (cached?.key === key) return { transporter: cached.transporter, from: config.from };
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
// Implicit TLS on 465, STARTTLS on 587 - the usual pair.
|
// Implicit TLS on 465, STARTTLS on 587 - the usual pair.
|
||||||
secure: env.SMTP_SECURE || env.SMTP_PORT === 465,
|
secure: config.security === 'tls',
|
||||||
auth:
|
ignoreTLS: config.security === 'none',
|
||||||
env.SMTP_USER && env.SMTP_PASSWORD
|
auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined,
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return transporter;
|
cached = { key, transporter, from: config.from };
|
||||||
|
return { transporter, from: config.from };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MailResult = { ok: true } | { ok: false; error: string };
|
export type MailResult = { ok: true } | { ok: false; error: string };
|
||||||
@@ -36,14 +43,14 @@ export async function sendMail(input: {
|
|||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
}): Promise<MailResult> {
|
}): Promise<MailResult> {
|
||||||
const transport = getTransporter();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.transporter.sendMail({
|
||||||
from: env.SMTP_FROM,
|
from: transport.from,
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
@@ -60,13 +67,13 @@ export async function sendMail(input: {
|
|||||||
|
|
||||||
/** Checks the connection without sending anything. */
|
/** Checks the connection without sending anything. */
|
||||||
export async function verifyMailConnection(): Promise<MailResult> {
|
export async function verifyMailConnection(): Promise<MailResult> {
|
||||||
const transport = getTransporter();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.verify();
|
await transport.transporter.verify();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { user, wishlist } from '@/db/schema';
|
import { user, wishlist } from '@/db/schema';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import { isMailConfigured } from './mail-settings';
|
||||||
import { sendMail } from './mailer';
|
import { sendMail } from './mailer';
|
||||||
import {
|
import {
|
||||||
reminderSubject,
|
reminderSubject,
|
||||||
@@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] {
|
|||||||
|
|
||||||
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
|
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
|
||||||
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
|
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
|
||||||
if (!env.isMailConfigured) return result;
|
if (!isMailConfigured()) return result;
|
||||||
|
|
||||||
const candidates = loadCandidates();
|
const candidates = loadCandidates();
|
||||||
result.checked = candidates.length;
|
result.checked = candidates.length;
|
||||||
@@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void {
|
|||||||
* cooldown keeps that from turning into repeated mail.
|
* cooldown keeps that from turning into repeated mail.
|
||||||
*/
|
*/
|
||||||
export function startReminderScheduler(): void {
|
export function startReminderScheduler(): void {
|
||||||
if (!env.isMailConfigured) {
|
// Always scheduled: the mail server can be set up in the app at any time,
|
||||||
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.');
|
// and each run checks whether one is configured.
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`,
|
isMailConfigured()
|
||||||
|
? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`
|
||||||
|
: '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.',
|
||||||
);
|
);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts secrets that settings must keep, such as the SMTP password.
|
||||||
|
*
|
||||||
|
* AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the
|
||||||
|
* database alone does not reveal the password. Changing BETTER_AUTH_SECRET
|
||||||
|
* makes stored secrets unreadable - `openSecret` then returns null and the
|
||||||
|
* password has to be entered again. Pure apart from node:crypto, so tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERSION = 'v1';
|
||||||
|
|
||||||
|
function deriveKey(masterSecret: string): Buffer {
|
||||||
|
return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealSecret(plain: string, masterSecret: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSecret(sealed: string, masterSecret: string): string | null {
|
||||||
|
const [version, iv, tag, encrypted] = sealed.split(':');
|
||||||
|
if (version !== VERSION || !iv || !tag || encrypted === undefined) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encrypted, 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
// Wrong key or tampered value: treat as "no password stored".
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,39 @@ export const reservationSchema = z.object({
|
|||||||
|
|
||||||
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
||||||
export type ThemeInput = z.infer<typeof themeSchema>;
|
export type ThemeInput = z.infer<typeof themeSchema>;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mail (reminders)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']);
|
||||||
|
|
||||||
|
export const mailSettingsSchema = z.object({
|
||||||
|
host: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Bitte den SMTP-Server angeben.')
|
||||||
|
.max(255)
|
||||||
|
.regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'),
|
||||||
|
port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587),
|
||||||
|
security: mailSecurityEnum.default('starttls'),
|
||||||
|
user: optionalText(255),
|
||||||
|
/** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.max(500)
|
||||||
|
.nullish()
|
||||||
|
.transform((value) => (value ? value : null)),
|
||||||
|
clearPassword: checkbox,
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, 'Bitte einen Absender angeben.')
|
||||||
|
.max(255)
|
||||||
|
.refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MailSettingsInput = z.infer<typeof mailSettingsSchema>;
|
||||||
|
|
||||||
export type LocationInput = z.infer<typeof locationSchema>;
|
export type LocationInput = z.infer<typeof locationSchema>;
|
||||||
export type SetInput = z.infer<typeof setSchema>;
|
export type SetInput = z.infer<typeof setSchema>;
|
||||||
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { openSecret, sealSecret } from '@/lib/secret-box';
|
||||||
|
import { mailSettingsSchema } from '@/lib/validation';
|
||||||
|
|
||||||
|
const SECRET = 'test-secret-0123456789-abcdefghijklmnop';
|
||||||
|
|
||||||
|
describe('Passwort verschluesselt speichern', () => {
|
||||||
|
it('kommt mit demselben Schluessel unveraendert zurueck', () => {
|
||||||
|
const sealed = sealSecret('geheim mit Leerzeichen ', SECRET);
|
||||||
|
expect(sealed).not.toContain('geheim');
|
||||||
|
expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschluesselt dasselbe Passwort jedes Mal anders', () => {
|
||||||
|
expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => {
|
||||||
|
const sealed = sealSecret('geheim', SECRET);
|
||||||
|
expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull();
|
||||||
|
|
||||||
|
const [version, iv, tag, data] = sealed.split(':');
|
||||||
|
const flipped = Buffer.from(data!, 'base64');
|
||||||
|
flipped[0] = flipped[0]! ^ 0xff;
|
||||||
|
expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull();
|
||||||
|
expect(openSecret('kaputt', SECRET)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SMTP-Eingaben pruefen', () => {
|
||||||
|
const valid = {
|
||||||
|
host: 'mail.example.de',
|
||||||
|
port: '587',
|
||||||
|
security: 'starttls',
|
||||||
|
user: 'bricktrack@example.de',
|
||||||
|
password: ' mit Rand ',
|
||||||
|
from: 'Bricktrack <bricktrack@example.de>',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => {
|
||||||
|
const parsed = mailSettingsSchema.parse(valid);
|
||||||
|
expect(parsed.port).toBe(587);
|
||||||
|
expect(parsed.password).toBe(' mit Rand ');
|
||||||
|
expect(parsed.clearPassword).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macht aus einem leeren Passwort "unveraendert"', () => {
|
||||||
|
expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt URLs, Ports und Absender ohne Adresse ab', () => {
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||