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>
260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
/**
|
||
* 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 };
|