feat: add user authentication UI to header component

Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-04 02:33:29 -03:00
1 parent e641138887
commit 55eebde0ba
55 files changed
+7353 -7

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useSession } from "@/lib/hooks/use-session";
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
const [emailOrUsername, setEmailOrUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { refreshSession } = useSession();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
emailOrUsername,
password,
}),
});
const data = await response.json();
if (data.success) {
// Atualizar sessão
await refreshSession();
// Fechar diálogo
onOpenChange(false);
// Limpar formulário
setEmailOrUsername("");
setPassword("");
// Recarregar página para atualizar estado
window.location.reload();
} else {
setError(data.error || "Erro ao fazer login");
}
} catch (err) {
setError("Erro ao conectar com o servidor");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Entrar</DialogTitle>
<DialogDescription>
Faça login para acessar sua conta
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="emailOrUsername">Email ou Username</Label>
<Input
id="emailOrUsername"
type="text"
placeholder="[email protected] ou username"
value={emailOrUsername}
onChange={(e) => setEmailOrUsername(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Entrando..." : "Entrar"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+635
View File
@@ -0,0 +1,635 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useSession } from "@/lib/hooks/use-session";
import { useServers } from "@/lib/hooks/use-servers";
import { useTranslations } from "next-intl";
import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters";
import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react";
interface RegisterDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface ServerSelection {
serverName: string;
characterName: string;
isMain: boolean;
}
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const t = useTranslations("auth.register");
const { refreshSession } = useSession();
const { servers, isLoading: serversLoading, error: serversError } = useServers();
// Form fields
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [phone, setPhone] = useState("");
const [selectedCountry, setSelectedCountry] = useState<Country>(DEFAULT_COUNTRY);
const [countrySearch, setCountrySearch] = useState("");
// Servers selection
const [selectedServers, setSelectedServers] = useState<ServerSelection[]>([]);
const [serverSearch, setServerSearch] = useState("");
const [showServerDropdown, setShowServerDropdown] = useState(false);
// UI state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [passwordStrength, setPasswordStrength] = useState<"weak" | "medium" | "strong">("weak");
// Filtered servers based on search
const filteredServers = useMemo(() => {
if (!serverSearch.trim()) return servers;
const searchLower = serverSearch.toLowerCase();
return servers.filter(
(s) =>
s.serverName.toLowerCase().includes(searchLower) &&
!selectedServers.some((sel) => sel.serverName === s.serverName)
);
}, [serverSearch, servers, selectedServers]);
// Update password strength
useEffect(() => {
if (password) {
setPasswordStrength(checkPasswordStrength(password));
} else {
setPasswordStrength("weak");
}
}, [password]);
// Reset form when dialog closes
useEffect(() => {
if (!open) {
setUsername("");
setEmail("");
setPassword("");
setConfirmPassword("");
setFirstName("");
setLastName("");
setPhone("");
setSelectedCountry(DEFAULT_COUNTRY);
setCountrySearch("");
setSelectedServers([]);
setServerSearch("");
setError(null);
setFieldErrors({});
setPasswordStrength("weak");
}
}, [open]);
const addServer = (serverName: string) => {
if (selectedServers.some((s) => s.serverName === serverName)) {
return;
}
const isFirst = selectedServers.length === 0;
setSelectedServers([
...selectedServers,
{
serverName,
characterName: "",
isMain: isFirst,
},
]);
setServerSearch("");
setShowServerDropdown(false);
};
const removeServer = (index: number) => {
const newServers = selectedServers.filter((_, i) => i !== index);
// If we removed the main character and there are still servers, make first one main
if (selectedServers[index].isMain && newServers.length > 0) {
newServers[0].isMain = true;
}
setSelectedServers(newServers);
};
const updateServerCharacter = (index: number, characterName: string) => {
const newServers = [...selectedServers];
newServers[index].characterName = characterName;
setSelectedServers(newServers);
};
const setMainServer = (index: number) => {
const newServers = selectedServers.map((s, i) => ({
...s,
isMain: i === index,
}));
setSelectedServers(newServers);
};
const handlePhoneChange = (value: string) => {
const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code);
setPhone(formatted);
};
// Filtrar países para busca
const filteredCountries = useMemo(() => {
if (!countrySearch.trim()) return COUNTRIES;
const searchLower = countrySearch.toLowerCase();
return COUNTRIES.filter(
(c) =>
c.name.toLowerCase().includes(searchLower) ||
c.dialCode.includes(searchLower) ||
c.code.toLowerCase().includes(searchLower)
);
}, [countrySearch]);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!username.trim()) {
errors.username = "Username é obrigatório";
} else if (username.length < 3 || username.length > 50) {
errors.username = "Username deve ter entre 3 e 50 caracteres";
}
if (!email.trim()) {
errors.email = "Email é obrigatório";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "Email inválido";
}
const passwordValidation = validatePasswordRequirements(password);
if (!password) {
errors.password = "Senha é obrigatória";
} else if (!passwordValidation.isValid) {
errors.password = passwordValidation.errors[0];
}
if (password !== confirmPassword) {
errors.confirmPassword = t("errors.passwordMismatch");
}
if (!firstName.trim()) {
errors.firstName = "Nome é obrigatório";
}
if (!lastName.trim()) {
errors.lastName = "Sobrenome é obrigatório";
}
if (selectedServers.length === 0) {
errors.servers = t("errors.noServers");
}
selectedServers.forEach((server, index) => {
if (!server.characterName.trim()) {
errors[`server_${index}`] = t("errors.characterNameRequired");
}
});
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setFieldErrors({});
if (!validateForm()) {
return;
}
setIsLoading(true);
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
username: username.trim().toLowerCase(),
email: email.trim().toLowerCase(),
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined,
servers: selectedServers.map((s) => ({
serverName: s.serverName,
characterName: s.characterName.trim(),
isMain: s.isMain,
})),
}),
});
// Verificar se a resposta é JSON antes de fazer parse
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await response.text();
console.error("Resposta não é JSON do registro:", text.substring(0, 200));
setError("Erro: resposta inválida do servidor");
setIsLoading(false);
return;
}
const data = await response.json();
if (data.success) {
await refreshSession();
onOpenChange(false);
window.location.reload();
} else {
if (data.details) {
// Field-specific errors
const detailsErrors: Record<string, string> = {};
data.details.forEach((detail: { field: string; message: string }) => {
detailsErrors[detail.field] = detail.message;
});
setFieldErrors(detailsErrors);
} else {
setError(data.error || "Erro ao criar conta");
}
}
} catch (err) {
setError("Erro ao conectar com o servidor");
} finally {
setIsLoading(false);
}
};
const getPasswordStrengthColor = () => {
switch (passwordStrength) {
case "weak":
return "bg-red-500";
case "medium":
return "bg-yellow-500";
case "strong":
return "bg-green-500";
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Credentials Section */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Credenciais</h3>
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
type="text"
placeholder={t("usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
className={fieldErrors.username ? "border-destructive" : ""}
/>
{fieldErrors.username && (
<p className="text-sm text-destructive">{fieldErrors.username}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
type="email"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
className={fieldErrors.email ? "border-destructive" : ""}
/>
{fieldErrors.email && (
<p className="text-sm text-destructive">{fieldErrors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className={fieldErrors.password ? "border-destructive" : ""}
/>
{password && (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${getPasswordStrengthColor()} transition-all`}
style={{
width: passwordStrength === "weak" ? "33%" : passwordStrength === "medium" ? "66%" : "100%",
}}
/>
</div>
<span className="text-xs text-muted-foreground">
{t(`passwordStrength.${passwordStrength}`)}
</span>
</div>
</div>
)}
{fieldErrors.password && (
<p className="text-sm text-destructive">{fieldErrors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("confirmPassword")}</Label>
<Input
id="confirmPassword"
type="password"
placeholder={t("confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
className={fieldErrors.confirmPassword ? "border-destructive" : ""}
/>
{fieldErrors.confirmPassword && (
<p className="text-sm text-destructive">{fieldErrors.confirmPassword}</p>
)}
</div>
</div>
{/* Personal Info Section */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">Dados Pessoais</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">{t("firstName")}</Label>
<Input
id="firstName"
type="text"
placeholder={t("firstNamePlaceholder")}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isLoading}
className={fieldErrors.firstName ? "border-destructive" : ""}
/>
{fieldErrors.firstName && (
<p className="text-sm text-destructive">{fieldErrors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("lastName")}</Label>
<Input
id="lastName"
type="text"
placeholder={t("lastNamePlaceholder")}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isLoading}
className={fieldErrors.lastName ? "border-destructive" : ""}
/>
{fieldErrors.lastName && (
<p className="text-sm text-destructive">{fieldErrors.lastName}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t("phone")}</Label>
<div className="flex gap-2">
{/* Seletor de País */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="w-[140px] justify-between shrink-0"
disabled={isLoading}
>
<span className="flex items-center gap-2">
<span>{selectedCountry.flag}</span>
<span className="text-sm">{selectedCountry.dialCode}</span>
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-h-[300px] overflow-y-auto">
<div className="p-2">
<Input
type="text"
placeholder={t("searchCountry") || "Buscar país..."}
value={countrySearch}
onChange={(e) => setCountrySearch(e.target.value)}
className="mb-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
{filteredCountries.map((country) => (
<DropdownMenuItem
key={country.code}
onClick={() => {
setSelectedCountry(country);
setCountrySearch("");
// Reformatar telefone com novo país
if (phone) {
const unformatted = unformatPhone(phone);
const reformatted = formatInternationalPhone(
unformatted,
country.dialCode,
country.code
);
setPhone(reformatted);
}
}}
className="cursor-pointer"
>
<span className="flex items-center gap-2 w-full">
<span>{country.flag}</span>
<span className="flex-1">{country.name}</span>
<span className="text-muted-foreground text-sm">{country.dialCode}</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Campo de Telefone */}
<Input
id="phone"
type="text"
placeholder={getPhonePlaceholder(selectedCountry.code)}
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
disabled={isLoading}
maxLength={20}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">{t("phoneHint")}</p>
{fieldErrors.phone && (
<p className="text-sm text-destructive">{fieldErrors.phone}</p>
)}
</div>
</div>
{/* Servers Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">{t("servers")}</h3>
<p className="text-xs text-muted-foreground">{t("serversDescription")}</p>
</div>
</div>
{/* Server Search/Add */}
<div className="relative">
<div className="flex gap-2">
<div className="flex-1 relative">
<Input
type="text"
placeholder={t("selectServer")}
value={serverSearch}
onChange={(e) => {
setServerSearch(e.target.value);
setShowServerDropdown(true);
}}
onFocus={() => setShowServerDropdown(true)}
disabled={isLoading || serversLoading}
/>
{showServerDropdown && filteredServers.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-y-auto">
{filteredServers.slice(0, 10).map((server) => (
<button
key={server.serverId}
type="button"
className="w-full text-left px-3 py-2 hover:bg-accent rounded-sm text-sm"
onClick={() => addServer(server.serverName)}
>
{server.serverName}
{server.serverType && (
<span className="text-xs text-muted-foreground ml-2">
({server.serverType})
</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* Selected Servers */}
{selectedServers.length > 0 && (
<div className="space-y-3">
{selectedServers.map((server, index) => (
<div
key={index}
className="p-3 border rounded-md space-y-2 bg-muted/30"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="secondary">{server.serverName}</Badge>
{server.isMain && (
<Badge variant="default" className="text-xs">
{t("mainCharacter")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
{!server.isMain && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setMainServer(index)}
disabled={isLoading}
>
{t("mainCharacter")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeServer(index)}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`character-${index}`}>{t("characterName")}</Label>
<Input
id={`character-${index}`}
type="text"
placeholder={t("characterNamePlaceholder")}
value={server.characterName}
onChange={(e) => updateServerCharacter(index, e.target.value)}
disabled={isLoading}
className={fieldErrors[`server_${index}`] ? "border-destructive" : ""}
/>
{fieldErrors[`server_${index}`] && (
<p className="text-sm text-destructive">
{fieldErrors[`server_${index}`]}
</p>
)}
</div>
</div>
))}
</div>
)}
{fieldErrors.servers && (
<p className="text-sm text-destructive">{fieldErrors.servers}</p>
)}
{serversError && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
Erro ao carregar servidores: {serversError}
</p>
)}
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || serversLoading}>
{isLoading ? t("submitting") : t("submit")}
</Button>
</form>
</DialogContent>
</Dialog>
);
}