feat: initial commit

This commit is contained in:
Janpeter Visser 2026-04-18 14:18:26 +02:00
commit 7d443a004a
76 changed files with 15704 additions and 0 deletions

71
lib/auth/messages.ts Normal file
View file

@ -0,0 +1,71 @@
export type AuthNoticeTone = "error" | "success" | "info";
export type AuthNotice = {
tone: AuthNoticeTone;
text: string;
};
const errorMessages: Record<string, AuthNotice> = {
"auth-not-configured": {
tone: "error",
text: "Supabase is nog niet geconfigureerd. Voeg eerst je URL en publishable key toe.",
},
"invalid-credentials": {
tone: "error",
text: "De combinatie van e-mailadres en wachtwoord klopt niet.",
},
"email-not-confirmed": {
tone: "error",
text: "Bevestig eerst je e-mailadres via de link in je inbox.",
},
"missing-fields": {
tone: "error",
text: "Vul zowel je e-mailadres als je wachtwoord in.",
},
"signup-failed": {
tone: "error",
text: "Je account kon niet worden aangemaakt. Probeer het opnieuw.",
},
"signup-rate-limited": {
tone: "error",
text: "Er zijn nu te veel verificatie-e-mails verstuurd. Wacht even en probeer het daarna opnieuw.",
},
"login-failed": {
tone: "error",
text: "Inloggen is niet gelukt. Probeer het opnieuw.",
},
"verification-failed": {
tone: "error",
text: "De verificatielink is ongeldig of verlopen. Vraag zo nodig een nieuwe aan.",
},
};
const statusMessages: Record<string, AuthNotice> = {
"check-email": {
tone: "success",
text: "Controleer je e-mail en activeer je account via de verificatielink.",
},
"signed-out": {
tone: "info",
text: "Je bent uitgelogd.",
},
verified: {
tone: "success",
text: "Je e-mailadres is bevestigd. Welkom terug.",
},
};
export function getAuthNotice(
error?: string | null,
status?: string | null,
): AuthNotice | null {
if (error && errorMessages[error]) {
return errorMessages[error];
}
if (status && statusMessages[status]) {
return statusMessages[status];
}
return null;
}

41
lib/auth/navigation.ts Normal file
View file

@ -0,0 +1,41 @@
const DEFAULT_NEXT_PATH = "/dashboard";
export function sanitizeNextPath(candidate?: string | null): string {
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
return DEFAULT_NEXT_PATH;
}
return candidate;
}
export function buildPathWithQuery(
pathname: string,
params: Record<string, string | null | undefined>,
): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) {
search.set(key, value);
}
}
const query = search.toString();
return query ? `${pathname}?${query}` : pathname;
}
export function getRequestOrigin(headers: Pick<Headers, "get">): string {
const origin = headers.get("origin");
if (origin) {
return origin;
}
const protocol = headers.get("x-forwarded-proto") ?? "http";
const host = headers.get("x-forwarded-host") ?? headers.get("host");
if (host) {
return `${protocol}://${host}`;
}
return "http://localhost:3000";
}

64
lib/auth/session.ts Normal file
View file

@ -0,0 +1,64 @@
import { createClient } from "@/lib/supabase/server";
import { hasSupabaseEnv } from "@/lib/supabase/config";
type ClaimsShape = {
sub?: string;
email?: string;
};
export type AuthenticatedUser = {
id: string;
email: string | null;
};
export type AuthState = {
isConfigured: boolean;
isAuthenticated: boolean;
userId: string | null;
email: string | null;
};
export async function getAuthenticatedUser(): Promise<AuthenticatedUser | null> {
if (!hasSupabaseEnv()) {
return null;
}
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) {
return null;
}
const claims = data.claims as ClaimsShape;
const userId = typeof claims.sub === "string" ? claims.sub : null;
if (!userId) {
return null;
}
return {
id: userId,
email: typeof claims.email === "string" ? claims.email : null,
};
}
export async function getAuthState(): Promise<AuthState> {
if (!hasSupabaseEnv()) {
return {
isConfigured: false,
isAuthenticated: false,
userId: null,
email: null,
};
}
const authenticatedUser = await getAuthenticatedUser();
return {
isConfigured: true,
isAuthenticated: Boolean(authenticatedUser),
userId: authenticatedUser?.id ?? null,
email: authenticatedUser?.email ?? null,
};
}

28
lib/onboarding/options.ts Normal file
View file

@ -0,0 +1,28 @@
export const ONBOARDING_TIMEZONE_OPTIONS = [
{
value: "Europe/Amsterdam",
label: "Europa/Amsterdam",
},
{
value: "Europe/Brussels",
label: "Europa/Brussel",
},
{
value: "Europe/Berlin",
label: "Europa/Berlijn",
},
{
value: "UTC",
label: "UTC",
},
] as const;
const ONBOARDING_TIMEZONE_SET = new Set(
ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value),
);
export function isSupportedOnboardingTimezone(value: string) {
return ONBOARDING_TIMEZONE_SET.has(
value as (typeof ONBOARDING_TIMEZONE_OPTIONS)[number]["value"],
);
}

385
lib/profile/service.ts Normal file
View file

@ -0,0 +1,385 @@
import { getAuthenticatedUser } from "@/lib/auth/session";
import { isSupportedOnboardingTimezone } from "@/lib/onboarding/options";
import { createClient } from "@/lib/supabase/server";
import type {
OnboardingSubmission,
ProfileBundle,
ProfileRecord,
SettingsSubmission,
UserSettingsRecord,
} from "@/lib/profile/types";
type SupabaseServerClient = Awaited<ReturnType<typeof createClient>>;
type ProfileRow = {
id: string;
email: string | null;
display_name: string | null;
locale: string;
timezone: string;
onboarding_seen: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
};
type UserSettingsRow = {
profile_id: string;
morning_reminder_enabled: boolean;
morning_reminder_time: string | null;
reflection_reminder_enabled: boolean;
show_energy_points: boolean;
created_at: string;
updated_at: string;
};
type ProfileInsert = {
id: string;
email: string | null;
display_name: string | null;
locale: string;
timezone: string;
onboarding_seen: boolean;
onboarding_completed: boolean;
};
type UserSettingsInsert = {
profile_id: string;
morning_reminder_enabled: boolean;
morning_reminder_time: string | null;
reflection_reminder_enabled: boolean;
show_energy_points: boolean;
};
const PROFILE_COLUMNS =
"id, email, display_name, locale, timezone, onboarding_seen, onboarding_completed, created_at, updated_at";
const USER_SETTINGS_COLUMNS =
"profile_id, morning_reminder_enabled, morning_reminder_time, reflection_reminder_enabled, show_energy_points, created_at, updated_at";
const DEFAULT_LOCALE = "nl-NL";
const DEFAULT_TIMEZONE = "Europe/Amsterdam";
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
function mapProfileRow(row: ProfileRow): ProfileRecord {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
locale: row.locale,
timezone: row.timezone,
onboardingSeen: row.onboarding_seen,
onboardingCompleted: row.onboarding_completed,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapUserSettingsRow(row: UserSettingsRow): UserSettingsRecord {
return {
profileId: row.profile_id,
morningReminderEnabled: row.morning_reminder_enabled,
morningReminderTime: row.morning_reminder_time,
reflectionReminderEnabled: row.reflection_reminder_enabled,
showEnergyPoints: row.show_energy_points,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function buildDefaultProfileFromClaims(user: {
id: string;
email: string | null;
}): ProfileInsert {
return {
id: user.id,
email: user.email,
display_name: null,
locale: DEFAULT_LOCALE,
timezone: DEFAULT_TIMEZONE,
onboarding_seen: false,
onboarding_completed: false,
};
}
export function buildDefaultSettings(profileId: string): UserSettingsInsert {
return {
profile_id: profileId,
morning_reminder_enabled: false,
morning_reminder_time: null,
reflection_reminder_enabled: false,
show_energy_points: true,
};
}
function normalizeDisplayName(value: string | null) {
const trimmedValue = value?.trim() ?? "";
return trimmedValue ? trimmedValue : null;
}
function normalizeReminderTime(value: string | null, enabled: boolean) {
if (!enabled) {
return null;
}
const trimmedValue = value?.trim() ?? "";
return trimmedValue || "08:30";
}
function normalizeLocale(value: string) {
return SUPPORTED_LOCALES.has(value) ? value : DEFAULT_LOCALE;
}
function resolveTimezone(value: string) {
return isSupportedOnboardingTimezone(value) ? value : DEFAULT_TIMEZONE;
}
async function readProfileRow(
supabase: SupabaseServerClient,
userId: string,
): Promise<ProfileRow | null> {
const { data, error } = await supabase
.from("profiles")
.select(PROFILE_COLUMNS)
.eq("id", userId)
.maybeSingle();
if (error) {
throw new Error(`Profiel kon niet worden geladen: ${error.message}`);
}
return data;
}
async function readUserSettingsRow(
supabase: SupabaseServerClient,
userId: string,
): Promise<UserSettingsRow | null> {
const { data, error } = await supabase
.from("user_settings")
.select(USER_SETTINGS_COLUMNS)
.eq("profile_id", userId)
.maybeSingle();
if (error) {
throw new Error(`Instellingen konden niet worden geladen: ${error.message}`);
}
return data;
}
async function insertMissingProfile(
supabase: SupabaseServerClient,
user: { id: string; email: string | null },
) {
const { error } = await supabase.from("profiles").upsert(
buildDefaultProfileFromClaims(user),
{
onConflict: "id",
ignoreDuplicates: true,
},
);
if (error) {
throw new Error(`Profiel kon niet worden aangemaakt: ${error.message}`);
}
}
async function syncProfileEmailIfNeeded(
supabase: SupabaseServerClient,
profile: ProfileRow,
email: string | null,
): Promise<ProfileRow> {
if (!email || profile.email === email) {
return profile;
}
const { data, error } = await supabase
.from("profiles")
.update({ email })
.eq("id", profile.id)
.select(PROFILE_COLUMNS)
.single();
if (error) {
throw new Error(`Profiel-e-mailadres kon niet worden bijgewerkt: ${error.message}`);
}
return data;
}
async function insertMissingUserSettings(
supabase: SupabaseServerClient,
userId: string,
) {
const { error } = await supabase.from("user_settings").upsert(
buildDefaultSettings(userId),
{
onConflict: "profile_id",
ignoreDuplicates: true,
},
);
if (error) {
throw new Error(`Instellingen konden niet worden aangemaakt: ${error.message}`);
}
}
export async function markOnboardingSeenForCurrentUser() {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
const supabase = await createClient();
const { error } = await supabase
.from("profiles")
.update({ onboarding_seen: true, onboarding_completed: false })
.eq("id", user.id);
if (error) {
throw new Error(`Onboardingstatus kon niet worden bijgewerkt: ${error.message}`);
}
}
export async function completeOnboardingForCurrentUser(
submission: OnboardingSubmission,
) {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
const timezone = resolveTimezone(submission.timezone);
const supabase = await createClient();
const displayName = normalizeDisplayName(submission.displayName);
const morningReminderTime = normalizeReminderTime(
submission.morningReminderTime,
submission.morningReminderEnabled,
);
const { error: profileError } = await supabase
.from("profiles")
.update({
display_name: displayName,
timezone,
onboarding_seen: true,
onboarding_completed: true,
})
.eq("id", user.id);
if (profileError) {
throw new Error(`Profiel kon niet worden bijgewerkt: ${profileError.message}`);
}
const { error: settingsError } = await supabase
.from("user_settings")
.update({
morning_reminder_enabled: submission.morningReminderEnabled,
morning_reminder_time: morningReminderTime,
reflection_reminder_enabled: submission.reflectionReminderEnabled,
show_energy_points: submission.showEnergyPoints,
})
.eq("profile_id", user.id);
if (settingsError) {
throw new Error(`Instellingen konden niet worden bijgewerkt: ${settingsError.message}`);
}
return ensureProfileBundleForCurrentUser();
}
export async function saveSettingsForCurrentUser(
submission: SettingsSubmission,
) {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
await ensureProfileBundleForCurrentUser();
const supabase = await createClient();
const locale = normalizeLocale(submission.locale);
const timezone = resolveTimezone(submission.timezone);
const morningReminderTime = normalizeReminderTime(
submission.morningReminderTime,
submission.morningReminderEnabled,
);
const { error: profileError } = await supabase
.from("profiles")
.update({
locale,
timezone,
})
.eq("id", user.id);
if (profileError) {
throw new Error(`Profielinstellingen konden niet worden bijgewerkt: ${profileError.message}`);
}
const { error: settingsError } = await supabase
.from("user_settings")
.update({
morning_reminder_enabled: submission.morningReminderEnabled,
morning_reminder_time: morningReminderTime,
reflection_reminder_enabled: submission.reflectionReminderEnabled,
show_energy_points: submission.showEnergyPoints,
})
.eq("profile_id", user.id);
if (settingsError) {
throw new Error(`Gebruikersinstellingen konden niet worden bijgewerkt: ${settingsError.message}`);
}
return ensureProfileBundleForCurrentUser();
}
export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
const user = await getAuthenticatedUser();
if (!user) {
return null;
}
const supabase = await createClient();
// We bootstrap records app-side so the first protected page load is enough
// to give every authenticated user a minimal profile and settings basis.
let profileRow = await readProfileRow(supabase, user.id);
if (!profileRow) {
await insertMissingProfile(supabase, user);
profileRow = await readProfileRow(supabase, user.id);
}
if (!profileRow) {
throw new Error("Profielrecord ontbreekt na bootstrap.");
}
profileRow = await syncProfileEmailIfNeeded(supabase, profileRow, user.email);
let userSettingsRow = await readUserSettingsRow(supabase, user.id);
if (!userSettingsRow) {
await insertMissingUserSettings(supabase, user.id);
userSettingsRow = await readUserSettingsRow(supabase, user.id);
}
if (!userSettingsRow) {
throw new Error("Settingsrecord ontbreekt na bootstrap.");
}
return {
profile: mapProfileRow(profileRow),
settings: mapUserSettingsRow(userSettingsRow),
};
}
export async function getProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
return ensureProfileBundleForCurrentUser();
}

44
lib/profile/types.ts Normal file
View file

@ -0,0 +1,44 @@
export type ProfileRecord = {
id: string;
email: string | null;
displayName: string | null;
locale: string;
timezone: string;
onboardingSeen: boolean;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
};
export type UserSettingsRecord = {
profileId: string;
morningReminderEnabled: boolean;
morningReminderTime: string | null;
reflectionReminderEnabled: boolean;
showEnergyPoints: boolean;
createdAt: string;
updatedAt: string;
};
export type ProfileBundle = {
profile: ProfileRecord;
settings: UserSettingsRecord;
};
export type OnboardingSubmission = {
displayName: string | null;
timezone: string;
morningReminderEnabled: boolean;
morningReminderTime: string | null;
reflectionReminderEnabled: boolean;
showEnergyPoints: boolean;
};
export type SettingsSubmission = {
locale: string;
timezone: string;
morningReminderEnabled: boolean;
morningReminderTime: string | null;
reflectionReminderEnabled: boolean;
showEnergyPoints: boolean;
};

15
lib/supabase/client.ts Normal file
View file

@ -0,0 +1,15 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
import { getSupabaseEnv } from "@/lib/supabase/config";
let browserClient: ReturnType<typeof createBrowserClient> | undefined;
export function createClient() {
if (!browserClient) {
const { url, publishableKey } = getSupabaseEnv();
browserClient = createBrowserClient(url, publishableKey);
}
return browserClient;
}

22
lib/supabase/config.ts Normal file
View file

@ -0,0 +1,22 @@
export function hasSupabaseEnv(): boolean {
return Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
);
}
export function getSupabaseEnv() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const publishableKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!url || !publishableKey) {
throw new Error(
"Supabase configuratie ontbreekt. Voeg NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY toe.",
);
}
return {
url,
publishableKey,
};
}

40
lib/supabase/proxy.ts Normal file
View file

@ -0,0 +1,40 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { getSupabaseEnv, hasSupabaseEnv } from "@/lib/supabase/config";
export async function updateSession(request: NextRequest) {
let response = NextResponse.next({
request,
});
if (!hasSupabaseEnv()) {
return response;
}
const { url, publishableKey } = getSupabaseEnv();
const supabase = createServerClient(url, publishableKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value);
});
response = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.getClaims();
return response;
}

26
lib/supabase/server.ts Normal file
View file

@ -0,0 +1,26 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { getSupabaseEnv } from "@/lib/supabase/config";
export async function createClient() {
const cookieStore = await cookies();
const { url, publishableKey } = getSupabaseEnv();
return createServerClient(url, publishableKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Server Components cannot always write cookies directly.
// The proxy keeps the session in sync for those cases.
}
},
},
});
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}