/// // Die öffentliche Seite der Einladungslinks. // // Warum überhaupt eigene Routen? Wer einem Team über einen Link beitritt, ist // noch nicht eingeloggt und muss dabei drei Dinge tun, die keine // Collection-Regel erlauben darf: // // 1. ein Konto anlegen — `users.createRule` verlangt einen Login, sonst // wäre es eine offene Registrierung für jedermann; // 2. sich in `teams.users` eintragen — das darf sonst nur die Teamleitung; // 3. einen Fahrer im Kader anlegen — dito. // // Ein Hook läuft an den Regeln vorbei und kann deshalb genau diese drei // Schritte erlauben, aber nur zusammen und nur gegen einen gültigen Token. // Die Collection `team_invites` selbst bleibt für Nicht-Mitglieder unsichtbar. // // Routen: // GET /api/team-invites/{token} — öffentlich, zeigt das Team // POST /api/team-invites/{token}/accept — öffentlich, Konto + Beitritt // POST /api/team-invites/{token}/join — eingeloggt, nur Beitritt // // Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt // Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort // nicht sichtbar. // --- Wofür gilt der Link? --------------------------------------------------- // // Öffentlich, weil die Seite den Teamnamen zeigen soll, bevor jemand seine // Daten einträgt. Mehr als Name und Logo gibt sie nicht preis; die Einladung // selbst — Zähler, Ablauf, wer sie erzeugt hat — bleibt drin. routerAdd('GET', '/api/team-invites/{token}', (e) => { const lib = require(`${__hooks}/team_invites_lib.js`) const invite = lib.findValidInvite(e.app, e.request.pathValue('token')) if (!invite) { throw new NotFoundError('Diese Einladung gibt es nicht mehr.') } return e.json(200, { team: lib.teamPayload(e.app, invite), recipient: lib.recipientPayload(e.app, invite), }) }) // --- Konto anlegen und beitreten ------------------------------------------- routerAdd('POST', '/api/team-invites/{token}/accept', (e) => { const lib = require(`${__hooks}/team_invites_lib.js`) const data = new DynamicModel({ email: '', password: '', passwordConfirm: '', name: '', }) e.bindBody(data) const email = (data.email || '').trim() const name = (data.name || '').trim() if (!name) { throw new BadRequestError('Bitte einen Namen angeben.') } if (email.indexOf('@') < 1) { throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.') } if ((data.password || '').length < 8) { throw new BadRequestError('Das Passwort braucht mindestens 8 Zeichen.') } if (data.password !== data.passwordConfirm) { throw new BadRequestError('Die beiden Passwörter stimmen nicht überein.') } let result e.app.runInTransaction((txApp) => { // Innerhalb der Transaktion noch einmal nachsehen: Zwischen Anzeige // der Seite und dem Absenden kann die Einladung abgelaufen, // aufgebraucht oder gelöscht worden sein. const invite = lib.findValidInvite(txApp, e.request.pathValue('token')) if (!invite) { throw new BadRequestError('Diese Einladung gilt nicht mehr.') } // Eine persönliche Einladung ging an genau eine Adresse. Sie hier // erneut zu prüfen ist der Punkt, an dem es zählt: Das Feld auf der // Seite ist gesperrt, aber ein gesperrtes Feld ist eine Bitte und // keine Bedingung — die Anfrage kann jeder von Hand stellen. const invitedEmail = invite.getString('email') if (invitedEmail && invitedEmail.toLowerCase() !== email.toLowerCase()) { throw new BadRequestError( 'Diese Einladung gilt für eine andere E-Mail-Adresse.', ) } // Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique". // Der Hinweis auf den zweiten Weg ist hier mehr wert als die // Fehlermeldung. let existing = null try { existing = txApp.findAuthRecordByEmail('users', email) } catch (err) { existing = null } if (existing) { throw new BadRequestError( 'Zu dieser E-Mail-Adresse gibt es schon ein Konto. ' + 'Melde dich damit an und öffne den Link danach erneut.', ) } const user = new Record(txApp.findCollectionByNameOrId('users')) user.setEmail(email) user.setPassword(data.password) user.setVerified(false) user.set('emailVisibility', false) user.set('name', name) txApp.save(user) result = lib.joinTeam(txApp, invite, user, name) }) // Kein Auth-Token in der Antwort: Die App meldet sich anschließend ganz // normal mit E-Mail und Passwort an und läuft damit durch dieselbe // Anmeldung wie jeder andere auch. return e.json(200, { ok: true, team: result.teamId }) }) // --- Als bereits angemeldeter Nutzer beitreten ------------------------------ routerAdd('POST', '/api/team-invites/{token}/join', (e) => { const lib = require(`${__hooks}/team_invites_lib.js`) const data = new DynamicModel({ name: '' }) e.bindBody(data) const user = e.auth if (!user) { throw new UnauthorizedError() } let result e.app.runInTransaction((txApp) => { const invite = lib.findValidInvite(txApp, e.request.pathValue('token')) if (!invite) { throw new BadRequestError('Diese Einladung gilt nicht mehr.') } // Fällt der Name weg, stünde der Beitretende ohne Namen im Kader. Der // Kontoname ist die bessere Näherung als gar nichts. const name = (data.name || '').trim() || user.getString('name') result = lib.joinTeam(txApp, invite, user, name) }) return e.json(200, { ok: true, team: result.teamId }) }, $apis.requireAuth()) // --- Persönliche Einladung: Konto per Mail statt Passwort per Zuruf -------- // // Der Ersatz für „Konto anlegen" im Kader. Dort tippte die Teamleitung eine // Adresse und ein Passwort ein und musste dieses Passwort anschließend // irgendwie zum Fahrer bringen — über einen Kanal, den niemand geplant hat. // Sie kannte es außerdem, und der Fahrer hatte eins, das er sich nicht // ausgesucht hat. // // Jetzt geht eine Mail raus, und das Passwort entsteht erst beim Empfänger. // // Warum eine Route und kein Collection-Create mit anschließendem Versand? // Weil beides zusammengehört: Eine Einladung, die niemanden erreicht, ist // keine — sie stünde nur als Datensatz herum und die Teamleitung wartete auf // jemanden, der nie eine Mail bekommen hat. Hier scheitert beides zusammen // oder gar nichts. // // POST /api/team-invites/send { rider, email, days? } routerAdd('POST', '/api/team-invites/send', (e) => { const lib = require(`${__hooks}/team_invites_lib.js`) const mail = require(`${__hooks}/mail_lib.js`) const user = e.auth if (!user) { throw new UnauthorizedError() } const base = mail.appURL() if (!base) { // Ohne Adresse des Frontends gibt es keinen Link, und ohne Link ist // die Mail wertlos. Das hier ist ein Konfigurationsfehler und gehört // sichtbar gemeldet, nicht stillschweigend umschifft. throw new InternalServerError( 'APP_URL ist nicht gesetzt — ohne sie lässt sich kein Einladungslink bilden.', ) } const data = new DynamicModel({ rider: '', email: '', days: 14 }) e.bindBody(data) const email = (data.email || '').trim() if (email.indexOf('@') < 1) { throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.') } let rider try { rider = e.app.findRecordById('riders', (data.rider || '').trim()) } catch (err) { throw new NotFoundError('Diesen Fahrer gibt es nicht.') } if (rider.getString('user')) { throw new BadRequestError('Dieser Fahrer hat schon ein Konto.') } const team = e.app.findRecordById('teams', rider.getString('team')) // Einladen darf die Teamleitung — dieselbe Bedingung wie in der // createRule von team_invites, hier von Hand geprüft, weil die Route an // den Collection-Regeln vorbeiläuft. let allowed = false try { e.app.findFirstRecordByFilter( 'teams', 'id = {:team} && (owner.id ?= {:user} || admins.id ?= {:user})', { team: team.id, user: user.id }, ) allowed = true } catch (err) { allowed = false } if (!allowed) { throw new ForbiddenError('Einladen darf nur die Teamleitung.') } // Ohne die Vorabprüfung liefe der Eingeladene erst beim Anlegen des // Kontos in „Value must be unique" — nach der Mail, nach dem Klick, am // Ende eines Weges, den er umsonst gegangen wäre. let existing = null try { existing = e.app.findAuthRecordByEmail('users', email) } catch (err) { existing = null } if (existing) { throw new BadRequestError( 'Zu dieser Adresse gibt es schon ein Konto. Es kann über einen ' + 'Einladungslink ins Team kommen.', ) } const days = parseInt(data.days, 10) let expires = '' if (days > 0) { const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000) expires = until.toISOString().replace('T', ' ').replace('Z', 'Z') } let invite e.app.runInTransaction((txApp) => { // Eine zweite Einladung an denselben Fahrer ersetzt die erste. Sonst // stapelten sich bei jedem „nochmal senden" Datensätze, von denen // jeder für sich gültig bliebe — und ein zurückgezogener wäre keiner. try { const old = txApp.findFirstRecordByFilter( 'team_invites', 'rider = {:rider}', { rider: rider.id }, ) txApp.delete(old) } catch (err) { // Keine vorhandene — der Normalfall. } invite = new Record(txApp.findCollectionByNameOrId('team_invites')) invite.set('team', team.id) invite.set('rider', rider.id) invite.set('email', email) invite.set('created_by', user.id) invite.set('max_uses', 1) invite.set('uses', 0) if (expires) { invite.set('expires', expires) } txApp.save(invite) }) const name = rider.getString('name') || 'Radlfahrer' const teamName = team.getString('name') || mail.appName() // Der Versand steht bewusst NACH der Transaktion: Ein SMTP-Aufruf gehört // nicht in eine offene Datenbanktransaktion, er dauert und kann hängen. // Scheitert er, verschwindet die Einladung wieder — eine, von der niemand // weiß, wäre schlimmer als keine. try { mail.sendView( e.app, { address: email, name: name }, `${teamName} lädt dich zu ${mail.appName()} ein`, 'invite.html', { App: mail.appName(), Team: teamName, Name: name, Link: `${base}/invite/${invite.getString('token')}`, Expires: mail.formatDay(expires), }, ) } catch (err) { try { e.app.delete(invite) } catch (cleanupErr) { // Aufräumen ist der Versuch wert, aber nicht der Fehler, den der // Aufrufer sehen soll. } throw new InternalServerError( 'Die Einladung konnte nicht verschickt werden. Stimmen die SMTP-Einstellungen?', ) } invite.set('sent', new Date().toISOString().replace('T', ' ')) e.app.save(invite) return e.json(200, { ok: true, email: email, expires: expires }) }, $apis.requireAuth())