Skip to content

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 context

Task 1: JWT Token Helpers

Covers: [S1]

Files:

  • Create: src/lib/auth.ts

  • [ ] Step 1: Create auth token helpers

typescript
// 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
bash
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

typescript
// 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
bash
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

typescript
// 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
bash
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

typescript
// 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
bash
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

typescript
// 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
bash
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

typescript
// 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
bash
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

typescript
// 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
bash
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:

typescript
import { AuthProvider } from "@/contexts/AuthContext";

Wrap the <QueryClientProvider> in the RootComponent function with <AuthProvider>:

typescript
function RootComponent() {
  const { queryClient } = Route.useRouteContext();

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <Outlet />
      </AuthProvider>
    </QueryClientProvider>
  );
}
  • [ ] Step 2: Commit
bash
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:

typescript
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:

typescript
import { authedFetch, authedJson } from "@/lib/auth";
  • [ ] Step 2: Commit
bash
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:

typescript
import { useAuth } from "@/contexts/AuthContext";

In the TopBar component, destructure logout from useAuth():

typescript
const { logout } = useAuth();

Add a logout button next to the user avatar (replace the avatar div at the end of the header):

typescript
<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
bash
git add src/components/circle/TopBar.tsx
git commit -m "feat: add logout to TopBar"

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:

tsx
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
bash
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
bash
cd frontend && npm run typecheck

Expected: No TypeScript errors.

  • [ ] Step 2: Run build
bash
cd frontend && npm run build

Expected: Build succeeds without errors.

  • [ ] Step 3: Final commit if any fixes needed
bash
git add -A
git commit -m "fix: resolve typecheck issues in auth module"

Strum — Documentação.