Auth Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement JWT-based authentication with login, registration, password reset, and protected routes for the Circle.so clone frontend.
Architecture: TanStack Start SPA authenticates via django-allauth (registration, password reset) and SimpleJWT (login, token refresh). Auth state managed via React Context. Tokens stored in localStorage with auto-refresh before expiry.
Tech Stack: React 19, TanStack Router, TanStack React Query, shadcn/ui, Zod, react-hook-form, Vite
File Structure
src/
├── lib/
│ └── auth.ts # JWT helpers, token management, auto-refresh
├── contexts/
│ └── AuthContext.tsx # Auth provider with user, login, logout, register
├── components/
│ └── auth/
│ └── ProtectedRoute.tsx # Route guard wrapper
├── routes/
│ ├── login.tsx # Login page
│ ├── register.tsx # Registration page
│ ├── forgot-password.tsx # Password reset request
│ ├── reset-password/
│ │ └── $key.tsx # Password reset form
│ └── __root.tsx # Modify: wrap with AuthProvider
└── lib/api/
└── spaces.tsx # Modify: use dynamic token from auth contextTask 1: JWT Token Helpers
Covers: [S1]
Files:
Create:
src/lib/auth.ts[ ] Step 1: Create auth token helpers
// src/lib/auth.ts
const ACCESS_KEY = "auth_access_token";
const REFRESH_KEY = "auth_refresh_token";
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(ACCESS_KEY);
}
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(REFRESH_KEY);
}
export function setTokens(access: string, refresh: string): void {
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp < now;
} catch {
return true;
}
}
export function getTokenExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000;
} catch {
return null;
}
}
export async function refreshAccessToken(): Promise<string | null> {
const refresh = getRefreshToken();
if (!refresh) return null;
try {
const res = await fetch("/api/v1/auth/token/refresh/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json();
setTokens(data.access, data.refresh ?? refresh);
return data.access;
} catch {
clearTokens();
return null;
}
}
export async function authedFetch<T>(url: string): Promise<T> {
let token = getAccessToken();
if (!token || isTokenExpired(token)) {
token = await refreshAccessToken();
}
if (!token) {
throw new Error("Not authenticated");
}
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
const newToken = await refreshAccessToken();
if (!newToken) throw new Error("Not authenticated");
const retryRes = await fetch(url, {
headers: { Authorization: `Bearer ${newToken}` },
});
if (!retryRes.ok) {
const body = (await retryRes.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed with status ${retryRes.status}`);
}
return retryRes.json() as Promise<T>;
}
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed with status ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function authedJson<T>(url: string, method: string, body?: unknown): Promise<T> {
let token = getAccessToken();
if (!token || isTokenExpired(token)) {
token = await refreshAccessToken();
}
if (!token) {
throw new Error("Not authenticated");
}
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
const newToken = await refreshAccessToken();
if (!newToken) throw new Error("Not authenticated");
const retryRes = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${newToken}`,
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!retryRes.ok) {
const data = (await retryRes.json().catch(() => ({}))) as { message?: string };
throw new Error(data.message ?? `Request failed with status ${retryRes.status}`);
}
return retryRes.json() as Promise<T>;
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(data.message ?? `Request failed with status ${res.status}`);
}
return res.json() as Promise<T>;
}- [ ] Step 2: Commit
git add src/lib/auth.ts
git commit -m "feat: add JWT token helpers and auto-refresh"Task 2: Auth Context Provider
Covers: [S1]
Files:
Create:
src/contexts/AuthContext.tsx[ ] Step 1: Create AuthContext
// src/contexts/AuthContext.tsx
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
import {
getAccessToken,
setTokens,
clearTokens,
getTokenExpiry,
refreshAccessToken,
} from "@/lib/auth";
type User = {
id: number;
name: string;
email: string;
headline: string;
avatar_url: string | null;
};
type AuthContextValue = {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refetchUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUser = useCallback(async (): Promise<User | null> => {
const token = getAccessToken();
if (!token) return null;
try {
const res = await fetch("/api/headless/v1/community_member", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const data = await res.json();
return {
id: data.id,
name: data.name,
email: data.email,
headline: data.headline ?? "",
avatar_url: data.avatar_url ?? null,
};
} catch {
return null;
}
}, []);
const refetchUser = useCallback(async () => {
const u = await fetchUser();
setUser(u);
}, [fetchUser]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
async function init() {
const token = getAccessToken();
if (!token) {
setIsLoading(false);
return;
}
const u = await fetchUser();
setUser(u);
setIsLoading(false);
const expiry = getTokenExpiry(token);
if (expiry) {
const msUntilRefresh = Math.max(0, expiry - Date.now() - 5 * 60 * 1000);
timer = setTimeout(async () => {
await refreshAccessToken();
const newToken = getAccessToken();
if (newToken) {
const newExpiry = getTokenExpiry(newToken);
if (newExpiry) {
const nextMs = Math.max(0, newExpiry - Date.now() - 5 * 60 * 1000);
timer = setTimeout(async () => {
await refreshAccessToken();
}, nextMs);
}
}
}, msUntilRefresh);
}
}
init();
return () => clearTimeout(timer);
}, [fetchUser]);
const login = useCallback(async (email: string, password: string) => {
const res = await fetch("/api/v1/auth/token/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(data.detail ?? "Credenciais inválidas");
}
const data = await res.json();
setTokens(data.access, data.refresh);
const u = await fetchUser();
setUser(u);
}, [fetchUser]);
const register = useCallback(async (email: string, password: string) => {
const csrfRes = await fetch("/accounts/signup/", { credentials: "same-origin" });
const csrfMatch = document.cookie.match(/csrftoken=([^;]+)/);
const csrfToken = csrfMatch?.[1] ?? "";
const res = await fetch("/accounts/signup/", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
credentials: "same-origin",
body: new URLSearchParams({
email,
password1: password,
terms: "on",
phone_number_x: "",
}),
});
if (!res.ok && res.redirected) {
// allauth redirects on success — auto-login
} else if (!res.ok) {
throw new Error("Falha no registro. Tente novamente.");
}
const loginRes = await fetch("/api/v1/auth/token/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (loginRes.ok) {
const tokenData = await loginRes.json();
setTokens(tokenData.access, tokenData.refresh);
const u = await fetchUser();
setUser(u);
}
}, [fetchUser]);
const logout = useCallback(async () => {
const token = getAccessToken();
if (token) {
try {
const csrfMatch = document.cookie.match(/csrftoken=([^;]+)/);
const csrfToken = csrfMatch?.[1] ?? "";
await fetch("/api/v1/auth/token/blacklist/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
},
credentials: "same-origin",
body: JSON.stringify({ refresh: token }),
});
} catch {
// ignore logout errors
}
}
clearTokens();
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
refetchUser,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}- [ ] Step 2: Commit
git add src/contexts/AuthContext.tsx
git commit -m "feat: add AuthContext provider with login, register, logout"Task 3: ProtectedRoute Component
Covers: [S1]
Files:
Create:
src/components/auth/ProtectedRoute.tsx[ ] Step 1: Create ProtectedRoute
// src/components/auth/ProtectedRoute.tsx
import { Navigate, useLocation } from "@tanstack/react-router";
import { useAuth } from "@/contexts/AuthContext";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" search={{ redirect: location.href }} />;
}
return <>{children}</>;
}- [ ] Step 2: Commit
git add src/components/auth/ProtectedRoute.tsx
git commit -m "feat: add ProtectedRoute component"Task 4: Login Page
Covers: [S1]
Files:
Create:
src/routes/login.tsx[ ] Step 1: Create Login page
// src/routes/login.tsx
import { createFileRoute, Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => ({
redirect: (search.redirect as string) ?? "/",
}),
component: LoginPage,
});
function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const { redirect } = Route.useSearch();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
if (isAuthenticated) {
return <Navigate to={redirect} />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate({ to: redirect });
} catch (err) {
setError(err instanceof Error ? err.message : "Falha ao entrar");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-gradient-to-br from-fuchsia-400 to-cyan-400" />
<h1 className="mt-4 text-2xl font-bold">Entrar</h1>
<p className="mt-1 text-sm text-muted-foreground">
Acesse sua conta na comunidade
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="voce@exemplo.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
Senha
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Entrando…" : "Entrar"}
</button>
</form>
<div className="space-y-3 text-center text-sm">
<Link
to="/forgot-password"
className="text-primary hover:underline"
>
Esqueceu a senha?
</Link>
<div>
<span className="text-muted-foreground">Não tem conta? </span>
<Link to="/register" className="text-primary hover:underline">
Criar conta
</Link>
</div>
</div>
</div>
</div>
);
}
// Need to import Navigate for the isAuthenticated redirect
import { Navigate } from "@tanstack/react-router";- [ ] Step 2: Commit
git add src/routes/login.tsx
git commit -m "feat: add login page with email/password form"Task 5: Registration Page
Covers: [S1]
Files:
Create:
src/routes/register.tsx[ ] Step 1: Create Register page
// src/routes/register.tsx
import { createFileRoute, Link, Navigate, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
export const Route = createFileRoute("/register")({
component: RegisterPage,
});
function RegisterPage() {
const { register, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [terms, setTerms] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
if (isAuthenticated) {
return <Navigate to="/" />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!terms) {
setError("Você precisa aceitar os termos de uso.");
return;
}
setLoading(true);
try {
await register(email, password);
navigate({ to: "/" });
} catch (err) {
setError(err instanceof Error ? err.message : "Falha ao criar conta");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-gradient-to-br from-fuchsia-400 to-cyan-400" />
<h1 className="mt-4 text-2xl font-bold">Criar conta</h1>
<p className="mt-1 text-sm text-muted-foreground">
Junte-se à comunidade
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="voce@exemplo.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
Senha
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="Mínimo 8 caracteres"
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={terms}
onChange={(e) => setTerms(e.target.checked)}
className="mt-0.5 rounded border-border"
/>
<span className="text-muted-foreground">
Li e aceito os{" "}
<a href="/terms" target="_blank" className="text-primary hover:underline">
termos de uso
</a>{" "}
e a{" "}
<a href="/privacy" target="_blank" className="text-primary hover:underline">
política de privacidade
</a>
.
</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Criando conta…" : "Criar conta"}
</button>
</form>
<div className="text-center text-sm">
<span className="text-muted-foreground">Já tem conta? </span>
<Link to="/login" className="text-primary hover:underline">
Entrar
</Link>
</div>
</div>
</div>
);
}- [ ] Step 2: Commit
git add src/routes/register.tsx
git commit -m "feat: add registration page with terms acceptance"Task 6: Forgot Password Page
Covers: [S1]
Files:
Create:
src/routes/forgot-password.tsx[ ] Step 1: Create ForgotPassword page
// src/routes/forgot-password.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const csrfMatch = document.cookie.match(/csrftoken=([^;]+)/);
const csrfToken = csrfMatch?.[1] ?? "";
const res = await fetch("/accounts/password/reset/", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
credentials: "same-origin",
body: new URLSearchParams({ email }),
});
if (res.ok || res.redirected) {
setSent(true);
} else {
setError("Não foi possível enviar o e-mail. Tente novamente.");
}
} catch {
setError("Erro de conexão. Tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-gradient-to-br from-fuchsia-400 to-cyan-400" />
<h1 className="mt-4 text-2xl font-bold">Esqueceu a senha?</h1>
<p className="mt-1 text-sm text-muted-foreground">
Informe seu e-mail para receber o link de redefinição.
</p>
</div>
{sent ? (
<div className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 text-center">
E-mail enviado! Verifique sua caixa de entrada.
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="voce@exemplo.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Enviando…" : "Enviar link de redefinição"}
</button>
</form>
)}
<div className="text-center text-sm">
<Link to="/login" className="text-primary hover:underline">
← Voltar para o login
</Link>
</div>
</div>
</div>
);
}- [ ] Step 2: Commit
git add src/routes/forgot-password.tsx
git commit -m "feat: add forgot password page"Task 7: Reset Password Page
Covers: [S1]
Files:
Create:
src/routes/reset-password/$key.tsx[ ] Step 1: Create ResetPassword page
// src/routes/reset-password/$key.tsx
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/reset-password/$key")({
component: ResetPasswordPage,
});
function ResetPasswordPage() {
const { key } = Route.useParams();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== password2) {
setError("As senhas não conferem.");
return;
}
if (password.length < 8) {
setError("A senha deve ter pelo menos 8 caracteres.");
return;
}
setLoading(true);
try {
const csrfMatch = document.cookie.match(/csrftoken=([^;]+)/);
const csrfToken = csrfMatch?.[1] ?? "";
const res = await fetch(`/accounts/password/reset/key/${key}/`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
credentials: "same-origin",
body: new URLSearchParams({
password1: password,
password2: password2,
}),
});
if (res.ok || res.redirected) {
setSuccess(true);
} else {
setError("Não foi possível redefinir a senha. O link pode ter expirado.");
}
} catch {
setError("Erro de conexão. Tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-gradient-to-br from-fuchsia-400 to-cyan-400" />
<h1 className="mt-4 text-2xl font-bold">Redefinir senha</h1>
<p className="mt-1 text-sm text-muted-foreground">
Informe sua nova senha abaixo.
</p>
</div>
{success ? (
<div className="space-y-4">
<div className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 text-center">
Senha redefinida com sucesso!
</div>
<button
onClick={() => navigate({ to: "/login" })}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
Ir para o login
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1.5">
Nova senha
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="Mínimo 8 caracteres"
/>
</div>
<div>
<label htmlFor="password2" className="block text-sm font-medium mb-1.5">
Confirmar senha
</label>
<input
id="password2"
type="password"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="Repita a senha"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Redefinindo…" : "Redefinir senha"}
</button>
</form>
)}
<div className="text-center text-sm">
<Link to="/login" className="text-primary hover:underline">
← Voltar para o login
</Link>
</div>
</div>
</div>
);
}- [ ] Step 2: Commit
git add src/routes/reset-password/\$key.tsx
git commit -m "feat: add reset password page"Task 8: Wrap Root with AuthProvider
Covers: [S1]
Files:
Modify:
src/routes/__root.tsx[ ] Step 1: Add AuthProvider to root component
Add the import at the top of src/routes/__root.tsx:
import { AuthProvider } from "@/contexts/AuthContext";Wrap the <QueryClientProvider> in the RootComponent function with <AuthProvider>:
function RootComponent() {
const { queryClient } = Route.useRouteContext();
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Outlet />
</AuthProvider>
</QueryClientProvider>
);
}- [ ] Step 2: Commit
git add src/routes/__root.tsx
git commit -m "feat: wrap root with AuthProvider"Task 9: Update API Layer to Use Dynamic Tokens
Covers: [S1]
Files:
Modify:
src/lib/api/spaces.tsx[ ] Step 1: Replace hardcoded token with auth helpers
Remove the hardcoded VALID_TOKEN constant and the old authedFetch/authedJson functions. Replace them with imports from @/lib/auth:
Remove these lines:
const VALID_TOKEN = "Bearer valid-token";
async function authedFetch<T>(url: string): Promise<T> {
const res = await fetch(url, {
headers: { Authorization: VALID_TOKEN },
});
// ... rest of old implementation
}
async function authedJson<T>(url: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: {
Authorization: VALID_TOKEN,
"Content-Type": "application/json",
},
// ... rest of old implementation
});
}Add this import at the top:
import { authedFetch, authedJson } from "@/lib/auth";- [ ] Step 2: Commit
git add src/lib/api/spaces.tsx
git commit -m "feat: use dynamic JWT tokens in API layer"Task 10: Add Logout to TopBar
Covers: [S1]
Files:
Modify:
src/components/circle/TopBar.tsx[ ] Step 1: Add logout button to TopBar
Import useAuth:
import { useAuth } from "@/contexts/AuthContext";In the TopBar component, destructure logout from useAuth():
const { logout } = useAuth();Add a logout button next to the user avatar (replace the avatar div at the end of the header):
<div className="flex items-center gap-1">
<button
onClick={() => logout()}
className="h-8 w-8 rounded-full bg-gradient-to-br from-orange-400 to-pink-500 ring-2 ring-background shrink-0 grid place-items-center text-white text-xs font-semibold cursor-pointer hover:ring-primary/50 transition"
title="Sair"
>
{member ? member.name.charAt(0) : ""}
</button>
</div>- [ ] Step 2: Commit
git add src/components/circle/TopBar.tsx
git commit -m "feat: add logout to TopBar"Task 11: Add Auth Route Links
Covers: [S1]
Files:
Modify:
src/routes/__root.tsx(not found page)Modify:
src/components/circle/MobileBottomNav.tsx(if exists)[ ] Step 1: Add login link to NotFoundComponent
In src/routes/__root.tsx, update the NotFoundComponent to include a login link:
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
<p className="mt-2 text-sm text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Go home
</Link>
<Link
to="/login"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
Login
</Link>
</div>
</div>
</div>
);
}- [ ] Step 2: Commit
git add src/routes/__root.tsx
git commit -m "feat: add login link to 404 page"Task 12: Verify Build
Covers: [S1]
Files: None (verification only)
- [ ] Step 1: Run typecheck
cd frontend && npm run typecheckExpected: No TypeScript errors.
- [ ] Step 2: Run build
cd frontend && npm run buildExpected: Build succeeds without errors.
- [ ] Step 3: Final commit if any fixes needed
git add -A
git commit -m "fix: resolve typecheck issues in auth module"