RLS policies implementeren, Demo auth flow

This commit is contained in:
colinislit
2025-11-15 23:59:38 +01:00
parent b1cfb339a2
commit 99804656d7
49 changed files with 5841 additions and 123 deletions

162
lib/auth/client.ts Normal file
View File

@@ -0,0 +1,162 @@
/**
* Supabase Auth Client Utilities
*
* Client-side auth helpers for login, logout, and session management
*/
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/database.types'
/**
* Create Supabase client for browser/client-side use
*/
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
/**
* Send magic link to user's email
* Auto-creates account if user doesn't exist
*/
export async function loginWithMagicLink(email: string) {
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
shouldCreateUser: true, // Auto-create account on first login
}
})
if (error) throw error
return {
success: true,
message: 'Check je email voor de magic link!',
data
}
}
/**
* Login with email + password (for demo accounts)
*/
export async function loginWithPassword(email: string, password: string) {
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})
if (error) throw error
return {
success: true,
user: data.user,
session: data.session
}
}
/**
* Logout current user
*/
export async function logout() {
const supabase = createClient()
const { error } = await supabase.auth.signOut()
if (error) throw error
// Redirect to login
window.location.href = '/login'
}
/**
* Get current session
*/
export async function getSession() {
const supabase = createClient()
const { data: { session }, error } = await supabase.auth.getSession()
if (error) throw error
return session
}
/**
* Get current user
*/
export async function getUser() {
const supabase = createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error) throw error
return user
}
/**
* Check if user is authenticated
*/
export async function isAuthenticated() {
const session = await getSession()
return !!session
}
/**
* Subscribe to auth state changes
*/
export function onAuthStateChange(
callback: (event: string, session: any) => void
) {
const supabase = createClient()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
callback
)
return subscription
}
/**
* Check if current user is a demo user
*/
export async function isDemoUser(): Promise<boolean> {
const supabase = createClient()
const user = await getUser()
if (!user) return false
const { data, error } = await supabase
.from('demo_users')
.select('id')
.eq('user_id', user.id)
.single()
if (error) return false
return !!data
}
/**
* Get demo user access level
*/
export async function getDemoAccessLevel(): Promise<'read_only' | 'interactive' | 'presenter' | null> {
const supabase = createClient()
const user = await getUser()
if (!user) return null
const { data, error } = await supabase
.from('demo_users')
.select('access_level')
.eq('user_id', user.id)
.single()
if (error) return null
return data.access_level as 'read_only' | 'interactive' | 'presenter'
}

154
lib/auth/server.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Supabase Auth Server Utilities
*
* Server-side auth helpers for API routes, middleware, and server components
*/
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/lib/database.types'
/**
* Create Supabase client for server-side use
* Handles cookies for session management
*/
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
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 {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
/**
* Get current session (server-side)
*/
export async function getSession() {
const supabase = await createClient()
const { data: { session }, error } = await supabase.auth.getSession()
if (error) throw error
return session
}
/**
* Get current user (server-side)
*/
export async function getUser() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error) throw error
return user
}
/**
* Require authentication - throws if not authenticated
* Use in API routes and server actions
*/
export async function requireAuth() {
const session = await getSession()
if (!session) {
throw new Error('Authentication required')
}
return session
}
/**
* Check if user is authenticated (server-side)
*/
export async function isAuthenticated() {
const session = await getSession()
return !!session
}
/**
* Check if current user is a demo user (server-side)
*/
export async function isDemoUser(): Promise<boolean> {
const supabase = await createClient()
const user = await getUser()
if (!user) return false
const { data, error } = await supabase
.from('demo_users')
.select('id')
.eq('user_id', user.id)
.single()
if (error) return false
return !!data
}
/**
* Get demo user info (server-side)
*/
export async function getDemoUserInfo() {
const supabase = await createClient()
const user = await getUser()
if (!user) return null
const { data, error } = await supabase
.from('demo_users')
.select('*')
.eq('user_id', user.id)
.single()
if (error) return null
return data
}
/**
* Check if demo user has write access
*/
export async function canWrite(): Promise<boolean> {
const demoInfo = await getDemoUserInfo()
// Non-demo users can write
if (!demoInfo) return true
// Only 'interactive' and 'presenter' demo users can write
return ['interactive', 'presenter'].includes(demoInfo.access_level)
}
/**
* Track demo user login
*/
export async function trackDemoLogin(userId: string) {
const supabase = await createClient()
await supabase
.from('demo_users')
.update({
usage_count: supabase.rpc('increment', { row_id: userId }),
last_login_at: new Date().toISOString()
})
.eq('user_id', userId)
}

View File

@@ -3,13 +3,19 @@
*
* Loads JSON content files from the content directory structure.
* Supports server-side loading with TypeScript type safety.
* Also supports loading and parsing markdown files.
*
* @example
* ```ts
* const manifesto = await getContent<ManifestoContent>('nl', 'manifesto')
* const markdownSections = await getMarkdownContent('docs/manifesto.md')
* ```
*/
import { readFile } from 'fs/promises'
import { join } from 'path'
import { markdownToSections } from './markdown-parser'
export async function getContent<T>(
locale: string = 'nl',
file: string
@@ -23,6 +29,27 @@ export async function getContent<T>(
}
}
/**
* Load and parse markdown file to ManifestoSection format
* Useful for converting manifesto.md to React components
*/
export async function getMarkdownContent(
filePath: string
): Promise<Array<{
id: string
type: 'paragraph'
content: string
}>> {
try {
const fullPath = join(process.cwd(), filePath)
const markdown = await readFile(fullPath, 'utf-8')
return markdownToSections(markdown)
} catch (error) {
console.error(`Failed to load markdown: ${filePath}`, error)
throw new Error(`Markdown file not found: ${filePath}`)
}
}
/**
* Type-safe content loader with fallback
*

View File

@@ -0,0 +1,62 @@
/**
* Markdown Parser Utility
*
* Simple markdown parser for converting manifesto.md content
* to React components with proper typography.
*
* This is a lightweight parser for basic markdown features:
* - Paragraphs
* - Line breaks
* - Basic formatting (bold, italic)
*/
export interface ParsedMarkdown {
paragraphs: string[]
metadata?: Record<string, string>
}
/**
* Parse markdown content into structured format
* Splits content by double line breaks into paragraphs
*/
export function parseMarkdown(markdown: string): ParsedMarkdown {
// Split by double line breaks or single line breaks followed by empty line
const paragraphs = markdown
.split(/\n\s*\n/)
.map((para) => para.trim())
.filter((para) => para.length > 0)
return {
paragraphs,
}
}
/**
* Convert markdown paragraphs to ManifestoSection format
* This allows markdown content to be used with existing components
*/
export function markdownToSections(markdown: string): Array<{
id: string
type: 'paragraph'
content: string
}> {
const parsed = parseMarkdown(markdown)
return parsed.paragraphs.map((content, index) => ({
id: `paragraph-${index + 1}`,
type: 'paragraph' as const,
content: content.trim(),
}))
}
/**
* Simple markdown renderer for inline formatting
* Converts **bold** and *italic* to HTML
*/
export function renderMarkdownInline(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
}

365
lib/database.types.ts Normal file
View File

@@ -0,0 +1,365 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.5"
}
public: {
Tables: {
ai_events: {
Row: {
client_id: string | null
created_at: string
duration_ms: number
id: string
kind: string
note_id: string | null
request: Json
response: Json
}
Insert: {
client_id?: string | null
created_at?: string
duration_ms?: number
id?: string
kind: string
note_id?: string | null
request?: Json
response?: Json
}
Update: {
client_id?: string | null
created_at?: string
duration_ms?: number
id?: string
kind?: string
note_id?: string | null
request?: Json
response?: Json
}
Relationships: [
{
foreignKeyName: "ai_events_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
{
foreignKeyName: "ai_events_note_id_fkey"
columns: ["note_id"]
isOneToOne: false
referencedRelation: "intake_notes"
referencedColumns: ["id"]
},
]
}
clients: {
Row: {
birth_date: string
created_at: string
first_name: string
id: string
last_name: string
updated_at: string
}
Insert: {
birth_date: string
created_at?: string
first_name: string
id?: string
last_name: string
updated_at?: string
}
Update: {
birth_date?: string
created_at?: string
first_name?: string
id?: string
last_name?: string
updated_at?: string
}
Relationships: []
}
intake_notes: {
Row: {
author: string | null
client_id: string
content_json: Json
content_text: string | null
created_at: string
id: string
tag: string | null
title: string | null
updated_at: string
}
Insert: {
author?: string | null
client_id: string
content_json?: Json
content_text?: string | null
created_at?: string
id?: string
tag?: string | null
title?: string | null
updated_at?: string
}
Update: {
author?: string | null
client_id?: string
content_json?: Json
content_text?: string | null
created_at?: string
id?: string
tag?: string | null
title?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "intake_notes_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
]
}
problem_profiles: {
Row: {
category: string
client_id: string
created_at: string
id: string
remarks: string | null
severity: string
source_note_id: string | null
updated_at: string
}
Insert: {
category: string
client_id: string
created_at?: string
id?: string
remarks?: string | null
severity: string
source_note_id?: string | null
updated_at?: string
}
Update: {
category?: string
client_id?: string
created_at?: string
id?: string
remarks?: string | null
severity?: string
source_note_id?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "problem_profiles_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
{
foreignKeyName: "problem_profiles_source_note_id_fkey"
columns: ["source_note_id"]
isOneToOne: false
referencedRelation: "intake_notes"
referencedColumns: ["id"]
},
]
}
treatment_plans: {
Row: {
client_id: string
created_at: string
created_by: string | null
id: string
plan: Json
published_at: string | null
status: string
updated_at: string
version: number
}
Insert: {
client_id: string
created_at?: string
created_by?: string | null
id?: string
plan?: Json
published_at?: string | null
status?: string
updated_at?: string
version?: number
}
Update: {
client_id?: string
created_at?: string
created_by?: string | null
id?: string
plan?: Json
published_at?: string | null
status?: string
updated_at?: string
version?: number
}
Relationships: [
{
foreignKeyName: "treatment_plans_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const