diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..7ee2ba3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Rikx Beheer", + description: "Rikx platform beheer", }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d37fb66 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,74 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { getUserByEmail, verifyPassword, createSession } from "../../lib/auth"; +import { getSession } from "../../lib/auth"; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + const session = await getSession(); + if (session) redirect("/tenants"); + + const { error } = await searchParams; + + return ( +
+
+

+ Rikx Beheer +

+
{ + "use server"; + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const user = getUserByEmail(email); + if (!user || !verifyPassword(password, user.password_hash)) { + redirect("/login?error=1"); + } + await createSession({ userId: user.id, email: user.email, name: user.name }); + redirect("/tenants"); + }} + className="flex flex-col gap-4 bg-white dark:bg-zinc-900 p-8 rounded-xl border border-zinc-200 dark:border-zinc-800" + > + {error && ( +

+ Ongeldig e-mailadres of wachtwoord. +

+ )} +
+ + +
+
+ + +
+ +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index c887311..fe337f2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,7 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; +import { getSession } from "../lib/auth"; -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); +export default async function Home() { + const session = await getSession(); + redirect(session ? "/tenants" : "/login"); } diff --git a/app/tenants/actions.ts b/app/tenants/actions.ts new file mode 100644 index 0000000..2196dd9 --- /dev/null +++ b/app/tenants/actions.ts @@ -0,0 +1,59 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { randomBytes, scryptSync } from "crypto"; +import { getPlatformDb } from "../../lib/platform-db"; +import { getSession } from "../../lib/auth"; + +function requireAuth() { + // Called inside server actions — session check is synchronous-safe via cache +} + +function hashPassword(password: string): string { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +export async function createTenant(formData: FormData) { + const session = await getSession(); + if (!session) redirect("/login"); + + const name = (formData.get("name") as string).trim(); + const slug = (formData.get("slug") as string).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-"); + const adminEmail = (formData.get("admin_email") as string).trim(); + const adminPassword = formData.get("admin_password") as string; + const adminName = (formData.get("admin_name") as string).trim(); + + if (!name || !slug || !adminEmail || !adminPassword || !adminName) { + redirect("/tenants?error=missing"); + } + + const db = getPlatformDb(); + + const existing = db.prepare("SELECT id FROM accounts WHERE slug = ?").get(slug); + if (existing) redirect("/tenants?error=slug"); + + db.prepare("INSERT INTO accounts (name, slug) VALUES (?, ?)").run(name, slug); + const account = db.prepare("SELECT id FROM accounts WHERE slug = ?").get(slug) as { id: number }; + + db.prepare( + "INSERT INTO users (account_id, email, password_hash, name, role) VALUES (?, ?, ?, ?, 'admin')" + ).run(account.id, adminEmail, hashPassword(adminPassword), adminName); + + redirect("/tenants"); +} + +export async function deleteTenant(formData: FormData) { + const session = await getSession(); + if (!session) redirect("/login"); + + const accountId = Number(formData.get("account_id")); + if (!accountId) redirect("/tenants"); + + const db = getPlatformDb(); + // Cascade via foreign keys: users, participants, measurements are deleted automatically + db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId); + + redirect("/tenants"); +} diff --git a/app/tenants/page.tsx b/app/tenants/page.tsx new file mode 100644 index 0000000..07962d5 --- /dev/null +++ b/app/tenants/page.tsx @@ -0,0 +1,192 @@ +import { redirect } from "next/navigation"; +import { getSession, destroySession } from "../../lib/auth"; +import { listAccounts } from "../../lib/platform-db"; +import { createTenant, deleteTenant } from "./actions"; + +export default async function TenantsPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string }>; +}) { + const session = await getSession(); + if (!session) redirect("/login"); + + const { error } = await searchParams; + const accounts = listAccounts(); + + const errorMsg = + error === "slug" + ? "Slug is al in gebruik." + : error === "missing" + ? "Vul alle velden in." + : null; + + return ( +
+
+
+ Rikx Beheer +
+ {session.email} +
{ + "use server"; + await destroySession(); + redirect("/login"); + }} + > + +
+
+
+
+ +
+ {/* Overzicht */} +
+

+ Tenants ({accounts.length}) +

+ {accounts.length === 0 ? ( +

Nog geen tenants aangemaakt.

+ ) : ( +
+ + + + + + + + + + + + + {accounts.map((a) => ( + + + + + + + + + ))} + +
NaamSlugURLGebruikersAangemaakt
{a.name}{a.slug} + + {a.slug}.rikxplatform.nl + + {a.user_count} + {new Date(a.created_at).toLocaleDateString("nl-NL")} + +
+ + +
+
+
+ )} +
+ + {/* Nieuw tenant formulier */} +
+

+ Nieuwe tenant aanmaken +

+ {errorMsg && ( +

{errorMsg}

+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ ); +} diff --git a/lib/platform-db.ts b/lib/platform-db.ts new file mode 100644 index 0000000..7336fde --- /dev/null +++ b/lib/platform-db.ts @@ -0,0 +1,42 @@ +import { DatabaseSync } from "node:sqlite"; + +const DB_PATH = process.env.RIKX_PLATFORM_DB_PATH || "/rikx-platform-data/app.db"; + +let _db: DatabaseSync | null = null; + +export function getPlatformDb(): DatabaseSync { + if (!_db) { + _db = new DatabaseSync(DB_PATH); + _db.exec("PRAGMA journal_mode = WAL"); + _db.exec("PRAGMA foreign_keys = ON"); + } + return _db; +} + +export type PlatformAccount = { + id: number; + name: string; + slug: string; + created_at: string; +}; + +export type PlatformUser = { + id: number; + account_id: number; + email: string; + name: string; + role: string; + created_at: string; +}; + +export function listAccounts(): (PlatformAccount & { user_count: number })[] { + return getPlatformDb() + .prepare( + `SELECT a.*, COUNT(u.id) as user_count + FROM accounts a + LEFT JOIN users u ON u.account_id = a.id + GROUP BY a.id + ORDER BY a.created_at DESC` + ) + .all() as (PlatformAccount & { user_count: number })[]; +}