Skip to content
34 changes: 29 additions & 5 deletions backend/src/modules/types/credentials.types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import z from "zod";

export const RegisterSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Senha deve ter no mínimo 8 caracteres"),
name: z.string().min(1, "Nome é obrigatório").optional(),
phone: z.string().optional(),
cpf: z.string().optional(),
email: z
.string()
.email("Email inválido")
.max(254, "Email deve ter no máximo 254 caracteres"),
password: z
.string()
.min(8, "Senha deve ter no mínimo 8 caracteres")
.max(128, "Senha deve ter no máximo 128 caracteres"),
name: z
.string()
.min(1, "Nome é obrigatório")
.max(100, "Nome deve ter no máximo 100 caracteres")
.optional(),
phone: z
.string()
.max(16, "Telefone deve ter no máximo 15 dígitos")
.refine(
(value) => value.replace(/\D/g, "").length <= 15,
"Telefone deve ter no máximo 15 dígitos",
)
.optional(),
cpf: z
.string()
.max(14, "CPF deve ter no máximo 11 dígitos")
.refine(
(value) => value.replace(/\D/g, "").length <= 11,
"CPF deve ter no máximo 11 dígitos",
)
.optional(),
technologies: z.array(z.string()).optional(),
level: z.string().optional(),
});
Expand Down
34 changes: 24 additions & 10 deletions backend/tests/unit/modules/auth/credentials.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,19 @@ describe("CredentialsService", () => {
};
});
mocks.insertReturning.mockResolvedValue([mockUser]);
mocks.transaction.mockImplementation(async (callback) => callback({
insert: vi.fn(() => ({
values: mocks.insertValues.mockImplementation(() => ({
returning: mocks.insertReturning,
mocks.transaction.mockImplementation(async (callback) =>
callback({
insert: vi.fn(() => ({
values: mocks.insertValues.mockImplementation(() => ({
returning: mocks.insertReturning,
})),
})),
})),
query: {
credentials: { findFirst: mocks.credentialsFindFirst },
users: { findFirst: mocks.usersFindFirst },
},
}));
query: {
credentials: { findFirst: mocks.credentialsFindFirst },
users: { findFirst: mocks.usersFindFirst },
},
}),
);

// Define retornos seguros e limpos para evitar falhas colaterais
mocks.credentialsFindFirst.mockResolvedValue(null);
Expand Down Expand Up @@ -178,6 +180,18 @@ describe("CredentialsService", () => {
).rejects.toThrow();
});

it.each([
["nome", { name: "a".repeat(101) }],
["email", { email: `${"a".repeat(250)}@x.com` }],
["telefone", { phone: "1".repeat(16) }],
["senha", { password: "a".repeat(129) }],
["CPF", { cpf: "1".repeat(12) }],
])("rejeita %s acima do limite permitido", async (_field, override) => {
await expect(
service.register({ ...registerInput, ...override }),
).rejects.toThrow();
});

it("session contém userId e role", async () => {
const { session } = await service.register(registerInput);

Expand Down
4 changes: 0 additions & 4 deletions docker-compose.infra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- vagas-net
restart: unless-stopped
Expand All @@ -24,8 +22,6 @@ services:
container_name: vagas-valkey
volumes:
- valkey_data:/data
ports:
- "6379:6379"
networks:
- vagas-net
restart: unless-stopped
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ export function AppRoutes() {

return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route
path="/"
element={
<PublicRoute>
<LandingPage />
</PublicRoute>
}
/>
<Route path="/home" element={dashboardElement} />
<Route path="/dashboard" element={dashboardElement} />
<Route path="/vagas" element={dashboardElement} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ const LEVEL_OPTIONS = [
{ value: "senior", label: "Sênior" },
];

const REGISTER_LIMITS = {
name: 100,
email: 254,
phoneDigits: 15,
password: 128,
cpf: 14,
} as const;

function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
Expand Down Expand Up @@ -163,8 +171,8 @@ export default function RegisterSide() {
if (!password) {
setPasswordError("O campo de senha é obrigatório.");
isValid = false;
} else if (password.length < 6) {
setPasswordError("A senha precisa conter pelo menos 6 caracteres.");
} else if (password.length < 8) {
setPasswordError("A senha precisa conter pelo menos 8 caracteres.");
isValid = false;
}

Expand Down Expand Up @@ -196,7 +204,9 @@ export default function RegisterSide() {
window.location.href = "/login?registered=true";
} catch (error: unknown) {
console.error("Erro no cadastro:", error);
setApiError(getErrorMessage(error, "Erro ao cadastrar. Tente novamente."));
setApiError(
getErrorMessage(error, "Erro ao cadastrar. Tente novamente."),
);
} finally {
setIsLoading(false);
}
Expand Down Expand Up @@ -260,6 +270,7 @@ export default function RegisterSide() {
type="text"
value={nome}
onChange={(e) => setNome(e.target.value)}
maxLength={REGISTER_LIMITS.name}
placeholder="benevanio"
disabled={isLoading}
className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${nomeError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`}
Expand All @@ -282,6 +293,7 @@ export default function RegisterSide() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={REGISTER_LIMITS.email}
placeholder="benevanio@dev.com.br"
disabled={isLoading}
className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${emailError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`}
Expand All @@ -303,7 +315,15 @@ export default function RegisterSide() {
international
defaultCountry="BR"
value={telefone}
onChange={setTelefone}
onChange={(value) => {
if (
!value ||
value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits
) {
setTelefone(value);
}
}}
numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }}
disabled={isLoading}
className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom"
placeholder="(34) 23456-7890"
Expand All @@ -328,6 +348,7 @@ export default function RegisterSide() {
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={REGISTER_LIMITS.password}
placeholder="Ex: ••••••••••••"
disabled={isLoading}
className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${passwordError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`}
Expand Down Expand Up @@ -366,6 +387,7 @@ export default function RegisterSide() {
type="text"
value={cpf}
onChange={(e) => setCpf(formatCpf(e.target.value))}
maxLength={REGISTER_LIMITS.cpf}
placeholder="091.000.000-00"
disabled={isLoading}
className={`w-full px-4 py-3.5 rounded-xl border bg-white/80 dark:bg-neutral-800/80 backdrop-blur-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 transition-all shadow-sm ${cpfError ? "border-red-500 focus:ring-red-500" : "border-gray-200 dark:border-neutral-700 focus:ring-blue-500 focus:border-transparent"} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function JobsFiltersCard({
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
maxLength={100}
className="h-14 w-full rounded-2xl border border-slate-300 bg-white pl-11 pr-14 text-base text-slate-900 placeholder:text-slate-500 focus-visible:ring-[#14AE5C]/40 dark:border-[#35506f] dark:bg-[#091224] dark:text-slate-100 dark:placeholder:text-slate-400"
placeholder="Buscar por título, empresa, local ou link"
/>
Expand Down Expand Up @@ -183,11 +184,12 @@ export function JobsFiltersCard({
selectedFilters.map((filter) => (
<Badge
key={filter}
className="gap-1.5 rounded-full border border-[#14AE5C]/30 bg-[#14AE5C]/12 px-2 py-1 text-xs font-semibold text-[#0c6b35] dark:text-[#8df0af]"
title={filter}
className="max-w-full gap-1.5 rounded-full border border-[#14AE5C]/30 bg-[#14AE5C]/12 px-2 py-1 text-xs font-semibold text-[#0c6b35] dark:text-[#8df0af]"
>
<FiCheck className="h-3 w-3" />
<FiTag className="h-3 w-3" />
<span>{filter}</span>
<span className="max-w-48 truncate">{filter}</span>
<button
type="button"
aria-label={`Remover filtro ${filter}`}
Expand All @@ -212,4 +214,4 @@ export function JobsFiltersCard({
)}
</>
);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { fetchKeywords, saveKeywords } from "@/domains/jobs/infrastructure/jobsApi";
import {
fetchKeywords,
saveKeywords,
} from "@/domains/jobs/infrastructure/jobsApi";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";

interface KeywordsModalProps {
Expand Down Expand Up @@ -61,7 +70,8 @@ export function KeywordsModal({ onClose }: KeywordsModalProps) {
<CardHeader>
<CardTitle>Gerenciar filtros</CardTitle>
<CardDescription>
Adicione ou remova as palavras-chave usadas na busca e nos filtros de vagas.
Adicione ou remova as palavras-chave usadas na busca e nos filtros
de vagas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
Expand All @@ -71,6 +81,7 @@ export function KeywordsModal({ onClose }: KeywordsModalProps) {
aria-label="Nova keyword ou filtro"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
maxLength={100}
onKeyDown={(e) => e.key === "Enter" && handleAddKeyword()}
/>
<Button onClick={handleAddKeyword} disabled={isLoading || isSaving}>
Expand All @@ -80,14 +91,25 @@ export function KeywordsModal({ onClose }: KeywordsModalProps) {

<div className="flex flex-wrap gap-2 p-4 border rounded-md bg-muted/20">
{isLoading ? (
<p className="text-sm text-muted-foreground italic">Carregando keywords...</p>
<p className="text-sm text-muted-foreground italic">
Carregando keywords...
</p>
) : keywords.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Nenhuma keyword cadastrada.</p>
<p className="text-sm text-muted-foreground italic">
Nenhuma keyword cadastrada.
</p>
) : (
keywords.map((kw) => (
<Badge key={kw} variant="secondary" className="pl-3 pr-1 gap-1">
{kw}
<Badge
key={kw}
variant="secondary"
title={kw}
className="max-w-full pl-3 pr-1 gap-1"
>
<span className="max-w-72 truncate">{kw}</span>
<button
type="button"
aria-label={`Remover keyword ${kw}`}
onClick={() => handleRemoveKeyword(kw)}
className="hover:bg-red-500/50 rounded-full p-0.5"
>
Expand Down
Loading
Loading