Jesse Daniel Brown commited on
Commit
4e8eee4
·
1 Parent(s): 11ea8e3

Implement authentication and RBAC

Browse files
middleware.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { auth } from "@/lib/auth";
4
+
5
+ const protectedRoutes = ["/dashboard", "/capture"];
6
+
7
+ export default auth((req) => {
8
+ const { nextUrl } = req;
9
+ const isProtected = protectedRoutes.some((path) =>
10
+ nextUrl.pathname.startsWith(path)
11
+ );
12
+
13
+ if (isProtected && !req.auth) {
14
+ const loginUrl = new URL("/login", nextUrl.origin);
15
+ const callbackUrl = `${nextUrl.pathname}${nextUrl.search}`;
16
+ loginUrl.searchParams.set("callbackUrl", callbackUrl);
17
+ return NextResponse.redirect(loginUrl);
18
+ }
19
+
20
+ return NextResponse.next();
21
+ });
22
+
23
+ export const config = {
24
+ matcher: ["/dashboard/:path*", "/capture/:path*"],
25
+ };
package.json CHANGED
@@ -20,10 +20,13 @@
20
  "@radix-ui/react-label": "^2.1.0",
21
  "@radix-ui/react-select": "^2.1.4",
22
  "@radix-ui/react-slot": "^1.1.2",
 
23
  "@prisma/client": "^5.20.0",
 
24
  "class-variance-authority": "^0.7.0",
25
  "clsx": "^2.1.1",
26
  "lucide-react": "^0.469.0",
 
27
  "next": "15.1.2",
28
  "react": "19.0.0-rc.1",
29
  "react-dom": "19.0.0-rc.1",
 
20
  "@radix-ui/react-label": "^2.1.0",
21
  "@radix-ui/react-select": "^2.1.4",
22
  "@radix-ui/react-slot": "^1.1.2",
23
+ "@auth/prisma-adapter": "^1.0.13",
24
  "@prisma/client": "^5.20.0",
25
+ "bcryptjs": "^2.4.3",
26
  "class-variance-authority": "^0.7.0",
27
  "clsx": "^2.1.1",
28
  "lucide-react": "^0.469.0",
29
+ "next-auth": "^5.0.0-beta.25",
30
  "next": "15.1.2",
31
  "react": "19.0.0-rc.1",
32
  "react-dom": "19.0.0-rc.1",
src/app/api/auth/[...nextauth]/route.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export { handlers as GET, handlers as POST } from "@/lib/auth";
src/app/api/auth/register/route.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import bcrypt from "bcryptjs";
3
+ import { Role } from "@prisma/client";
4
+ import { z } from "zod";
5
+
6
+ import prisma from "@/lib/db";
7
+
8
+ const registerSchema = z.object({
9
+ email: z.string().email({ message: "E-mail inválido." }),
10
+ password: z
11
+ .string()
12
+ .min(8, { message: "A senha deve ter pelo menos 8 caracteres." })
13
+ .max(64, { message: "A senha deve ter no máximo 64 caracteres." }),
14
+ orgName: z
15
+ .string()
16
+ .min(2, { message: "Informe o nome da organização." })
17
+ .max(120, { message: "Nome da organização muito longo." }),
18
+ });
19
+
20
+ export async function POST(request: Request) {
21
+ try {
22
+ const body = await request.json();
23
+ const { email, password, orgName } = registerSchema.parse(body);
24
+
25
+ const existingUser = await prisma.user.findUnique({ where: { email } });
26
+
27
+ if (existingUser) {
28
+ return NextResponse.json(
29
+ { error: "E-mail já registrado." },
30
+ { status: 400 }
31
+ );
32
+ }
33
+
34
+ const passwordHash = await bcrypt.hash(password, 12);
35
+
36
+ const organization = await prisma.organization.create({
37
+ data: { name: orgName },
38
+ });
39
+
40
+ await prisma.user.create({
41
+ data: {
42
+ email,
43
+ passwordHash,
44
+ memberships: {
45
+ create: {
46
+ orgId: organization.id,
47
+ role: Role.ADMIN,
48
+ },
49
+ },
50
+ },
51
+ });
52
+
53
+ return NextResponse.json({ message: "Conta criada com sucesso." }, { status: 201 });
54
+ } catch (error) {
55
+ if (error instanceof z.ZodError) {
56
+ const message = error.issues[0]?.message ?? "Dados inválidos.";
57
+ return NextResponse.json({ error: message }, { status: 400 });
58
+ }
59
+
60
+ console.error("Erro ao registrar usuário", error);
61
+ return NextResponse.json(
62
+ { error: "Não foi possível criar a conta agora." },
63
+ { status: 500 }
64
+ );
65
+ }
66
+ }
src/app/dashboard/page.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+
3
+ import { auth } from "@/lib/auth";
4
+
5
+ export default async function DashboardPage() {
6
+ const session = await auth();
7
+
8
+ if (!session?.user) {
9
+ redirect("/login");
10
+ }
11
+
12
+ return (
13
+ <main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-12">
14
+ <header className="space-y-2">
15
+ <p className="text-sm text-muted-foreground">Painel</p>
16
+ <h1 className="text-3xl font-bold">Bem-vindo(a), {session.user.email}</h1>
17
+ <p className="text-muted-foreground">
18
+ Use este espaço para acompanhar planos, gravações e atividades geradas.
19
+ </p>
20
+ </header>
21
+ <section className="rounded-lg border bg-card p-6 shadow-sm">
22
+ <p className="text-sm text-muted-foreground">
23
+ Em breve você verá aqui os indicadores da sua organização {"("}
24
+ {session.user.orgId ?? "sem organização"}
25
+ {")"}. Perfil atual: {session.user.role ?? "sem papel"}.
26
+ </p>
27
+ </section>
28
+ </main>
29
+ );
30
+ }
src/app/login/page.tsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { FormEvent, useState, useTransition } from "react";
6
+ import { signIn } from "next-auth/react";
7
+
8
+ import { Button } from "@/components/ui/button";
9
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+
13
+ export default function LoginPage() {
14
+ const router = useRouter();
15
+ const searchParams = useSearchParams();
16
+ const [error, setError] = useState<string | null>(null);
17
+ const [isPending, startTransition] = useTransition();
18
+
19
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
20
+ event.preventDefault();
21
+ setError(null);
22
+
23
+ const formData = new FormData(event.currentTarget);
24
+ const email = (formData.get("email") as string) ?? "";
25
+ const password = (formData.get("password") as string) ?? "";
26
+ const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
27
+
28
+ startTransition(async () => {
29
+ const result = await signIn("credentials", {
30
+ email,
31
+ password,
32
+ redirect: false,
33
+ callbackUrl,
34
+ });
35
+
36
+ if (result?.error) {
37
+ setError("Não foi possível entrar. Confira suas credenciais.");
38
+ return;
39
+ }
40
+
41
+ router.push(result?.url ?? "/dashboard");
42
+ });
43
+ };
44
+
45
+ return (
46
+ <main className="mx-auto flex min-h-screen w-full max-w-lg items-center px-4 py-12">
47
+ <Card className="w-full">
48
+ <CardHeader>
49
+ <CardTitle>Entrar</CardTitle>
50
+ <CardDescription>
51
+ Use seu e-mail e senha para acessar o painel da organização.
52
+ </CardDescription>
53
+ </CardHeader>
54
+ <CardContent className="space-y-4">
55
+ <form className="space-y-4" onSubmit={onSubmit}>
56
+ <div className="space-y-2">
57
+ <Label htmlFor="email">E-mail</Label>
58
+ <Input
59
+ id="email"
60
+ name="email"
61
+ type="email"
62
+ autoComplete="email"
63
+ required
64
+ disabled={isPending}
65
+ />
66
+ </div>
67
+ <div className="space-y-2">
68
+ <Label htmlFor="password">Senha</Label>
69
+ <Input
70
+ id="password"
71
+ name="password"
72
+ type="password"
73
+ autoComplete="current-password"
74
+ required
75
+ disabled={isPending}
76
+ />
77
+ </div>
78
+ {error ? <p className="text-sm text-destructive">{error}</p> : null}
79
+ <Button className="w-full" type="submit" disabled={isPending}>
80
+ {isPending ? "Entrando..." : "Entrar"}
81
+ </Button>
82
+ </form>
83
+ <p className="text-sm text-muted-foreground">
84
+ Ainda não tem conta? {" "}
85
+ <Link className="underline" href="/register">
86
+ Cadastre-se
87
+ </Link>
88
+ </p>
89
+ </CardContent>
90
+ </Card>
91
+ </main>
92
+ );
93
+ }
src/app/register/page.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { FormEvent, useState, useTransition } from "react";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Label } from "@/components/ui/label";
11
+
12
+ export default function RegisterPage() {
13
+ const router = useRouter();
14
+ const [message, setMessage] = useState<string | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [isPending, startTransition] = useTransition();
17
+
18
+ const onSubmit = (event: FormEvent<HTMLFormElement>) => {
19
+ event.preventDefault();
20
+ setMessage(null);
21
+ setError(null);
22
+
23
+ const formData = new FormData(event.currentTarget);
24
+ const orgName = (formData.get("orgName") as string) ?? "";
25
+ const email = (formData.get("email") as string) ?? "";
26
+ const password = (formData.get("password") as string) ?? "";
27
+
28
+ startTransition(async () => {
29
+ const response = await fetch("/api/auth/register", {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ orgName, email, password }),
33
+ });
34
+
35
+ const data = (await response.json()) as { message?: string; error?: string };
36
+
37
+ if (!response.ok) {
38
+ setError(data.error ?? "Não foi possível criar sua conta.");
39
+ return;
40
+ }
41
+
42
+ setMessage(data.message ?? "Conta criada com sucesso.");
43
+ setTimeout(() => router.push("/login"), 500);
44
+ });
45
+ };
46
+
47
+ return (
48
+ <main className="mx-auto flex min-h-screen w-full max-w-lg items-center px-4 py-12">
49
+ <Card className="w-full">
50
+ <CardHeader>
51
+ <CardTitle>Criar conta</CardTitle>
52
+ <CardDescription>
53
+ Cadastre sua organização e receba acesso ao painel administrativo.
54
+ </CardDescription>
55
+ </CardHeader>
56
+ <CardContent className="space-y-4">
57
+ <form className="space-y-4" onSubmit={onSubmit}>
58
+ <div className="space-y-2">
59
+ <Label htmlFor="orgName">Organização</Label>
60
+ <Input
61
+ id="orgName"
62
+ name="orgName"
63
+ placeholder="Escola, centro ou grupo"
64
+ required
65
+ disabled={isPending}
66
+ />
67
+ </div>
68
+ <div className="space-y-2">
69
+ <Label htmlFor="email">E-mail</Label>
70
+ <Input
71
+ id="email"
72
+ name="email"
73
+ type="email"
74
+ autoComplete="email"
75
+ required
76
+ disabled={isPending}
77
+ />
78
+ </div>
79
+ <div className="space-y-2">
80
+ <Label htmlFor="password">Senha</Label>
81
+ <Input
82
+ id="password"
83
+ name="password"
84
+ type="password"
85
+ autoComplete="new-password"
86
+ minLength={8}
87
+ maxLength={64}
88
+ required
89
+ disabled={isPending}
90
+ />
91
+ </div>
92
+ {error ? <p className="text-sm text-destructive">{error}</p> : null}
93
+ {message ? <p className="text-sm text-green-600">{message}</p> : null}
94
+ <Button className="w-full" type="submit" disabled={isPending}>
95
+ {isPending ? "Criando conta..." : "Registrar"}
96
+ </Button>
97
+ </form>
98
+ <p className="text-sm text-muted-foreground">
99
+ Já tem acesso? {" "}
100
+ <Link className="underline" href="/login">
101
+ Entre aqui
102
+ </Link>
103
+ </p>
104
+ </CardContent>
105
+ </Card>
106
+ </main>
107
+ );
108
+ }
src/lib/auth.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextAuthConfig } from "next-auth";
2
+ import NextAuth from "next-auth";
3
+ import Credentials from "next-auth/providers/credentials";
4
+ import { PrismaAdapter } from "@auth/prisma-adapter";
5
+ import bcrypt from "bcryptjs";
6
+ import { Role } from "@prisma/client";
7
+
8
+ import prisma from "@/lib/db";
9
+
10
+ export const authOptions = {
11
+ adapter: PrismaAdapter(prisma),
12
+ session: { strategy: "jwt" },
13
+ pages: { signIn: "/login" },
14
+ providers: [
15
+ Credentials({
16
+ name: "Credenciais",
17
+ credentials: {
18
+ email: { label: "E-mail", type: "email" },
19
+ password: { label: "Senha", type: "password" },
20
+ },
21
+ async authorize(credentials) {
22
+ if (!credentials?.email || !credentials?.password) {
23
+ return null;
24
+ }
25
+
26
+ const user = await prisma.user.findUnique({
27
+ where: { email: credentials.email },
28
+ });
29
+
30
+ if (!user) {
31
+ return null;
32
+ }
33
+
34
+ const passwordMatch = await bcrypt.compare(
35
+ credentials.password,
36
+ user.passwordHash
37
+ );
38
+
39
+ if (!passwordMatch) {
40
+ return null;
41
+ }
42
+
43
+ const membership = await prisma.membership.findFirst({
44
+ where: { userId: user.id },
45
+ orderBy: { createdAt: "asc" },
46
+ });
47
+
48
+ return {
49
+ id: user.id,
50
+ email: user.email,
51
+ orgId: membership?.orgId ?? null,
52
+ role: membership?.role ?? null,
53
+ } satisfies {
54
+ id: string;
55
+ email: string;
56
+ orgId: string | null;
57
+ role: Role | null;
58
+ };
59
+ },
60
+ }),
61
+ ],
62
+ callbacks: {
63
+ async jwt({ token, user }) {
64
+ if (user) {
65
+ token.userId = user.id;
66
+ token.orgId = (user as { orgId?: string | null }).orgId ?? null;
67
+ token.role = (user as { role?: Role | null }).role ?? null;
68
+ }
69
+
70
+ if (token.userId && (!token.orgId || !token.role)) {
71
+ const membership = await prisma.membership.findFirst({
72
+ where: { userId: token.userId as string },
73
+ orderBy: { createdAt: "asc" },
74
+ });
75
+
76
+ token.orgId = membership?.orgId ?? null;
77
+ token.role = membership?.role ?? null;
78
+ }
79
+
80
+ return token;
81
+ },
82
+ async session({ session, token }) {
83
+ if (session.user) {
84
+ session.user.id = token.userId as string;
85
+ session.user.orgId = (token.orgId as string | null) ?? null;
86
+ session.user.role = (token.role as Role | null) ?? null;
87
+ }
88
+
89
+ return session;
90
+ },
91
+ },
92
+ secret: process.env.AUTH_SECRET,
93
+ trustHost: true,
94
+ } satisfies NextAuthConfig;
95
+
96
+ export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
src/lib/rbac.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Role } from "@prisma/client";
2
+ import { Session } from "next-auth";
3
+
4
+ export function requireRole(session: Session | null, roles: Role[]) {
5
+ if (!session?.user?.role) {
6
+ throw new Error("Usuário sem sessão ativa.");
7
+ }
8
+
9
+ if (!roles.includes(session.user.role)) {
10
+ throw new Error("Acesso negado para este perfil.");
11
+ }
12
+
13
+ return true;
14
+ }
src/types/next-auth.d.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Role } from "@prisma/client";
2
+ import NextAuth from "next-auth";
3
+
4
+ declare module "next-auth" {
5
+ interface Session {
6
+ user?: {
7
+ id: string;
8
+ email?: string | null;
9
+ orgId: string | null;
10
+ role: Role | null;
11
+ };
12
+ }
13
+
14
+ interface User {
15
+ id: string;
16
+ email?: string | null;
17
+ orgId?: string | null;
18
+ role?: Role | null;
19
+ }
20
+ }
21
+
22
+ declare module "next-auth/jwt" {
23
+ interface JWT {
24
+ userId?: string;
25
+ orgId?: string | null;
26
+ role?: Role | null;
27
+ }
28
+ }