174 lines
5.6 KiB
TypeScript
174 lines
5.6 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState, useTransition } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
|
|
import { toast } from "sonner"
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import type { RoleOption } from "@/lib/authz"
|
|
|
|
type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
|
|
|
|
type InviteSummary = {
|
|
id: string
|
|
email: string
|
|
name: string | null
|
|
role: RoleOption
|
|
tenantId: string
|
|
status: InviteStatus
|
|
token: string
|
|
expiresAt: string
|
|
}
|
|
|
|
function formatDate(dateIso: string) {
|
|
return new Intl.DateTimeFormat("pt-BR", {
|
|
dateStyle: "long",
|
|
timeStyle: "short",
|
|
}).format(new Date(dateIso))
|
|
}
|
|
|
|
function statusLabel(status: InviteStatus) {
|
|
switch (status) {
|
|
case "pending":
|
|
return "Pendente"
|
|
case "accepted":
|
|
return "Aceito"
|
|
case "revoked":
|
|
return "Revogado"
|
|
case "expired":
|
|
return "Expirado"
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
function statusVariant(status: InviteStatus) {
|
|
if (status === "pending") return "secondary"
|
|
if (status === "accepted") return "default"
|
|
if (status === "revoked") return "destructive"
|
|
return "outline"
|
|
}
|
|
|
|
export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
|
|
const router = useRouter()
|
|
const [name, setName] = useState(invite.name ?? "")
|
|
const [password, setPassword] = useState("")
|
|
const [confirmPassword, setConfirmPassword] = useState("")
|
|
const [isPending, startTransition] = useTransition()
|
|
|
|
const formattedExpiry = useMemo(() => formatDate(invite.expiresAt), [invite.expiresAt])
|
|
const isDisabled = invite.status !== "pending"
|
|
|
|
function validate() {
|
|
if (!password || password.length < 8) {
|
|
toast.error("A senha deve ter pelo menos 8 caracteres")
|
|
return false
|
|
}
|
|
if (password !== confirmPassword) {
|
|
toast.error("As senhas não coincidem")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
if (isDisabled) return
|
|
if (!validate()) return
|
|
|
|
startTransition(async () => {
|
|
try {
|
|
const response = await fetch(`/api/invites/${invite.token}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, password }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}))
|
|
throw new Error(data.error ?? "Não foi possível aceitar o convite")
|
|
}
|
|
|
|
toast.success("Convite aceito! Faça login para começar.")
|
|
router.push(`/login?email=${encodeURIComponent(invite.email)}`)
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Falha ao aceitar convite"
|
|
toast.error(message)
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
|
{statusLabel(invite.status)}
|
|
</Badge>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-neutral-600">
|
|
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
|
|
</p>
|
|
<p className="text-xs text-neutral-500">
|
|
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> • Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
|
|
</p>
|
|
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isDisabled ? (
|
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-600">
|
|
<p>
|
|
Este convite encontra-se <span className="font-semibold text-neutral-900">{statusLabel(invite.status).toLowerCase()}</span>.
|
|
Solicite um novo convite à equipe administradora caso precise de acesso.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="invite-name">Nome completo</Label>
|
|
<Input
|
|
id="invite-name"
|
|
placeholder="Seu nome"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
autoComplete="name"
|
|
disabled={isDisabled}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="invite-password">Defina uma senha</Label>
|
|
<Input
|
|
id="invite-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
placeholder="Mínimo de 8 caracteres"
|
|
autoComplete="new-password"
|
|
disabled={isDisabled}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="invite-password-confirm">Confirme a senha</Label>
|
|
<Input
|
|
id="invite-password-confirm"
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
autoComplete="new-password"
|
|
disabled={isDisabled}
|
|
required
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full" disabled={isDisabled || isPending}>
|
|
{isPending ? "Processando..." : "Ativar acesso"}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|