Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend. Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
21 KiB
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 <script src="config.js"> before app.js |
app.js |
Modify | Remove hardcoded PB_URL; add inviteRecord/loginTab globals; refactor showLogin; add password reset, registration, and invite management functions; update startup block |
style.css |
Modify | Add tab styles, password-reset link style, invite list/link styles |
Task 1: Create config.js and wire it up
Files:
-
Create:
config.js -
Modify:
index.html -
Modify:
app.js(remove line 4) -
Step 1: Create
config.js
var PB_URL = 'http://miniweb.kuehn.home:8090';
var APP_URL = 'http://miniweb.kuehn.home';
- Step 2: Add script tag to
index.html
In index.html, before <script src="app.js"></script>, add:
<script src="config.js"></script>
- Step 3: Remove hardcoded
PB_URLfromapp.js
Delete line 4 of app.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
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
invitescollection
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
.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
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:
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:
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:
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
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
showPasswordResetfunction aftershowLogininapp.js
function showPasswordReset(){
var o=document.getElementById('ov');
o.innerHTML='<div class="sheet">'+
'<div class="shandle"></div>'+
'<div class="stitle">Passwort zurücksetzen</div>'+
'<div class="ssub">Wir senden dir einen Reset-Link per E-Mail</div>'+
'<div class="ff"><label>E-Mail</label><input class="fi" id="premail" type="email" autocomplete="email"/></div>'+
'<div class="factions">'+
'<button class="btn-p" id="prsub">Link senden</button>'+
'<button class="btn-c" id="prback">Zurück</button>'+
'</div>'+
'</div>';
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=
'<div class="shandle"></div>'+
'<div class="stitle">E-Mail gesendet</div>'+
'<div class="ssub">Falls ein Konto mit dieser Adresse existiert, hast du einen Link erhalten.</div>'+
'<div class="factions"><button class="btn-p" id="prdone">Zum Login</button></div>';
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
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 —
showLogincallsrenderRegisterFormandwireRegisterFormwhich are defined here.
Files:
-
Modify:
app.js -
Step 1: Replace the entire
showLoginfunction 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:
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
?'<div class="ltabs">'+
'<button class="ltab'+(loginTab==='login'?' active':'')+'" id="ltab-login">Anmelden</button>'+
'<button class="ltab'+(loginTab==='register'?' active':'')+'" id="ltab-reg">Registrieren</button>'+
'</div>'
:'';
var formHtml=(hasInvite&&loginTab==='register')?renderRegisterForm(err):renderLoginForm(err);
o.innerHTML='<div class="sheet">'+
'<div class="shandle"></div>'+
'<div class="stitle">Anmelden</div>'+
'<div class="ssub">ZielTracker</div>'+
tabsHtml+formHtml+
'</div>';
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?'<div class="login-err">'+err+'</div>':'')+
'<div class="ff"><label>E-Mail</label><input class="fi" id="lemail" type="email" autocomplete="email"/></div>'+
'<div class="ff"><label>Passwort</label><input class="fi" id="lpass" type="password" autocomplete="current-password"/></div>'+
'<div class="factions"><button class="btn-p" id="lsub">Anmelden</button></div>'+
'<button class="pw-link" id="lpw">Passwort vergessen?</button>';
}
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?'<div class="login-err">'+err+'</div>':'')+
'<div class="ssub" style="margin-bottom:8px">Eingeladen als: <strong>'+inviteRecord.label+'</strong></div>'+
'<div class="ff"><label>E-Mail</label><input class="fi" id="remail" type="email" autocomplete="email"/></div>'+
'<div class="ff"><label>Passwort</label><input class="fi" id="rpass" type="password" autocomplete="new-password"/></div>'+
'<div class="ff"><label>Passwort wiederholen</label><input class="fi" id="rpass2" type="password" autocomplete="new-password"/></div>'+
'<div class="factions"><button class="btn-p" id="rsub">Registrieren</button></div>';
}
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
- In PocketBase admin, create a test invite: token =
testtoken123, label =Testkind, used_by = empty. - Open
http://miniweb.kuehn.home/?invite=testtoken123in a private window. - Login sheet shows two tabs: "Anmelden" / "Registrieren".
- Click "Registrieren" → form shows "Eingeladen als: Testkind".
- Enter mismatched passwords → error "Passwörter stimmen nicht überein".
- Enter a 7-char password → error "Passwort muss mindestens 8 Zeichen haben".
- Enter valid email + matching passwords (8+ chars) → user created, logged in, goals list shown.
- In PocketBase admin, confirm invite record has
used_byandused_atset. - Open the same link again in a private window → only "Anmelden" tab visible (token is used).
- Step 4: Commit
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
genTokenutility function toapp.js
Add after the now() function (around line 16):
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
showInvitesandshowNewInvitefunctions afteropenDatainapp.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<items.length;i++){
var inv=items[i];
var usedEmail=inv.expand&&inv.expand.used_by?inv.expand.used_by.email:'registriert';
var status=inv.used_by
?'<span style="color:var(--green)">✓ '+usedEmail+'</span>'
:'<span style="color:var(--text3)">Ausstehend</span>';
rows+='<div class="inv-row"><span class="inv-lbl">'+inv.label+'</span>'+status+'</div>';
}
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Einladungen</div>'+
(rows||'<div style="color:var(--text3);padding:12px 0;font-size:14px">Noch keine Einladungen verschickt.</div>')+
'<div class="ddiv"></div>'+
'<button class="btn-p" id="inew" style="width:100%">+ Neue Einladung</button>'+
'<button class="btn-c" id="icls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
);
document.getElementById('icls').onclick=closeOv;
document.getElementById('inew').onclick=showNewInvite;
})
.catch(function(){ showToast('Fehler beim Laden'); });
}
function showNewInvite(){
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Neue Einladung</div>'+
'<div class="ff"><label>Name / Für wen?</label><input class="fi" id="ilabel" type="text" placeholder="z.B. Mama"/></div>'+
'<div class="factions">'+
'<button class="btn-p" id="isub">Einladung erstellen</button>'+
'<button class="btn-c" id="iback">Zurück</button>'+
'</div>'
);
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(
'<div class="shandle"></div>'+
'<div class="stitle">Einladung erstellt</div>'+
'<div class="ssub">Teile diesen Link mit '+label+':</div>'+
'<div class="inv-link" id="ilink">'+link+'</div>'+
'<div class="factions">'+
'<button class="btn-p" id="icopy">Link kopieren</button>'+
'<button class="btn-c" id="idone">Fertig</button>'+
'</div>'
);
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:
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
With:
'<button class="dbtn" id="dinv"><span class="dico">✉</span><span class="dlbl">Einladungen<span class="dsub">Freunde und Familie einladen</span></span></button>'+
'<div class="ddiv"></div>'+
'<button class="dbtn" id="dexp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
Add the event binding after document.getElementById('dcls').onclick=closeOv;:
document.getElementById('dinv').onclick=function(){ closeOv(); showInvites(); };
- Step 4: Verify invite management
- Log in, open ⋯ menu → "Einladungen" button appears at the top.
- Click → invite list sheet opens (empty).
- "Neue Einladung" → enter "Papa" → "Einladung erstellen".
- Result sheet shows invite link with "Link kopieren" button.
- "Link kopieren" → toast "Link kopiert!" appears, clipboard contains the URL.
- "Fertig" → sheet closes.
- Open ⋯ → Einladungen → "Papa" listed as "Ausstehend".
- Open the invite link in a private window and register.
- Back in original session: ⋯ → Einladungen → "Papa" shows "✓ [email]".
- Step 5: Commit
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.jsholds 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