fix: add les 9
This commit is contained in:
552
Les09-Docenttekst.md
Normal file
552
Les09-Docenttekst.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# Les 9 — Supabase Auth
|
||||
## Docenttekst
|
||||
|
||||
**Les:** 9 van 18
|
||||
**Onderwerp:** Supabase Authentication (signUp, signIn, signOut, middleware, RLS)
|
||||
**Duur:** 120 minuten
|
||||
**Vorige les:** Les 8 — Students hebben Supabase gekoppeld, /create pagina werkend, Server Component patroon, polls database
|
||||
|
||||
---
|
||||
|
||||
## Leerdoelen
|
||||
- Authenticatie vs autorisatie begrijpen
|
||||
- Supabase Auth functies gebruiken: signUp, signInWithPassword, signOut, getUser
|
||||
- Server client (SSR) vs Browser client onderscheiden
|
||||
- Middleware voor session refresh implementeren
|
||||
- Authenticated Navbar bouwen met getUser
|
||||
- Row Level Security (RLS) voor authenticated users toepassen
|
||||
|
||||
---
|
||||
|
||||
## Lesopbouw & Timing
|
||||
|
||||
### 09:00–09:10 | Welkom + Terugblik (10 min)
|
||||
**Slides:** 1, 2, 3
|
||||
|
||||
Ik start de les. Korte recap van Les 8:
|
||||
- Supabase project aangemaakt
|
||||
- NEXT_PUBLIC_SUPABASE_URL en ANON_KEY in .env
|
||||
- /create pagina with VoteForm component
|
||||
- Polls tabel in database met votes
|
||||
- "Na vandaag kunnen jullie je app beveiligen met authenticatie"
|
||||
|
||||
**Planning tonen (slide 3):**
|
||||
- 09:10–10:00: Uitleg Auth concepten + Demo
|
||||
- 10:00–10:15: Samen Middleware + Auth Callback bouwen
|
||||
- 10:15–10:30: Pauze
|
||||
- 10:30–11:30: Zelf Doen (signup, login, logout, Navbar)
|
||||
- 11:30–11:45: Vragen
|
||||
- 11:45–12:00: Huiswerk + Afsluiting
|
||||
|
||||
---
|
||||
|
||||
### 09:10–10:00 | Deel 1a: Uitleg Auth Concepten (50 min)
|
||||
**Slides:** 4, 5, 6
|
||||
|
||||
#### 09:10 | Slide 4: Wat is Auth?
|
||||
|
||||
**Vertel:**
|
||||
"Authenticatie is: wie ben jij? Login, password, je identiteit bewijzen.
|
||||
Autorisatie is: wat mag je doen? Wie mag polls maken? Dit regelen we later met RLS.
|
||||
Supabase Auth beheert alles: signUp, login, sessies, JWT tokens."
|
||||
|
||||
**Demo:** Open https://supabase.com/dashboard
|
||||
- Klik project → Authentication → Providers → Email
|
||||
- Laat zien: Disable Email Confirmations is AAN (sneller testen)
|
||||
- Zeg: "Students zien zelf deze checkbox na Le 9"
|
||||
|
||||
#### 09:20 | Slide 5: Auth Functies
|
||||
|
||||
**Vertel:**
|
||||
"Vier kern functies in Supabase Auth:
|
||||
1. signUp({ email, password }) — Nieuw account
|
||||
2. signInWithPassword({ email, password }) — Inloggen
|
||||
3. signOut() — Uitloggen
|
||||
4. getUser() — Wie is ingelogd?
|
||||
|
||||
Hieronder toon ik hoe we deze gebruiken in Next.js."
|
||||
|
||||
**Code tonen (slide 5):**
|
||||
```typescript
|
||||
// signUp
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
|
||||
// signIn
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
|
||||
// signOut
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// getUser (server of browser)
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
```
|
||||
|
||||
#### 09:30 | Slide 6: Server vs Browser Client
|
||||
|
||||
**Vertel:**
|
||||
"Supabase Auth werkt in twee omgevingen:
|
||||
- **Server Client** (Node.js, SSR): via cookies, secure
|
||||
- **Browser Client** (React, CSR): via localstorage, minder secure
|
||||
|
||||
We gebruiken @supabase/ssr package. Dit handelt beide af."
|
||||
|
||||
**Toon twee code blokken naast elkaar (slide 6):**
|
||||
|
||||
**Server Client** (middleware, Navbar):
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch { }
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Browser Client** (signup, login, logout):
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Zeg:**
|
||||
"Cookies zijn beveiligd. localStorage in browser kan hack worden. Daarom: server client voor getUser in Navbar, browser client voor login forms."
|
||||
|
||||
**📌 Slide 6 referentie voor Middleware:**
|
||||
|
||||
Middleware zorgt dat de session word gerefresht op elke request:
|
||||
```typescript
|
||||
// middleware.ts
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
const supabase = createServerClient(...);
|
||||
await supabase.auth.getUser();
|
||||
return supabaseResponse;
|
||||
}
|
||||
```
|
||||
|
||||
"Dit zorgt dat je Session JWT token altijd up-to-date is."
|
||||
|
||||
---
|
||||
|
||||
### 10:00–10:15 | Deel 1b: Samen Coderen (15 min)
|
||||
|
||||
#### Stap 1: npm install
|
||||
```bash
|
||||
npm install @supabase/ssr
|
||||
```
|
||||
|
||||
#### Stap 2: lib/supabase-server.ts aanmaken
|
||||
Voeg dit in (hieronder exact):
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch { }
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Stap 3: lib/supabase-browser.ts aanmaken
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Stap 4: middleware.ts (root project)
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return request.cookies.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||
supabaseResponse = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
await supabase.auth.getUser();
|
||||
return supabaseResponse;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
#### Stap 5: auth/callback route
|
||||
`app/auth/callback/route.ts`:
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
if (code) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
return NextResponse.redirect(origin);
|
||||
}
|
||||
```
|
||||
|
||||
**Zeg:** "Dit is standaard Supabase/Next.js boilerplate. Niet allemaal letterlijk begrijpen. Focus op: server vs browser client."
|
||||
|
||||
---
|
||||
|
||||
### 10:15–10:30 | Pauze
|
||||
**Slide 7**
|
||||
|
||||
---
|
||||
|
||||
### 10:30–11:30 | Deel 2: Zelf Doen (60 min)
|
||||
**Slide 8**
|
||||
|
||||
Students bouwen nu zelf:
|
||||
1. app/signup/page.tsx
|
||||
2. app/login/page.tsx
|
||||
3. components/LogoutButton.tsx
|
||||
4. components/Navbar.tsx (met getUser)
|
||||
5. Uitloggen in layout.tsx
|
||||
|
||||
**Reference code:**
|
||||
|
||||
#### app/signup/page.tsx
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignUp() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) { setMessage(error.message); }
|
||||
else { setMessage("Account aangemaakt!"); router.push("/login"); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Registreren</h1>
|
||||
<form onSubmit={handleSignUp} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Wachtwoord</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded" minLength={6} required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "Bezig..." : "Registreren"}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className="mt-4 text-sm text-center">{message}</p>}
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Al een account? <Link href="/login" className="text-blue-600 hover:underline">Inloggen</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### app/login/page.tsx
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) { setMessage(error.message); }
|
||||
else { router.push("/"); router.refresh(); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Inloggen</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Wachtwoord</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "Bezig..." : "Inloggen"}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className="mt-4 text-sm text-red-600 text-center">{message}</p>}
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Nog geen account? <Link href="/signup" className="text-blue-600 hover:underline">Registreren</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### components/LogoutButton.tsx
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
return (
|
||||
<button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Uitloggen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### components/Navbar.tsx
|
||||
```typescript
|
||||
import Link from "next/link";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
import { LogoutButton } from "./LogoutButton";
|
||||
|
||||
export async function Navbar() {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
return (
|
||||
<nav className="w-full border-b p-4 flex justify-between items-center">
|
||||
<Link href="/" className="text-xl font-bold">QuickPoll</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-600">{user.email}</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="text-sm hover:underline">Inloggen</Link>
|
||||
<Link href="/signup" className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">Registreren</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### app/layout.tsx (updated)
|
||||
```typescript
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = { title: "QuickPoll", description: "Stem op je favoriete opties" };
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="nl">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Navbar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Instructies voor students:**
|
||||
1. Maak app/signup/page.tsx — form met email/password inputs
|
||||
2. Maak app/login/page.tsx — inlog form
|
||||
3. Maak components/LogoutButton.tsx — knop die signOut() aanroept
|
||||
4. Maak components/Navbar.tsx — toon email als ingelogd, login/signup links anders
|
||||
5. Update layout.tsx — voeg `<Navbar />` toe
|
||||
|
||||
**Ik loop rond en help. Studenten kunnen stuck raken op:**
|
||||
|
||||
#### Veelvoorkomende problemen
|
||||
|
||||
| Probleem | Oorzaak | Oplossing |
|
||||
|----------|---------|----------|
|
||||
| "Module not found: @supabase/ssr" | npm install niet gedaan | `npm install @supabase/ssr` |
|
||||
| Navbar toont altijd "Inloggen" | getUser() returns null | Check cookies middleware, browser dev tools |
|
||||
| Login werkt niet | Verkeerde credentials | Check Supabase dashboard → Auth Users |
|
||||
| "Invalid PKCE flow" | Browser client misconfigured | Zorg dat .env keys correct zijn |
|
||||
| Logout werkt niet | signOut() niet wacht | `await supabase.auth.signOut()` |
|
||||
| Layout.tsx error: Navbar is async | Navbar is Server Component | `async` is ok, use await in getUser() |
|
||||
|
||||
---
|
||||
|
||||
#### 11:00 | Check-in: Navbar
|
||||
|
||||
Ik check of iedereen Navbar werkend heeft. Zeg:
|
||||
"Navbar is een **Server Component** (async). Daarom kunnen we direct getUser() callen zonder hooks. Dit is uniek voor Next.js."
|
||||
|
||||
Toon: `const { data: { user } } = await supabase.auth.getUser();`
|
||||
|
||||
#### 11:15 | RLS Update
|
||||
|
||||
**Vertel:**
|
||||
"Nu authenticatie werkt, beveiligen we polls. Wie mag die maken?
|
||||
- Anoniem (niet ingelogd): mag zien en stemmen
|
||||
- Authenticated (ingelogd): mag polls maken EN zien EN stemmen"
|
||||
|
||||
**Stap 1:** Open Supabase dashboard → SQL Editor
|
||||
|
||||
**Stap 2:** Voer uit:
|
||||
```sql
|
||||
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "polls_select_all" ON polls
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY "polls_insert_authenticated" ON polls
|
||||
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
CREATE POLICY "polls_update_owner" ON polls
|
||||
FOR UPDATE USING (auth.uid() = created_by);
|
||||
```
|
||||
|
||||
(Zeg: "Auth.uid() is de ID van ingelogde user. NULL als anoniem.")
|
||||
|
||||
**Stap 3:** Test in /create:
|
||||
- Niet ingelogd: Knop grijs / gedeactiveerd
|
||||
- Ingelogd: Knop blauw, poll aanmaken werkt
|
||||
- Na uitloggen: Weer grijs
|
||||
|
||||
---
|
||||
|
||||
### 11:30–11:45 | Vragen & Debugging
|
||||
|
||||
Ik loop rond. Studenten kunnen vragen:
|
||||
- "Hoe debug ik auth?"
|
||||
- Supabase dashboard → Auth Users
|
||||
- Browser dev tools → Application → Cookies (zoek sb-*)
|
||||
- "Hoe reset ik mijn account?"
|
||||
- Dashboard → Auth Users → delete user → registreer opnieuw
|
||||
|
||||
---
|
||||
|
||||
### 11:45–12:00 | Huiswerk + Afsluiting (15 min)
|
||||
**Slides:** 9, 10
|
||||
|
||||
**Slide 9: Huiswerk**
|
||||
|
||||
1. **Google OAuth (optioneel, moeilijk)**
|
||||
- Supabase dashboard → Auth → Providers → Google
|
||||
- Copy Client ID, Secret
|
||||
- Voeg signInWithOAuth button toe
|
||||
|
||||
2. **Profiel pagina (les 10)**
|
||||
- app/profile/page.tsx
|
||||
- Toon user.email, user.id
|
||||
- Update password / email form (kan les 10 zijn)
|
||||
|
||||
3. **Maker tonen bij poll (les 10)**
|
||||
- Voeg `created_by` toe aan polls tabel
|
||||
- Toon bij elke poll wie het maakte
|
||||
- Autorisatie: alleen maker mag aanpassen
|
||||
|
||||
**Slide 10: Afsluiting**
|
||||
|
||||
"Volgende les: Deployment! We zetten je app live op Vercel. Daarna: Google OAuth, profiel, meer RLS."
|
||||
|
||||
---
|
||||
|
||||
## Extra: Supabase Auth Docs
|
||||
https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
263
Les09-Lesopdracht.pdf
Normal file
263
Les09-Lesopdracht.pdf
Normal file
@@ -0,0 +1,263 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 6 0 R /F5 14 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/BaseFont /Symbol /Name /F5 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 19 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
17 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 19 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
18 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260331161655+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260331161655+02'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
19 0 obj
|
||||
<<
|
||||
/Count 10 /Kids [ 5 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 15 0 R 16 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
20 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 367
|
||||
>>
|
||||
stream
|
||||
Gatn`h+kg@(^Api?H%8IB%6fuo1TfT-D3Zi"Vb!G7OJ!uDr5Hj&d[EPD:7mGDu2\33EVZ]>N[Npf&GtdK+$<jE((N.#^;T!K^Y(%;5oL0Lc(raAh';qPG8/@oRW0%@771RW(`RP"_]C?JSIJ;;r0Tj4S2k^Kn)"^6AX28GT:VJ1*g[j_8t5!04<[G0/;8>%,AsEO\++'M,`]<[8]68FL--H=*r_=R\_[+I.!I$ZfmDGnM<Y:W",D_cYB9?"!qC3p*!RT_;QVApQ6J@;!3brFWI0XO2\OkDjgsl4Nfqd\#Xn0-hfK><J6^do[eNrb70`"`@%oO:&;XI=LjK@n:e&Nj0NMlNj5YI9*clg!=IlmkuaGJ~>endstream
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1444
|
||||
>>
|
||||
stream
|
||||
GauHKD/\E'&H88.0g2KN[)@4f%TSc@3HB<q:!s#9.p7`FZ]HKQbO$p$<L!O?Ymh1?i8U9%)@9I<XIj-sH.db!M'%n/7l,[Z4uCRk&.A>E+HGDG^C-V3_<1p+[^7'mG!*eI5A1q;A)"L(*ugMYq2Pi;*LM#n!i'>)(Wa]!^6<BLoOeG<!=:N;+9e^WmrZ-+g+_GGAeR.1kZ^"]8]9b[-$AEIU&SUpj%oU>U>jo3U,j`gh$/mH(mO2U$EeP)8&k4jC>^T%GZ985fg[m+a3S^p*!g[G1E\1O,)-glY(Q$Te5A,hBHC$q)2,E`Nu)4FBVJQpa:X5R)cl`Aa:am2kn/#LPfN8,bc+JjZtM^C0rIC=(bD*9A'S$UI_5$#(3d9$_Nuff^d9a^@Q7q]hKWXEn6q<Q05LX/2arm,bsjWkLNVWg"h[n%70Tbu!i/R!a]7<jEW;HQ(lK9&^m)Z+YiH(md58Nk(NHNqFdLs4cS\6O@53)>_WOV1<O\=Kn-F^rj$V`D[Vq>B$0HN/0W:FJ7AR4E3('qeP,WU2QB&XIijlk8XH,,T@Y+%q^7XR^F-A>Op`s"diJ26c92qTt\QeR,$$6C/@nLa'@*fNI@5H,N%!j2;*JPuDQ[h$n.e#k]W0a2_Cjd%k/iODQ=ICcBkE@B7Ik5'R&'$NDC''8Q<<CRUWut_ZR2a^@#Vh0="$U]&EA;80dudKSPcjC^b9660Ec[(?(fW)r^Gn-b;^!$K/WpBd9o0!l>(L+/QF3'Pj'.4'<J"r`%H\s-\P@_OrM=%7+_ObaVnSsimL1:/DLM.:&8VM)&rG%U%noShR0WKjk_`g_<U7q@Ufh6h]YYT@jbH4N3Zc07cVO?(@dA7C+m)iO[*6DF/L:7Y>]Y>*WP&pbRT>">J'%MNj0qWr[qm:."+.5Y[c\G2D^"MeU-og(SQ5bp:!\7Q+cW^,7N[C&T':Yl+6"%?<V;b64i">fAUUYG?\#jb^5](gD>_Yu\qdtU[q8`73\*AhE9c=2S`L*28R-b[JSPu)Pp[k)ELMTXJeKS6WL'M;a2+I&fXQ6<nX9PtNL9n-)>EGdbbe8nXLE20M/Zd:^lhVuO"Ur%(F*Nc<An<IJE)m38#)HRW+=.o\f=W$m.IMR9VN<m^6/1hM0:WBiWKI#Mgd3$)Y\2e^k&Qf<t%TTAs@-s<.)V*]ns]2^XJ,n1JM(c[b?5D'25-[6.Hs3q67DLoMB,81N]$&cWVu.;iZN5]*a_Ik!Gp[RK%EP;eR2,#8g)gK6L+cnZF:YXhB!4'cReZ75o-&*AON7mB\tB9=BVUJ!1/Xfi3TVobr2eZV;VqICLpQ!O'.kjHsWcYbHDZ7e5qiO_p3hs/72)Hjjddm*]Z%7>0?+Y[hI<GQ.$f>RjN?T2'<NK?N;=j'Ln\E7OoinbR+Ck83;?^N]\1Z1n7NS_VQ8S+bn&/1'645K=L.!<~>endstream
|
||||
endobj
|
||||
22 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 581
|
||||
>>
|
||||
stream
|
||||
GasalIr!dm&H1LYi`[0YUJkVpp/8oMP2a;[aKes"%1GPmPh)=@V398DP^N4rm3HNeBB^:,+ME-rlcFc#IU9^b"i-3epO`^B%t"C!,7O>Q,4=5Ih?HOhTe'^tKHVWBUD3Zs(DAA:8f,?Jq-tVUE-aBH?TZ?=FnK^I+n,PC0^.+3r$m4):[D9=6(THAdXcbld]RZuWD^Jd)-`p`H+8>+l\c8BT>4eTd[1KFEA_"?dT2T&*@eDlmg2BnO!tC$*f9Lno5X5m'SX2M4-?^c;bNUZd_Hd=^3t3gomsZQ'+7@d2Su82OpcX.?d'Ed@FH03nG4R$88h^mpGj;8hVFi`^9e4rn48UHZ:[[T!'f*:DB7_lrbE8LTm]&qJR$XGSnP8&CSCR&\OOfNddh52263R@$R9$'[JFa9-J%PFfU>4ddp3U4Q@$C(VY7)n.W)H\WKtED@?\Z1T#oF?=;G'TE;k<UA_.cI^2%B;;Yd4;=KKZP;T"5:hDjll2/d/H38=1L(d)dKk.9%lQ,XA<hN;gMNR&s#iTT=P/=oT@_a,9eVI)i+LN5B=2AgobcTXeTc^q*TE;fmqBI_^~>endstream
|
||||
endobj
|
||||
23 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1164
|
||||
>>
|
||||
stream
|
||||
GatU2qetI`&H4hB`>n.F:?`Z]![LXsP)*)8VRc><UfMVl2J+\Xpm'Ym8Het[2iA-R_9cG*/M,PFq<$FWU-fkAIJ%bNJ4gigCE7L!lj;kkZ,"))JUR04V!!o<780alFeF(rOqZ"q;%J^b;;YdG@#(\_FL>ldFAnkFULSDJ'-LnjD4VY2://-HHdM].+--Nt9K@_ITV'@U>gX[=T^H:tm*B(S<uh4XViF:AUr9%A3p'b6OJ)r^0%O?7bYZg3<d>SiLa-:``a=;'Vj%KZi.Ju:'@+O\$'8'iJdLD2S;\Om"<rTP3/)>lr@#Kl&q\d-fGNUIOuEs@R&&?A'^tm>Zb&DN3nj&9)V5d#c[sLP2`E$\r&6G'oOMh7q`AmbbX3j+)rFLfCH[Vo22tpSg4EYQa*sDo>f%IccDY8@`mM\*J^5MH9:53.KZ:2>:$;Zo9G<":cI_t$h-9Rs5;A;=O^Q3P?PA`DXhqH]M/Z6(.glO'$AlHtOJmD`>aV=L.od,gY;jchjC`%3VZlZ$)8HtpLt>n`I,L_cO!kW9F2#pC=?<SU$AaUL"UqF"HH'Z"1t5-BlsfAp(<sqfi%KG!GCOo0iG(3^^[#8#LP+"QVjhnN?a&%qrHLF#IIMH?mX/XpUE;_s9UtK1<XUFO*s>>;q%*EH;]]dJ.T\!ObW.4uD6K;I`H!gt*jXV+Cp1,u_;lV5GD_\?-kQnCJS$`0R0j@-r4GB*/NV.>SC?1%i"!eGc%uD=rts``A[0<0EY,L9k2%%qOIargcpQF;'@N?:AS#KnP<Mp[-l,-h1oSp7a!tHX:.7-<[I&kf6Hcs;OOYSi-bbA0X/6[+4a5'D)>qt1<![qu2p/^jUsaH'fag<C*a;>%bXWA!Y6B4?3!?)(`6;F2Ogk+UW"8J8Sj-@?Z\QJ5IHRt6XO[*TnB(76d[TP`F0@ri[aj1q1sA^A,Ohg6K-lGP4Kc6j0\/3Y[^V5-6SE\sH%tNpkMQatQdt&t^GC*)S>`&E>2VVYla6KP.ji>U"1pBNp5P%__rfUq.!MT3!MR:Qj1a\H8E'[pp'fTg2c(d]62fGTj0@l&AL(7d::$Wh)V[m`"B=h0ZeD"]1PrDPg[DE'P1s'5m)>ODaCA:bq`<lWlu1@A9,1r>=2^@6b1YG1bXMDse&hs>8hS!.(QeA!d/~>endstream
|
||||
endobj
|
||||
24 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1414
|
||||
>>
|
||||
stream
|
||||
Gb!SjD/Z1=&H88.0h(Au;\^uE[Zd5PlfI6(8njP;Xt$jILBY\b"r;)Z@],OlHY4-^S:GZ@l0_1\al[/@Sp=pkFr]_ojlg&9!B?R#rQtYD#Lb[mY3!Om$8/4]T+7BnADafkma)ukkigY;\G\VUd:]L=m#Bd$=T,"4s0r<bEF1:/i;qVRG2,b)EmY!P8@piZ%QYDmQWMNqJK(,S5jfkeVZFZ9f,MPu(sh.b7=GNkIpi,*K(BpeD3.58MnHY/;S.H@WWc^ifr6_#93O/aQ=Y4\KtPLnN.kreI._c<NpG0p^:tFC:IW-r`8I'jG0ne!:Ye*<(_ATb^kIrYSd+\ma:\>068%.rSi]f4V*`$;I"=uND36Q6j`i-#AWGbT9>^tr,#,lJac7UlcF5R+<sfi6Rb#R19j1(%[4]LbZoPaZ67/IlmnpZ]"JXHj8A8n4?:Fd0f4n"*O&B-o='@Ap3@ePFo7Hk^/)4#(Ia;VsfcdG4ZTu]0GqS&/9NW#/#W^%#Fn[ggC/hO"@E]5=[:s2'c^1U:[,UcPiK^t\juJ]Z<rJl-J#-bIZ68/#f"<L$T5)YSS7B1:.$HH49U+P49sVo"ahCRq:UH`s=>1f7=>EZQ=T6*aQuu5B?coSFO_Xa`?5YbVam$.EW.e8!:^LBkl"i[dl]Hm(7uF#Qpf;"ME>u*BOOo"LUoJYGmug,#1g$i@$S1,#&]p:A;cNg6g5JLB'sFpAlL"rcrRT[<!U7Iu)*VX%R?mBZpX=_;-=].`#jb<8Vo%-Le]Nbs5uMlrL(QAuc0k/Q=t,qQ63YLuE!iIND&bt<e6Ifa?Whso@qj5H3/3*gbb^^1OK;,b/qqLh)Q+$]D.2Di)N_>4c/'q)H-2uJV(pZmPk#9<;(dRfqdY!3lnP@U-)CIIn<-=;f"3fLfl`KkbX_9@FSDH.@b^o_9>o&lGh9)7Vu@_#+S)=e+i,9N&f$+1SEff'[^Ft)Ce$T<,fkb"bj'4)i1@mk:=f8U6XcQ><#nZ`<);U&WYu_fYTsl*8k\Ko%\&1AHo86tR6C&<\uB19Jai^BK1b*$&IAl]IXsKU7HV;fYCA6U+dXX/rGlqZ9jI9o^">hpe`smib=TXUOG@j2=V!GuKlaGZOq,>ai`pC4"+hq6?Au^2]ff>$`o"`&Kr.@kS&9-Q9[7Yjd6Xp%g;X2PoH(tC,rd*bb3MVkRkU!9k+MUg/l\m'#OfJg_geH.Z7H)Q^t<?.`d[8a]8V27c`gEuB`GNtG%c-l#0,=K!JrYh\fUOZ:F?lO7AlErAo)+Xm@[d$C2ZK^@R0"ib2_DCN:h"oTL!QqFoOp0XV4]H6tJm8#_g:_c^[s$eQ8$(I4SsS%kU5ZH^`I#lTr!sbj@JmJ(/W7%cU6-:^_Z16!R@["`sH(j6Ib7d2_Rts-I6*AdJtH"6fRN[/~>endstream
|
||||
endobj
|
||||
25 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1156
|
||||
>>
|
||||
stream
|
||||
Gb!#Z;/b2I&:XAW3!`XF%$P3^E]0`%$`$FH!a1X3#5U%BJWe:k=,Bm*.X#G!^[F]rYnP5npFou3CeN9++7Hp**YlJH5(WZl$\58]p`BPC`irQ2&:,KNDW#gqpc8]SQKOrc*aXL90V1tN8U,\Z\A8]b$<L+?9P[=>R::f22-9$m(gCZ-Jk8Ll"g&BR06W2W=^]=/;CW,7>^V*fOp*fH2qlUBhKaGPMn`Zi,FIp22qBb#'ruBbX'3H19*$t,j^L5TT#%kC*s.F(MFtc.=>6?H";U3;F7gp)TW[cj1QBSOj"/O5p7Y)Z<GSQH$bY<QHmcA<Wa[D\k\n`=G9V+M&h[(?]P4O@,?HG"YTKrWX\gt,GP9a(dUu_5-&YlZA/d$EhY[8iQ'1G?ebbl3d*L&l(c!G$*3J]u_Q$*<_fCR"E/-!qh?U'9lNb=I#.+,`[$ZDNWR)L[g*F#jW>PpQQ:%e>:>C2c]u5?)*^&9\.O>hrN5'/J%k;h*esTV0<.#CP\^&o>08G3pC8WWff_$i3=!i+.DG'Dd7s<J<f)oGif9Lq#8uO(`?<L>I"lc"Bg,[ko$I*ZW_6HVGjg",`;5(.X+LaFKdtBqNS5[4q=Fi8@mB5_:LOsBu[9FnQCg>C]6Kqf9o+'J0AAL!OVVUU!2`[t8cu(+MieS$<UW@#8U6td[E.Z4('p.FhNXDq%gS:e'*A:9T/4TB]%8V5u`c_-W[E)L(q(ZfPbP2It!32d`^VGNSKo$pkk-gmUI>dt,2]$&-5sLZ'CQ$>d#F<t$gY07[7JWOl950!XVV*kEa.`l91R4^./7s1Y/V9=aS&*Mn6UWe*/V<7[,a^Pj#%)/K0EM1giVZcBq_*'ibi2=",rtkk`GN*LPJ6G&.>\SirVrs><,52u>n^^ZpO`PSY?7$!1>D6CNRp>]H5i\u7%q1.UJ[58FiH;m+SOYZ-6H&i[Y@#1@Ctg$Ui"0FUH9@,ZL?"Nh1Kq0>iFR&ofUQO95rT#i1+t<l8PE/kRbIB&cL%aqmHlNhS?K\NkWkXpf)_9T<`ML+f9;ePr!=bG#Q/GpNuZehNmG;Gb8LR.A?eXa/$4o#?1LHPYoo%3PIM=lI+qhe#09)kqT/8oYuR0r!lY3Bna\d<^V"%\?c")V[/@0Os^S"V==3[%/p9pBBc;~>endstream
|
||||
endobj
|
||||
26 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1392
|
||||
>>
|
||||
stream
|
||||
Gatm:D/\E'&H88.0sXba;Oq\&&kG!T<N9h7.@900mSd%aCO7e]R9b#\9gFO7hUsae3uF.Sc:B(joCC,n4O:)6*W10E4.ZIXNPYSD"4)bm"ME(f@=/M+a!^nWGT4gKFFp_9G+j*t39O]]TH>dJIb4%BkMQIZS`%p$k;(BpM"Cg[HJ:7tN+\Np#;<#RkQIe<T,!knVPJR`;dQK);CTm2,o:2JIY@6P)T6:dZiE,TH!;CUg^6+$@RcTVaXn+cUgqW<0Y@njSVmhH#T!]$Dh,JN-&">)&B_WJbbl>1'+.bO$VD,H0+/C)8Zr8HP#V?gXC2PO_.R0Sm,hI3b)MB$<CYPprFhS`%SNX-lc\KY\W"b=Pc0PISWe&h$fa2q=fb\u^/GQ?%qi-#joBd<cD1M=AJjj@C"!SEQHtZ?p.1a0]VgE/3NXE4"<s0`#(pYMH3$](jG@EZPR].me<*nVr[:u+kbkGu.7uUK343#WL^eNXT\Ab/c+<^P:fRjW+SPpH,."8a)&=6B*Nn\XXg(*HPW*,BLR,djZfMV>D^riVmWLWFj`;pJ]6BU4N]#P6U7X*,b'BFR[ZA8,5W&H)-*8-9BHp!uF"Vd4k!f/nE;mNL*=7*\jq/2DUTX(YSe8W'\NqTZA+#(Pf,=HhB2aijgW7u_nTubJY;'/uS$lFA,&Y)bL6ArC\RN[Hd+-D^UiMHf'n$SDE/:>@8;JJg8pn#6XLH$7>YM-n,29Ka#\d,FlI:'a-Y8l:`'m"H*C4+h0ZiP\*_WK<ajQ3JHD6WiVW^_-0uCrtD5YLk*:";#A;K$u,N'c$$"qD@Gb/$4cR!W*QKS(ZeSYU9-<UGQ5upJW.7(lWnE=Lr[d^3'X_8t@*+<V,$c64M'l.>83cmisHp)GVdi2aTRaXFf0K?@u:"fOf[FS+dV(Z?Ua,-'_$<f3$*Fpk37_c9%-]ah__*!%LPXTA^.(,h_Udhf;];I*$!>Go'J7<5>HDhsDA:s+!]Vj*9Ni&4DJr7%R3ki5lp,7&u:\WZLqNgU[P6#SdNo(.i#=jDjiGT1>FZ,>UE-&>'0%<5hH`/JlKkU$rpA0='<]L'X1a+tDbJOu[/IRF;pk^Jp`NX@XC/ala-m%sVlK@V3SFP`a9ad#OEh;E8h]&IYRe)?rdYXf:MdC+HD<C1ZdMJ[R'(T"s\L1(FIN)f.V^n,JOc_&iIB[PB#bj&[-/)Mj;;i`kQ0cD\<fZoIB)Mo!HIpXjRkfc,RZE/piVMm!lZ98P1u"NcIs2Yqg_#6A,+W3oitskO1)=#dV^/G#UJ(bCi/tm0bdkeC17$rMPud:8GOSess,Wad]lS#48a(hlH0o3ma18c>*O#2hSn<8")?K.fgej5RQ7iKTfB-YG*:nTr'+4&&r<?ZPJ<PH`.e;!i~>endstream
|
||||
endobj
|
||||
27 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 317
|
||||
>>
|
||||
stream
|
||||
Garo=;+ne\'SYH9.h4pTo:^$P=<)3B#l:?,@NP9JgG\@IBo6UE8EkU_X`Gn[2fKpqF7+6>3$NkId'F(hQO"Znp_7`V/cl(LKEoWU)*^)j&]>k*$'Nsm\/')NF6mpYe[Dn$3N)E_=^ocd[fu@:J"k`-."\N_6f;QA9RFPl^8u>semLY45e]NjIB.e@GHTB@A;\@IkgA4C@5+O#reX<Idk%IrMfs0h(923+/"%a:P:\66Wl%,NY"ejf-0%VG,rI10FQWZ,A4cZ_/AhE=D71s;DO'J3Y8R%\T#Tr%s2_tOH?+AF+/eq\G[Xue01B_i~>endstream
|
||||
endobj
|
||||
28 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 757
|
||||
>>
|
||||
stream
|
||||
Gatn#h/8]I&;BTO'M"98ftANj+1LNhj),c7A37#u'a_u+Y_d'JU^P[ET>%p1*'(!f\nXN5L=r!dc#O"/6ZMDV2r+IH'W:+TF9D.>\trQ&!=8(@lfjH\9hR8oa)^*a,L*Yj;rie>AiPN\g!EE2BT<L7,?#ZA[lmSehij`Q+^mY1P_:CfqI-d#^qR,V&PH?"2\1`A@1^D1!U*V3\!#]JS;0(oI?(!.^Jm'O<RKGqW>_=B&E1>Qnhau:=F!j8P[V2,O<u9Z9)MC%<$dtj6^BRNF](")@4Ru22587oH&8K5<tV^5?=-b?c_-q,cgBn9Z!f_TOV].?'ZopCin=tRgTK\LQL+?RgbGG\Ds!RH[Pg_+Y\]p#jP^"C(0uFF+q*6KfJoNL"<F*<7.+#OK1sfi3*RRL?V#^'9GX<tYMS``jjfuVA5!<lZbW2<iC)e1C0%RG=X?LVK%[L]\BF/)*9XY)e-j?N@M/Fb:Vm5DDco1^U3L"qN/Q\tG["aCVhsX4'cDK>OcX&:dgJi%aQ0\SP.=bcB5p-W^lL1?;+qclE0Pfu0.at@e[B#d(#?k,UWhQ%pIt,[;U-Q^lM=pb;`Wg`HT""2(g?@rNA*2\(*#Q4qU:+dh+'^j@4=Z3(fZFtCYo$CUUFh&8cR%3`k(KQB,T`gQ+.KkfGX-J/oK]uB9-Q:$b#*n$c9D%,X>\mZD+qT(g>n%)L$%$Z5qC@\Pl=qR>um)]`.sXBV[IdgTtcZ*-;[T4IsTCiBco[JQ%$d~>endstream
|
||||
endobj
|
||||
29 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 735
|
||||
>>
|
||||
stream
|
||||
GatUq?#SFN'Sc)P($Dae<12D7E8YW!g9+>_Yr1i"`\\QR#VJlB`<ipOISKs#TlpeT92Uppm.^&?CR]oc)f=H\']:HmrXB%R+IW?(L^2<j>XuKb4hAp\4lUd=(B=sN`:mNgp9S2E!ldAFL`8DCJ,pOto`IY2ZX_E(R4,HU$u"9aKXJVkLJ"!M(5iQVSfi>\.t4bj2-'&W/)\sXY:"RBS'W16DR%u?jpfld(:fUWGmWO&HSe!Yn\q^7,9,tl3)+X)OL9\Ke4q70)F6u'<9T`<"h@07-=G%7#Ee3J]`,+=DuV%2c#X*u8rpoH[*G="=:e/[<1!!]N7T0#!_%t902ZnCE#c_^rq:XA+kn=`7`bp`'FQIbe/;jPO\_'SV>edT"Q#9(NuA\a9RlU7<@)-WBVeW8JW7<n;G=;&Kbk_#nL]eR!8N!K\GdCtbb3i;%<p0RRl0L3_SRN`)P<PU>*Fb2$*i(#&:QI%q0<0*LUg0bp1jm;He,FJp#!-5+,8O';n)Fbc73>5[*D<YXb3Y&Y4fE46Z>Sa`ftPSeN-P5TAN`3]\V*K<A#W8+N2.@-X%3=1_fm-J$2*.K6:^CA8"Fo\<f?gQ86Is*8\)o7YbaSm$)7EKJ=uo.oN%VZE9IU8TpGKQfWT6SLM8;^uBCOq;<@WZ7"`\iDalX$=$2._,@<:ZPZ!%l/414i9h^-;Qoe')X^lNQ[]n@''rUV[D<]$V-d-Ep5a+Tr;gEmk@a~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 30
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000133 00000 n
|
||||
0000000240 00000 n
|
||||
0000000352 00000 n
|
||||
0000000467 00000 n
|
||||
0000000672 00000 n
|
||||
0000000777 00000 n
|
||||
0000000982 00000 n
|
||||
0000001187 00000 n
|
||||
0000001392 00000 n
|
||||
0000001598 00000 n
|
||||
0000001804 00000 n
|
||||
0000002010 00000 n
|
||||
0000002216 00000 n
|
||||
0000002294 00000 n
|
||||
0000002500 00000 n
|
||||
0000002706 00000 n
|
||||
0000002776 00000 n
|
||||
0000003057 00000 n
|
||||
0000003178 00000 n
|
||||
0000003636 00000 n
|
||||
0000005172 00000 n
|
||||
0000005844 00000 n
|
||||
0000007100 00000 n
|
||||
0000008606 00000 n
|
||||
0000009854 00000 n
|
||||
0000011338 00000 n
|
||||
0000011746 00000 n
|
||||
0000012594 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<c2b0ca93638236c118c39bf1e4748e63><c2b0ca93638236c118c39bf1e4748e63>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 18 0 R
|
||||
/Root 17 0 R
|
||||
/Size 30
|
||||
>>
|
||||
startxref
|
||||
13420
|
||||
%%EOF
|
||||
622
Les09-Live-Coding-Guide.md
Normal file
622
Les09-Live-Coding-Guide.md
Normal file
@@ -0,0 +1,622 @@
|
||||
# Les 9 — Supabase Auth
|
||||
## Live Coding Guide voor Docent
|
||||
|
||||
This is your cheat sheet for the full lesson. Follow the timing in Les09-Docenttekst.md.
|
||||
|
||||
---
|
||||
|
||||
## DEEL 1A: UITLEG AUTH (09:10–10:00)
|
||||
|
||||
### 09:10 | SLIDE 4: Wat is Auth?
|
||||
|
||||
**Demo:** Open https://supabase.com/dashboard
|
||||
|
||||
Vertel:
|
||||
"Authenticatie is: wie ben jij? Login en password, je identiteit bewijzen.
|
||||
Autorisatie is: wat mag je doen? Wie mag polls maken? Dit regelen we met RLS policies.
|
||||
Supabase Auth beheert alles: signUp, login, sessies, JWT tokens."
|
||||
|
||||
**Stap 1:** Dashboard openen, project selecteren
|
||||
**Stap 2:** Klik Authentication → Providers → Email
|
||||
**Stap 3:** Toon: "Disable Email Confirmations" is AAN
|
||||
|
||||
Vertel:
|
||||
"Deze checkbox is cruciaal voor testen. Normaal zouden users een confirmation email krijgen voordat ze inloggen. Dat slaan we over voor deze les. In productie zet je dit uit."
|
||||
|
||||
---
|
||||
|
||||
### 09:20 | SLIDE 5: Auth Functies
|
||||
|
||||
**Vertel:**
|
||||
"Supabase Auth heeft vier kern functies:"
|
||||
|
||||
**Code tonen (copy-paste in terminal of code editor):**
|
||||
|
||||
```typescript
|
||||
// 1. signUp — Nieuw account
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email: "user@example.com",
|
||||
password: "secure123"
|
||||
});
|
||||
if (error) console.error(error.message);
|
||||
|
||||
// 2. signInWithPassword — Inloggen
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: "user@example.com",
|
||||
password: "secure123"
|
||||
});
|
||||
if (error) console.error(error.message);
|
||||
|
||||
// 3. signOut — Uitloggen
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// 4. getUser — Wie is ingelogd?
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
console.log(user.email); // "user@example.com"
|
||||
console.log(user.id); // "abc-123-def"
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"Diese vier functies zijn alles wat je nodig hebt. Error handling: altijd checken of `error` null is."
|
||||
|
||||
---
|
||||
|
||||
### 09:30 | SLIDE 6: Server vs Browser Client
|
||||
|
||||
**Vertel:**
|
||||
"Supabase Auth werkt op twee plaatsen:
|
||||
1. **Server (Node.js)** — Secure, via cookies
|
||||
2. **Browser (React)** — Less secure, via localStorage
|
||||
|
||||
We gebruiken @supabase/ssr. Dit switcht automatisch."
|
||||
|
||||
**Show left code block (Server Client):**
|
||||
|
||||
```typescript
|
||||
// lib/supabase-server.ts
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch { }
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"Server client gebruikt cookies. Cookies kunnen beveiligd worden (httpOnly, secure-only). Dit is safer."
|
||||
|
||||
**Show right code block (Browser Client):**
|
||||
|
||||
```typescript
|
||||
// lib/supabase-browser.ts
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"Browser client werkt in React. localStorage is minder secure (scripts kunnen het uitlezen), maar nodig voor login forms."
|
||||
|
||||
**Key difference:**
|
||||
"Server component (Navbar) → Server client (getUser)
|
||||
Client component (LoginForm) → Browser client (signUp, signIn, signOut)"
|
||||
|
||||
---
|
||||
|
||||
## DEEL 1B: SAMEN CODEREN (10:00–10:15)
|
||||
|
||||
Students volgen mee terwijl je dit live codeert (of ze kopieren uit les09-live-coding-guide.md).
|
||||
|
||||
### Stap 1: npm install
|
||||
|
||||
```bash
|
||||
npm install @supabase/ssr
|
||||
```
|
||||
|
||||
Wacht tot dit klaar is.
|
||||
|
||||
### Stap 2: lib/supabase-server.ts
|
||||
|
||||
Maak dit bestand aan:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch { }
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Stap 3: lib/supabase-browser.ts
|
||||
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Stap 4: middleware.ts (ROOT of project, naast app/)
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return request.cookies.getAll(); },
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||
supabaseResponse = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
await supabase.auth.getUser();
|
||||
return supabaseResponse;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"Middleware runt op elke request. `await supabase.auth.getUser()` refresht de session token. Dit zorgt dat je niet uitgelogd wordt als je token expired."
|
||||
|
||||
### Stap 5: app/auth/callback/route.ts
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
if (code) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
}
|
||||
return NextResponse.redirect(origin);
|
||||
}
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"Dit is voor OAuth (Google, GitHub). Supabase stuur je hier naartoe na login. De `code` wordt ge-exchanged voor een session. Voor nu: boilerplate, niet essentieel."
|
||||
|
||||
### Test middleware
|
||||
|
||||
Vertel:
|
||||
"Test of middleware werkt: open http://localhost:3000. Je zou geen errors moeten zien. In browser dev tools → Application → Cookies → zoek naar `sb-*`. Die cookies beteken dat middleware goed werkt."
|
||||
|
||||
---
|
||||
|
||||
## PAUZE (10:15–10:30)
|
||||
|
||||
---
|
||||
|
||||
## DEEL 2: ZELF DOEN (10:30–11:30)
|
||||
|
||||
Students bouwen nu zelf. Jij loopt rond, helpt, en toont code op beamer als studenten stuck zijn.
|
||||
|
||||
### Zelf Doen Checklist (wat moet elke student doen):
|
||||
|
||||
- [ ] app/signup/page.tsx
|
||||
- [ ] app/login/page.tsx
|
||||
- [ ] components/LogoutButton.tsx
|
||||
- [ ] components/Navbar.tsx
|
||||
- [ ] app/layout.tsx updated
|
||||
- [ ] Test signup → login → poll maken → logout
|
||||
|
||||
---
|
||||
|
||||
### 1. app/signup/page.tsx
|
||||
|
||||
Dit is een 'use client' component met form. Students schrijven dit zelf, maar hier is de reference:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignUp() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) { setMessage(error.message); }
|
||||
else { setMessage("Account aangemaakt!"); router.push("/login"); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Registreren</h1>
|
||||
<form onSubmit={handleSignUp} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Wachtwoord</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded" minLength={6} required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "Bezig..." : "Registreren"}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className="mt-4 text-sm text-center">{message}</p>}
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Al een account? <Link href="/login" className="text-blue-600 hover:underline">Inloggen</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Students moeten begrijpen:**
|
||||
- `'use client'` = React component
|
||||
- `createSupabaseBrowserClient()` = browser auth
|
||||
- `supabase.auth.signUp({ email, password })` = nieuwe user
|
||||
- `if (error)` = error handling
|
||||
- `router.push("/login")` = redirect na success
|
||||
|
||||
---
|
||||
|
||||
### 2. app/login/page.tsx
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) { setMessage(error.message); }
|
||||
else { router.push("/"); router.refresh(); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Inloggen</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Wachtwoord</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded" required />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? "Bezig..." : "Inloggen"}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className="mt-4 text-sm text-red-600 text-center">{message}</p>}
|
||||
<p className="mt-4 text-sm text-center">
|
||||
Nog geen account? <Link href="/signup" className="text-blue-600 hover:underline">Registreren</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Key difference van signup:**
|
||||
- `signInWithPassword()` i.p.v. `signUp()`
|
||||
- `router.refresh()` om Navbar te update
|
||||
- Error styling: `text-red-600`
|
||||
|
||||
---
|
||||
|
||||
### 3. components/LogoutButton.tsx
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
import { createSupabaseBrowserClient } from "@/lib/supabase-browser";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const supabase = createSupabaseBrowserClient();
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
return (
|
||||
<button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900">
|
||||
Uitloggen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Dit is een klein client component. Belangrijk:**
|
||||
- `'use client'` (event handler)
|
||||
- `signOut()` — geen params
|
||||
- `router.refresh()` — update Navbar
|
||||
|
||||
---
|
||||
|
||||
### 4. components/Navbar.tsx
|
||||
|
||||
Dit is het interessantste component. Server Component met `async`:
|
||||
|
||||
```typescript
|
||||
import Link from "next/link";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
import { LogoutButton } from "./LogoutButton";
|
||||
|
||||
export async function Navbar() {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
return (
|
||||
<nav className="w-full border-b p-4 flex justify-between items-center">
|
||||
<Link href="/" className="text-xl font-bold">QuickPoll</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-600">{user.email}</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="text-sm hover:underline">Inloggen</Link>
|
||||
<Link href="/signup" className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700">Registreren</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Vertel:**
|
||||
"Navbar is een **Server Component** (geen 'use client'). Dit betekent `async` is ok. We callen `getUser()` direct — geen hooks nodig!
|
||||
|
||||
`getUser()` gebruikt server client + cookies. Dit is beveiligd en efficient."
|
||||
|
||||
**Logica:**
|
||||
- Als `user` bestaat: toon email + LogoutButton
|
||||
- Anders: toon Inloggen + Registreren links
|
||||
|
||||
---
|
||||
|
||||
### 5. app/layout.tsx (update)
|
||||
|
||||
Voeg Navbar toe:
|
||||
|
||||
```typescript
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = { title: "QuickPoll", description: "Stem op je favoriete opties" };
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="nl">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Navbar />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TROUBLESHOOTING (10:30–11:30)
|
||||
|
||||
Als studenten stuck zijn, gebruik deze tabel:
|
||||
|
||||
| Symptoom | Oorzaak | Oplossing |
|
||||
|----------|---------|----------|
|
||||
| "Module not found: @supabase/ssr" | npm install niet gedaan | `npm install @supabase/ssr` |
|
||||
| Navbar toont altijd "Inloggen" (ook na login) | getUser() returns null | Check: cookies middleware working? Browser dev tools → Cookies (zoek sb-*) |
|
||||
| Login werkt, maar redirect loopt vast | router.refresh() niet in handleLogin | Voeg `router.refresh()` toe na success |
|
||||
| "Invalid PKCE flow" | Browser client not configured | Check .env: NEXT_PUBLIC_SUPABASE_URL en ANON_KEY kloppen |
|
||||
| Logout knop werkt niet | signOut() niet awaited | Zorg: `await supabase.auth.signOut()` |
|
||||
| Navbar.tsx error: "cannot use async in component" | Navbar is client component | Zorg: geen `'use client'` aan top van Navbar! |
|
||||
|
||||
---
|
||||
|
||||
### 11:00 | CHECK-IN: NAVBAR DEMO
|
||||
|
||||
Toon op beamer je eigen Navbar. Vertel:
|
||||
|
||||
"Navbar is een **Server Component**. Dit is uniek voor Next.js. In React kan je geen `async` functions gebruiken als components.
|
||||
|
||||
Hier kan het wel, omdat Next.js bij build-time Server Components render. Cookies zijn secure. getUser() is beveiligd. Dit is beter dan client-side auth check."
|
||||
|
||||
Toon: `const { data: { user } } = await supabase.auth.getUser();`
|
||||
|
||||
"Dat een lijn doet alles: leest cookies → vraagt Supabase → geeft user object."
|
||||
|
||||
---
|
||||
|
||||
### 11:15 | RLS UPDATE
|
||||
|
||||
Voer in Supabase dashboard SQL uit:
|
||||
|
||||
**SQL Editor → New Query:**
|
||||
|
||||
```sql
|
||||
-- Enable RLS on polls table
|
||||
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Anyone can read polls
|
||||
CREATE POLICY "polls_select_all" ON polls
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Only authenticated users can create polls
|
||||
CREATE POLICY "polls_insert_authenticated" ON polls
|
||||
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- Only the creator can update their own poll
|
||||
CREATE POLICY "polls_update_owner" ON polls
|
||||
FOR UPDATE USING (auth.uid() = created_by);
|
||||
```
|
||||
|
||||
Vertel:
|
||||
"RLS = Row Level Security. Dit enforces wie wat kan doen op database level.
|
||||
- Iedereen (anoniem) kan polls zien (SELECT)
|
||||
- Alleen ingelogde users (auth.uid() NOT NULL) mogen polls maken (INSERT)
|
||||
- Alleen de maker mag hun eigen poll updaten (UPDATE)
|
||||
|
||||
auth.uid() is de user ID van Supabase. NULL als je niet ingelogd bent."
|
||||
|
||||
**Test:**
|
||||
1. Open http://localhost:3000/create
|
||||
2. Niet ingelogd → knop grijs / form gedeactiveerd
|
||||
3. Inloggen
|
||||
4. Poll aanmaken → werkt!
|
||||
5. Uitloggen
|
||||
6. /create opnieuw → weer grijs (RLS blokkeert INSERT)
|
||||
|
||||
---
|
||||
|
||||
### 11:30–11:45 | VRAGEN & DEBUGGING
|
||||
|
||||
Loopround. Antwoord vragen:
|
||||
|
||||
**Q: Hoe debug ik auth?**
|
||||
A: Supabase dashboard → Auth → Users. Daar zie je alle users. Of: Browser dev tools → Application → Cookies (zoek `sb-*` prefix).
|
||||
|
||||
**Q: Hoe reset ik mijn test account?**
|
||||
A: Dashboard → Auth → Users → klik user → delete → registreer opnieuw.
|
||||
|
||||
**Q: Waarom zie ik geen email na login?**
|
||||
A: Middleware werkt niet. Zorg middleware.ts in root project staat. Check: `matcher` is correct.
|
||||
|
||||
**Q: Kan ik multiple providers (Google, GitHub) toevoegen?**
|
||||
A: Ja, later. Dashboard → Auth → Providers. Voor nu: email-password is genoeg.
|
||||
|
||||
---
|
||||
|
||||
## HUISWERK & AFSLUITING (11:45–12:00)
|
||||
|
||||
**Huiswerk (slides 9):**
|
||||
|
||||
1. **Profiel pagina (Les 10)**
|
||||
```typescript
|
||||
// app/profile/page.tsx
|
||||
import { createSupabaseServerClient } from "@/lib/supabase-server";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
return <div>{user?.email}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Maker tonen bij poll (Les 10)**
|
||||
- Voeg `created_by uuid` kolom toe polls tabel
|
||||
- Update INSERT in /create om `created_by: user.id` toe te voegen
|
||||
- Toon "Gemaakt door: [email]" op homepage
|
||||
|
||||
3. **Google OAuth (Bonus)**
|
||||
- Supabase dashboard → Auth → Providers → Google
|
||||
- Copy Client ID en Secret van Google Cloud
|
||||
- Voeg button toe: `signInWithOAuth({ provider: 'google' })`
|
||||
|
||||
**Afsluiting (slide 10):**
|
||||
|
||||
"Volgende les: **Deployment**. We zetten je app live op Vercel. Dan kunnen je vrienden echt je polls gebruiken!
|
||||
|
||||
Daarna: Google OAuth, profiel updaten, meer security features.
|
||||
|
||||
Vandaag hebben we de kern van auth gebouwd. Goed gedaan!"
|
||||
|
||||
---
|
||||
|
||||
## DOCS
|
||||
|
||||
Supabase Auth docs: https://supabase.com/docs/guides/auth/server-side/nextjs
|
||||
|
||||
Next.js Server Components: https://nextjs.org/docs/app/building-your-application/rendering/server-components
|
||||
270
Les09-Slide-Overzicht.md
Normal file
270
Les09-Slide-Overzicht.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Les 9 — Supabase Auth
|
||||
## Slide Overzicht
|
||||
|
||||
---
|
||||
|
||||
## Slide 1: Title
|
||||
### Les 9 — Supabase Auth
|
||||
|
||||
**Visual:** Large centered title with QuickPoll icon
|
||||
- Background: CREAM
|
||||
- "Les 9" in BLUE
|
||||
- "Supabase Auth" in BLACK
|
||||
- Subtitle: "signUp, signIn, signOut, Navbar, RLS"
|
||||
|
||||
---
|
||||
|
||||
## Slide 2: Terugblik (Recap)
|
||||
### Waar staan we?
|
||||
|
||||
**Content:**
|
||||
- Supabase project aangemaakt en gekoppeld
|
||||
- /create pagina gebouwd
|
||||
- Server Component + VoteForm patroon
|
||||
- Polls werkend in database
|
||||
- Real-time votes
|
||||
- "Nu: beveiligde login"
|
||||
|
||||
**Visual:**
|
||||
- Left: screenshot van huidige app
|
||||
- Right: checkmarks of badges
|
||||
|
||||
---
|
||||
|
||||
## Slide 3: Planning
|
||||
### Vandaag — 120 minuten
|
||||
|
||||
| Tijd | Onderwerp | Duur |
|
||||
|------|-----------|------|
|
||||
| 09:00–09:10 | Welkom + Terugblik | 10 min |
|
||||
| 09:10–10:00 | Uitleg Auth | 50 min |
|
||||
| 10:00–10:15 | Samen Middleware bouwen | 15 min |
|
||||
| 10:15–10:30 | **Pauze** | 15 min |
|
||||
| 10:30–11:30 | Zelf Doen (signup, login, Navbar) | 60 min |
|
||||
| 11:30–11:45 | Vragen & Debugging | 15 min |
|
||||
| 11:45–12:00 | Huiswerk + Afsluiting | 15 min |
|
||||
|
||||
**Visual:** Timeline with YELLOW background, icons per blok
|
||||
|
||||
---
|
||||
|
||||
## Slide 4: Wat is Auth?
|
||||
### Authenticatie vs Autorisatie
|
||||
|
||||
**Authenticatie (WHO):**
|
||||
- Wie ben jij?
|
||||
- Email + password
|
||||
- Supabase verifies en geeft JWT token
|
||||
- User object: email, id, created_at
|
||||
|
||||
**Autorisatie (WHAT):**
|
||||
- Wat mag je doen?
|
||||
- Wie mag polls maken?
|
||||
- Later: RLS policies
|
||||
|
||||
**Features van Supabase Auth:**
|
||||
- Email/password signup & signin
|
||||
- Session management (cookies)
|
||||
- JWT tokens
|
||||
- Password reset
|
||||
- Multi-factor auth (later)
|
||||
- OAuth (Google, GitHub, etc.)
|
||||
|
||||
**Visual:**
|
||||
- Left: "Authentication" icon (person + key)
|
||||
- Right: "Authorization" icon (person + checkmark)
|
||||
- Supabase logo
|
||||
|
||||
---
|
||||
|
||||
## Slide 5: Auth Functies
|
||||
### Vier Core Operations
|
||||
|
||||
**signUp**
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email: "user@example.com",
|
||||
password: "secure123"
|
||||
});
|
||||
```
|
||||
→ Account aanmaken
|
||||
|
||||
**signInWithPassword**
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: "user@example.com",
|
||||
password: "secure123"
|
||||
});
|
||||
```
|
||||
→ Inloggen
|
||||
|
||||
**signOut**
|
||||
```typescript
|
||||
await supabase.auth.signOut();
|
||||
```
|
||||
→ Uitloggen
|
||||
|
||||
**getUser**
|
||||
```typescript
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
// user.email, user.id, user.email_confirmed_at
|
||||
```
|
||||
→ Huidige user
|
||||
|
||||
**Visual:** Code blocks in BLUE boxes, icons above each
|
||||
|
||||
---
|
||||
|
||||
## Slide 6: Server vs Browser Client
|
||||
### Two Clients, One Auth
|
||||
|
||||
**Server Client** (@supabase/ssr)
|
||||
```typescript
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createSupabaseServerClient() {
|
||||
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) { /* ... */ },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Use in:**
|
||||
- Middleware (refresh token)
|
||||
- Server Components (Navbar, getUser)
|
||||
- API routes
|
||||
|
||||
**Browser Client** (@supabase/ssr)
|
||||
```typescript
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Use in:**
|
||||
- Client Components ('use client')
|
||||
- Login forms
|
||||
- Logout buttons
|
||||
|
||||
**Visual:** Two side-by-side code blocks
|
||||
- Left: Server (BLUE bg), lock icon
|
||||
- Right: Browser (PINK bg), web icon
|
||||
|
||||
**Key difference:**
|
||||
- Server: Cookies (secure, secure-only, httpOnly)
|
||||
- Browser: localStorage (accessible, but less safe)
|
||||
|
||||
---
|
||||
|
||||
## Slide 7: Pauze
|
||||
### Pauze!
|
||||
|
||||
**Visual:** Relaxed illustration, "15 minuten", clock
|
||||
|
||||
---
|
||||
|
||||
## Slide 8: Zelf Doen — Auth Bouwen
|
||||
### Nu jij — 60 minuten
|
||||
|
||||
**To-Do:**
|
||||
- [ ] app/signup/page.tsx (form)
|
||||
- [ ] app/login/page.tsx (form)
|
||||
- [ ] components/LogoutButton.tsx
|
||||
- [ ] components/Navbar.tsx (Server Component + getUser)
|
||||
- [ ] app/layout.tsx (add `<Navbar />`)
|
||||
- [ ] Update RLS policies (authenticated only!)
|
||||
|
||||
**Reference code beschikbaar** (docent toont op beamer)
|
||||
|
||||
**Process:**
|
||||
1. Start simpel: form met email + password inputs
|
||||
2. Voeg supabase.auth.signUp / signInWithPassword toe
|
||||
3. Test in browser
|
||||
4. Navbar: toon email of login link
|
||||
5. RLS: polls INSERT nur voor authenticated users
|
||||
|
||||
**Expected result:**
|
||||
- Registreren → inloggen → poll maken → uitloggen
|
||||
- Na logout: kan geen poll meer maken (RLS!)
|
||||
|
||||
**Visual:** Big BLUE background, "Bouw Auth" header, checklist
|
||||
|
||||
---
|
||||
|
||||
## Slide 9: Huiswerk
|
||||
### Volgende Stap
|
||||
|
||||
**Verplicht (Les 10):**
|
||||
1. Profiel pagina (app/profile/page.tsx)
|
||||
- Toon user.email, user.id
|
||||
- Later: password update form
|
||||
|
||||
2. Maker tonen bij poll
|
||||
- Voeg `created_by` kolom toe polls tabel
|
||||
- Toon "Gemaakt door: [email]" bij elke poll
|
||||
- RLS: alleen maker mag aanpassen (UPDATE)
|
||||
|
||||
**Bonus (optioneel):**
|
||||
1. Google OAuth signup
|
||||
- Supabase dashboard → Auth → Providers → Google
|
||||
- Voeg "Sign in with Google" knop toe
|
||||
|
||||
2. Password reset
|
||||
- Email link naar reset form
|
||||
- supabase.auth.resetPasswordForEmail()
|
||||
|
||||
**Visual:** Checklist, bonus items in PINK
|
||||
|
||||
---
|
||||
|
||||
## Slide 10: Afsluiting
|
||||
### Volgende Les — Deployment
|
||||
|
||||
**Wat hebben we gedaan vandaag:**
|
||||
- Auth concepten: authenticatie vs autorisatie
|
||||
- Supabase Auth functies: signUp, signIn, signOut, getUser
|
||||
- Server vs browser client
|
||||
- Middleware voor session refresh
|
||||
- Navbar met authenticated user
|
||||
- RLS policies
|
||||
|
||||
**Volgende keer:**
|
||||
- Vercel deployment
|
||||
- Google OAuth
|
||||
- Profiel pagina
|
||||
- Meer security!
|
||||
|
||||
**Vragen? Feedback?**
|
||||
|
||||
**Visual:** Vercel logo, rocket icon, "Deployment!" in YELLOW
|
||||
|
||||
---
|
||||
|
||||
## Slide Summary
|
||||
|
||||
| # | Title | Duration | Key Content |
|
||||
|---|-------|----------|-------------|
|
||||
| 1 | Title | Opening | Les 9 — Supabase Auth |
|
||||
| 2 | Recap | 09:10 | Where we are |
|
||||
| 3 | Plan | 09:05 | 120-min schedule |
|
||||
| 4 | Auth Concepts | 09:10 | Auth vs AuthN, Supabase features |
|
||||
| 5 | Functions | 09:20 | signUp, signIn, signOut, getUser |
|
||||
| 6 | Clients | 09:30 | Server vs Browser (@supabase/ssr) |
|
||||
| 7 | Break | 10:15 | 15 min pauze |
|
||||
| 8 | Build | 10:30 | Students implement auth |
|
||||
| 9 | Homework | 11:45 | Profile, maker, Google OAuth |
|
||||
| 10 | Closing | 11:55 | Next: Deployment |
|
||||
BIN
Les09-Slides.pptx
Normal file
BIN
Les09-Slides.pptx
Normal file
Binary file not shown.
Reference in New Issue
Block a user