Files
klarbild/src/components/KontoApp.tsx
T
tillandClaude Opus 5 63d4b7d894 feat: e-mail, password reset and shareable links for images and collections
Mail goes out through the account's own mailbox (SMTP) rather than a sending
service: the domain lives there anyway, so SPF and DKIM line up by themselves
and no third party gets to see the messages. Credentials are AES-256-GCM
encrypted in settings like every other secret, never in the repo. nodemailer
has zero dependencies and no native postinstall -- checked before adding it,
given the history.

Password reset: single-use token, only its hash is stored, one hour, and the
request endpoint answers identically whether or not the address exists so it
cannot be used to enumerate accounts. Rate limited per address.

Share links (/s/<slug>) let someone without an account see the pictures and
download them at full resolution. These are photos of children, so the
defaults lean safe: every link expires by itself (30 days), can be revoked,
optionally carries a passphrase, and the page and files are noindex. Access is
bound to the link, not the image -- swapping in another item id on a valid link
returns 404. Private-session results can never be shared at all.

Tracking records when and with what, never who: IP addresses are only stored
salted-and-hashed, so repeat visitors can be counted but nobody identified.

Sharing goes out by mail (several recipients at once, no attachment -- a link
can be withdrawn later, an attachment cannot) or via wa.me, which opens
WhatsApp with the text prepared. The WhatsApp Business API would need a Meta
account, approved templates and per-conversation fees; this works today for
free and the API can slot in behind it later.

Verified end to end against a local SMTP server: reset issues and consumes its
token exactly once, the old password stops working, an unknown address gives
the same answer, share mail reaches several recipients. Link behaviour checked
for expiry (410), revocation (410), wrong passphrase (401) and download lock
(403). Outbound SMTP is blocked in the sandbox, so the real mailbox still needs
one test from the live server.

Also fixes a regression this introduced: the seventh nav item pushed the logout
button off screen at 820px. The bar now shrinks and scrolls, logout never does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-09-01 03:02:33 +00:00

149 lines
7.0 KiB
TypeScript

import { useEffect, useState } from 'react';
/**
* Eigenes Konto: E-Mail hinterlegen und Benachrichtigungen schalten.
*
* Die Adresse ist zugleich der Weg, ein vergessenes Passwort zurückzusetzen —
* das steht auch so dabei, damit niemand sie für reine Werbepost hält.
*/
interface Me {
username: string | null; display_name: string | null;
email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string;
}
export default function KontoApp() {
const [me, setMe] = useState<Me | null>(null);
const [email, setEmail] = useState('');
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
const [laeuft, setLaeuft] = useState(false);
useEffect(() => {
fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {});
}, []);
const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
const patch = async (body: unknown, erfolg: string) => {
setLaeuft(true);
const r = await fetch('/api/me', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
setLaeuft(false);
if (r.error) { sagen(r.error, false); return false; }
sagen(erfolg, true);
const m = await fetch('/api/me').then((x) => x.json()).catch(() => null);
if (m) setMe(m);
return true;
};
if (!me) return <p className="ko-fein">Einen Moment …</p>;
return (
<div className="ko">
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Anmeldung</span></div>
<div className="ko-inhalt">
<div className="ko-zeile">
<span className="ko-label">Benutzername</span>
<span className="ko-wert">{me.username}</span>
</div>
{me.display_name && (
<div className="ko-zeile">
<span className="ko-label">Angezeigt als</span>
<span className="ko-wert">{me.display_name}</span>
</div>
)}
<div className="ko-zeile">
<span className="ko-label">Passwort</span>
<span className="ko-wert"><a href="/passwort">Neues Passwort setzen</a></span>
</div>
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">E-Mail-Adresse</span></div>
<div className="ko-inhalt">
<p className="ko-fein">
Damit kannst du dir ein <b>neues Passwort schicken lassen</b>, wenn du deines vergessen hast.
Ohne hinterlegte Adresse geht das nicht — dann muss ein Admin ran.
</p>
<div className="ko-eingabe">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="du@beispiel.de" aria-label="E-Mail-Adresse" autoComplete="email" />
<button className="ko-btn stark" disabled={laeuft || email === (me.email || '')}
onClick={() => patch({ email }, email ? 'Adresse gespeichert.' : 'Adresse entfernt.')}>
Speichern
</button>
</div>
{!me.mail_ready && (
<p className="ko-warn">
Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar
gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter
<b> Admin → E-Mail</b> ein.
</p>
)}
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Benachrichtigungen</span></div>
<div className="ko-inhalt">
<label className="ko-schalt">
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email}
onChange={(e) => patch({ notify_jobs: e.target.checked },
e.target.checked ? 'Hinweise eingeschaltet.' : 'Hinweise ausgeschaltet.')} />
<span>
<b>Sag mir Bescheid, wenn ein Auftrag fertig ist</b>
<em>Eine kurze Mail je Auftrag — kein Newsletter, keine Werbung.
{!me.email && ' Dafür brauchst du zuerst eine E-Mail-Adresse (oben).'}</em>
</span>
</label>
<p className="ko-fein">
Aufträge aus einer <b>privaten Sitzung</b> lösen bewusst nie eine Mail aus — die sollen
ja gerade keine Spur hinterlassen.
</p>
</div>
</section>
{msg && <p className={`ko-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<style>{`
.ko{display:grid;gap:14px;max-width:620px;}
.ko-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.ko-kopf{padding:13px 16px;border-bottom:1px solid var(--line);}
.ko-inhalt{padding:16px;display:grid;gap:12px;}
.ko-zeile{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
.ko-zeile:last-child{border-bottom:none;padding-bottom:0;}
.ko-label{color:var(--soft);font-size:.85rem;}
.ko-wert{font-weight:500;overflow-wrap:anywhere;text-align:right;}
.ko-wert a{color:var(--accent);}
.ko-fein{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
.ko-eingabe{display:flex;gap:8px;}
.ko-eingabe input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
.ko-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.ko-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.ko-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
.ko-schalt{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
.ko-schalt input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
.ko-schalt input:disabled{cursor:not-allowed;}
.ko-schalt b{display:block;}
.ko-schalt em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
.ko-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
.ko-msg{margin:0;font-size:.9rem;}
.ko-msg.ok{color:var(--ok);}
.ko-msg.err{color:var(--err);}
@media(max-width:560px){
.ko-eingabe{flex-direction:column;}
.ko-btn{width:100%;padding:13px;}
.ko-zeile{flex-direction:column;gap:3px;}
.ko-wert{text-align:left;}
}
`}</style>
</div>
);
}