# Registration & Invite System Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add invite-only registration, referral tracking, and password reset to Zieltracker. **Architecture:** Vanilla JS single-page app. A new `config.js` externalizes server URLs. PocketBase handles auth, email verification, and password reset natively via SMTP. A new `invites` collection tracks tokens with labels and referral state. All new UI flows (register tab, password reset, invite management) are added to `app.js` following the existing sheet pattern. **Tech Stack:** Vanilla JS, PocketBase REST API, Brevo SMTP (configured in PocketBase admin) > **Note:** This app has no test framework. Each task uses browser-based manual verification instead of automated tests. --- ## File Map | File | Action | Responsibility | |---|---|---| | `config.js` | Create | `PB_URL` and `APP_URL` globals | | `index.html` | Modify | Add ``, add: ```html ``` - [ ] **Step 3: Remove hardcoded `PB_URL` from `app.js`** Delete line 4 of `app.js`: ```js var PB_URL = 'http://miniweb.kuehn.home:8090'; ``` - [ ] **Step 4: Verify in browser** Open the app. Goals load, login/logout works, no console errors. - [ ] **Step 5: Commit** ```bash git add config.js index.html app.js git commit -m "feat: externalize server URLs into config.js" ``` --- ### Task 2: PocketBase — create `invites` collection (manual) **Files:** None — PocketBase admin UI only - [ ] **Step 1: Open PocketBase admin** Navigate to `http://miniweb.kuehn.home:8090/_/` and log in. - [ ] **Step 2: Create `invites` collection** Collections → New collection → Name: `invites` Add fields: | Name | Type | Options | |---|---|---| | `token` | Text | Required | | `label` | Text | Required | | `created_by` | Relation | Collection: `users`, Required | | `used_by` | Relation | Collection: `users`, Optional | | `used_at` | Date | Optional | - [ ] **Step 3: Set API rules** In the `invites` collection → API rules: | Rule | Value | |---|---| | List / View | *(empty — public)* | | Create | `@request.auth.id != ""` | | Update | `used_by = ""` | | Delete | `@request.auth.id = created_by` | - [ ] **Step 4: Verify** In PocketBase admin, create a test invite record manually with any token string. Then open in browser: `http://miniweb.kuehn.home:8090/api/collections/invites/records` Should return the record as JSON. --- ### Task 3: PocketBase — configure SMTP (manual) **Files:** None — PocketBase admin UI only - [ ] **Step 1: Get Brevo SMTP credentials** Sign up at brevo.com (free tier). Go to: SMTP & API → SMTP → Generate SMTP key Note: host `smtp-relay.brevo.com`, port `587`, your Brevo login email, and the generated key. - [ ] **Step 2: Configure PocketBase SMTP** PocketBase admin → Settings → Mail settings: | Field | Value | |---|---| | Sender name | ZielTracker | | Sender address | your-email@example.com | | SMTP host | smtp-relay.brevo.com | | SMTP port | 587 | | Username | your-brevo-login@example.com | | Password | (Brevo SMTP key) | | TLS | enabled | - [ ] **Step 3: Send test email** PocketBase admin → Settings → Mail settings → "Send test email". Confirm arrival in inbox. --- ### Task 4: Add CSS for new UI elements **Files:** - Modify: `style.css` - [ ] **Step 1: Append styles to `style.css`** ```css .ltabs{display:flex;gap:0;margin:0 0 16px;border-bottom:2px solid var(--border)} .ltab{flex:1;padding:10px;background:none;border:none;font-size:15px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color .15s;font-family:'DM Sans',sans-serif} .ltab.active{color:var(--text);border-bottom-color:var(--text)} .pw-link{background:none;border:none;color:var(--text3);font-size:13px;padding:8px 0 0;text-decoration:underline;cursor:pointer;display:block;text-align:center;font-family:'DM Sans',sans-serif;width:100%} .inv-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)} .inv-lbl{font-weight:500;font-size:15px} .inv-link{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--rs);padding:12px;font-size:13px;word-break:break-all;margin:8px 0 16px;font-family:'DM Mono',monospace;line-height:1.5} ``` - [ ] **Step 2: Verify** Open app in browser, confirm no visual regressions. - [ ] **Step 3: Commit** ```bash git add style.css git commit -m "feat: add CSS for login tabs, password-reset link, and invite rows" ``` --- ### Task 5: Add globals and refactor startup block **Files:** - Modify: `app.js` - [ ] **Step 1: Add globals near top of `app.js`** After line 3 (`var authToken = '', authUserId = '';`), insert: ```js var inviteRecord = null; var loginTab = 'login'; ``` - [ ] **Step 2: Replace the startup block at the bottom of `app.js`** The current startup block (after `scheduleMidnight` and `visibilitychange`) is: ```js authToken=localStorage.getItem('zt_token')||''; authUserId=localStorage.getItem('zt_uid')||''; if(!authToken){ render(); showLogin(); } else { pbGoals() .then(function(g){ goals=g; render(); }) .catch(function(){ clearAuth(); render(); showLogin(); }); } ``` Replace with: ```js authToken=localStorage.getItem('zt_token')||''; authUserId=localStorage.getItem('zt_uid')||''; function init(){ if(!authToken){ render(); showLogin(); } else{ pbGoals() .then(function(g){ goals=g; render(); }) .catch(function(){ clearAuth(); render(); showLogin(); }); } } var urlInviteToken=(new URLSearchParams(window.location.search)).get('invite'); if(urlInviteToken){ api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(token="'+urlInviteToken+'"&&used_by="")')) .then(function(res){ if(res.items&&res.items.length>0) inviteRecord=res.items[0]; }) .catch(function(){}) .then(function(){ init(); }); }else{ init(); } ``` - [ ] **Step 3: Verify** Open app normally (no `?invite` param). Works as before, no console errors. - [ ] **Step 4: Commit** ```bash git add app.js git commit -m "feat: add invite globals and refactored startup with URL token check" ``` --- ### Task 6: Password reset UI **Files:** - Modify: `app.js` - [ ] **Step 1: Add `showPasswordReset` function after `showLogin` in `app.js`** ```js function showPasswordReset(){ var o=document.getElementById('ov'); o.innerHTML='
'+ '
'+ '
Passwort zurücksetzen
'+ '
Wir senden dir einen Reset-Link per E-Mail
'+ '
'+ '
'+ ''+ ''+ '
'+ '
'; o.onclick=null; setTimeout(function(){ var el=document.getElementById('premail'); if(el) el.focus(); },50); document.getElementById('prback').onclick=function(){ loginTab='login'; showLogin(); }; document.getElementById('prsub').onclick=function(){ var email=document.getElementById('premail').value.trim(); if(!email) return; var btn=this; btn.disabled=true; btn.textContent='…'; api('POST','/api/collections/users/request-password-reset',{email:email}) .then(function(){ document.querySelector('#ov .sheet').innerHTML= '
'+ '
E-Mail gesendet
'+ '
Falls ein Konto mit dieser Adresse existiert, hast du einen Link erhalten.
'+ '
'; document.getElementById('prdone').onclick=function(){ loginTab='login'; showLogin(); }; }) .catch(function(){ btn.disabled=false; btn.textContent='Link senden'; showToast('Fehler beim Senden'); }); }; } ``` - [ ] **Step 2: Verify** Open app. The login sheet still works. (The "Passwort vergessen?" link is wired in the next task.) - [ ] **Step 3: Commit** ```bash git add app.js git commit -m "feat: add password reset sheet" ``` --- ### Task 7: Login sheet refactor + registration form > All six functions in this task must be added together in one commit — `showLogin` calls `renderRegisterForm` and `wireRegisterForm` which are defined here. **Files:** - Modify: `app.js` - [ ] **Step 1: Replace the entire `showLogin` function and add all six helper functions** Replace the existing `showLogin` function in `app.js` with the following block. This is a complete replacement — remove the old function and insert all six functions in its place: ```js function showLogin(err){ var o=document.getElementById('ov'); o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease'; var hasInvite=!!inviteRecord; var tabsHtml=hasInvite ?'
'+ ''+ ''+ '
' :''; var formHtml=(hasInvite&&loginTab==='register')?renderRegisterForm(err):renderLoginForm(err); o.innerHTML='
'+ '
'+ '
Anmelden
'+ '
ZielTracker
'+ tabsHtml+formHtml+ '
'; o.onclick=null; if(hasInvite){ document.getElementById('ltab-login').onclick=function(){loginTab='login';showLogin();}; document.getElementById('ltab-reg').onclick=function(){loginTab='register';showLogin();}; } if(hasInvite&&loginTab==='register') wireRegisterForm(); else wireLoginForm(); } function renderLoginForm(err){ return (err?'
'+err+'
':'')+ '
'+ '
'+ '
'+ ''; } function wireLoginForm(){ setTimeout(function(){ var le=document.getElementById('lemail'),lp=document.getElementById('lpass'); if(le){le.focus();le.onkeydown=function(e){if(e.key==='Enter')lp.focus();};} if(lp) lp.onkeydown=function(e){if(e.key==='Enter')document.getElementById('lsub').click();}; var lpw=document.getElementById('lpw'); if(lpw) lpw.onclick=showPasswordReset; },50); document.getElementById('lsub').onclick=function(){ var email=document.getElementById('lemail').value.trim(); var pass=document.getElementById('lpass').value; if(!email||!pass) return; var btn=this; btn.disabled=true; btn.textContent='…'; api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass}) .then(function(res){ authToken=res.token; authUserId=res.record.id; localStorage.setItem('zt_token',authToken); localStorage.setItem('zt_uid',authUserId); return pbGoals(); }) .then(function(g){ goals=g; closeOv(); render(); }) .catch(function(e){ btn.disabled=false; btn.textContent='Anmelden'; if(authToken){ closeOv(); render(); } else{ showLogin(e.status===400||e.status===401?'Falsche E-Mail oder Passwort':'Verbindungsfehler'); } }); }; } function renderRegisterForm(err){ return (err?'
'+err+'
':'')+ '
Eingeladen als: '+inviteRecord.label+'
'+ '
'+ '
'+ '
'+ '
'; } function wireRegisterForm(){ setTimeout(function(){ var el=document.getElementById('remail'); if(el) el.focus(); },50); document.getElementById('rsub').onclick=function(){ var email=document.getElementById('remail').value.trim(); var pass=document.getElementById('rpass').value; var pass2=document.getElementById('rpass2').value; if(!email||!pass||!pass2) return; if(pass!==pass2){showLogin('Passwörter stimmen nicht überein');return;} if(pass.length<8){showLogin('Passwort muss mindestens 8 Zeichen haben');return;} var btn=this; btn.disabled=true; btn.textContent='…'; var savedInviteId=inviteRecord.id; api('POST','/api/collections/users/records',{email:email,password:pass,passwordConfirm:pass2}) .then(function(){ return api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass}); }) .then(function(res){ authToken=res.token; authUserId=res.record.id; localStorage.setItem('zt_token',authToken); localStorage.setItem('zt_uid',authUserId); return api('PATCH','/api/collections/invites/records/'+savedInviteId,{used_by:authUserId,used_at:new Date().toISOString()}); }) .then(function(){ inviteRecord=null; return pbGoals(); }) .then(function(g){ goals=g; closeOv(); render(); }) .catch(function(e){ btn.disabled=false; btn.textContent='Registrieren'; showLogin(e.status===400?'E-Mail bereits vergeben oder ungültig':'Registrierung fehlgeschlagen'); }); }; } ``` - [ ] **Step 2: Verify login still works** Open app normally (no `?invite` param). Login works as before. "Passwort vergessen?" link appears and opens the reset sheet. - [ ] **Step 3: Verify registration flow** 1. In PocketBase admin, create a test invite: token = `testtoken123`, label = `Testkind`, used_by = empty. 2. Open `http://miniweb.kuehn.home/?invite=testtoken123` in a private window. 3. Login sheet shows two tabs: "Anmelden" / "Registrieren". 4. Click "Registrieren" → form shows "Eingeladen als: **Testkind**". 5. Enter mismatched passwords → error "Passwörter stimmen nicht überein". 6. Enter a 7-char password → error "Passwort muss mindestens 8 Zeichen haben". 7. Enter valid email + matching passwords (8+ chars) → user created, logged in, goals list shown. 8. In PocketBase admin, confirm invite record has `used_by` and `used_at` set. 9. Open the same link again in a private window → only "Anmelden" tab visible (token is used). - [ ] **Step 4: Commit** ```bash git add app.js git commit -m "feat: refactor login sheet with tabs, registration form, and password-reset link" ``` --- ### Task 8: Invite management UI **Files:** - Modify: `app.js` - Modify: `style.css` - [ ] **Step 1: Add `genToken` utility function to `app.js`** Add after the `now()` function (around line 16): ```js function genToken(){ var c='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789',t=''; for(var i=0;i<12;i++) t+=c[Math.floor(Math.random()*c.length)]; return t; } ``` - [ ] **Step 2: Add `showInvites` and `showNewInvite` functions after `openData` in `app.js`** ```js function showInvites(){ api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(created_by="'+authUserId+'")&sort=-created&expand=used_by')) .then(function(res){ var items=res.items||[]; var rows=''; for(var i=0;i✓ '+usedEmail+'' :'Ausstehend'; rows+='
'+inv.label+''+status+'
'; } showSheet( '
'+ '
Einladungen
'+ (rows||'
Noch keine Einladungen verschickt.
')+ '
'+ ''+ '' ); document.getElementById('icls').onclick=closeOv; document.getElementById('inew').onclick=showNewInvite; }) .catch(function(){ showToast('Fehler beim Laden'); }); } function showNewInvite(){ showSheet( '
'+ '
Neue Einladung
'+ '
'+ '
'+ ''+ ''+ '
' ); setTimeout(function(){ var el=document.getElementById('ilabel'); if(el) el.focus(); },50); document.getElementById('iback').onclick=showInvites; document.getElementById('isub').onclick=function(){ var label=(document.getElementById('ilabel').value||'').trim(); if(!label) return; var btn=this; btn.disabled=true; btn.textContent='…'; var token=genToken(); api('POST','/api/collections/invites/records',{token:token,label:label,created_by:authUserId}) .then(function(){ var link=APP_URL+'/?invite='+token; showSheet( '
'+ '
Einladung erstellt
'+ '
Teile diesen Link mit '+label+':
'+ ''+ '
'+ ''+ ''+ '
' ); document.getElementById('idone').onclick=closeOv; document.getElementById('icopy').onclick=function(){ navigator.clipboard.writeText(link).then(function(){ showToast('Link kopiert!'); }); }; }) .catch(function(){ btn.disabled=false; btn.textContent='Einladung erstellen'; showToast('Fehler'); }); }; } ``` - [ ] **Step 3: Add "Einladungen" button to `openData`** In `openData`, the sheet HTML starts with the export button. Add a new button and divider at the very beginning of the sheet content (before the export button): Replace the beginning of `openData`'s sheet HTML: ```js ''+ ``` With: ```js ''+ '
'+ ''+ ``` Add the event binding after `document.getElementById('dcls').onclick=closeOv;`: ```js document.getElementById('dinv').onclick=function(){ closeOv(); showInvites(); }; ``` - [ ] **Step 4: Verify invite management** 1. Log in, open ⋯ menu → "Einladungen" button appears at the top. 2. Click → invite list sheet opens (empty). 3. "Neue Einladung" → enter "Papa" → "Einladung erstellen". 4. Result sheet shows invite link with "Link kopieren" button. 5. "Link kopieren" → toast "Link kopiert!" appears, clipboard contains the URL. 6. "Fertig" → sheet closes. 7. Open ⋯ → Einladungen → "Papa" listed as "Ausstehend". 8. Open the invite link in a private window and register. 9. Back in original session: ⋯ → Einladungen → "Papa" shows "✓ [email]". - [ ] **Step 5: Commit** ```bash git add app.js style.css git commit -m "feat: add invite management UI with create, list, and referral tracking" ``` --- ## Done After Task 8, the full feature set is live: - `config.js` holds server URLs (update for production deployment) - Invite links are the only way to register - Password reset works via email (requires PocketBase SMTP configured in Task 3) - Invite list shows referral status per person