Jesse Daniel Brown commited on
Commit ·
4e8eee4
1
Parent(s): 11ea8e3
Implement authentication and RBAC
Browse files- middleware.ts +25 -0
- package.json +3 -0
- src/app/api/auth/[...nextauth]/route.ts +1 -0
- src/app/api/auth/register/route.ts +66 -0
- src/app/dashboard/page.tsx +30 -0
- src/app/login/page.tsx +93 -0
- src/app/register/page.tsx +108 -0
- src/lib/auth.ts +96 -0
- src/lib/rbac.ts +14 -0
- src/types/next-auth.d.ts +28 -0
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 |
+
}
|