934 lines
48 KiB
Markdown
934 lines
48 KiB
Markdown
# Les 10 -- Slide-overzicht
|
|
## Supabase Authenticatie & Row Level Security (15 slides)
|
|
|
|
**Cursus:** AI Developer -- NOVI Hogeschool Utrecht
|
|
**Duur:** 3 uur (180 minuten) | 09:00 - 12:00
|
|
**Project:** Poll App -- authenticatie toevoegen met Supabase Auth
|
|
|
|
> **Voorkennis studenten:** Supabase basics uit Les 8 (project setup, database, tabellen). Poll App met Next.js 16, TypeScript, Tailwind CSS. Database met `polls` en `options` tabellen. Nog geen authenticatie.
|
|
|
|
---
|
|
|
|
## Timing-overzicht
|
|
|
|
| Tijd | Duur | Onderwerp | Slide(s) | Vorm |
|
|
|---------------|--------|----------------------------------------|----------|---------------------|
|
|
| 09:00 - 09:05 | 5 min | Titelslide | 1 | Presentatie |
|
|
| 09:05 - 09:10 | 5 min | Planning vandaag | 2 | Presentatie |
|
|
| 09:10 - 09:15 | 5 min | Terugblik Les 8-9 | 3 | Presentatie |
|
|
| 09:15 - 09:25 | 10 min | Eindexamenopdracht | 4 | Presentatie |
|
|
| 09:25 - 09:30 | 5 min | Wat is authenticatie? | 5 | Presentatie |
|
|
| 09:30 - 09:40 | 10 min | Supabase Auth -- 3 methodes | 6 | Presentatie + Demo |
|
|
| 09:40 - 09:45 | 5 min | Hoe werkt een sessie? | 7 | Presentatie |
|
|
| 09:45 - 09:55 | 10 min | Auth in Next.js | 8 | Presentatie |
|
|
| 09:55 - 10:00 | 5 min | Row Level Security (RLS) | 9 | Presentatie |
|
|
| 10:00 - 10:15 | 15 min | Pauze | 10 | Pauze |
|
|
| 10:15 - 10:30 | 15 min | Hands-on: Auth opzetten in Supabase | 11 | Hands-on (ref) |
|
|
| 10:30 - 10:55 | 25 min | Hands-on: Login & registratie | 12 | Hands-on (ref) |
|
|
| 10:55 - 11:10 | 15 min | Hands-on: Sessie & beschermde routes | 13 | Hands-on (ref) |
|
|
| 11:10 - 11:25 | 15 min | Hands-on: Basis RLS | 14 | Hands-on (ref) |
|
|
| 11:45 - 12:00 | 15 min | Samenvatting & huiswerk | 15 | Presentatie |
|
|
|
|
---
|
|
|
|
## Slide-indeling
|
|
|
|
---
|
|
|
|
### Slide 1: Titelslide
|
|
**Timing:** 09:00 - 09:05 (5 min)
|
|
|
|
**Titel:** Les 10 -- Supabase Auth & RLS
|
|
**Ondertitel:** Authenticatie toevoegen aan de Poll App
|
|
|
|
```
|
|
+========================================================+
|
|
| |
|
|
| LES 10: SUPABASE AUTH & RLS |
|
|
| |
|
|
| Authenticatie toevoegen aan de Poll App |
|
|
| |
|
|
| Tim -- NOVI Hogeschool Utrecht |
|
|
| AI Developer Cursus |
|
|
| |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Les 10 van de AI Developer cursus
|
|
- Vandaag draaien we alles om beveiliging: wie mag wat?
|
|
- Supabase Auth + Row Level Security
|
|
- We bouwen verder op de Poll App uit Les 8-9
|
|
|
|
**Spreektekst:**
|
|
- "Goedemorgen allemaal, welkom bij Les 10!"
|
|
- "Vandaag gaan we onze Poll App beveiligen met echte authenticatie."
|
|
- "Na vandaag kan niet zomaar iedereen meer polls aanmaken -- je moet ingelogd zijn."
|
|
- "We gebruiken Supabase Auth, wat het hele inlogproces voor ons afhandelt."
|
|
- "En we leren over Row Level Security: beveiliging op database-niveau."
|
|
|
|
---
|
|
|
|
### Slide 2: Planning Vandaag
|
|
**Timing:** 09:05 - 09:10 (5 min)
|
|
|
|
**Titel:** Planning Vandaag
|
|
**Ondertitel:** Wat gaan we doen en leren?
|
|
|
|
```
|
|
+========================================================+
|
|
| PLANNING VANDAAG |
|
|
| |
|
|
| 09:00 Theorie: Auth & RLS concepten |
|
|
| 09:15 Eindexamenopdracht introductie |
|
|
| 09:25 Supabase Auth deep dive |
|
|
| 10:00 -- PAUZE -- |
|
|
| 10:15 Hands-on: Auth opzetten |
|
|
| 10:30 Hands-on: Login & registratie |
|
|
| 10:55 Hands-on: Sessie & beschermde routes |
|
|
| 11:10 Hands-on: Basis RLS |
|
|
| 11:45 Samenvatting & huiswerk |
|
|
| |
|
|
| LEERDOELEN: |
|
|
| [x] Supabase Auth instellen (email/password) |
|
|
| [x] Login/registratie pagina bouwen |
|
|
| [x] Sessie beheren (wie is ingelogd?) |
|
|
| [x] Row Level Security policies schrijven |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Eerste helft: theorie over authenticatie, autorisatie, sessies
|
|
- Introductie van de eindexamenopdracht
|
|
- Tweede helft: volledig hands-on, stap voor stap
|
|
- Vier concrete leerdoelen die je vandaag behaalt
|
|
|
|
**Spreektekst:**
|
|
- "Dit is de planning voor vandaag. We beginnen met een stuk theorie."
|
|
- "Daarna introduceer ik de eindexamenopdracht -- heel belangrijk."
|
|
- "Na de pauze gaan we volledig hands-on. Ik laat reference slides zien die op het scherm blijven staan terwijl jullie werken."
|
|
- "Aan het einde van vandaag heb je een werkende login, registratie, en beveiligde database."
|
|
- "Vier leerdoelen: Auth instellen, login bouwen, sessie beheren, en RLS schrijven."
|
|
|
|
---
|
|
|
|
### Slide 3: Terugblik Les 8-9
|
|
**Timing:** 09:10 - 09:15 (5 min)
|
|
|
|
**Titel:** Terugblik Les 8-9
|
|
**Ondertitel:** Waar staan we nu?
|
|
|
|
```
|
|
+========================================================+
|
|
| TERUGBLIK LES 8-9 |
|
|
| |
|
|
| +------------------+ +-------------------------+ |
|
|
| | SUPABASE | | NEXT.JS APP | |
|
|
| | | | | |
|
|
| | polls | | / (homepage) | |
|
|
| | +----------+ | | /polls (lijst) | |
|
|
| | | id | |<-->| /polls/new (aanmaken) | |
|
|
| | | question | | | /polls/[id] (stemmen) | |
|
|
| | +----------+ | | | |
|
|
| | | | | |
|
|
| | options | | TypeScript + Tailwind | |
|
|
| | +----------+ | | | |
|
|
| | | id | | +-------------------------+ |
|
|
| | | poll_id | | |
|
|
| | | text | | PROBLEEM: |
|
|
| | | votes | | Iedereen kan alles! |
|
|
| | +----------+ | Geen login nodig |
|
|
| +------------------+ Geen beveiliging |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Supabase project met `polls` en `options` tabellen
|
|
- Next.js app met pagina's voor lijst, aanmaken en stemmen
|
|
- Alles werkt, maar er is geen beveiliging
|
|
- Het probleem: iedereen kan polls aanmaken en data manipuleren
|
|
|
|
**Spreektekst:**
|
|
- "Laten we even terugkijken naar waar we staan."
|
|
- "We hebben een werkende Poll App: je kunt polls bekijken, nieuwe polls aanmaken, en stemmen."
|
|
- "De database draait op Supabase met twee tabellen: polls en options."
|
|
- "Maar... er is een groot probleem. Iedereen kan alles doen. Er is geen login."
|
|
- "Als je de Supabase URL en API key kent, kun je direct de database benaderen."
|
|
- "Vandaag gaan we dat oplossen!"
|
|
|
|
---
|
|
|
|
### Slide 4: Eindexamenopdracht
|
|
**Timing:** 09:15 - 09:25 (10 min)
|
|
|
|
**Titel:** Eindexamenopdracht
|
|
**Ondertitel:** Vrije keuze app -- jouw project!
|
|
|
|
```
|
|
+========================================================+
|
|
| EINDEXAMENOPDRACHT |
|
|
| |
|
|
| Bouw je eigen full-stack applicatie! |
|
|
| |
|
|
| VEREISTEN: |
|
|
| +----------------------------------------------------+|
|
|
| | [x] Next.js 16 + TypeScript ||
|
|
| | [x] Supabase (database + auth) ||
|
|
| | [x] Authenticatie (login/registratie) ||
|
|
| | [x] Row Level Security (RLS policies) ||
|
|
| | [x] CRUD operaties (Create, Read, Update, Delete) ||
|
|
| | [x] Deployed (Vercel + Supabase) ||
|
|
| | [x] Nette code (componenten, types, error handling)||
|
|
| +----------------------------------------------------+|
|
|
| |
|
|
| TIJDLIJN: |
|
|
| Les 10 (vandaag) Introductie + Auth leren |
|
|
| Les 11-12 Bouwen aan je project |
|
|
| Les 13 Inleveren + presentatie |
|
|
| |
|
|
| IDEEN: Todo app, Blog, Recepten, Budget tracker, |
|
|
| Quiz app, Bookmark manager, Habit tracker... |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Vrije keuze: kies zelf welke app je bouwt
|
|
- Zeven technische vereisten waar je aan moet voldoen
|
|
- Alles wat we in de cursus geleerd hebben komt samen
|
|
- Tijdlijn: 3-4 lessen om te bouwen, daarna inleveren + presentatie
|
|
- Voorbeelden ter inspiratie, maar eigen ideeen zijn welkom
|
|
|
|
**Spreektekst:**
|
|
- "Nu iets heel belangrijks: de eindexamenopdracht."
|
|
- "Jullie gaan je eigen full-stack applicatie bouwen. Vrije keuze -- dus kies iets dat je leuk vindt."
|
|
- "Er zijn zeven vereisten. Laten we ze doorlopen."
|
|
- "Next.js 16 met TypeScript -- dat kennen jullie al."
|
|
- "Supabase voor de database EN authenticatie -- dat leren we vandaag."
|
|
- "Je app moet login en registratie hebben, en de database moet beveiligd zijn met RLS."
|
|
- "CRUD: je moet data kunnen aanmaken, lezen, updaten en verwijderen."
|
|
- "De app moet gedeployed zijn op Vercel, zodat ik hem kan bekijken."
|
|
- "En nette code: goede componenten, TypeScript types, error handling."
|
|
- "Qua tijdlijn: vandaag leer je Auth. De komende lessen heb je tijd om te bouwen. En dan presenteren."
|
|
- "Begin alvast na te denken over wat je wilt bouwen. Een todo app, blog, recepten-app, budget tracker... het mag allemaal."
|
|
|
|
---
|
|
|
|
### Slide 5: Wat is Authenticatie?
|
|
**Timing:** 09:25 - 09:30 (5 min)
|
|
|
|
**Titel:** Wat is Authenticatie?
|
|
**Ondertitel:** Auth vs Autorisatie
|
|
|
|
```
|
|
+========================================================+
|
|
| WAT IS AUTHENTICATIE? |
|
|
| |
|
|
| +------------------------+ +------------------------+|
|
|
| | | | ||
|
|
| | AUTHENTICATIE | | AUTORISATIE ||
|
|
| | | | ||
|
|
| | "Wie ben je?" | | "Wat mag je?" ||
|
|
| | | | ||
|
|
| | +--------+ | | +--------+ ||
|
|
| | | SLOT | | | | SCHILD | ||
|
|
| | | [====] | | | | {X} | ||
|
|
| | +--------+ | | +--------+ ||
|
|
| | | | ||
|
|
| | - Inloggen | | - Rechten ||
|
|
| | - Email + wachtwoord | | - Rollen (admin/user) ||
|
|
| | - Identiteit bewijzen | | - Wat mag je zien? ||
|
|
| | | | ||
|
|
| +------------------------+ +------------------------+|
|
|
| |
|
|
| VOORBEELD: |
|
|
| Bioscoop: ticket tonen = auth | stoel kiezen = autor. |
|
|
| School: pasje scannen = auth | lokaal betreden = a. |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Authenticatie = wie ben je? Identiteit bewijzen.
|
|
- Autorisatie = wat mag je? Rechten en rollen.
|
|
- Twee aparte concepten die samenwerken
|
|
- Vandaag leren we beide: Auth met Supabase, Autorisatie met RLS
|
|
|
|
**Spreektekst:**
|
|
- "Voordat we gaan bouwen, moeten we twee begrippen begrijpen."
|
|
- "Authenticatie: wie ben je? Je bewijst je identiteit. Bijvoorbeeld door in te loggen met email en wachtwoord."
|
|
- "Autorisatie: wat mag je? Welke rechten heb je? Mag je alleen lezen, of ook schrijven?"
|
|
- "Denk aan een bioscoop. Je laat je ticket zien bij de ingang -- dat is authenticatie. Maar je mag alleen in zaal 3 zitten -- dat is autorisatie."
|
|
- "Of school: je scant je pasje -- authenticatie. Maar je mag niet zomaar in elk lokaal -- autorisatie."
|
|
- "Vandaag leren we beide. Supabase Auth regelt de authenticatie. Row Level Security regelt de autorisatie."
|
|
|
|
---
|
|
|
|
### Slide 6: Supabase Auth -- 3 Methodes
|
|
**Timing:** 09:30 - 09:40 (10 min)
|
|
|
|
**Titel:** Supabase Auth -- 3 Methodes
|
|
**Ondertitel:** Hoe kunnen gebruikers inloggen?
|
|
|
|
```
|
|
+========================================================+
|
|
| SUPABASE AUTH -- 3 METHODES |
|
|
| |
|
|
| +----------------+ +----------------+ +--------------+|
|
|
| | EMAIL/PASSWORD | | MAGIC LINK | | GOOGLE OAUTH ||
|
|
| | | | | | ||
|
|
| | +------------+ | | +------------+ | | +----------+||
|
|
| | | email: | | | | email: | | | | Google |||
|
|
| | | [........] | | | | [........] | | | | [G] |||
|
|
| | | password: | | | | | | | | Login |||
|
|
| | | [........] | | | | Klik link | | | +----------+||
|
|
| | | [INLOGGEN] | | | | in je mail | | | ||
|
|
| | +------------+ | | +------------+ | | ||
|
|
| | | | | | ||
|
|
| | Klassiek | | Geen wachtw. | | Social login ||
|
|
| | Makkelijk te | | Veilig | | Makkelijkst ||
|
|
| | begrijpen | | Simpel | | voor users ||
|
|
| | | | | | ||
|
|
| | WIJ GEBRUIKEN | | OPTIONEEL | | NIET VANDAAG ||
|
|
| | DIT VANDAAG | | (bonus) | | (complex) ||
|
|
| +----------------+ +----------------+ +--------------+|
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Email/Password: klassieke methode, makkelijk te begrijpen, we gebruiken dit vandaag
|
|
- Magic Link: email zonder wachtwoord, gebruiker klikt link in mailbox
|
|
- Google OAuth: social login via Google account, meest gebruiksvriendelijk maar complexer
|
|
- Supabase ondersteunt alle drie out-of-the-box
|
|
- In het Supabase Dashboard kun je providers aan/uitzetten
|
|
|
|
**Spreektekst:**
|
|
- "Supabase biedt drie manieren om in te loggen. Laten we ze bekijken."
|
|
- "Nummer 1: email en wachtwoord. De klassieke manier. Je maakt een account aan met je email en een wachtwoord, en daarna log je in. Dit gaan we vandaag gebruiken."
|
|
- "Nummer 2: Magic Link. Je vult alleen je email in, en Supabase stuurt een linkje. Klik erop en je bent ingelogd. Geen wachtwoord nodig. Dit voegen we toe als bonus."
|
|
- "Nummer 3: Google OAuth. De 'Log in met Google' knop die je overal ziet. Heel handig voor gebruikers, maar de setup is complexer. Dat doen we niet vandaag."
|
|
- "Laat me even het Supabase Dashboard laten zien waar je deze providers configureert..."
|
|
- *Tim opent Supabase Dashboard > Authentication > Providers en laat de opties zien*
|
|
- "Zie je? Email staat standaard aan. Je kunt hier ook Magic Link, Google, GitHub en meer aanzetten."
|
|
|
|
---
|
|
|
|
### Slide 7: Hoe Werkt een Sessie?
|
|
**Timing:** 09:40 - 09:45 (5 min)
|
|
|
|
**Titel:** Hoe Werkt een Sessie?
|
|
**Ondertitel:** Van login tot beveiligde requests
|
|
|
|
```
|
|
+========================================================+
|
|
| HOE WERKT EEN SESSIE? |
|
|
| |
|
|
| +--------+ +----------+ +-----------+ |
|
|
| | USER |----->| SUPABASE |----->| JWT TOKEN | |
|
|
| | login | | Auth | | (sessie) | |
|
|
| +--------+ +----------+ +-----------+ |
|
|
| | | |
|
|
| | email + wachtwoord | |
|
|
| | v |
|
|
| | +---------------+ |
|
|
| | | COOKIE | |
|
|
| | | (browser) | |
|
|
| | +---------------+ |
|
|
| | | |
|
|
| v v |
|
|
| +---------------------------------------------+ |
|
|
| | ELKE REQUEST | |
|
|
| | Browser stuurt cookie automatisch mee | |
|
|
| | Middleware checkt: is de sessie geldig? | |
|
|
| | Zo ja: door naar de pagina | |
|
|
| | Zo nee: redirect naar /login | |
|
|
| +---------------------------------------------+ |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- User logt in met email + wachtwoord
|
|
- Supabase Auth verifieert en stuurt een JWT token terug
|
|
- JWT wordt opgeslagen als cookie in de browser
|
|
- Bij elke request stuurt de browser de cookie automatisch mee
|
|
- Middleware controleert of de sessie geldig is
|
|
|
|
**Spreektekst:**
|
|
- "Hoe werkt een sessie eigenlijk? Laten we het stap voor stap bekijken."
|
|
- "Stap 1: de gebruiker logt in met email en wachtwoord."
|
|
- "Stap 2: Supabase Auth controleert de gegevens. Kloppen ze? Dan krijg je een JWT token terug."
|
|
- "JWT staat voor JSON Web Token. Het is een gecodeerde string die zegt: 'deze gebruiker is ingelogd en dit is hun user ID'."
|
|
- "Stap 3: dat token wordt opgeslagen als cookie in de browser."
|
|
- "Stap 4: bij elke pagina die je bezoekt, stuurt de browser die cookie automatisch mee."
|
|
- "In onze Next.js app hebben we middleware die bij elke request checkt: is er een geldige sessie? Zo nee, dan redirect hij naar /login."
|
|
- "Dit is belangrijk om te begrijpen. Je hoeft het niet zelf te bouwen -- Supabase en de @supabase/ssr package regelen het voor je."
|
|
|
|
---
|
|
|
|
### Slide 8: Auth in Next.js
|
|
**Timing:** 09:45 - 09:55 (10 min)
|
|
|
|
**Titel:** Auth in Next.js
|
|
**Ondertitel:** Architectuur en bestandsstructuur
|
|
|
|
```
|
|
+========================================================+
|
|
| AUTH IN NEXT.JS -- ARCHITECTUUR |
|
|
| |
|
|
| Package: @supabase/ssr |
|
|
| |
|
|
| +----------------------------------------------------+|
|
|
| | BESTANDEN DIE WE GAAN MAKEN: ||
|
|
| | ||
|
|
| | src/ ||
|
|
| | +-- lib/supabase/ ||
|
|
| | | +-- client.ts <-- Browser client ||
|
|
| | | +-- server.ts <-- Server client ||
|
|
| | | ||
|
|
| | +-- middleware.ts <-- Sessie refreshen ||
|
|
| | | ||
|
|
| | +-- app/ ||
|
|
| | +-- auth/ ||
|
|
| | | +-- callback/ ||
|
|
| | | +-- route.ts <-- Auth callback ||
|
|
| | +-- login/ ||
|
|
| | +-- page.tsx <-- Login pagina ||
|
|
| +----------------------------------------------------+|
|
|
| |
|
|
| BROWSER CLIENT SERVER CLIENT |
|
|
| - Gebruikt in - Gebruikt in Server |
|
|
| Client Components Components & API routes |
|
|
| - createBrowser... - createServer... |
|
|
| - Leest cookies - Leest EN schrijft cookies |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- `@supabase/ssr` is de package die cookies en sessies afhandelt
|
|
- Browser client: voor Client Components (interactieve UI)
|
|
- Server client: voor Server Components en API routes (data ophalen)
|
|
- Middleware: draait bij elke request, refresht de sessie
|
|
- Auth callback route: verwerkt magic links en OAuth redirects
|
|
|
|
**Spreektekst:**
|
|
- "Laten we kijken naar de architectuur. We installeren het package @supabase/ssr."
|
|
- "We maken twee Supabase clients. Waarom twee? Omdat Next.js twee omgevingen heeft."
|
|
- "De browser client gebruik je in Client Components -- dat zijn de interactieve componenten met useState en onClick."
|
|
- "De server client gebruik je in Server Components en API routes -- dat is code die op de server draait."
|
|
- "Het verschil: de browser client kan alleen cookies lezen. De server client kan ze ook schrijven."
|
|
- "Dan hebben we middleware.ts. Die draait bij ELKE request. Zijn taak: de sessie refreshen zodat de gebruiker ingelogd blijft."
|
|
- "En de auth callback route. Die is nodig voor magic links. Als een gebruiker op een magic link klikt, komt hij terug op /auth/callback, en die route wisselt de code om voor een sessie."
|
|
- "Na de pauze gaan we al deze bestanden stap voor stap aanmaken."
|
|
|
|
---
|
|
|
|
### Slide 9: Row Level Security (RLS)
|
|
**Timing:** 09:55 - 10:00 (5 min)
|
|
|
|
**Titel:** Row Level Security (RLS)
|
|
**Ondertitel:** Beveiliging op database-niveau
|
|
|
|
```
|
|
+========================================================+
|
|
| ROW LEVEL SECURITY (RLS) |
|
|
| |
|
|
| ZONDER RLS: |
|
|
| +--------------------------------------------+ |
|
|
| | polls tabel GEEN SLOT | |
|
|
| | id | question | created_by | |
|
|
| | 1 | Beste taal? | user_abc <- leesbaar| |
|
|
| | 2 | Beste editor? | user_xyz <- leesbaar| |
|
|
| | ** Iedereen kan ALLES lezen, schrijven, | |
|
|
| | updaten en verwijderen ** | |
|
|
| +--------------------------------------------+ |
|
|
| |
|
|
| MET RLS: |
|
|
| +--------------------------------------------+ |
|
|
| | polls tabel [SLOT] | |
|
|
| | id | question | created_by | |
|
|
| | 1 | Beste taal? | user_abc <- policy! | |
|
|
| | 2 | Beste editor? | user_xyz <- policy! | |
|
|
| | | |
|
|
| | POLICIES: | |
|
|
| | SELECT: iedereen mag lezen | |
|
|
| | INSERT: alleen ingelogde users | |
|
|
| | UPDATE: alleen eigen polls | |
|
|
| | DELETE: alleen eigen polls | |
|
|
| +--------------------------------------------+ |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Zonder RLS: de API key geeft volledige toegang tot alle data
|
|
- Met RLS: elke query wordt gecheckt tegen policies
|
|
- Policies definieer je per tabel, per operatie (SELECT, INSERT, UPDATE, DELETE)
|
|
- RLS draait op database-niveau -- je kunt het niet omzeilen vanuit de frontend
|
|
- Dit is de autorisatie-laag die we eerder bespraken
|
|
|
|
**Spreektekst:**
|
|
- "Nu het laatste theoretische concept: Row Level Security, oftewel RLS."
|
|
- "Onthoud: onze Supabase API key staat in de frontend code. Iedereen kan die zien."
|
|
- "Zonder RLS kan iemand met die key ALLES doen: lezen, schrijven, verwijderen. Dat is een groot beveiligingsprobleem."
|
|
- "Met RLS zet je een slot op je tabellen. Je definieert policies: regels die zeggen wie wat mag."
|
|
- "Bijvoorbeeld: iedereen mag polls LEZEN, maar alleen ingelogde users mogen polls AANMAKEN."
|
|
- "Of: je mag alleen je EIGEN polls updaten of verwijderen."
|
|
- "Het mooie van RLS is dat het op database-niveau draait. Zelfs als iemand direct de API aanroept, worden de policies gecontroleerd."
|
|
- "Na de pauze gaan we dit ook daadwerkelijk instellen."
|
|
|
|
---
|
|
|
|
### Slide 10: Pauze
|
|
**Timing:** 10:00 - 10:15 (15 min)
|
|
|
|
**Titel:** Pauze
|
|
**Ondertitel:** 15 minuten
|
|
|
|
```
|
|
+========================================================+
|
|
| |
|
|
| |
|
|
| PAUZE |
|
|
| |
|
|
| 15 minuten |
|
|
| |
|
|
| 10:00 -- 10:15 |
|
|
| |
|
|
| |
|
|
| Tip: Denk alvast na over je eindexamenopdracht! |
|
|
| Welke app wil je bouwen? |
|
|
| |
|
|
| |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- 15 minuten pauze
|
|
- Studenten kunnen alvast nadenken over hun eindexamenopdracht
|
|
- Na de pauze: hands-on, dus zorg dat je laptop klaar staat
|
|
|
|
**Spreektekst:**
|
|
- "Oké, tijd voor pauze! 15 minuten."
|
|
- "Denk alvast na over wat je wilt bouwen voor je eindexamenopdracht."
|
|
- "Na de pauze gaan we direct aan de slag. Zorg dat je Cursor open hebt en je Supabase project klaarstaat."
|
|
- "Tot zo!"
|
|
|
|
---
|
|
|
|
### Slide 11: Hands-on -- Auth Opzetten in Supabase (REFERENCE SLIDE)
|
|
**Timing:** 10:15 - 10:30 (15 min)
|
|
|
|
**Titel:** Hands-on -- Auth Opzetten
|
|
**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm
|
|
|
|
```
|
|
+========================================================+
|
|
| HANDS-ON: AUTH OPZETTEN [REF] |
|
|
| |
|
|
| STAP 1: Starter project |
|
|
| $ Pak de starter zip uit en open in Cursor |
|
|
| $ npm install |
|
|
| |
|
|
| STAP 2: Environment variabelen |
|
|
| Maak .env.local: |
|
|
| NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co |
|
|
| NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci... |
|
|
| |
|
|
| STAP 3: Packages installeren |
|
|
| $ npm install @supabase/ssr @supabase/supabase-js |
|
|
| |
|
|
| STAP 4: Browser client (src/lib/supabase/client.ts) |
|
|
| import { createBrowserClient } from '@supabase/ssr' |
|
|
| export function createClient() { |
|
|
| return createBrowserClient( |
|
|
| process.env.NEXT_PUBLIC_SUPABASE_URL!, |
|
|
| process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! |
|
|
| ) |
|
|
| } |
|
|
| |
|
|
| STAP 5: Server client (src/lib/supabase/server.ts) |
|
|
| import { createServerClient } from '@supabase/ssr' |
|
|
| import { cookies } from 'next/headers' |
|
|
| export async function createClient() { |
|
|
| const cookieStore = await cookies() |
|
|
| return createServerClient( |
|
|
| process.env.NEXT_PUBLIC_SUPABASE_URL!, |
|
|
| process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, |
|
|
| { cookies: { |
|
|
| getAll() { return cookieStore.getAll() }, |
|
|
| setAll(cookiesToSet) { |
|
|
| cookiesToSet.forEach(({ name, value, opts })|
|
|
| => cookieStore.set(name, value, opts)) |
|
|
| }, |
|
|
| }} |
|
|
| ) |
|
|
| } |
|
|
| |
|
|
| STAP 6: Middleware (src/middleware.ts) |
|
|
| import { createServerClient } from '@supabase/ssr' |
|
|
| import { NextResponse, type NextRequest } from 'next' |
|
|
| export async function middleware(request: NextRequest) |
|
|
| // Refresh sessie bij elke request |
|
|
| // Redirect naar /login als niet ingelogd |
|
|
| |
|
|
| STAP 7: Auth callback (src/app/auth/callback/route.ts)|
|
|
| // Verwerkt magic link codes |
|
|
| // Wisselt code om voor sessie |
|
|
| |
|
|
| STAP 8: Supabase Dashboard |
|
|
| Authentication > Providers > Email aanvinken |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Stap 1-2: Project openen en environment variabelen instellen
|
|
- Stap 3: Twee packages installeren: `@supabase/ssr` en `@supabase/supabase-js`
|
|
- Stap 4: Browser client voor Client Components
|
|
- Stap 5: Server client met cookie-handling voor Server Components
|
|
- Stap 6: Middleware die sessie refresht en ongeautoriseerde users redirect
|
|
- Stap 7: Auth callback route voor magic links
|
|
- Stap 8: Email provider aanzetten in Supabase Dashboard
|
|
|
|
**Spreektekst:**
|
|
- "Oké, we gaan aan de slag! Deze slide blijft op het scherm. Volg de stappen."
|
|
- "Stap 1: pak de starter zip uit die ik gestuurd heb, en open het project in Cursor. Doe npm install."
|
|
- "Stap 2: maak een .env.local bestand. Kopieer je Supabase URL en anon key uit het dashboard."
|
|
- "Stap 3: installeer de twee packages: @supabase/ssr en @supabase/supabase-js."
|
|
- "Stap 4: maak de browser client aan. Dit is heel simpel -- je roept createBrowserClient aan met je URL en key."
|
|
- "Stap 5: de server client is iets complexer. Die heeft cookie-handling nodig. Kopieer de code van de slide."
|
|
- "Stap 6: de middleware. Die zorgt dat de sessie bij elke request ververst wordt. En hij redirect naar /login als je niet ingelogd bent."
|
|
- "Stap 7: de auth callback route. Die heb je nodig voor magic links."
|
|
- "Stap 8: ga naar je Supabase Dashboard, Authentication, Providers, en zet Email aan."
|
|
- "Neem de tijd, volg het stap voor stap. Ik loop rond om te helpen."
|
|
|
|
---
|
|
|
|
### Slide 12: Hands-on -- Login & Registratie (REFERENCE SLIDE)
|
|
**Timing:** 10:30 - 10:55 (25 min)
|
|
|
|
**Titel:** Hands-on -- Login & Registratie
|
|
**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm
|
|
|
|
```
|
|
+========================================================+
|
|
| HANDS-ON: LOGIN & REGISTRATIE [REF] |
|
|
| |
|
|
| STAP 1: Maak src/app/login/page.tsx |
|
|
| 'use client' |
|
|
| import { useState } from 'react' |
|
|
| import { createClient } from '@/lib/supabase/client' |
|
|
| import { useRouter } from 'next/navigation' |
|
|
| |
|
|
| STAP 2: State |
|
|
| const [email, setEmail] = useState('') |
|
|
| const [password, setPassword] = useState('') |
|
|
| const [loading, setLoading] = useState(false) |
|
|
| const [message, setMessage] = useState('') |
|
|
| const router = useRouter() |
|
|
| const supabase = createClient() |
|
|
| |
|
|
| STAP 3: handleSignUp functie |
|
|
| async function handleSignUp() { |
|
|
| setLoading(true) |
|
|
| const { error } = await supabase.auth.signUp({ |
|
|
| email, password |
|
|
| }) |
|
|
| if (error) setMessage(error.message) |
|
|
| else setMessage('Check je email voor confirmatie!') |
|
|
| setLoading(false) |
|
|
| } |
|
|
| |
|
|
| STAP 4: handleSignIn functie |
|
|
| async function handleSignIn() { |
|
|
| setLoading(true) |
|
|
| const { error } = await |
|
|
| supabase.auth.signInWithPassword({ |
|
|
| email, password |
|
|
| }) |
|
|
| if (error) setMessage(error.message) |
|
|
| else router.push('/') |
|
|
| setLoading(false) |
|
|
| } |
|
|
| |
|
|
| STAP 5: handleMagicLink functie (bonus) |
|
|
| async function handleMagicLink() { |
|
|
| setLoading(true) |
|
|
| const { error } = await |
|
|
| supabase.auth.signInWithOtp({ email }) |
|
|
| if (error) setMessage(error.message) |
|
|
| else setMessage('Check je email voor de link!') |
|
|
| setLoading(false) |
|
|
| } |
|
|
| |
|
|
| STAP 6: Formulier JSX |
|
|
| <form> |
|
|
| <input type="email" value={email} |
|
|
| onChange={e => setEmail(e.target.value)} /> |
|
|
| <input type="password" value={password} |
|
|
| onChange={e => setPassword(e.target.value)} />|
|
|
| <button onClick={handleSignIn}>Inloggen</button> |
|
|
| <button onClick={handleSignUp}>Registreren</button> |
|
|
| <button onClick={handleMagicLink}>Magic Link</but.> |
|
|
| {message && <p>{message}</p>} |
|
|
| </form> |
|
|
| |
|
|
| STAP 7: Error handling & loading states |
|
|
| - disabled={loading} op alle buttons |
|
|
| - Loading tekst: "Even geduld..." |
|
|
| - Error message tonen in rode tekst |
|
|
| |
|
|
| STAP 8: Testen |
|
|
| - Registreer een account (check Supabase Dashboard) |
|
|
| - Log in met dat account |
|
|
| - Probeer magic link (check je email) |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Login pagina als Client Component (`'use client'`)
|
|
- State voor email, password, loading en foutmeldingen
|
|
- `signUp()`: registratie, stuurt een bevestigingsmail
|
|
- `signInWithPassword()`: inloggen met email + wachtwoord
|
|
- `signInWithOtp()`: magic link versturen (bonus)
|
|
- Formulier met drie buttons: inloggen, registreren, magic link
|
|
- Na succesvolle login: redirect naar homepage
|
|
- Testen: registreer, log in, check Supabase Dashboard
|
|
|
|
**Spreektekst:**
|
|
- "Nu gaan we de login pagina bouwen. Dit wordt een Client Component omdat we interactie nodig hebben."
|
|
- "Stap 1: maak het bestand src/app/login/page.tsx aan."
|
|
- "Stap 2: we hebben vier stukken state nodig: email, password, loading, en een message voor foutmeldingen."
|
|
- "Stap 3: de handleSignUp functie. Die roept supabase.auth.signUp aan met de email en het wachtwoord. Als het lukt, krijgt de gebruiker een bevestigingsmail."
|
|
- "Stap 4: handleSignIn. Dit is het echte inloggen. signInWithPassword. Als het lukt, redirect je naar de homepage."
|
|
- "Stap 5: de magic link als bonus. signInWithOtp -- je geeft alleen de email mee, geen wachtwoord."
|
|
- "Stap 6: het formulier. Twee input velden en drie buttons. Simpel."
|
|
- "Stap 7: vergeet de error handling niet! Disable de buttons als het laden is, en toon foutmeldingen."
|
|
- "Stap 8: testen! Registreer een account en check in het Supabase Dashboard of de user verschijnt onder Authentication > Users."
|
|
- "Dit is de meest uitgebreide stap, dus neem je tijd. Ik loop rond."
|
|
|
|
---
|
|
|
|
### Slide 13: Hands-on -- Sessie & Beschermde Routes (REFERENCE SLIDE)
|
|
**Timing:** 10:55 - 11:10 (15 min)
|
|
|
|
**Titel:** Hands-on -- Sessie & Beschermde Routes
|
|
**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm
|
|
|
|
```
|
|
+========================================================+
|
|
| HANDS-ON: SESSIE & BESCHERMDE ROUTES [REF] |
|
|
| |
|
|
| STAP 1: User ophalen in layout/navbar |
|
|
| // In een Server Component: |
|
|
| import { createClient } from '@/lib/supabase/server' |
|
|
| |
|
|
| const supabase = await createClient() |
|
|
| const { data: { user } } = await |
|
|
| supabase.auth.getUser() |
|
|
| |
|
|
| STAP 2: Conditional rendering |
|
|
| {user ? ( |
|
|
| <div> |
|
|
| <span>{user.email}</span> |
|
|
| <LogoutButton /> |
|
|
| </div> |
|
|
| ) : ( |
|
|
| <a href="/login">Inloggen</a> |
|
|
| )} |
|
|
| |
|
|
| STAP 3: LogoutButton component (Client Component) |
|
|
| 'use client' |
|
|
| import { createClient } from '@/lib/supabase/client' |
|
|
| import { useRouter } from 'next/navigation' |
|
|
| |
|
|
| export function LogoutButton() { |
|
|
| const router = useRouter() |
|
|
| const supabase = createClient() |
|
|
| |
|
|
| async function handleSignOut() { |
|
|
| await supabase.auth.signOut() |
|
|
| router.push('/login') |
|
|
| } |
|
|
| |
|
|
| return ( |
|
|
| <button onClick={handleSignOut}>Uitloggen</button>|
|
|
| ) |
|
|
| } |
|
|
| |
|
|
| STAP 4: Redirect na logout naar /login |
|
|
| router.push('/login') in handleSignOut |
|
|
| |
|
|
| STAP 5: Testen |
|
|
| [x] Ga naar / zonder login -> redirect naar /login |
|
|
| [x] Log in -> je ziet je email in de navbar |
|
|
| [x] Klik uitloggen -> redirect naar /login |
|
|
| [x] Na uitloggen kun je niet bij / komen |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- `getUser()` op de server om de huidige gebruiker op te halen
|
|
- Conditional rendering: toon email + logout als ingelogd, anders login-link
|
|
- LogoutButton is een Client Component (interactief)
|
|
- `signOut()` verwijdert de sessie, daarna redirect naar /login
|
|
- Middleware handelt de bescherming af: geen sessie = redirect naar /login
|
|
|
|
**Spreektekst:**
|
|
- "Nu gaan we de sessie zichtbaar maken in de UI."
|
|
- "Stap 1: in je layout of navbar haal je de user op. Let op: dit is een Server Component, dus gebruik de server client."
|
|
- "supabase.auth.getUser() geeft de huidige ingelogde user terug, of null als niemand ingelogd is."
|
|
- "Stap 2: conditional rendering. Als er een user is, toon de email en een logout button. Anders toon een link naar /login."
|
|
- "Stap 3: de LogoutButton moet een Client Component zijn, want hij heeft een onClick nodig."
|
|
- "De handleSignOut functie roept supabase.auth.signOut() aan. Dat verwijdert de sessie cookie."
|
|
- "Stap 4: na het uitloggen stuur je de gebruiker terug naar /login met router.push."
|
|
- "Stap 5: testen! Open een incognito venster en ga naar de homepage. Je zou geredirect moeten worden naar /login."
|
|
- "Log in, en je ziet je email in de navbar. Klik uitloggen, en je gaat terug naar /login."
|
|
- "Als dit allemaal werkt, heb je een werkend auth-systeem!"
|
|
|
|
---
|
|
|
|
### Slide 14: Hands-on -- Basis RLS (REFERENCE SLIDE)
|
|
**Timing:** 11:10 - 11:25 (15 min)
|
|
|
|
**Titel:** Hands-on -- Basis RLS
|
|
**Ondertitel:** REFERENCE SLIDE -- blijft op het scherm
|
|
|
|
```
|
|
+========================================================+
|
|
| HANDS-ON: BASIS RLS [REF] |
|
|
| |
|
|
| Ga naar Supabase Dashboard > SQL Editor |
|
|
| |
|
|
| STAP 1: Enable RLS op polls |
|
|
| ALTER TABLE polls ENABLE ROW LEVEL SECURITY; |
|
|
| |
|
|
| STAP 2: Enable RLS op options |
|
|
| ALTER TABLE options ENABLE ROW LEVEL SECURITY; |
|
|
| |
|
|
| STAP 3: Iedereen mag polls LEZEN |
|
|
| CREATE POLICY "Iedereen mag polls lezen" |
|
|
| ON polls FOR SELECT |
|
|
| TO public |
|
|
| USING (true); |
|
|
| |
|
|
| STAP 4: Alleen ingelogde users mogen polls AANMAKEN |
|
|
| CREATE POLICY "Ingelogde users mogen polls aanmaken" |
|
|
| ON polls FOR INSERT |
|
|
| TO authenticated |
|
|
| WITH CHECK (true); |
|
|
| |
|
|
| STAP 5: Iedereen mag options LEZEN |
|
|
| CREATE POLICY "Iedereen mag options lezen" |
|
|
| ON options FOR SELECT |
|
|
| TO public |
|
|
| USING (true); |
|
|
| |
|
|
| STAP 6: Ingelogde users mogen options AANMAKEN |
|
|
| CREATE POLICY "Ingelogde users mogen options maken" |
|
|
| ON options FOR INSERT |
|
|
| TO authenticated |
|
|
| WITH CHECK (true); |
|
|
| |
|
|
| STAP 7: Ingelogde users mogen stemmen (UPDATE) |
|
|
| CREATE POLICY "Ingelogde users mogen stemmen" |
|
|
| ON options FOR UPDATE |
|
|
| TO authenticated |
|
|
| USING (true) |
|
|
| WITH CHECK (true); |
|
|
| |
|
|
| STAP 8: Testen -- app werkt als ingelogd |
|
|
| [x] Polls laden nog steeds |
|
|
| [x] Je kunt nog steeds een poll aanmaken |
|
|
| [x] Je kunt nog steeds stemmen |
|
|
| |
|
|
| STAP 9: Testen -- zonder login geen schrijfrechten |
|
|
| [x] Polls zijn zichtbaar (SELECT = public) |
|
|
| [x] Poll aanmaken FAALT (INSERT = authenticated) |
|
|
| [x] Stemmen FAALT (UPDATE = authenticated) |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- RLS moet per tabel ingeschakeld worden met `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`
|
|
- Na het inschakelen van RLS is ALLES geblokkeerd totdat je policies aanmaakt
|
|
- `TO public` = iedereen (ook niet ingelogd)
|
|
- `TO authenticated` = alleen ingelogde users
|
|
- `USING (true)` = geen extra voorwaarden (alle rijen)
|
|
- `WITH CHECK (true)` = geen extra voorwaarden bij schrijven
|
|
- Altijd testen: werkt het als ingelogd? Faalt het zonder login?
|
|
|
|
**Spreektekst:**
|
|
- "De laatste stap: Row Level Security! Open de SQL Editor in je Supabase Dashboard."
|
|
- "Stap 1 en 2: enable RLS op beide tabellen. LET OP: zodra je dit doet, is alles geblokkeerd. Je app zal even niet werken totdat je policies toevoegt."
|
|
- "Stap 3: de eerste policy. Iedereen mag polls lezen. FOR SELECT, TO public, USING true. Public betekent iedereen, ook niet-ingelogde gebruikers."
|
|
- "Stap 4: alleen ingelogde users mogen polls aanmaken. FOR INSERT, TO authenticated. Authenticated betekent: je moet een geldige sessie hebben."
|
|
- "Stap 5 en 6: hetzelfde voor de options tabel. Iedereen mag lezen, alleen ingelogde users mogen aanmaken."
|
|
- "Stap 7: ingelogde users mogen stemmen. Stemmen is een UPDATE operatie -- je update het votes veld."
|
|
- "Stap 8: test als ingelogde user. Alles zou moeten werken zoals voorheen."
|
|
- "Stap 9: test zonder login. Open een incognito venster. Polls zijn zichtbaar -- want SELECT is public. Maar een poll aanmaken of stemmen? Dat faalt nu. Precies wat we willen!"
|
|
- "Gefeliciteerd! Je database is nu beveiligd."
|
|
|
|
---
|
|
|
|
### Slide 15: Samenvatting & Huiswerk
|
|
**Timing:** 11:45 - 12:00 (15 min)
|
|
|
|
**Titel:** Samenvatting & Huiswerk
|
|
**Ondertitel:** Wat hebben we geleerd?
|
|
|
|
```
|
|
+========================================================+
|
|
| SAMENVATTING |
|
|
| |
|
|
| 5 KEY TAKEAWAYS: |
|
|
| |
|
|
| 1. Authenticatie = wie ben je? |
|
|
| Autorisatie = wat mag je? |
|
|
| |
|
|
| 2. Supabase Auth regelt login/registratie |
|
|
| Email/password, magic link, of OAuth |
|
|
| |
|
|
| 3. Sessies werken via JWT tokens in cookies |
|
|
| Middleware refresht de sessie automatisch |
|
|
| |
|
|
| 4. Twee clients: browser (client) + server |
|
|
| @supabase/ssr handelt cookies af |
|
|
| |
|
|
| 5. RLS beveiligt je database met policies |
|
|
| public = iedereen | authenticated = ingelogd |
|
|
| |
|
|
| ---------------------------------------------------- |
|
|
| |
|
|
| HUISWERK: |
|
|
| [1] Bedenk je eindexamenopdracht (welke app?) |
|
|
| [2] Maak een lijstje van je tabellen + kolommen |
|
|
| [3] Bedenk welke RLS policies je nodig hebt |
|
|
| [4] Optioneel: voeg user_id kolom toe aan polls |
|
|
| en maak een policy: alleen eigen polls deleten |
|
|
| |
|
|
| VOLGENDE LES: |
|
|
| Bouwen aan je eindexamenopdracht! |
|
|
+========================================================+
|
|
```
|
|
|
|
**Kernpunten:**
|
|
- Vijf key takeaways die alles van vandaag samenvatten
|
|
- Authenticatie vs autorisatie: twee aparte concepten
|
|
- Supabase Auth: drie methodes, wij gebruikten email/password
|
|
- Sessies: JWT tokens, cookies, middleware
|
|
- Twee Supabase clients: browser en server
|
|
- RLS: policies per tabel, per operatie, public vs authenticated
|
|
- Huiswerk: eindexamenopdracht bedenken en tabellen plannen
|
|
|
|
**Spreektekst:**
|
|
- "Laten we samenvatten wat we vandaag geleerd hebben."
|
|
- "Takeaway 1: authenticatie is wie ben je, autorisatie is wat mag je. Twee aparte dingen."
|
|
- "Takeaway 2: Supabase Auth regelt de authenticatie voor ons. We gebruikten email en wachtwoord, en als bonus magic links."
|
|
- "Takeaway 3: sessies werken via JWT tokens die in cookies worden opgeslagen. De middleware refresht ze automatisch."
|
|
- "Takeaway 4: in Next.js heb je twee Supabase clients nodig. Een voor de browser en een voor de server. Het @supabase/ssr package maakt dit mogelijk."
|
|
- "Takeaway 5: RLS beveiligt je database met policies. Public is voor iedereen, authenticated is alleen voor ingelogde users."
|
|
- "Nu het huiswerk. Dit is belangrijk voor de eindexamenopdracht."
|
|
- "Nummer 1: bedenk welke app je wilt bouwen. Kies iets dat je leuk vindt en dat haalbaar is."
|
|
- "Nummer 2: maak een lijstje van de tabellen die je nodig hebt en welke kolommen ze hebben."
|
|
- "Nummer 3: bedenk welke RLS policies je nodig hebt. Wie mag wat lezen en schrijven?"
|
|
- "Nummer 4 is optioneel: voeg een user_id kolom toe aan de polls tabel, zodat je kunt bijhouden wie een poll heeft aangemaakt. Dan kun je een policy maken dat je alleen je eigen polls mag verwijderen."
|
|
- "Volgende les gaan we bouwen aan jullie eindexamenopdracht. Zorg dat je een plan hebt!"
|
|
- "Goed gedaan vandaag. Vragen? Anders zie ik jullie volgende week!"
|