Files
skrift-programme/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
Lucas Orth 06c4078290 Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus
- Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/
  Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup
  (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange)
  und Empfängerliste mit Vorschau von Schriftstück und Kuvert.
- Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche.
- Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang.
- Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert),
  Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard.
- Absender aller Mails ist Skrift (Projektname + email_settings); die
  Angebotsanfrage nennt jetzt das angefragte Produkt.

Preislogik
- Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt
  (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt.
- Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen.
- Preis pro Stück ohne einmalige Kosten (unit_net_total).
- Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt.
- Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm.
- Briefnummern 1-basiert: Dateiname entspricht der Briefnummer.

app.skrift.de
- Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als
  eigene Seite, Kontaktprodukte direkt im Anfrageformular.
- Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator.
- Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet.
- Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar,
  Standardmenge 100; Checkout-Checkboxen im Kartenstil.

Produktion
- skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter
  und automatischer Übergang in die Kuvertierung.

WordPress
- Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt,
  stündlicher Abgleich der Preisdaten in WordPress und ein sicherer
  Formel-Interpreter, damit Formeländerungen automatisch greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:24:12 +02:00

260 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Passwortloser Login per E-Mail-Code
* ---------------------------------------------------------------------------
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
* Kunden-Login per Einmalcode:
*
* POST /skrift-auth/request-code { email } → Code per Mail
* POST /skrift-auth/verify-code { email, code } → Tokens
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
*
* Sicherheitsprinzipien:
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
*
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
*/
import crypto from 'node:crypto';
const CODE_TTL_MIN = 10;
const MAX_ATTEMPTS = 5;
const RESEND_COOLDOWN_SEC = 60;
const CUSTOMER_ROLE_NAME = 'Kunde';
const handler = (router, { services, getSchema, env, logger }) => {
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
const norm = (e) => String(e || '').trim().toLowerCase();
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const hashCode = (code, email) =>
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
// Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt
// (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und
// rotiert bei jedem Login.
const tempPasswort = () =>
`Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
const ctx = async () => ({ schema: await getSchema(), accountability: null });
async function findUserByEmail(email, schema) {
const users = new UsersService({ schema, accountability: null });
const found = await users.readByQuery({
filter: { email: { _eq: email } },
fields: ['id', 'email', 'status'],
limit: 1,
});
return found?.[0] || null;
}
async function sendCodeMail(email, code) {
const schema = await getSchema();
// Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist).
let subject = `Ihr Anmeldecode: ${code}`;
let body =
`<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
`<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>` +
`<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` +
`angefordert haben, können Sie diese E-Mail ignorieren.</p>`;
let signature = '';
let absender = '';
// Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden).
try {
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
});
const tpl = rows?.[0];
const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : ''));
if (tpl) {
if (tpl.subject) subject = fuelle(tpl.subject);
if (tpl.body) body = fuelle(tpl.body);
}
const es = await new ItemsService('email_settings', { schema, accountability: null })
.readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] });
if (es?.signature) signature = es.signature;
if (es?.sender_email) {
absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email;
}
} catch { /* Vorlage optional – Fallback oben greift */ }
const mail = new MailService({ schema, accountability: null });
const nachricht = { to: email, subject, html: body + signature };
if (absender) nachricht.from = absender;
await mail.send(nachricht);
}
// ── Code anfordern ────────────────────────────────────────────────────────
router.post('/request-code', async (req, res) => {
const email = norm(req.body?.email);
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
const generic = () => res.json({ ok: true });
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
// Wiederhol-Sperre
const recent = await codes.readByQuery({
filter: { email: { _eq: email } },
sort: ['-date_created'], limit: 1, fields: ['date_created'],
});
if (recent?.[0]?.date_created) {
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
if (age < RESEND_COOLDOWN_SEC) return generic();
}
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active') return generic();
const code = genCode();
await codes.createOne({
email,
code_hash: hashCode(code, email),
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
attempts: 0,
used: false,
});
await sendCodeMail(email, code);
return generic();
} catch (err) {
logger.error(`[skrift-auth] request-code: ${err.message}`);
return generic();
}
});
// ── Code prüfen + anmelden ────────────────────────────────────────────────
router.post('/verify-code', async (req, res) => {
const email = norm(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!isEmail(email) || !/^\d{6}$/.test(code))
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
const rows = await codes.readByQuery({
filter: { email: { _eq: email }, used: { _eq: false } },
sort: ['-date_created'], limit: 1,
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
});
const row = rows?.[0];
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
if (new Date(row.expires_at).getTime() < Date.now()) {
await codes.updateOne(row.id, { used: true });
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
}
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
await codes.updateOne(row.id, { used: true });
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
}
const expected = Buffer.from(row.code_hash, 'utf8');
const actual = Buffer.from(hashCode(code, email), 'utf8');
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
if (!ok) {
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
return res.status(401).json({ error: 'Code ungültig.' });
}
await codes.updateOne(row.id, { used: true });
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active')
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
const users = new UsersService({ schema, accountability: null });
const tempPassword = tempPasswort();
await users.updateOne(user.id, { password: tempPassword });
// Die Signatur von AuthenticationService.login variiert zwischen
// Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
const auth = new AuthenticationService({ schema, accountability: null });
const versuche = [
() => auth.login('default', { email, password: tempPassword }),
() => auth.login({ email, password: tempPassword }),
() => auth.login({ email, password: tempPassword }, {}),
];
let result, letzterFehler;
for (const versuch of versuche) {
try { result = await versuch(); break; }
catch (e) { letzterFehler = e; }
}
if (!result) {
logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`);
const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`);
e.detail = letzterFehler?.message;
throw e;
}
// Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig.
return res.json({
access_token: result.accessToken ?? result.access_token,
refresh_token: result.refreshToken ?? result.refresh_token,
expires: result.expires,
});
} catch (err) {
logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
}
});
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
router.post('/register', async (req, res) => {
const email = norm(req.body?.email);
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
const existing = await findUserByEmail(email, schema);
if (existing) return res.json({ ok: true, existed: true });
const roles = new RolesService({ schema, accountability: null });
const role = (await roles.readByQuery({
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
}))?.[0];
if (!role) {
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
// Kunde = Login-Konto: Profil hängt direkt am Nutzer.
const users = new UsersService({ schema, accountability: null });
await users.createOne({
email,
password: crypto.randomBytes(32).toString('base64url'),
role: role.id,
status: 'active',
first_name: req.body?.first_name ?? null,
last_name: req.body?.last_name ?? null,
company: req.body?.company ?? null,
phone: req.body?.phone ?? null,
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
});
return res.json({ ok: true, existed: false });
} catch (err) {
logger.error(`[skrift-auth] register: ${err.message}`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
});
};
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
// unabhängig von Ordner- oder Paketnamen.
export default { id: 'skrift-auth', handler };