# 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='
':'')+
''+
''+
''+
'';
}
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+':
'+
'
'+link+'
'+
'
'+
''+
''+
'
'
);
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