🚀 Initial commit - PDIMaker v1.0.0

Sistema completo de gestão de PDI com:
- Autenticação com email/senha e Google OAuth
- Workspaces privados isolados
- Sistema de convites com código único
- Interface profissional com Next.js 14
- Backend NestJS com PostgreSQL
- Docker com Nginx e SSL

Desenvolvido por Sergio Correa
This commit is contained in:
2025-11-19 02:09:04 +00:00
commit 0524656198
58 changed files with 6660 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth"
import { authOptions } from "@/lib/auth/config"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,15 @@
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
const response = NextResponse.redirect(new URL("/login", request.url))
// Limpar todos os cookies de autenticação
response.cookies.delete("next-auth.session-token")
response.cookies.delete("__Secure-next-auth.session-token")
response.cookies.delete("next-auth.csrf-token")
response.cookies.delete("__Host-next-auth.csrf-token")
return response
}

View File

@@ -0,0 +1,51 @@
// app/api/auth/register/route.ts
import { NextRequest, NextResponse } from "next/server"
import { createUser } from "@/lib/auth/credentials"
export async function POST(request: NextRequest) {
try {
const { email, password, name } = await request.json()
// Validações
if (!email || !password || !name) {
return NextResponse.json(
{ error: "Todos os campos são obrigatórios" },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: "A senha deve ter pelo menos 6 caracteres" },
{ status: 400 }
)
}
// Criar usuário
const user = await createUser(email, password, name)
return NextResponse.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name
}
})
} catch (error: any) {
console.error("Erro ao registrar:", error)
if (error.message === "Email já cadastrado") {
return NextResponse.json(
{ error: "Este email já está cadastrado" },
{ status: 400 }
)
}
return NextResponse.json(
{ error: "Erro ao criar conta" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,84 @@
// app/api/invites/accept/route.ts
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
import { prisma } from "@/lib/prisma"
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 })
}
const { inviteCode } = await request.json()
if (!inviteCode) {
return NextResponse.json({ error: "Código de convite inválido" }, { status: 400 })
}
// Buscar convite
const invite = await prisma.invite.findUnique({
where: { token: inviteCode }
})
if (!invite) {
return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 })
}
// Buscar workspace
const workspace = invite.workspaceId
? await prisma.workspace.findUnique({ where: { id: invite.workspaceId } })
: null
if (!workspace) {
return NextResponse.json({ error: "Workspace não encontrado" }, { status: 404 })
}
// Verificar se está expirado
if (invite.expiresAt < new Date()) {
return NextResponse.json({ error: "Convite expirado" }, { status: 400 })
}
// Verificar se já foi aceito
if (invite.status !== "PENDING") {
return NextResponse.json({ error: "Convite já foi utilizado" }, { status: 400 })
}
// Verificar se o email confere
if (invite.email !== session.user.email) {
return NextResponse.json({ error: "Este convite não é para você" }, { status: 403 })
}
// Atualizar convite
await prisma.invite.update({
where: { id: invite.id },
data: {
status: "ACCEPTED",
acceptedAt: new Date()
}
})
// Ativar workspace
await prisma.workspace.update({
where: { id: invite.workspaceId! },
data: { status: "ACTIVE" }
})
return NextResponse.json({
success: true,
workspace: {
id: workspace.id,
slug: workspace.slug
}
})
} catch (error: any) {
console.error("Erro ao aceitar convite:", error)
return NextResponse.json(
{ error: "Erro ao aceitar convite" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,97 @@
// app/api/workspaces/create/route.ts
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
import { prisma } from "@/lib/prisma"
import { generateInviteCode, generateWorkspaceSlug } from "@/lib/utils/invite-code"
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || !session.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 })
}
const { email, inviteRole } = await request.json()
console.log("Criando workspace:", { email, inviteRole, userId: session.user.id })
// Validar email
if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Email inválido" }, { status: 400 })
}
// Buscar ou criar o usuário convidado
let invitedUser = await prisma.user.findUnique({ where: { email } })
if (!invitedUser) {
// Criar registro temporário do usuário convidado
invitedUser = await prisma.user.create({
data: {
email,
name: email.split("@")[0],
role: inviteRole === "MANAGER" ? "MANAGER" : "EMPLOYEE"
}
})
}
// Determinar quem é employee e quem é manager
const employeeId = inviteRole === "MANAGER" ? session.user.id : invitedUser.id
const managerId = inviteRole === "MANAGER" ? invitedUser.id : session.user.id
// Buscar nomes para o slug
const employee = await prisma.user.findUnique({ where: { id: employeeId } })
const manager = await prisma.user.findUnique({ where: { id: managerId } })
if (!employee || !manager) {
return NextResponse.json({ error: "Erro ao buscar usuários" }, { status: 400 })
}
// Gerar slug único
const slug = generateWorkspaceSlug(employee.name, manager.name)
// Criar workspace
const workspace = await prisma.workspace.create({
data: {
slug,
employeeId,
managerId,
status: "PENDING_INVITE"
}
})
// Criar convite
const inviteCode = generateInviteCode()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // Expira em 7 dias
await prisma.invite.create({
data: {
email: invitedUser.email,
role: inviteRole === "MANAGER" ? "MANAGER" : "EMPLOYEE",
token: inviteCode,
invitedBy: session.user.id,
workspaceId: workspace.id,
expiresAt
}
})
// TODO: Enviar email com o código de convite
return NextResponse.json({
success: true,
workspace: { id: workspace.id, slug: workspace.slug },
inviteCode
})
} catch (error: any) {
console.error("Erro ao criar workspace:", error)
console.error("Stack:", error.stack)
console.error("Message:", error.message)
return NextResponse.json(
{ error: error.message || "Erro ao criar workspace" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,51 @@
// app/dashboard/page.tsx
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
import { prisma } from "@/lib/prisma"
export default async function DashboardPage() {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
// Buscar workspaces do usuário
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
workspacesAsEmployee: {
where: { status: "ACTIVE" },
include: {
manager: { select: { name: true, avatar: true } }
}
},
workspacesAsManager: {
where: { status: "ACTIVE" },
include: {
employee: { select: { name: true, avatar: true } }
}
}
}
})
// Se tiver apenas 1 workspace, redireciona diretamente
const allWorkspaces = [
...(user?.workspacesAsEmployee || []),
...(user?.workspacesAsManager || [])
]
if (allWorkspaces.length === 1) {
redirect(`/workspace/${allWorkspaces[0].slug}`)
}
// Se não tiver workspaces, redireciona para criar/aceitar convite
if (allWorkspaces.length === 0) {
redirect("/onboarding")
}
// Lista todos os workspaces
redirect("/workspaces")
}

23
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
// app/layout.tsx
import { SessionProvider } from "@/components/SessionProvider"
export const metadata = {
title: 'PDIMaker',
description: 'Plataforma de Desenvolvimento Individual',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
)
}

244
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,244 @@
// app/login/page.tsx
"use client"
import { signIn } from "next-auth/react"
import { useSearchParams, useRouter } from "next/navigation"
import { Suspense, useState } from "react"
import Link from "next/link"
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleCredentialsLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const result = await signIn("credentials", {
email,
password,
redirect: false
})
if (result?.error) {
setError("Email ou senha incorretos")
} else {
router.push(callbackUrl)
}
} catch (err) {
setError("Erro ao fazer login")
} finally {
setLoading(false)
}
}
const handleGoogleLogin = () => {
signIn("google", { callbackUrl })
}
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundImage: "url('/pdimaker-background.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
position: "relative"
}}>
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)"
}} />
<div style={{
position: "relative",
zIndex: 1,
background: "rgba(255, 255, 255, 0.95)",
padding: "3rem",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
maxWidth: "450px",
width: "100%"
}}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem", color: "#333", textAlign: "center" }}>
🚀 PDIMaker
</h1>
<p style={{ color: "#666", marginBottom: "2rem", textAlign: "center" }}>
Plataforma de Desenvolvimento Individual
</p>
<form onSubmit={handleCredentialsLogin}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Senha
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer",
marginBottom: "1rem"
}}
>
{loading ? "Entrando..." : "Entrar"}
</button>
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
<span style={{ color: "#666", fontSize: "0.875rem" }}>
Não tem uma conta?{" "}
</span>
<Link href="/register" style={{ color: "#667eea", fontWeight: "500", fontSize: "0.875rem" }}>
Criar conta
</Link>
</div>
<div style={{
position: "relative",
textAlign: "center",
margin: "1.5rem 0"
}}>
<div style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
height: "1px",
background: "#e2e8f0"
}} />
<span style={{
position: "relative",
background: "rgba(255, 255, 255, 0.95)",
padding: "0 1rem",
color: "#999",
fontSize: "0.875rem"
}}>
ou
</span>
</div>
<button
type="button"
onClick={handleGoogleLogin}
style={{
width: "100%",
padding: "0.75rem",
background: "white",
color: "#333",
border: "1px solid #e2e8f0",
borderRadius: "0.5rem",
fontSize: "1rem",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "500"
}}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continuar com Google
</button>
<div style={{ textAlign: "center", marginTop: "2rem", paddingTop: "1.5rem", borderTop: "1px solid #e2e8f0" }}>
<p style={{ color: "#999", fontSize: "0.75rem", margin: 0 }}>
Powered By{" "}
<a
href="https://sergiocorrea.link"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#667eea", textDecoration: "none", fontWeight: "500" }}
>
Sergio Correa
</a>
</p>
</div>
</form>
</div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={<div>Carregando...</div>}>
<LoginForm />
</Suspense>
)
}

View File

@@ -0,0 +1,197 @@
// app/onboarding/page.tsx
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
export default function OnboardingPage() {
const router = useRouter()
const { data: session } = useSession()
const [activeTab, setActiveTab] = useState<"accept" | "create">("accept")
const [inviteCode, setInviteCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleAcceptInvite = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const response = await fetch("/api/invites/accept", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inviteCode: inviteCode.trim() })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Erro ao aceitar convite")
}
router.push(`/workspace/${data.workspace.slug}`)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem"
}}>
<div style={{
maxWidth: "600px",
width: "100%",
background: "white",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflow: "hidden"
}}>
{/* Header */}
<div style={{ padding: "2rem", textAlign: "center" }}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem" }}>
🚀 Bem-vindo ao PDIMaker
</h1>
<p style={{ color: "#666" }}>
Escolha como deseja começar
</p>
</div>
{/* Tabs */}
<div style={{ display: "flex", borderBottom: "1px solid #e2e8f0" }}>
<button
onClick={() => setActiveTab("accept")}
style={{
flex: 1,
padding: "1rem",
background: activeTab === "accept" ? "white" : "#f7fafc",
border: "none",
borderBottom: activeTab === "accept" ? "3px solid #667eea" : "none",
fontWeight: activeTab === "accept" ? "600" : "normal",
cursor: "pointer",
color: activeTab === "accept" ? "#667eea" : "#666"
}}
>
Tenho um código
</button>
<button
onClick={() => setActiveTab("create")}
style={{
flex: 1,
padding: "1rem",
background: activeTab === "create" ? "white" : "#f7fafc",
border: "none",
borderBottom: activeTab === "create" ? "3px solid #667eea" : "none",
fontWeight: activeTab === "create" ? "600" : "normal",
cursor: "pointer",
color: activeTab === "create" ? "#667eea" : "#666"
}}
>
Criar workspace
</button>
</div>
{/* Content */}
<div style={{ padding: "2rem" }}>
{activeTab === "accept" ? (
<div>
<h2 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Aceitar Convite
</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
Cole o código de convite que você recebeu do seu mentor ou mentorado:
</p>
<form onSubmit={handleAcceptInvite}>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="cmhrtjzk6001jox4z9dzb5al"
required
style={{
width: "100%",
padding: "1rem",
border: "2px solid #e2e8f0",
borderRadius: "0.5rem",
fontSize: "1rem",
fontFamily: "monospace",
marginBottom: "1rem"
}}
/>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "1rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer"
}}
>
{loading ? "Aceitando..." : "Aceitar Convite"}
</button>
</form>
</div>
) : (
<div>
<h2 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Criar Novo Workspace
</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
Crie um workspace e convide seu mentor ou mentorado para colaborar.
</p>
<button
onClick={() => router.push("/workspace/create")}
style={{
width: "100%",
padding: "1rem",
background: "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: "pointer"
}}
>
Criar Workspace
</button>
</div>
)}
</div>
</div>
</div>
)
}

15
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
export default async function Home() {
const session = await getServerSession(authOptions)
// Se estiver autenticado, redireciona para dashboard
if (session) {
redirect("/dashboard")
}
// Se não estiver autenticado, redireciona para login
redirect("/login")
}

View File

@@ -0,0 +1,251 @@
// app/register/page.tsx
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { signIn } from "next-auth/react"
import Link from "next/link"
export default function RegisterPage() {
const router = useRouter()
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: ""
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
// Validações
if (formData.password !== formData.confirmPassword) {
setError("As senhas não coincidem")
setLoading(false)
return
}
if (formData.password.length < 6) {
setError("A senha deve ter pelo menos 6 caracteres")
setLoading(false)
return
}
try {
// Registrar usuário
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Erro ao criar conta")
}
// Fazer login automático
const result = await signIn("credentials", {
email: formData.email,
password: formData.password,
redirect: false
})
if (result?.error) {
setError("Conta criada! Faça login para continuar")
setTimeout(() => router.push("/login"), 2000)
} else {
router.push("/dashboard")
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: "100vh",
backgroundImage: "url('/pdimaker-background.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
position: "relative"
}}>
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)"
}} />
<div style={{
position: "relative",
zIndex: 1,
background: "rgba(255, 255, 255, 0.95)",
padding: "3rem",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
maxWidth: "450px",
width: "100%"
}}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem", textAlign: "center" }}>
🚀 PDIMaker
</h1>
<p style={{ color: "#666", marginBottom: "2rem", textAlign: "center" }}>
Criar nova conta
</p>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Nome completo
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Senha
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
minLength={6}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Confirmar senha
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
minLength={6}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer",
marginBottom: "1rem"
}}
>
{loading ? "Criando conta..." : "Criar conta"}
</button>
<div style={{ textAlign: "center", color: "#666", fontSize: "0.875rem" }}>
tem uma conta?{" "}
<Link href="/login" style={{ color: "#667eea", fontWeight: "500" }}>
Fazer login
</Link>
</div>
<div style={{ textAlign: "center", marginTop: "2rem", paddingTop: "1.5rem", borderTop: "1px solid #e2e8f0" }}>
<p style={{ color: "#999", fontSize: "0.75rem", margin: 0 }}>
Powered By{" "}
<a
href="https://sergiocorrea.link"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#667eea", textDecoration: "none", fontWeight: "500" }}
>
Sergio Correa
</a>
</p>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
// app/unauthorized/page.tsx
import Link from "next/link"
export default function UnauthorizedPage() {
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f7fafc"
}}>
<div style={{
textAlign: "center",
padding: "2rem"
}}>
<h1 style={{ fontSize: "4rem", marginBottom: "1rem" }}>🚫</h1>
<h2 style={{ fontSize: "2rem", marginBottom: "1rem", color: "#333" }}>
Acesso Negado
</h2>
<p style={{ color: "#666", marginBottom: "2rem" }}>
Você não tem permissão para acessar este workspace.
</p>
<Link
href="/"
style={{
padding: "0.75rem 2rem",
background: "#667eea",
color: "white",
textDecoration: "none",
borderRadius: "0.5rem",
display: "inline-block"
}}
>
Voltar para Home
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
// app/workspace/[slug]/page.tsx
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
import { CopyButton } from "@/components/CopyButton"
interface WorkspacePageProps {
params: { slug: string }
}
export default async function WorkspacePage({ params }: WorkspacePageProps) {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
const workspace = await prisma.workspace.findUnique({
where: { slug: params.slug },
include: {
employee: { select: { id: true, name: true, email: true, avatar: true } },
manager: { select: { id: true, name: true, email: true, avatar: true } },
hr: { select: { id: true, name: true, email: true, avatar: true } }
}
})
if (!workspace) {
redirect("/workspaces")
}
// Verificar se o usuário tem acesso
const hasAccess =
workspace.employeeId === session.user.id ||
workspace.managerId === session.user.id ||
workspace.hrId === session.user.id
if (!hasAccess) {
redirect("/unauthorized")
}
// Determinar o papel do usuário
const userRole =
workspace.employeeId === session.user.id ? "employee" :
workspace.managerId === session.user.id ? "manager" : "hr"
// Buscar código de convite ativo (se for gestor)
let inviteCode = null
if (userRole === "manager") {
const activeInvite = await prisma.invite.findFirst({
where: {
workspaceId: workspace.id,
status: "PENDING",
expiresAt: { gt: new Date() }
},
select: { token: true }
})
inviteCode = activeInvite?.token
}
return (
<div style={{ minHeight: "100vh", background: "#f7fafc" }}>
{/* Header */}
<div style={{ background: "#667eea", color: "white", padding: "1.5rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: "bold" }}>Workspace</h1>
<p style={{ opacity: 0.9, fontSize: "0.875rem" }}>
Gerencie sua área de colaboração
</p>
</div>
<Link
href="/workspaces"
style={{
padding: "0.5rem 1rem",
background: "rgba(255,255,255,0.2)",
borderRadius: "0.375rem",
textDecoration: "none",
color: "white"
}}
>
Voltar
</Link>
</div>
</div>
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "2rem" }}>
{/* Código de Convite (apenas para gestores) */}
{userRole === "manager" && inviteCode && (
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)", marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "1rem" }}>
Código de Convite
</h2>
<p style={{ color: "#666", marginBottom: "1rem" }}>
Compartilhe este código com seu mentor para que ele possa se juntar ao workspace:
</p>
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<code style={{
flex: 1,
padding: "1rem",
background: "#f7fafc",
border: "2px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1.25rem",
fontFamily: "monospace",
color: "#667eea"
}}>
{inviteCode}
</code>
<CopyButton text={inviteCode} />
</div>
<div style={{
marginTop: "1rem",
padding: "0.75rem",
background: "#d1fae5",
borderRadius: "0.375rem",
fontSize: "0.875rem",
color: "#047857"
}}>
Mentor conectado! Seu workspace está ativo e pronto para colaboração
</div>
</div>
)}
{/* Membros do Workspace */}
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "1.5rem" }}>
Membros do Workspace
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Funcionário */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#667eea",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.employee.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.employee.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.employee.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#e0e7ff",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#4c51bf",
fontWeight: "500"
}}>
👨💼 Mentorado
</div>
</div>
{/* Gestor */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#10b981",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.manager.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.manager.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.manager.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#d1fae5",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#047857",
fontWeight: "500"
}}>
🎓 Mentor
</div>
</div>
{/* RH (se existir) */}
{workspace.hr && (
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#f59e0b",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.hr.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.hr.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.hr.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#fef3c7",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#b45309",
fontWeight: "500"
}}>
👔 RH
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
// app/workspace/create/page.tsx
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
export default function CreateWorkspacePage() {
const router = useRouter()
const { data: session } = useSession()
const [email, setEmail] = useState("")
const [role, setRole] = useState<"EMPLOYEE" | "MANAGER">("EMPLOYEE")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const response = await fetch("/api/workspaces/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, inviteRole: role })
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Erro ao criar workspace")
}
router.push(`/workspace/${data.workspace.slug}`)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: "100vh", background: "#f7fafc", padding: "2rem" }}>
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h1 style={{ fontSize: "2rem", fontWeight: "bold", marginBottom: "2rem" }}>
Criar Novo Workspace
</h1>
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)" }}>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Você será o:
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as any)}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
>
<option value="MANAGER">Gestor (Mentor)</option>
<option value="EMPLOYEE">Funcionário (Mentorado)</option>
</select>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email do {role === "MANAGER" ? "Funcionário" : "Gestor"}:
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="email@exemplo.com"
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.375rem",
fontSize: "1rem",
fontWeight: "500",
cursor: loading ? "not-allowed" : "pointer"
}}
>
{loading ? "Criando..." : "Criar Workspace e Enviar Convite"}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,172 @@
// app/workspaces/page.tsx
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth/config"
import { prisma } from "@/lib/prisma"
import Link from "next/link"
export default async function WorkspacesPage() {
const session = await getServerSession(authOptions)
if (!session) {
redirect("/login")
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
workspacesAsEmployee: {
where: { status: "ACTIVE" },
include: {
manager: { select: { name: true, avatar: true, email: true } }
}
},
workspacesAsManager: {
where: { status: "ACTIVE" },
include: {
employee: { select: { name: true, avatar: true, email: true } }
}
}
}
})
return (
<div style={{ minHeight: "100vh", background: "#f7fafc", padding: "2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div style={{ marginBottom: "2rem" }}>
<h1 style={{ fontSize: "2rem", fontWeight: "bold", marginBottom: "0.5rem" }}>
Meus Workspaces
</h1>
<p style={{ color: "#666" }}>
Selecione um workspace para acessar
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "1.5rem" }}>
{/* Workspaces como Funcionário */}
{user?.workspacesAsEmployee.map((workspace) => (
<Link
key={workspace.id}
href={`/workspace/${workspace.slug}`}
style={{
background: "white",
padding: "1.5rem",
borderRadius: "0.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
cursor: "pointer"
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#667eea",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.manager.name[0]}
</div>
<div>
<div style={{ fontWeight: "600", fontSize: "1.1rem" }}>
{workspace.manager.name}
</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>
Seu Gestor
</div>
</div>
</div>
<div style={{
padding: "0.5rem",
background: "#e0e7ff",
borderRadius: "0.25rem",
fontSize: "0.875rem",
color: "#4c51bf"
}}>
👤 Você é o Funcionário
</div>
</Link>
))}
{/* Workspaces como Gestor */}
{user?.workspacesAsManager.map((workspace) => (
<Link
key={workspace.id}
href={`/workspace/${workspace.slug}`}
style={{
background: "white",
padding: "1.5rem",
borderRadius: "0.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
cursor: "pointer"
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#10b981",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.employee.name[0]}
</div>
<div>
<div style={{ fontWeight: "600", fontSize: "1.1rem" }}>
{workspace.employee.name}
</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>
Seu Mentorado
</div>
</div>
</div>
<div style={{
padding: "0.5rem",
background: "#d1fae5",
borderRadius: "0.25rem",
fontSize: "0.875rem",
color: "#047857"
}}>
🎓 Você é o Gestor
</div>
</Link>
))}
</div>
{/* Botão para criar novo workspace */}
<div style={{ marginTop: "2rem", textAlign: "center" }}>
<Link
href="/workspace/create"
style={{
display: "inline-block",
padding: "1rem 2rem",
background: "#667eea",
color: "white",
borderRadius: "0.5rem",
textDecoration: "none",
fontWeight: "500"
}}
>
+ Criar Novo Workspace
</Link>
</div>
</div>
</div>
)
}