feat(api,web): fundação de Documentos + EDEN Parceiros/Assinante com login real
Continuação da leva "pelo menos mais 20 etapas" — 2 subagentes em paralelo.
## Documents (apps/api/src/documents/ + migration documents)
Metade "templates + geração" do eden.md módulo 4 (§1.1-1.3): document_templates,
document_template_versions (ciclo draft→publish, no máximo 1 rascunho por
template via índice único parcial, versão publicada imutável), document_generations
(idempotente por (template_version_id, origem), CHECK de origem única). Motor de
merge de variáveis ({{chave}}→valor, nunca lança exceção) como função pura
testável. A metade de Assinatura Eletrônica (envelopes, signatários, OTP,
cadeia de auditoria hash-chain — eden.md §1.4-1.9/§5-7, 9 tabelas satélite)
fica deliberadamente fora — grande e sensível demais para dividir espaço
com esta leva, fica para uma leva própria. PDF real via Chromium também
deferido; document_generations grava rendered_html, pdf_* ficam NULL.
## EDEN Parceiros / EDEN Assinante (apps/reseller-web, apps/subscriber-web)
Replicam o shell autenticado do core-web (leva anterior): login real com
suporte a MFA, sessão, AppShell do Design System — trocando só
applicationKey ("reseller"/"subscriber"), título e chave de localStorage
da sessão. As duas telas "Bootstrap da Fase 1" saem de circulação.
Testado ao vivo contra os containers reais: ciclo completo de template
(criar→draft→409 no segundo draft→editar→publicar→gerar documento com
merge de variáveis funcionando→regeneração idempotente reaproveita o
registro), e login via /v1/auth/login com applicationKey "reseller"/
"subscriber" retornando tokens válidos nos 2 portais reconstruídos.
pnpm turbo run typecheck build (28/28), pnpm --filter @eden/api test
(30/30, sem regressão), trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
5
apps/reseller-web/.env.example
Normal file
5
apps/reseller-web/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Copiar para `.env` neste diretório (apps/reseller-web/.env) para
|
||||
# desenvolvimento local com `pnpm --filter @eden/reseller-web dev` — Vite só
|
||||
# lê .env do root do próprio app, não do root do monorepo (ver .env.example
|
||||
# na raiz do repo).
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
@@ -11,8 +11,10 @@
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eden/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
|
||||
BIN
apps/reseller-web/public/eden-logo-horizontal.png
Normal file
BIN
apps/reseller-web/public/eden-logo-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
@@ -1,12 +1,23 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./lib/auth/AuthContext.js";
|
||||
import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js";
|
||||
import { DashboardPage } from "./routes/DashboardPage.js";
|
||||
import { LoginPage } from "./routes/LoginPage.js";
|
||||
import { ProtectedRoute } from "./routes/ProtectedRoute.js";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold">EDEN Parceiros</h1>
|
||||
<p className="mt-2 text-slate-400">
|
||||
Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AuthenticatedLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
67
apps/reseller-web/src/lib/api/auth.ts
Normal file
67
apps/reseller-web/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Client for `apps/api` `/v1/auth/*` (see apps/api/src/auth/auth.controller.ts
|
||||
* and auth.service.ts for the source of truth — shapes below are mirrored,
|
||||
* not invented, and were smoke-tested against the real running API).
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface MfaRequiredResult {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export type LoginResult = AuthResult | MfaRequiredResult;
|
||||
|
||||
export function isMfaRequired(result: LoginResult): result is MfaRequiredResult {
|
||||
return "mfaRequired" in result && result.mfaRequired === true;
|
||||
}
|
||||
|
||||
export type PermissionGrant = { resource: string; action: string; scope: string };
|
||||
|
||||
export interface MeResult {
|
||||
user: PublicUser;
|
||||
permissions: "ALL" | PermissionGrant[];
|
||||
applications: Array<"core" | "reseller" | "subscriber">;
|
||||
}
|
||||
|
||||
const APPLICATION_KEY = "reseller" as const;
|
||||
|
||||
export function login(email: string, password: string): Promise<LoginResult> {
|
||||
return apiRequest<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function completeMfaLogin(mfaChallenge: string, code: string): Promise<AuthResult> {
|
||||
return apiRequest<AuthResult>("/auth/mfa/challenge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mfaChallenge, code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMe(accessToken: string): Promise<MeResult> {
|
||||
return apiRequest<MeResult>("/auth/me", { method: "GET" }, { accessToken });
|
||||
}
|
||||
|
||||
export async function logout(refreshToken: string): Promise<void> {
|
||||
await apiRequest<void>("/auth/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
}
|
||||
73
apps/reseller-web/src/lib/api/http.ts
Normal file
73
apps/reseller-web/src/lib/api/http.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Minimal fetch wrapper for the EDEN API. Base URL comes from
|
||||
* `VITE_API_BASE_URL` (set in apps/reseller-web/.env — see .env.example),
|
||||
* falling back to the documented local dev default so `pnpm dev` works
|
||||
* out of the box against `docker compose up eden-postgres` + a locally
|
||||
* run `apps/api`.
|
||||
*/
|
||||
const DEFAULT_BASE_URL = "http://localhost:8080/api/v1";
|
||||
|
||||
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorBody {
|
||||
message?: string | string[];
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
function extractErrorMessage(body: unknown, fallback: string): string {
|
||||
const parsed = body as ApiErrorBody | null;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
if (typeof parsed.error === "string") return parsed.error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export async function apiRequest<TResponse>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: RequestOptions,
|
||||
): Promise<TResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente.");
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponse;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const body = text ? JSON.parse(text) : undefined;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, extractErrorMessage(body, "Ocorreu um erro inesperado."));
|
||||
}
|
||||
|
||||
return body as TResponse;
|
||||
}
|
||||
109
apps/reseller-web/src/lib/auth/AuthContext.tsx
Normal file
109
apps/reseller-web/src/lib/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
completeMfaLogin,
|
||||
fetchMe,
|
||||
isMfaRequired,
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
} from "../api/auth.js";
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
import { clearSession, readSession, writeSession } from "./session.js";
|
||||
|
||||
export type AuthStatus = "loading" | "authenticated" | "anonymous";
|
||||
|
||||
/** Result of `login()` when the account has MFA enabled — caller must render a code field and call `submitMfa`. */
|
||||
export interface MfaPending {
|
||||
mfaRequired: true;
|
||||
mfaChallenge: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: PublicUser | null;
|
||||
accessToken: string | null;
|
||||
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
|
||||
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
|
||||
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readSession();
|
||||
if (!stored) {
|
||||
setStatus("anonymous");
|
||||
return;
|
||||
}
|
||||
// Validate the persisted token against the API rather than trusting
|
||||
// localStorage blindly — it may have expired since the last visit.
|
||||
// No refresh-token rotation in this leva: an expired access token here
|
||||
// just sends the user back to /login.
|
||||
fetchMe(stored.accessToken)
|
||||
.then((me) => {
|
||||
setUser(me.user);
|
||||
setAccessToken(stored.accessToken);
|
||||
setRefreshToken(stored.refreshToken);
|
||||
setStatus("authenticated");
|
||||
})
|
||||
.catch(() => {
|
||||
clearSession();
|
||||
setStatus("anonymous");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const result = await apiLogin(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true as const, mfaChallenge: result.mfaChallenge };
|
||||
}
|
||||
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
return { mfaRequired: false as const };
|
||||
}, []);
|
||||
|
||||
const submitMfa = useCallback(async (mfaChallenge: string, code: string) => {
|
||||
const result = await completeMfaLogin(mfaChallenge, code);
|
||||
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
|
||||
setUser(result.user);
|
||||
setAccessToken(result.accessToken);
|
||||
setRefreshToken(result.refreshToken);
|
||||
setStatus("authenticated");
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (refreshToken) {
|
||||
// Best-effort — even if the API call fails (e.g. session already
|
||||
// expired server-side), the local session must still be cleared.
|
||||
await apiLogout(refreshToken).catch(() => undefined);
|
||||
}
|
||||
clearSession();
|
||||
setUser(null);
|
||||
setAccessToken(null);
|
||||
setRefreshToken(null);
|
||||
setStatus("anonymous");
|
||||
}, [refreshToken]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ status, user, accessToken, login, submitMfa, logout }),
|
||||
[status, user, accessToken, login, submitMfa, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
34
apps/reseller-web/src/lib/auth/session.ts
Normal file
34
apps/reseller-web/src/lib/auth/session.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PublicUser } from "../api/auth.js";
|
||||
|
||||
const STORAGE_KEY = "eden.reseller.session";
|
||||
|
||||
export interface StoredSession {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic session persistence (localStorage) so a page refresh doesn't log
|
||||
* the user out. Deliberately NOT rotating the refresh token automatically
|
||||
* on expiry in this leva — see apps/reseller-web/src/lib/auth/AuthContext.tsx.
|
||||
*/
|
||||
export function readSession(): StoredSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.user) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: StoredSession): void {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
35
apps/reseller-web/src/routes/AuthenticatedLayout.tsx
Normal file
35
apps/reseller-web/src/routes/AuthenticatedLayout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
import { AppShell } from "@eden/ui";
|
||||
import type { AppShellNavItem } from "@eden/ui";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }];
|
||||
|
||||
export function AuthenticatedLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
appName="EDEN Parceiros"
|
||||
logoSrc="/eden-logo-horizontal.png"
|
||||
navItems={NAV_ITEMS}
|
||||
userName={user?.fullName ?? user?.email}
|
||||
userRole={user?.roleKey}
|
||||
onLogout={handleLogout}
|
||||
LinkComponent={({ href, className, children }) => (
|
||||
<Link to={href} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
26
apps/reseller-web/src/routes/DashboardPage.tsx
Normal file
26
apps/reseller-web/src/routes/DashboardPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
/**
|
||||
* Placeholder landing page — out of scope for this leva to build real
|
||||
* business screens (comissões, pedidos, etc.). This just proves the
|
||||
* authenticated shell renders with real session data end to end.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`Bem-vindo, ${user?.fullName ?? user?.email ?? ""}`}
|
||||
description="Portal Parceiros — Fase 1"
|
||||
/>
|
||||
<Card>
|
||||
<p className="text-sm text-default">
|
||||
O shell autenticado está funcionando. As telas de negócio (comissões, pedidos, etc.) chegam nas próximas
|
||||
levas.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/reseller-web/src/routes/LoginPage.tsx
Normal file
132
apps/reseller-web/src/routes/LoginPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input } from "@eden/ui";
|
||||
import { ApiError } from "../lib/api/http.js";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
export function LoginPage() {
|
||||
const { status, login, submitMfa } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (status === "authenticated") {
|
||||
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
async function handleCredentialsSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await login(email, password);
|
||||
if (result.mfaRequired) {
|
||||
setMfaChallenge(result.mfaChallenge);
|
||||
} else {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível entrar. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMfaSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!mfaChallenge) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitMfa(mfaChallenge, mfaCode);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Código inválido. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-light p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="mb-6 text-center">
|
||||
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
|
||||
<h1 className="!text-xl font-bold text-title">
|
||||
{mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-default">
|
||||
{mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Parceiros."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{mfaChallenge ? (
|
||||
<form className="space-y-4" onSubmit={handleMfaSubmit}>
|
||||
<Input
|
||||
label="Código de verificação"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={submitting}>
|
||||
Confirmar
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-default hover:text-primary"
|
||||
onClick={() => {
|
||||
setMfaChallenge(null);
|
||||
setMfaCode("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleCredentialsSubmit}>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={submitting}>
|
||||
Entrar
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
24
apps/reseller-web/src/routes/ProtectedRoute.tsx
Normal file
24
apps/reseller-web/src/routes/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth/AuthContext.js";
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-light">
|
||||
<span
|
||||
aria-label="Carregando"
|
||||
className="size-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "anonymous") {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
9
apps/reseller-web/src/vite-env.d.ts
vendored
Normal file
9
apps/reseller-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import edenPreset from "@eden/ui/tailwind-preset";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
presets: [edenPreset],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
// Design-system components live in packages/ui and ship as source (not
|
||||
// pre-built CSS) — Tailwind's JIT scanner needs to see them too, or
|
||||
// classes only used inside @eden/ui get purged from reseller-web's build.
|
||||
"../../packages/ui/src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user