feat:admin
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
|
"dev": "vite dev",
|
||||||
"dev:bare": "vite dev"
|
"dev:bare": "vite dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { orpc } from "@/utils/orpc";
|
||||||
|
|
||||||
export default function EventRegistrationForm() {
|
export default function EventRegistrationForm() {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -12,9 +15,34 @@ export default function EventRegistrationForm() {
|
|||||||
experience: "",
|
experience: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
...orpc.submitRegistration.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Registratie succesvol!");
|
||||||
|
setFormData({
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
artForm: "",
|
||||||
|
experience: "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Er is iets misgegaan: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Form submitted:", formData);
|
submitMutation.mutate({
|
||||||
|
firstName: formData.firstName,
|
||||||
|
lastName: formData.lastName,
|
||||||
|
email: formData.email,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
artForm: formData.artForm,
|
||||||
|
experience: formData.experience || undefined,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
@@ -57,6 +85,7 @@ export default function EventRegistrationForm() {
|
|||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Jouw voornaam"
|
placeholder="Jouw voornaam"
|
||||||
|
required
|
||||||
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +104,7 @@ export default function EventRegistrationForm() {
|
|||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Jouw achternaam"
|
placeholder="Jouw achternaam"
|
||||||
|
required
|
||||||
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +126,7 @@ export default function EventRegistrationForm() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="jouw@email.nl"
|
placeholder="jouw@email.nl"
|
||||||
|
required
|
||||||
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +165,7 @@ export default function EventRegistrationForm() {
|
|||||||
value={formData.artForm}
|
value={formData.artForm}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Muziek, Theater, Dans, etc."
|
placeholder="Muziek, Theater, Dans, etc."
|
||||||
|
required
|
||||||
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
className="border-white/30 border-b bg-transparent pb-2 font-['Intro',sans-serif] text-lg text-white placeholder:text-white/40 focus:border-white focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,9 +196,10 @@ export default function EventRegistrationForm() {
|
|||||||
<div className="mt-auto flex flex-col items-center gap-4 pt-12">
|
<div className="mt-auto flex flex-col items-center gap-4 pt-12">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-white px-12 py-4 font-['Intro',sans-serif] text-2xl text-[#214e51] transition-all hover:scale-105 hover:bg-gray-100"
|
disabled={submitMutation.isPending}
|
||||||
|
className="bg-white px-12 py-4 font-['Intro',sans-serif] text-2xl text-[#214e51] transition-all hover:scale-105 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Bevestigen
|
{submitMutation.isPending ? "Bezig..." : "Bevestigen"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center font-['Intro',sans-serif] text-sm text-white/60">
|
<p className="text-center font-['Intro',sans-serif] text-sm text-white/60">
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authClient.getSession().then((session) => {
|
||||||
|
const user = session.data?.user as { role?: string } | undefined;
|
||||||
|
setIsAdmin(user?.role === "admin");
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="snap-section relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]">
|
<footer className="snap-section relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -21,6 +38,14 @@ export default function Footer() {
|
|||||||
<a href="/contact" className="transition-colors hover:text-white">
|
<a href="/contact" className="transition-colors hover:text-white">
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
|
{!isLoading && isAdmin && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/40">|</span>
|
||||||
|
<Link to="/admin" className="transition-colors hover:text-white">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 font-['Intro',sans-serif] text-white/50 text-xs">
|
<div className="mt-6 font-['Intro',sans-serif] text-white/50 text-xs">
|
||||||
|
|||||||
@@ -9,10 +9,22 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AdminRoute = AdminRouteImport.update({
|
||||||
|
id: '/admin',
|
||||||
|
path: '/admin',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -31,36 +43,58 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/admin': typeof AdminRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/admin': typeof AdminRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/admin': typeof AdminRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/api/auth/$' | '/api/rpc/$'
|
fullPaths: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/api/auth/$' | '/api/rpc/$'
|
to: '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/api/auth/$' | '/api/rpc/$'
|
id: '__root__' | '/' | '/admin' | '/login' | '/api/auth/$' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AdminRoute: typeof AdminRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/admin': {
|
||||||
|
id: '/admin'
|
||||||
|
path: '/admin'
|
||||||
|
fullPath: '/admin'
|
||||||
|
preLoaderRoute: typeof AdminRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -87,6 +121,8 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AdminRoute: AdminRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
463
apps/web/src/routes/admin.tsx
Normal file
463
apps/web/src/routes/admin.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
Link,
|
||||||
|
redirect,
|
||||||
|
useNavigate,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import { Check, Download, LogOut, Search, Users, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/utils/orpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/admin")({
|
||||||
|
component: AdminPage,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
if (!session.data?.user) {
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
}
|
||||||
|
const user = session.data.user as { role?: string };
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
throw redirect({ to: "/login" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AdminPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [artForm, setArtForm] = useState("");
|
||||||
|
const [fromDate, setFromDate] = useState("");
|
||||||
|
const [toDate, setToDate] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions());
|
||||||
|
|
||||||
|
const registrationsQuery = useQuery(
|
||||||
|
orpc.getRegistrations.queryOptions({
|
||||||
|
input: {
|
||||||
|
search: search || undefined,
|
||||||
|
artForm: artForm || undefined,
|
||||||
|
fromDate: fromDate || undefined,
|
||||||
|
toDate: toDate || undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions());
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
...orpc.exportRegistrations.mutationOptions(),
|
||||||
|
onSuccess: (data: { csv: string; filename: string }) => {
|
||||||
|
const blob = new Blob([data.csv], { type: "text/csv" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = data.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveRequestMutation = useMutation({
|
||||||
|
...orpc.approveAdminRequest.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Admin toegang goedgekeurd");
|
||||||
|
adminRequestsQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Fout: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectRequestMutation = useMutation({
|
||||||
|
...orpc.rejectAdminRequest.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Admin toegang geweigerd");
|
||||||
|
adminRequestsQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Fout: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
navigate({ to: "/" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
exportMutation.mutate(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprove = (requestId: string) => {
|
||||||
|
approveRequestMutation.mutate({ requestId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = (requestId: string) => {
|
||||||
|
rejectRequestMutation.mutate({ requestId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = statsQuery.data;
|
||||||
|
const registrations = registrationsQuery.data?.data ?? [];
|
||||||
|
const pagination = registrationsQuery.data?.pagination;
|
||||||
|
const adminRequests = adminRequestsQuery.data ?? [];
|
||||||
|
const pendingRequests = adminRequests.filter((r) => r.status === "pending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#214e51]">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/" className="text-white hover:opacity-80">
|
||||||
|
← Terug naar website
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-['Intro',sans-serif] text-3xl text-white">
|
||||||
|
Admin Dashboard
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/30 bg-transparent text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Uitloggen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="mx-auto max-w-7xl p-8">
|
||||||
|
{/* Pending Admin Requests */}
|
||||||
|
{pendingRequests.length > 0 && (
|
||||||
|
<Card className="mb-6 border-yellow-500/30 bg-yellow-500/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-xl text-yellow-200">
|
||||||
|
Openstaande Admin Aanvragen ({pendingRequests.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-yellow-200/60">
|
||||||
|
Gebruikers die admin toegang hebben aangevraagd
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pendingRequests.map((request) => (
|
||||||
|
<div
|
||||||
|
key={request.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
{request.userName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
{request.userEmail}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/40 text-xs">
|
||||||
|
Aangevraagd:{" "}
|
||||||
|
{new Date(request.requestedAt).toLocaleDateString(
|
||||||
|
"nl-BE",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleApprove(request.id)}
|
||||||
|
disabled={approveRequestMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Check className="mr-1 h-4 w-4" />
|
||||||
|
Goedkeuren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleReject(request.id)}
|
||||||
|
disabled={rejectRequestMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
Weigeren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
<Card className="border-white/10 bg-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="text-white/60">
|
||||||
|
Totaal inschrijvingen
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
|
||||||
|
{stats?.total ?? 0}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Users className="h-5 w-5 text-white/40" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-white/10 bg-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="text-white/60">
|
||||||
|
Vandaag ingeschreven
|
||||||
|
</CardDescription>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
|
||||||
|
{stats?.today ?? 0}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-sm text-white/40">
|
||||||
|
Nieuwe registraties vandaag
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-white/10 bg-white/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="text-white/60">
|
||||||
|
Per kunstvorm
|
||||||
|
</CardDescription>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{stats?.byArtForm.slice(0, 5).map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.artForm}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-white/80">{item.artForm}</span>
|
||||||
|
<span className="font-['Intro',sans-serif] text-white">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card className="mb-6 border-white/10 bg-white/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-white text-xl">
|
||||||
|
Filters
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="search"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Zoeken
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-white/40" />
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
placeholder="Naam of email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="border-white/20 bg-white/10 pl-10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="artForm"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Kunstvorm
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="artForm"
|
||||||
|
placeholder="Filter op kunstvorm..."
|
||||||
|
value={artForm}
|
||||||
|
onChange={(e) => setArtForm(e.target.value)}
|
||||||
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="fromDate"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Vanaf
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="fromDate"
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
onChange={(e) => setFromDate(e.target.value)}
|
||||||
|
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="toDate"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Tot
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="toDate"
|
||||||
|
type="date"
|
||||||
|
value={toDate}
|
||||||
|
onChange={(e) => setToDate(e.target.value)}
|
||||||
|
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Export Button */}
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<p className="text-white/60">
|
||||||
|
{pagination?.total ?? 0} registraties gevonden
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
className="bg-white text-[#214e51] hover:bg-white/90"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations Table */}
|
||||||
|
<Card className="border-white/10 bg-white/5">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-white/10 border-b">
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Naam
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Telefoon
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Kunstvorm
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Ervaring
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-4 text-left font-medium text-sm text-white/60">
|
||||||
|
Datum
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{registrationsQuery.isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-8 text-center text-white/60"
|
||||||
|
>
|
||||||
|
Laden...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : registrations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-6 py-8 text-center text-white/60"
|
||||||
|
>
|
||||||
|
Geen registraties gevonden
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
registrations.map((reg) => (
|
||||||
|
<tr
|
||||||
|
key={reg.id}
|
||||||
|
className="border-white/5 border-b hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-white">
|
||||||
|
{reg.firstName} {reg.lastName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">{reg.email}</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{reg.phone || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{reg.artForm}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/80">
|
||||||
|
{reg.experience || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-white/60">
|
||||||
|
{new Date(reg.createdAt).toLocaleDateString("nl-BE")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Vorige
|
||||||
|
</Button>
|
||||||
|
<span className="mx-4 text-white">
|
||||||
|
Pagina {page} van {pagination.totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setPage((p) => Math.min(pagination.totalPages, p + 1))
|
||||||
|
}
|
||||||
|
disabled={page === pagination.totalPages}
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Volgende
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
apps/web/src/routes/login.tsx
Normal file
272
apps/web/src/routes/login.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { orpc } from "@/utils/orpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/login")({
|
||||||
|
component: LoginPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isSignup, setIsSignup] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const sessionQuery = useQuery({
|
||||||
|
queryKey: ["session"],
|
||||||
|
queryFn: () => authClient.getSession(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
const result = await authClient.signIn.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Succesvol ingelogd!");
|
||||||
|
// Check role and redirect
|
||||||
|
authClient.getSession().then((session) => {
|
||||||
|
const user = session.data?.user as { role?: string } | undefined;
|
||||||
|
if (user?.role === "admin") {
|
||||||
|
navigate({ to: "/admin" });
|
||||||
|
} else {
|
||||||
|
navigate({ to: "/" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Login mislukt: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}) => {
|
||||||
|
const result = await authClient.signUp.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Account aangemaakt! Wacht op goedkeuring van een admin.");
|
||||||
|
setIsSignup(false);
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Registratie mislukt: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestAdminMutation = useMutation({
|
||||||
|
...orpc.requestAdminAccess.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Admin toegang aangevraagd!");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Aanvraag mislukt: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isSignup) {
|
||||||
|
signupMutation.mutate({ email, password, name });
|
||||||
|
} else {
|
||||||
|
loginMutation.mutate({ email, password });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestAdmin = () => {
|
||||||
|
requestAdminMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If already logged in as admin, redirect
|
||||||
|
if (sessionQuery.data?.data?.user) {
|
||||||
|
const user = sessionQuery.data.data.user as { role?: string };
|
||||||
|
if (user.role === "admin") {
|
||||||
|
navigate({ to: "/admin" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = !!sessionQuery.data?.data?.user;
|
||||||
|
const user = sessionQuery.data?.data?.user as
|
||||||
|
| { role?: string; name?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#214e51] px-4">
|
||||||
|
<Card className="w-full max-w-md border-white/10 bg-white/5">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
|
||||||
|
{isLoggedIn
|
||||||
|
? "Admin Toegang"
|
||||||
|
: isSignup
|
||||||
|
? "Account Aanmaken"
|
||||||
|
: "Inloggen"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-white/60">
|
||||||
|
{isLoggedIn
|
||||||
|
? `Welkom, ${user?.name}`
|
||||||
|
: isSignup
|
||||||
|
? "Maak een account aan om toegang te krijgen"
|
||||||
|
: "Log in om toegang te krijgen tot het admin dashboard"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
|
||||||
|
<p className="text-center text-yellow-200">
|
||||||
|
Je bent ingelogd maar hebt geen admin toegang.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleRequestAdmin}
|
||||||
|
disabled={requestAdminMutation.isPending}
|
||||||
|
className="w-full bg-white text-[#214e51] hover:bg-white/90"
|
||||||
|
>
|
||||||
|
{requestAdminMutation.isPending
|
||||||
|
? "Bezig..."
|
||||||
|
: "Vraag Admin Toegang Aan"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => authClient.signOut()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Uitloggen
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="block text-center text-sm text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
← Terug naar website
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{isSignup && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Naam
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Jouw naam"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="jouw@email.be"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="mb-2 block text-sm text-white/60"
|
||||||
|
>
|
||||||
|
Wachtwoord
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loginMutation.isPending || signupMutation.isPending}
|
||||||
|
className="w-full bg-white text-[#214e51] hover:bg-white/90"
|
||||||
|
>
|
||||||
|
{loginMutation.isPending || signupMutation.isPending
|
||||||
|
? "Bezig..."
|
||||||
|
: isSignup
|
||||||
|
? "Account Aanmaken"
|
||||||
|
: "Inloggen"}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-2 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsSignup(!isSignup)}
|
||||||
|
className="text-sm text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
{isSignup
|
||||||
|
? "Al een account? Log in"
|
||||||
|
: "Nog geen account? Registreer"}
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="text-sm text-white/60 hover:text-white">
|
||||||
|
← Terug naar website
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
bun.lock
1
bun.lock
@@ -88,6 +88,7 @@
|
|||||||
"@orpc/server": "catalog:",
|
"@orpc/server": "catalog:",
|
||||||
"@orpc/zod": "catalog:",
|
"@orpc/zod": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@orpc/server": "catalog:",
|
"@orpc/server": "catalog:",
|
||||||
"@orpc/zod": "catalog:",
|
"@orpc/zod": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -18,3 +18,19 @@ const requireAuth = o.middleware(async ({ context, next }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const protectedProcedure = publicProcedure.use(requireAuth);
|
export const protectedProcedure = publicProcedure.use(requireAuth);
|
||||||
|
|
||||||
|
const requireAdmin = o.middleware(async ({ context, next }) => {
|
||||||
|
if (!context.session?.user) {
|
||||||
|
throw new ORPCError("UNAUTHORIZED");
|
||||||
|
}
|
||||||
|
if (context.session.user.role !== "admin") {
|
||||||
|
throw new ORPCError("FORBIDDEN");
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
session: context.session,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminProcedure = protectedProcedure.use(requireAdmin);
|
||||||
|
|||||||
@@ -1,17 +1,317 @@
|
|||||||
|
import { db } from "@kk/db";
|
||||||
|
import { adminRequest, registration } from "@kk/db/schema";
|
||||||
|
import { user } from "@kk/db/schema/auth";
|
||||||
import type { RouterClient } from "@orpc/server";
|
import type { RouterClient } from "@orpc/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { and, count, desc, eq, gte, like, lte } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
||||||
|
|
||||||
import { protectedProcedure, publicProcedure } from "../index";
|
const submitRegistrationSchema = z.object({
|
||||||
|
firstName: z.string().min(1),
|
||||||
|
lastName: z.string().min(1),
|
||||||
|
email: z.string().email(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
artForm: z.string().min(1),
|
||||||
|
experience: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRegistrationsSchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
artForm: z.string().optional(),
|
||||||
|
fromDate: z.string().datetime().optional(),
|
||||||
|
toDate: z.string().datetime().optional(),
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
export const appRouter = {
|
export const appRouter = {
|
||||||
healthCheck: publicProcedure.handler(() => {
|
healthCheck: publicProcedure.handler(() => {
|
||||||
return "OK";
|
return "OK";
|
||||||
}),
|
}),
|
||||||
|
|
||||||
privateData: protectedProcedure.handler(({ context }) => {
|
privateData: protectedProcedure.handler(({ context }) => {
|
||||||
return {
|
return {
|
||||||
message: "This is private",
|
message: "This is private",
|
||||||
user: context.session?.user,
|
user: context.session?.user,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
submitRegistration: publicProcedure
|
||||||
|
.input(submitRegistrationSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const result = await db.insert(registration).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
email: input.email,
|
||||||
|
phone: input.phone || null,
|
||||||
|
artForm: input.artForm,
|
||||||
|
experience: input.experience || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, id: result.lastInsertRowid };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRegistrations: adminProcedure
|
||||||
|
.input(getRegistrationsSchema)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (input.search) {
|
||||||
|
const searchTerm = `%${input.search}%`;
|
||||||
|
conditions.push(
|
||||||
|
and(
|
||||||
|
like(registration.firstName, searchTerm),
|
||||||
|
like(registration.lastName, searchTerm),
|
||||||
|
like(registration.email, searchTerm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.artForm) {
|
||||||
|
conditions.push(eq(registration.artForm, input.artForm));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.fromDate) {
|
||||||
|
conditions.push(gte(registration.createdAt, new Date(input.fromDate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.toDate) {
|
||||||
|
conditions.push(lte(registration.createdAt, new Date(input.toDate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const [data, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(registration)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(desc(registration.createdAt))
|
||||||
|
.limit(input.pageSize)
|
||||||
|
.offset((input.page - 1) * input.pageSize),
|
||||||
|
db.select({ count: count() }).from(registration).where(whereClause),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
page: input.page,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / input.pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRegistrationStats: adminProcedure.handler(async () => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [totalResult, todayResult, artFormResult] = await Promise.all([
|
||||||
|
db.select({ count: count() }).from(registration),
|
||||||
|
db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(registration)
|
||||||
|
.where(gte(registration.createdAt, today)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
artForm: registration.artForm,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(registration)
|
||||||
|
.groupBy(registration.artForm),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalResult[0]?.count ?? 0,
|
||||||
|
today: todayResult[0]?.count ?? 0,
|
||||||
|
byArtForm: artFormResult.map((r) => ({
|
||||||
|
artForm: r.artForm,
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
exportRegistrations: adminProcedure.handler(async () => {
|
||||||
|
const data = await db
|
||||||
|
.select()
|
||||||
|
.from(registration)
|
||||||
|
.orderBy(desc(registration.createdAt));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
"ID",
|
||||||
|
"First Name",
|
||||||
|
"Last Name",
|
||||||
|
"Email",
|
||||||
|
"Phone",
|
||||||
|
"Art Form",
|
||||||
|
"Experience",
|
||||||
|
"Created At",
|
||||||
|
];
|
||||||
|
const rows = data.map((r) => [
|
||||||
|
r.id,
|
||||||
|
r.firstName,
|
||||||
|
r.lastName,
|
||||||
|
r.email,
|
||||||
|
r.phone || "",
|
||||||
|
r.artForm,
|
||||||
|
r.experience || "",
|
||||||
|
r.createdAt.toISOString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(","),
|
||||||
|
...rows.map((row) =>
|
||||||
|
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
csv: csvContent,
|
||||||
|
filename: `registrations-${new Date().toISOString().split("T")[0]}.csv`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin Request Procedures
|
||||||
|
requestAdminAccess: protectedProcedure.handler(async ({ context }) => {
|
||||||
|
const userId = context.session.user.id;
|
||||||
|
|
||||||
|
// Check if user is already an admin
|
||||||
|
if (context.session.user.role === "admin") {
|
||||||
|
return { success: false, message: "Je bent al een admin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if request already exists
|
||||||
|
const existingRequest = await db
|
||||||
|
.select()
|
||||||
|
.from(adminRequest)
|
||||||
|
.where(eq(adminRequest.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingRequest.length > 0) {
|
||||||
|
if (existingRequest[0].status === "pending") {
|
||||||
|
return { success: false, message: "Je hebt al een aanvraag openstaan" };
|
||||||
|
}
|
||||||
|
if (existingRequest[0].status === "approved") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (existingRequest[0].status === "rejected") {
|
||||||
|
// Allow re-requesting if previously rejected
|
||||||
|
await db
|
||||||
|
.update(adminRequest)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
requestedAt: new Date(),
|
||||||
|
reviewedAt: null,
|
||||||
|
reviewedBy: null,
|
||||||
|
})
|
||||||
|
.where(eq(adminRequest.userId, userId));
|
||||||
|
return { success: true, message: "Nieuwe aanvraag ingediend" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request
|
||||||
|
await db.insert(adminRequest).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: "Admin toegang aangevraagd" };
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAdminRequests: adminProcedure.handler(async () => {
|
||||||
|
const requests = await db
|
||||||
|
.select({
|
||||||
|
id: adminRequest.id,
|
||||||
|
userId: adminRequest.userId,
|
||||||
|
status: adminRequest.status,
|
||||||
|
requestedAt: adminRequest.requestedAt,
|
||||||
|
reviewedAt: adminRequest.reviewedAt,
|
||||||
|
reviewedBy: adminRequest.reviewedBy,
|
||||||
|
userName: user.name,
|
||||||
|
userEmail: user.email,
|
||||||
|
})
|
||||||
|
.from(adminRequest)
|
||||||
|
.leftJoin(user, eq(adminRequest.userId, user.id))
|
||||||
|
.orderBy(desc(adminRequest.requestedAt));
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}),
|
||||||
|
|
||||||
|
approveAdminRequest: adminProcedure
|
||||||
|
.input(z.object({ requestId: z.string() }))
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const request = await db
|
||||||
|
.select()
|
||||||
|
.from(adminRequest)
|
||||||
|
.where(eq(adminRequest.id, input.requestId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (request.length === 0) {
|
||||||
|
throw new Error("Aanvraag niet gevonden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request[0].status !== "pending") {
|
||||||
|
throw new Error("Deze aanvraag is al behandeld");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update request status
|
||||||
|
await db
|
||||||
|
.update(adminRequest)
|
||||||
|
.set({
|
||||||
|
status: "approved",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: context.session.user.id,
|
||||||
|
})
|
||||||
|
.where(eq(adminRequest.id, input.requestId));
|
||||||
|
|
||||||
|
// Update user role to admin
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({ role: "admin" })
|
||||||
|
.where(eq(user.id, request[0].userId));
|
||||||
|
|
||||||
|
return { success: true, message: "Admin toegang goedgekeurd" };
|
||||||
|
}),
|
||||||
|
|
||||||
|
rejectAdminRequest: adminProcedure
|
||||||
|
.input(z.object({ requestId: z.string() }))
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const request = await db
|
||||||
|
.select()
|
||||||
|
.from(adminRequest)
|
||||||
|
.where(eq(adminRequest.id, input.requestId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (request.length === 0) {
|
||||||
|
throw new Error("Aanvraag niet gevonden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request[0].status !== "pending") {
|
||||||
|
throw new Error("Deze aanvraag is al behandeld");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(adminRequest)
|
||||||
|
.set({
|
||||||
|
status: "rejected",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: context.session.user.id,
|
||||||
|
})
|
||||||
|
.where(eq(adminRequest.id, input.requestId));
|
||||||
|
|
||||||
|
return { success: true, message: "Admin toegang geweigerd" };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
export type AppRouterClient = RouterClient<typeof appRouter>;
|
export type AppRouterClient = RouterClient<typeof appRouter>;
|
||||||
|
|||||||
@@ -15,5 +15,13 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [tanstackStartCookies()],
|
plugins: [tanstackStartCookies()],
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -5,11 +5,18 @@
|
|||||||
".": {
|
".": {
|
||||||
"default": "./src/index.ts"
|
"default": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
"./schema": {
|
||||||
|
"default": "./src/schema/index.ts"
|
||||||
|
},
|
||||||
|
"./schema/*": {
|
||||||
|
"default": "./src/schema/*.ts"
|
||||||
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"default": "./src/*.ts"
|
"default": "./src/*.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "turso dev --db-file local.db",
|
||||||
"db:local": "turso dev --db-file local.db",
|
"db:local": "turso dev --db-file local.db",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|||||||
23
packages/db/src/schema/admin-requests.ts
Normal file
23
packages/db/src/schema/admin-requests.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const adminRequest = sqliteTable(
|
||||||
|
"admin_request",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id").notNull().unique(),
|
||||||
|
status: text("status").default("pending").notNull(), // pending, approved, rejected
|
||||||
|
requestedAt: integer("requested_at", { mode: "timestamp_ms" })
|
||||||
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
|
.notNull(),
|
||||||
|
reviewedAt: integer("reviewed_at", { mode: "timestamp_ms" }),
|
||||||
|
reviewedBy: text("reviewed_by"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("admin_request_userId_idx").on(table.userId),
|
||||||
|
index("admin_request_status_idx").on(table.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AdminRequest = typeof adminRequest.$inferSelect;
|
||||||
|
export type NewAdminRequest = typeof adminRequest.$inferInsert;
|
||||||
@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
|
|||||||
.default(false)
|
.default(false)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
role: text("role").default("user").notNull(),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from "./admin-requests";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./registrations";
|
||||||
|
|||||||
23
packages/db/src/schema/registrations.ts
Normal file
23
packages/db/src/schema/registrations.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const registration = sqliteTable(
|
||||||
|
"registration",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
firstName: text("first_name").notNull(),
|
||||||
|
lastName: text("last_name").notNull(),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
phone: text("phone"),
|
||||||
|
artForm: text("art_form").notNull(),
|
||||||
|
experience: text("experience"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
|
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("registration_email_idx").on(table.email),
|
||||||
|
index("registration_artForm_idx").on(table.artForm),
|
||||||
|
index("registration_createdAt_idx").on(table.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
"db:local": {
|
"db:local": {
|
||||||
"cache": false
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
},
|
},
|
||||||
"deploy": {
|
"deploy": {
|
||||||
"cache": false
|
"cache": false
|
||||||
|
|||||||
Reference in New Issue
Block a user