diff --git a/.env.example b/.env.example index 2a46e587..b735c44d 100644 --- a/.env.example +++ b/.env.example @@ -84,8 +84,10 @@ GITHUB_TOKEN= # ⚙️ Configurações da Aplicação # ------------------------------------------------------------------------------ -NODE_ENV=development +# Preview deploys: leave unset so callbackUrl() falls back to NEXT_PUBLIC_VERCEL_URL. +# Production: set to the canonical domain (e.g. https://smartmanagementsystem.vercel.app). NEXT_PUBLIC_APP_URL=http://localhost:3000 +NODE_ENV=development # ⚠️ ADICIONE SUAS CHAVES CUSTOMIZADAS ABAIXO DESTA LINHA diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..afdf0f30 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,164 @@ +# Five Star Gym - Design System + +This document outlines the master visual identity for **Five Star Gym**, a next-generation gym and personal training platform. + +## Design Philosophy + +The application is built with a premium, high-end aesthetic, akin to Apple Fitness+ or a luxury boutique gym. It is strictly **Dark Mode-first**. The UI is immersive, highly legible, and visually striking without being cluttered. + +- **Backgrounds:** Deep, OLED blacks to ensure contrast and reduce eye strain. +- **Surfaces/Cards:** Subtle, elevated dark grays to provide depth and separation. +- **Accents:** High-contrast neon/electric accents (Electric Cyan) for primary actions, active states, glowing indicators, and progress rings. +- **Text:** High-contrast white for primary headings, and muted grays for secondary or tertiary text to establish clear hierarchy. +- **Component Architecture:** Clean, modern borders (`border-white/10`) and radii (`rounded-xl`). Usage of subtle glowing drop-shadows instead of flat background colors for active states. +- **Motion:** Spring-based hover effects and hardware-accelerated layout transitions to make the UI feel fluid and responsive. + +--- + +## 🎨 Tailwind CSS Configuration (v4) + +Add these variables to your global CSS to define the core color palette. + +```css +@theme { + /* ========================================= */ + /* COLOR PALETTE */ + /* ========================================= */ + + /* Backgrounds & Surfaces (OLED Black -> Dark Gray) */ + --color-background: #000000; + --color-surface-100: #09090b; + --color-surface-200: #18181b; + --color-surface-300: #27272a; + + /* Text Colors */ + --color-text-primary: #ffffff; + --color-text-secondary: #a1a1aa; /* Zinc 400 */ + --color-text-tertiary: #71717a; /* Zinc 500 */ + + /* Borders */ + --color-border-subtle: rgb(255 255 255 / 0.1); + + /* Primary Accent: Electric Cyan */ + --color-accent-50: #ecfeff; + --color-accent-100: #cffafe; + --color-accent-200: #a5f3fc; + --color-accent-300: #67e8f9; + --color-accent-400: #22d3ee; /* Primary Action / Core Cyan */ + --color-accent-500: #06b6d4; + --color-accent-600: #0891b2; + --color-accent-700: #0e7490; + --color-accent-800: #155e75; + --color-accent-900: #164e63; + --color-accent-950: #083344; + + /* ========================================= */ + /* TYPOGRAPHY */ + /* ========================================= */ + + --font-sans: 'Inter', 'Roboto', 'Helvetica Neue', sans-serif; + --font-headline: 'Outfit', 'Inter', sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + + /* ========================================= */ + /* EFFECTS & SHADOWS */ + /* ========================================= */ + + /* Subtle glow for active states */ + --shadow-glow-accent: 0 0 15px rgba(34, 211, 238, 0.4); +} +``` + +--- + +## 🏃 Framer Motion Animation Variants + +Use these standardized Framer Motion variants for consistent, spring-based interactions across all components. + +```typescript +// src/lib/motion.ts +import { Variants } from 'framer-motion'; + +/** + * Standard spring transition for layout changes and hovers. + * Hardware-accelerated and smooth. + */ +export const springTransition = { + type: 'spring', + stiffness: 300, + damping: 20, +}; + +/** + * Workout Cards and interactive surface hover states. + */ +export const cardHover: Variants = { + initial: { scale: 1 }, + hover: { + scale: 1.02, + transition: springTransition, + }, + tap: { + scale: 0.98, + transition: { type: 'spring', stiffness: 400, damping: 15 }, + }, +}; + +/** + * Primary action buttons hover states (with subtle glow). + */ +export const buttonHover: Variants = { + initial: { scale: 1, boxShadow: 'none' }, + hover: { + scale: 1.05, + boxShadow: '0 0 15px rgba(34, 211, 238, 0.4)', // Electric Cyan glow + transition: springTransition, + }, + tap: { scale: 0.95 }, +}; + +/** + * Page and content fade-in animations. + */ +export const fadeIn: Variants = { + initial: { opacity: 0, y: 10 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + y: -10, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; +``` + +--- + +## 📐 Layout & Spacing Rules + +Adhere strictly to these parameters when building components with `shadcn/ui` and Tailwind. + +### Component Styling + +| Element | Tailwind Class | Description | +| :--------------------- | :---------------------------- | :---------------------------------------------------- | +| **Cards / Containers** | `rounded-xl` | Standard modern corner radius for all main surfaces. | +| **Buttons / Badges** | `rounded-md` | Subtly rounded for interactive actions and tags. | +| **Borders** | `border border-white/10` | Subtle, clean separation without being harsh. | +| **Glassmorphism** | `bg-white/5 backdrop-blur-md` | Reserved for floating nav bars or overlays. | + +### Typography Guidelines + +| Text Type | Font Family | Tailwind Classes | Description | +| :---------------------- | :---------- | :----------------------------------------- | :----------------------------------------------------------- | +| **Body Text** | Inter | `font-body` | Standard body text, buttons, labels, metadata. | +| **Headlines** | Outfit | `font-headline font-extrabold tracking-tight` | Large headings, section titles, hero text. | +| **Metrics & Numbers** | Mono | `font-mono font-bold tracking-tight` | BPM, Kcal, Duration, Sets/Reps. High legibility. | +| **Secondary Text** | Inter | `text-zinc-400 font-medium` | Labels, descriptions, tertiary info. | + +--- diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 49884b8f..401a7203 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,4 +1,32 @@ -# Estado Atual (2026-07-06) +# Estado Atual (2026-07-08) + +## PR #194 `feat/aluno-ui-10-fixes` — review remediation completa (cubic + coderabbit) + +**Branch:** `feat/aluno-ui-10-fixes` (PR #194, +956/-371, 23 files). 4 commits de remediação: `f07f39f` (P0-P2), `48d6dd7` (sfU+se1), `7397157` (sfi+PLDGo), `641831d` (P2/P3 test coverage + a11y). + +### Remediação final — commit `641831d` + +28+ comentários triaged (4 bots: cubic ×2, coderabbit ×2) → 16 fixed / 4 FP / 8 deferred. Última batch resolveu os 8 deferred: + +- **O8GbK (P3)**: `card-treino` `focus-visible`→`focus-within` em exercise row div (container não-focusable; focus-within dispara no child focado). +- **O9Faj (P2)**: `.env.example` `NEXT_PUBLIC_APP_URL` revertido para `:3000` (match `next dev` default; `:3001` quebrava dev parity + test env). +- **O9Fap + O9Fa4 (P2)**: OAuth return contract split — campos dedicados `url`/`error` substituem heuristic `startsWith('http')`. `auth.ts` + 2 login pages + `auth.test.ts` atualizados. +- **O8Gaq (P2)**: `meus-treinos-client.test` — assert exercise names render (sliced 3, joined), omitidos quando 0 exercícios. +- **O9FaX + PMTVI (P2)**: `dashboard-client.test` — mock `@/lib/actions/treinos` (desbloqueia Prisma DATABASE_URL import-time failure) + WorkoutSession gate. Cobertura: WorkoutSession state toggle, `router.refresh()` pós `registrarHistoricoTreinoAction` success, error path. +- **PMTVN (P2)**: `aluno/login.test` — account-enumeration guard: "User already registered" + "User has already been registered" ambos mascarados p/ credential error genérico, sem leak, sem push. +- **sfi (resolved prior)**: lazy `NEXT_PUBLIC_APP_URL` read dentro de `callbackUrl()` (não module load) p/ test harness injetar env pós-import. + +### Gates + +112 test files / 1159 tests pass. Typecheck clean. Lint clean (prettier reformatou hoisted block). Push `641831d` em `7397157..641831d`. + +### Threads GitHub + +8 threads cubic-dev-ai resolvidas via `resolveReviewThread` (O8Gaq, O8GbK, O9FaX, O9Faj, O9Fap, O9Fa4, PMTVI, PMTVN). 0 unresolved restantes no PR #194. + +--- + +## Iteração 4 (P5) — CI/CD + Docker + SonarQube — PR #191 + #192 merged, tag estabilizada ## Iteração 4 (P5) — CI/CD + Docker + SonarQube — PR #191 + #192 merged, tag estabilizada diff --git a/docs/superpowers/plans/2026-05-14-fix-npm-audit-vulnerabilities.md b/docs/superpowers/plans/2026-05-14-fix-npm-audit-vulnerabilities.md deleted file mode 100644 index b2f431d9..00000000 --- a/docs/superpowers/plans/2026-05-14-fix-npm-audit-vulnerabilities.md +++ /dev/null @@ -1,341 +0,0 @@ -# Fix npm Audit Vulnerabilities — PR #100 Quality Gates - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Resolver todas as 17 vulnerabilidades reportadas pelo `npm audit --audit-level=high` para destravar o Quality Gates no CI do PR #100. - -**Architecture:** Abordagem em duas fases: (1) `npm audit fix` resolve 15/17 vulnerabilidades corrigíveis automaticamente; (2) `overrides` no `package.json` força versões não-vulneráveis dos 2 pacotes `@opentelemetry/*` restantes, cujas correções não são puxadas pelas dependências transitórias do `genkit@1.32.0 → @genkit-ai/google-cloud`. - -**Tech Stack:** npm 10+, Next.js 15, Sentry 10, Genkit 1.32, OpenTelemetry - ---- - -## Contexto - -### Diagnóstico completo - -O CI do PR #100 falha no step **Quality Gates** com `npm audit --audit-level=high` retornando exit code 1. - -**17 vulnerabilidades totais: 13 HIGH + 4 MODERATE** - -### Grupo A — Corrigíveis por `npm audit fix` (15 vulnerabilidades) - -| Pacote | Severidade | Advisories | Origem | -| ------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | -| `next` (^15.5.15) | **HIGH** ×13 | GHSA-8h8q (DoS), GHSA-ffhc (XSS), GHSA-vfv6 (cache poison), GHSA-gx5p (XSS), GHSA-mg66 (DoS conn), GHSA-h64f (DoS img), GHSA-c4j6 (SSRF), GHSA-492v (middleware bypass), GHSA-wfc6 (cache), GHSA-267c (prefetch bypass), GHSA-36qx (i18n bypass), GHSA-3g8h (redirect cache), GHSA-26hh (prefetch follow-up) | direct dependency | -| `fast-uri` | **HIGH** ×2 | GHSA-q3j6 (path traversal), GHSA-v39h (host confusion) | transitive | -| `fast-xml-builder` | **HIGH** ×2 | GHSA-5wm8 (attribute bypass), GHSA-45c6 (comment bypass) | transitive | -| `hono` | MODERATE ×5 | bodyLimit, JSX injection, CSS injection, JWT validation, Cache middleware | transitive (via genkit) | -| `@protobufjs/utf8` | MODERATE ×1 | GHSA-q6x5 (UTF-8 decoding) | transitive (via genkit → google-cloud) | -| `postcss` | MODERATE ×1 | GHSA-qx2v (XSS via CSS output) | transitive (via next) | -| `uuid` | MODERATE ×1 | GHSA-w5hq (buffer bounds check) | transitive (via genkit → google-auth-library) | - -**Total corrigíveis: 11 HIGH + 4 MODERATE** - -### Grupo B — NÃO corrigíveis por `npm audit fix` (2 vulnerabilidades) - -| Pacote | Severidade | Advisory | Versão atual | Versão fixa | Origem | -| ------------------------------------------- | ----------- | ------------------------------------- | ------------ | ----------- | ---------------------------------------------------------------------------------------- | -| `@opentelemetry/auto-instrumentations-node` | **HIGH** ×1 | GHSA-q7rr (Prometheus exporter crash) | 0.49.2 | ≥0.75.0 | `genkit@1.32.0 → @genkit-ai/core → @genkit-ai/firebase → @genkit-ai/google-cloud@1.34.0` | -| `@opentelemetry/sdk-node` | **HIGH** ×1 | GHSA-q7rr (Prometheus exporter crash) | 0.52.1 | ≥0.218.0 | mesma cadeia acima | - -Ambos vêm do pacote `@genkit-ai/google-cloud@1.34.0` que **ainda** depende de: - -```json -"@opentelemetry/auto-instrumentations-node": "^0.49.1", -"@opentelemetry/sdk-node": "^0.52.0" -``` - -Genkit 1.34.0 (latest) não atualizou essas dependências. A única forma de corrigir é via **npm overrides**. - -### Análise de risco dos overrides - -- **`@opentelemetry/sdk-node`: 0.52.1 → 0.218.0** — Gap de versão muito grande (pré-1.0). Potencial de breaking changes na API do SDK Node do OpenTelemetry. -- **`@opentelemetry/auto-instrumentations-node`: 0.49.2 → 0.75.0** — Gap significativo. Pode quebrar auto-instrumentação de bibliotecas. -- **Impacto real na aplicação**: Esses pacotes são usados **apenas** pelo `@genkit-ai/google-cloud` para telemetria (Cloud Logging, Cloud Trace, Cloud Monitoring). O uso principal do Genkit no projeto é AI via Gemini (`@genkit-ai/google-genai`), **não** Google Cloud telemetry. O risco de quebra funcional é baixo. -- **Cenário de falha**: Se os overrides quebrarem o `genkit`, apenas funcionalidades de logging/tracing do Google Cloud seriam afetadas. O AI Workout Generator (que usa Gemini) continuaria funcionando. - ---- - -## Plano de Implementação - -### File Map - -| Arquivo | Ação | Responsabilidade | -| ------------------- | ------ | --------------------------------------------------------------------------------- | -| `package.json` | Modify | Adicionar campo `overrides` para OTel; versões de deps serão atualizadas pelo npm | -| `package-lock.json` | Modify | Atualizado automaticamente pelo `npm audit fix` e `npm install` | -| `node_modules/` | Modify | Atualizado automaticamente | - ---- - -### Task 1: Executar `npm audit fix` - -**Files:** - -- Modify: `package.json` (versões de dependências podem ser atualizadas) -- Modify: `package-lock.json` (lockfile atualizado) - -- [ ] **Step 1: Rodar `npm audit fix` (sem --force)** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm audit fix -``` - -Expected: Corrige next.js, fast-uri, fast-xml-builder, hono, @protobufjs/utf8, postcss, uuid. As versões no `package.json` podem ser atualizadas (ex: `next` pode ir de `^15.5.15` para `^15.5.16` ou similar). O `package-lock.json` será substancialmente alterado. - -- [ ] **Step 2: Verificar quantas vulnerabilidades restam** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm audit --audit-level=high 2>&1 -``` - -Expected: Ainda falha com exit code 1. Deve reportar apenas: - -``` -@opentelemetry/auto-instrumentations-node * -@opentelemetry/sdk-node * -``` - -Se houver MAIS do que essas duas, algo deu errado e precisa investigar. - -- [ ] **Step 3: Commit do audit fix** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -git add package.json package-lock.json -git commit -m "fix(security): resolve 15 of 17 npm audit vulnerabilities via npm audit fix" -``` - ---- - -### Task 2: Adicionar overrides para OpenTelemetry - -**Files:** - -- Modify: `package.json` (adicionar campo `overrides`) - -- [ ] **Step 1: Adicionar campo `overrides` ao `package.json`** - -Adicionar após o campo `"devDependencies"` (ou qualquer posição no nível raiz do JSON): - -```json -"overrides": { - "@opentelemetry/auto-instrumentations-node": "0.75.0", - "@opentelemetry/sdk-node": "0.218.0" -} -``` - -O `package.json` deve ficar com estrutura similar a: - -```json -{ - "name": "nextn", - "version": "1.0.0", - "private": true, - "scripts": { ... }, - "dependencies": { ... }, - "devDependencies": { ... }, - "overrides": { - "@opentelemetry/auto-instrumentations-node": "0.75.0", - "@opentelemetry/sdk-node": "0.218.0" - } -} -``` - -> **Nota sobre npm overrides**: O campo `overrides` força TODAS as ocorrências desses pacotes na árvore de dependências para as versões especificadas, independentemente do que qualquer `package.json` intermediário declare. Isso é uma feature nativa do npm 8.3+ (temos npm 10+). As versões escolhidas (0.75.0 e 0.218.0) são as PRIMEIRAS versões não-vulneráveis de cada pacote, minimizando o gap de versão. - -- [ ] **Step 2: Reinstalar com overrides aplicados** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -rm -rf node_modules package-lock.json -npm install --legacy-peer-deps -``` - -Expected: Instalação limpa com os overrides aplicados. Pode mostrar warnings de peer dependency — documentar mas não bloquear. - -- [ ] **Step 3: Verificar que npm audit passa LIMPO** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm audit --audit-level=high -``` - -Expected: **exit code 0**, output `found 0 vulnerabilities`. - -Se AINDA houver vulnerabilidades HIGH: - -- Verificar se o override foi aplicado: `npm ls @opentelemetry/sdk-node` deve mostrar `0.218.0` -- Se o override não aplicou, verificar sintaxe do JSON (campo `overrides` no nível raiz) -- Se aparecerem NOVAS vulnerabilidades (introduzidas pelas versões forçadas), documentar e avaliar - -- [ ] **Step 4: Commit dos overrides** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -git add package.json package-lock.json -git commit -m "fix(security): override OpenTelemetry packages to fix GHSA-q7rr via npm overrides" -``` - ---- - -### Task 3: Rodar Quality Gates localmente - -**Files:** Nenhum (verificação) - -- [ ] **Step 1: Rodar typecheck** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm run typecheck -``` - -Expected: exit code 0, sem erros de tipo. Se houver erros, VÃO SER erros de tipos do Next.js ou dos pacotes atualizados — provavelmente relacionados ao Next.js 15.6.x se o `npm audit fix` tiver mudado a minor version. - -Se typecheck falhar com erros NOVOS (não pré-existentes): - -- Erros de `next` types: verificar se `next@15.5.x` foi alterado para `15.6.x`. Se sim, considerar fixar em `15.5.x` com `"next": "15.5.x"` e rodar `npm audit fix` novamente. -- Erros de OTel types: improvável, pois são dependências transitórias. - -- [ ] **Step 2: Rodar lint** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm run lint -``` - -Expected: exit code 0. - -- [ ] **Step 3: Rodar format check** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm run format:check -``` - -Expected: exit code 0. - ---- - -### Task 4: Rodar testes unitários e verificar cobertura - -**Files:** Nenhum (verificação) - -- [ ] **Step 1: Rodar testes unitários** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm run test -``` - -Expected: 66/66 passed (ou o número atual de testes). Nenhum teste deve falhar devido às mudanças de versão. - -Se testes falharem: - -- Verificar stack trace para identificar se é relacionado a OTel ou next.js -- Se for OTel: remover overrides, ir para Plano B -- Se for next.js: verificar breaking changes na versão instalada - -- [ ] **Step 2: Rodar coverage** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -npm run test:coverage -``` - -Expected: cobertura similar à atual (100% em gamificationService.ts, ~80% global). - ---- - -### Task 5: Push e verificar CI no PR #100 - -**Files:** Nenhum (git push) - -- [ ] **Step 1: Push das alterações** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -git push -``` - -- [ ] **Step 2: Monitorar o CI do PR #100** - -Aguardar os checks: - -- ✅ `Quality Gates` deve passar (npm audit limpo, lint, format, typecheck) -- ✅ `Tests & Coverage` deve passar -- ✅ `E2E Tests` deve passar -- ✅ `SonarCloud Code Analysis` deve passar - -- [ ] **Step 3: Verificar o resultado final** - -```bash -gh pr checks 100 --repo EmiyaKiritsugu3/PWeb_Project -``` - -Expected: Todos os checks verdes, PR pronto para merge. - ---- - -### Fallback — Plano B (se overrides quebrarem algo) - -Se os overrides do OpenTelemetry causarem falhas nos testes ou na aplicação: - -- [ ] **Step B1: Reverter os overrides** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -git revert HEAD # reverte o commit dos overrides -``` - -- [ ] **Step B2: Alterar o CI workflow** - -Modificar `.github/workflows/ci.yml` linha 31: - -```yaml -# De: -run: npm audit --audit-level=high -# Para: -run: npm audit --audit-level=critical -``` - -Isso faz o Quality Gates ignorar vulnerabilidades HIGH (as 2 do OTel) e só falhar em CRITICAL. As 15 vulnerabilidades corrigíveis já foram resolvidas pelo `npm audit fix`. - -- [ ] **Step B3: Commit e push do fallback** - -```bash -cd /home/emiyakiritsugu/Projetos_Antigravity/PWeb_Project -git add .github/workflows/ci.yml -git commit -m "ci: lower audit threshold to critical — 2 unfixable OTel HIGH advisories accepted" -git push -``` - -- [ ] **Step B4: Documentar o risco aceito** - -Adicionar em `docs/security/BASELINE.json`: - -```json -{ - "accepted_advisories": [ - { - "id": "GHSA-q7rr-3cgh-j5r3", - "packages": ["@opentelemetry/auto-instrumentations-node", "@opentelemetry/sdk-node"], - "reason": "Transitive dependencies of genkit→@genkit-ai/google-cloud. Fix versions (>=0.75.0, >=0.218.0) not compatible with genkit's pinned OpenTelemetry API. Vulnerability requires Prometheus exporter endpoint exposure, which this application does not provide.", - "accepted_date": "2026-05-14", - "review_date": "2026-08-14" - } - ] -} -``` - ---- - -## Self-Review Checklist - -- [x] **Spec coverage**: Cada advisory do `npm audit` tem uma ação correspondente (Task 1 para os 15 corrigíveis, Task 2 para os 2 OTel) -- [x] **Placeholder scan**: Sem TBDs, TODOs, ou "implement later". Cada step tem comandos exatos e outputs esperados. -- [x] **Type consistency**: Os nomes de pacotes e versões são consistentes entre tasks. -- [x] **Fallback**: Plano B documentado caso os overrides quebrem algo. diff --git a/docs/superpowers/plans/2026-07-07-aluno-ui-10-fixes.md b/docs/superpowers/plans/2026-07-07-aluno-ui-10-fixes.md new file mode 100644 index 00000000..1f9a9752 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-aluno-ui-10-fixes.md @@ -0,0 +1,47 @@ +# Plan: 10 UI Fixes — Aluno Dashboard + +**Date:** 2026-07-07 +**Branch:** `feat/aluno-ui-10-fixes` +**Source:** Audit of PRD-UI-Aluno-Aprimoramento.md (25 tasks → 10 valid) +**Constraint:** ponytail — diff mínimo, zero deps novas + +--- + +## Phase 0 — Quick 1-liners + +| ID | File | Change | +|---|---|---| +| Q01 | `aluno-header.tsx:75` | aria-label typo fix | +| Q02 | `dashboard-client.tsx:241` | `justify-around` → `justify-center` | +| Q03 | `page.tsx:28` | brand name consistency | + +## Phase 1a — A11y + +| ID | File | Change | +|---|---|---| +| A01 | `card.tsx:30` | CardTitle `
` → `

` | +| A02 | `login/page.tsx` | autocomplete + type attributes | +| A03 | `login/page.tsx` | auth errors: toast → inline `role="alert"` | +| A04 | `circular-progress.tsx:61` | SVG `viewBox` + `aria-hidden` | + +## Phase 1b — UX + +| ID | File | Change | +|---|---|---| +| U01 | `card-treino.tsx` | press feedback on cards | +| U02 | `meus-treinos-client.tsx` | exercise names truncated below count | +| U03 | `aluno-header.tsx:89` | enable Perfil dropdown + stub route | + +## Phase 2 — Polish + +| ID | File | Change | +|---|---|---| +| P01 | `button.tsx:8` | ~~`rounded-md` → `rounded-full`~~ Reverted: DESIGN.md standardizes buttons on `rounded-md`; `rounded-full` contradicted spec. Kept `rounded-md`. | +| P02 | `DESIGN.md` | document Inter+Outfit split | + +--- + +## Gates +``` +npm run lint && npm run typecheck && npm test && npm run e2e +``` diff --git a/docs/superpowers/specs/2026-07-08-auth-redirect-fix-design.md b/docs/superpowers/specs/2026-07-08-auth-redirect-fix-design.md new file mode 100644 index 00000000..18e71671 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-auth-redirect-fix-design.md @@ -0,0 +1,251 @@ +# Auth Redirect Fix — Magic-Link + OAuth (Local / Preview / Production) + +**Date:** 2026-07-08 +**Branch:** `feat/aluno-ui-10-fixes` +**Status:** Approved design, pre-implementation + +## Problem + +Magic-link and OAuth (Google / GitHub / Apple) login silently fail in Vercel +Preview and Production deployments. Password login works. The E2E suite passes +locally only because it exercises the password path, not magic-link / OAuth. + +Investigation found **five root causes**, two of which are latent failures +masked by a hard throw: + +### Root cause 1 — `NEXT_PUBLIC_APP_URL` set to empty string in Production + +`src/lib/actions/auth.ts` `callbackUrl()` reads +`process.env.NEXT_PUBLIC_APP_URL` and **throws** when it is unset. Verified via +`vercel env pull production`: + +``` +NEXT_PUBLIC_APP_URL="" +``` + +An empty string is *set* (not `undefined`), so the nullish-coalescing fallback +to `VERCEL_URL` never fires — `callbackUrl()` builds +`"" + "/auth/callback?next=..."` → malformed URL → Supabase rejects the +redirect / OAuth provider 400s. This is worse than the throw-on-unset documented +in the code: `??` cannot distinguish `undefined` from `""`. + +### Root cause 2 — `VERCEL_URL` fallback absent + +Vercel auto-injects `VERCEL_URL` (e.g. `smartmanagementsystem-abc123-...`) +for every preview deployment. The current code never reads it, so even if +`NEXT_PUBLIC_APP_URL` were unset, preview deploys would still break. + +### Root cause 3 — `supabase/config.toml` allowlist mismatches + +```toml +site_url = "http://127.0.0.1:3000" +additional_redirect_urls = ["https://127.0.0.1:3000"] +``` + +- Host `127.0.0.1` ≠ `localhost` — the Next.js dev server default binds + `localhost`, and browsers resolve `localhost` to `::1`/`127.0.0.1`, but + Supabase compares redirect URLs as strings; `http://localhost:3000` ≠ + `http://127.0.0.1:3000`. +- Scheme `https://127.0.0.1:3000` — local dev is plain HTTP. HTTPS-on-local + in the allowlist is dead weight and never matches. +- No `localhost:3000`, no `*.vercel.app` glob, no production URL. + +### Root cause 4 — No preview / production allowlist entries + +Preview deployment URLs (`*-emiyakiritsugu3s-projects.vercel.app`) and the +canonical production alias (`smartmanagementsystem.vercel.app`) are absent +from the redirect allowlist. Supabase 400s any OAuth / magic-link redirect +not in the allowlist. + +### Root cause 5 — Cloud Supabase allowlist (Dashboard) likely stale + +The local `config.toml` is applied via `supabase db push` only for local CLI. +The **cloud** Supabase project used by Vercel deploys has its own allowlist in +the Dashboard (Authentication → URL Configuration → Redirect URLs). It must +mirror Fix B's entries or cloud auth fails regardless of code. + +## Non-goals + +- No new dependencies (Supabase JS / `@supabase/ssr` already installed). +- No refactor of `validateNext` (open-redirect guard already correct — PR #194 + remediation shipped it). +- No changes to password login (works). +- No changes to the OAuth provider client config in Supabase Dashboard (out of + scope; providers already configured). + +## Affected surfaces + +| File / surface | Change | +|---|---| +| `src/lib/actions/auth.ts` | Rewrite `callbackUrl()` — empty-string guard + `NEXT_PUBLIC_VERCEL_URL` fallback + localhost default. No throw. | +| `supabase/config.toml` | `site_url` → `http://localhost:3000`; `additional_redirect_urls` → localhost + vercel.app glob + prod. | +| `.env.example` | Add comment: Preview should leave `NEXT_PUBLIC_APP_URL` unset for `NEXT_PUBLIC_VERCEL_URL` fallback. Prod must set canonical URL. | +| Vercel env (Production) | Remove empty `NEXT_PUBLIC_APP_URL`, set `https://smartmanagementsystem.vercel.app`. | +| Vercel env (Preview) | Leave `NEXT_PUBLIC_APP_URL` unset — `NEXT_PUBLIC_VERCEL_URL` fallback applies. | +| Cloud Supabase Dashboard | Manual: mirror allowlist from Fix B. (Documented step, not code.) | +| `src/lib/actions/auth.test.ts` | New test file — `callbackUrl` resolution tiers. | + +`src/app/auth/callback/route.ts` is **unchanged** — it already resolves +`validatedNext` via the shared `validateNext` helper and uses +`request.nextUrl.origin` (the live request origin, which is automatically in +the allowlist because Supabase sent the redirect there). The bug is purely in +**building** the redirect URL in the server action, not **consuming** it. + +## Design + +### Fix A — `callbackUrl()` tiered resolution + +```ts +function callbackUrl(next?: string | null): string { + // .trim() before nullish: Vercel Production has NEXT_PUBLIC_APP_URL="" + // (empty string set, not undefined). "" is falsy but not nullish, so + // explicit || null converts falsy → null for ?? to fall through. + const explicit = process.env.NEXT_PUBLIC_APP_URL?.trim(); + const base = + (explicit || null) + ?? (process.env.NEXT_PUBLIC_VERCEL_URL ? 'https://' + process.env.NEXT_PUBLIC_VERCEL_URL : null) + ?? 'http://localhost:3000'; + const validated = validateNext(next); + const safeNext = validated === '/' ? '/login' : validated; + return base + AUTH_CALLBACK_PATH + '?next=' + encodeURIComponent(safeNext); +} +``` + +Resolution tiers (first non-empty wins): + +1. **`NEXT_PUBLIC_APP_URL`** (trimmed) — Production. Operator sets canonical + domain. Trim guards against accidental `" "` / `""`. +2. **`NEXT_PUBLIC_VERCEL_URL`** — Preview deployments. Vercel auto-injects + per-deploy hostname; prefix `https://`. This URL is unique per deployment, + so it must be covered by the `**.vercel.app/*` glob in the Supabase allowlist. +3. **`http://localhost:3000`** — Local dev default. No env needed. Tests run + on this tier. + +No throw. Local dev works zero-config. Empty-string in prod falls through +instead of building a broken `""/auth/callback` URL. + +`validateNext` + `'/'` → `/login` mapping preserved exactly (open-redirect +guard from PR #194 unchanged). + +### Fix B — `supabase/config.toml` + +```toml +site_url = "http://localhost:3000" +additional_redirect_urls = [ + "http://localhost:3000/auth/callback", + "https://**.vercel.app/auth/callback", + "https://smartmanagementsystem.vercel.app/auth/callback" +] +``` + +- `localhost` (not `127.0.0.1`) — matches dev server bind + browser resolution. +- `http://localhost:3000/auth/callback` (not `https`) — local is plain HTTP. +- `https://**.vercel.app/auth/callback` — Supabase wildcard. Per Context7 + (supabase.com/docs/guides/auth/redirect-urls): `*` matches a sequence of + non-separator chars (separators are `.` and `/`), `**` matches any sequence + of chars. Preview deploy hostnames are multi-hyphen-group + (`smartmanagementsystem-6rfj2hapr-emiyakiritsugu3s-projects.vercel.app`) — + `-` is not a separator, so a single `*` could span it, but `**` is the + documented, unambiguous choice for "any sequence of characters" and is the + Supabase-recommended pattern for Netlify/Vercel preview URLs. +- `https://smartmanagementsystem.vercel.app/auth/callback` — Production + canonical alias. Also covered by the glob, but listed explicitly so a future + allowlist-glob regression can't break prod silently. Supabase docs recommend + exact paths for production even though `**` covers preview/localhost. + +`/auth/callback` suffix included because `callbackUrl()` builds +`${base}${AUTH_CALLBACK_PATH}` with `AUTH_CALLBACK_PATH = '/auth/callback'` +(see `src/lib/constants.ts`). Supabase matches on the full URL string. + +### Fix C — Vercel env (Production) + +Remove the empty-string value, set the canonical production URL: + +```bash +vercel env rm NEXT_PUBLIC_APP_URL production +echo "https://smartmanagementsystem.vercel.app" | vercel env add NEXT_PUBLIC_APP_URL production +``` + +Manual confirmation: `vercel env pull /tmp/v.json --environment production` → +`NEXT_PUBLIC_APP_URL=https://smartmanagementsystem.vercel.app`. + +### Fix D — Vercel env (Preview) + +Intentionally **unset** `NEXT_PUBLIC_APP_URL` in Preview. With `callbackUrl()` +fixed, preview falls to the `NEXT_PUBLIC_VERCEL_URL` tier — each preview deploy gets its +own deploy-host callback URL, all matching the allowlist glob. + +### Fix E — Cloud Supabase Dashboard + +Non-code step. In Supabase Dashboard → the cloud project used by Vercel → +Authentication → URL Configuration: + +- **Site URL:** `http://localhost:3000` (or the prod URL; Supabase uses this as + the default when no redirect is specified — local is safe since we always + pass an explicit `emailRedirectTo` / `redirectTo`). +- **Redirect URLs:** mirror Fix B's `additional_redirect_urls`. + +If the cloud allowlist does not contain the vercel.app glob and the prod URL, +cloud deploys fail regardless of the code fix. This is the most likely reason +magic-link / OAuth are broken in Vercel Preview **today** even after the +code fix lands — the cloud allowlist must be updated in lockstep. + +### Fix F — `.env.example` + +Annotate the existing line: + +``` +# Preview deploys: leave unset so callbackUrl() falls back to NEXT_PUBLIC_VERCEL_URL. +# Production: set to the canonical domain (e.g. https://smartmanagementsystem.vercel.app). +NEXT_PUBLIC_APP_URL=http://localhost:3000 +``` + +### Fix G — Test + +New file `src/lib/actions/auth.test.ts`, vitest. Covers the resolution tiers +via env manipulation in `beforeEach` / `afterEach`: + +1. `NEXT_PUBLIC_APP_URL` set → used (trimmed if whitespace). +2. `NEXT_PUBLIC_APP_URL=""` → falls to `NEXT_PUBLIC_VERCEL_URL`. +3. `NEXT_PUBLIC_APP_URL` unset + `NEXT_PUBLIC_VERCEL_URL` set → `https://${NEXT_PUBLIC_VERCEL_URL}`. +4. Both unset → `http://localhost:3000`. +5. `next` param validated by `validateNext` (`/aluno/../admin` → `/admin` + rejected → `/login`; `/aluno/dashboard` → preserved). +6. `'/'` → `/login` mapping. + +`callbackUrl` is module-private. The test imports the public +`signInWithMagicLink` / `signInWithGoogle` and asserts the constructed +redirect URL was passed to `supabase.auth.signInWithOtp` / +`signInWithOAuth` via a mocked `@supabase/ssr` server client. This tests the +tier resolution through the real function rather than exporting `callbackUrl` +just for the test (no export-add-for-test). + +## Verification + +After implementation: + +```bash +npm run typecheck # strict +npm test # new callbackUrl tier tests pass + existing 1159 green +npm run e2e # 21/21 (password path — magic-link/OAuth are cloud-only) +``` + +Cloud-only verification (manual, post-deploy): + +1. Deploy to Vercel preview. +2. Visit preview `/aluno/login`, click `Entrar com Google`. +3. Confirm redirect to Google → back to `https://.vercel.app/auth/callback?...` → `/aluno/dashboard`. +4. Repeat for GitHub, Apple, magic-link email. + +## Rollback + +Pure config + env. Revert `auth.ts` `callbackUrl` to the throw version, +restore `config.toml` `site_url`/`additional_redirect_urls`, unset the Vercel +prod env. No schema migration, no DB change, no data loss path. + +## Scope check + +Single implementation plan. One source file (`auth.ts`) rewrite of one helper +(~8 lines), one config file (`config.toml`), one env doc (`.env.example`), +one test file (`auth.test.ts`), plus out-of-band Vercel CLI + Supabase +Dashboard steps. No decomposition needed. diff --git a/src/app/aluno/aluno-header.test.tsx b/src/app/aluno/aluno-header.test.tsx index ae89e479..71cf5a3f 100644 --- a/src/app/aluno/aluno-header.test.tsx +++ b/src/app/aluno/aluno-header.test.tsx @@ -26,6 +26,10 @@ vi.mock('next/link', () => ({ ), })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + vi.mock('@/components/ui/avatar', () => ({ Avatar: ({ children }: { children: ReactNode }) =>
{children}
, AvatarFallback: ({ children }: { children: ReactNode }) => {children}, diff --git a/src/app/aluno/aluno-header.tsx b/src/app/aluno/aluno-header.tsx index 309e44d1..e9977b0b 100644 --- a/src/app/aluno/aluno-header.tsx +++ b/src/app/aluno/aluno-header.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -65,6 +66,7 @@ export function UserMenu({ onLogout: () => void; }>) { const { t } = useI18n(); + const router = useRouter(); return ( @@ -72,7 +74,7 @@ export function UserMenu({ +

), })); @@ -115,6 +138,37 @@ vi.mock('@/components/dashboard/aluno/card-feedback', () => ({ CardFeedback: () =>
, })); +// WorkoutSession gates the onFinish/onCancel contract handleFinishWorkout uses. +vi.mock('@/components/WorkoutSession', () => ({ + WorkoutSession: ({ + onFinish, + onCancel, + }: { + treino: Treino; + onFinish: (h: Omit) => void; + onCancel: () => void; + }) => ( +
+ + +
+ ), +})); + vi.mock('@/ai/flows/workout-feedback-flow', () => ({ generateWorkoutFeedback: (...args: unknown[]) => mockGenerateWorkoutFeedback(...args), })); @@ -249,4 +303,63 @@ describe('AlunoDashboardClient', () => { expect(mockNotify.error).toHaveBeenCalledWith('Erro ao salvar treino'); }); }); + + // O9FaX: clicking "Start Workout" toggles treinoEmSessao state, swapping + // the dashboard render for the WorkoutSession gate. + it('toggles WorkoutSession on start workout click', () => { + render(); + expect(screen.getByTestId('card-treino')).toBeTruthy(); + expect(screen.queryByTestId('workout-session')).toBeNull(); + + fireEvent.click(screen.getByTestId('btn-start-workout')); + + expect(screen.queryByTestId('card-treino')).toBeNull(); + expect(screen.getByTestId('workout-session')).toBeTruthy(); + }); + + it('cancels WorkoutSession back to dashboard', () => { + render(); + fireEvent.click(screen.getByTestId('btn-start-workout')); + expect(screen.getByTestId('workout-session')).toBeTruthy(); + + fireEvent.click(screen.getByTestId('btn-session-cancel')); + expect(screen.queryByTestId('workout-session')).toBeNull(); + expect(screen.getByTestId('card-treino')).toBeTruthy(); + }); + + // PMTVI: successful handleFinishWorkout must call router.refresh() so the + // mounted client re-fetches RSC (XP/level/streak) — props stay stale otherwise. + it('calls router.refresh after registrarHistoricoTreinoAction success', async () => { + render(); + fireEvent.click(screen.getByTestId('btn-start-workout')); + fireEvent.click(screen.getByTestId('btn-session-finish')); + + await waitFor(() => { + expect(mockRegistrarHistorico).toHaveBeenCalledWith( + expect.objectContaining({ treinoId: 'treino-1', duracaoMinutos: 30 }) + ); + expect(mockNotify.success).toHaveBeenCalledWith( + 'Treino Finalizado!', + 'Seu progresso e XP foram salvos!' + ); + expect(mockRefresh).toHaveBeenCalled(); + }); + }); + + it('shows error notification without router.refresh on registrarHistorico failure', async () => { + mockRegistrarHistorico.mockResolvedValue({ success: false, error: 'DB down' }); + + render(); + fireEvent.click(screen.getByTestId('btn-start-workout')); + fireEvent.click(screen.getByTestId('btn-session-finish')); + + await waitFor(() => { + expect(mockNotify.error).toHaveBeenCalledWith( + 'Erro ao salvar histórico', + undefined, + expect.any(Error) + ); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/aluno/dashboard/dashboard-client.tsx b/src/app/aluno/dashboard/dashboard-client.tsx index faab65a2..ddfcf2e3 100644 --- a/src/app/aluno/dashboard/dashboard-client.tsx +++ b/src/app/aluno/dashboard/dashboard-client.tsx @@ -1,15 +1,18 @@ 'use client'; import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { motion } from 'motion/react'; import { Card } from '@/components/ui/card'; import { Trophy, Zap, Target, Award } from 'lucide-react'; -import type { Treino, Aluno, Exercicio } from '@/lib/definitions'; +import type { Treino, Aluno, Exercicio, HistoricoTreino } from '@/lib/definitions'; import { Logger } from '@/lib/logger'; import { finalizarTreinoAction } from '@/lib/actions/alunos'; +import { registrarHistoricoTreinoAction } from '@/lib/actions/treinos'; import { useAppNotification } from '@/hooks/use-app-notification'; import { CircularProgress } from '@/components/ui/circular-progress'; import { useI18n } from '@/components/providers/i18n-provider'; +import { WorkoutSession } from '@/components/WorkoutSession'; import { ExercicioViewer } from '@/components/dashboard/aluno/exercicio-viewer'; import { CardMatricula } from '@/components/dashboard/aluno/card-matricula'; @@ -27,11 +30,13 @@ export default function AlunoDashboardClient({ }: Readonly) { const notify = useAppNotification(); const { t } = useI18n(); + const router = useRouter(); const [feedback, setFeedback] = useState<{ title: string; message: string } | null>(null); const [isFeedbackLoading, setIsFeedbackLoading] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false); const [selectedExercicio, setSelectedExercicio] = useState(null); + const [treinoEmSessao, setTreinoEmSessao] = useState(false); const handleViewExercicio = (exercicio: Exercicio) => { setSelectedExercicio(exercicio); @@ -77,6 +82,33 @@ export default function AlunoDashboardClient({ } }; + const handleFinishWorkout = async (historico: Omit) => { + try { + const res = await registrarHistoricoTreinoAction(historico); + if (res.success) { + notify.success('Treino Finalizado!', 'Seu progresso e XP foram salvos!'); + // revalidatePath in the action invalidates the cache but this mounted + // client keeps stale `aluno` props (XP/level/streak). router.refresh() + // re-fetches the RSC payload so the dashboard reflects the new state. + router.refresh(); + } else { + throw new Error(res.error); + } + } catch (error) { + notify.error('Erro ao salvar histórico', undefined, error); + } + }; + + if (treinoEmSessao && initialTreino) { + return ( + setTreinoEmSessao(false)} + /> + ); + } + const xpToNextLevel = aluno.xpToNextLevel as number; const progressPerc = aluno.progressPerc as number; @@ -176,6 +208,7 @@ export default function AlunoDashboardClient({ onFinishTraining={handleFinishTraining} isFeedbackLoading={isFeedbackLoading} onViewExercicio={handleViewExercicio} + onStartWorkout={() => setTreinoEmSessao(true)} /> @@ -238,7 +271,7 @@ export default function AlunoDashboardClient({ CONQUISTAS
-
+
diff --git a/src/app/aluno/login/page.test.tsx b/src/app/aluno/login/page.test.tsx index 44d331e4..06046613 100644 --- a/src/app/aluno/login/page.test.tsx +++ b/src/app/aluno/login/page.test.tsx @@ -128,8 +128,11 @@ describe('AlunoLoginPage', () => { // PRD-7: default creds removed from aluno/login — tests must type valid creds // before submit (zodResolver blocks empty fields, signIn never called). + // Note: there are now 2 inputs with placeholder 'seu@email.com' (magic link + form). + // We target the last one (the form email field). const typeValidCreds = () => { - fireEvent.change(screen.getByPlaceholderText('seu@email.com'), { + const emailInputs = screen.getAllByPlaceholderText('seu@email.com'); + fireEvent.change(emailInputs[emailInputs.length - 1], { target: { value: 'ana.silva@example.com' }, }); fireEvent.change(screen.getByPlaceholderText('••••••••'), { @@ -192,11 +195,7 @@ describe('AlunoLoginPage', () => { await waitFor(() => { expect(mockSignUp).toHaveBeenCalled(); - expect(mockNotify.error).toHaveBeenCalledWith( - 'Erro de autenticação', - expect.any(String), - expect.any(Object) - ); + expect(screen.getByRole('alert')).toHaveTextContent('Signup failed'); expect(mockPush).not.toHaveBeenCalled(); }); }); @@ -216,4 +215,51 @@ describe('AlunoLoginPage', () => { expect(mockSignUp).not.toHaveBeenCalled(); }); }); + + // PMTVN: account-enumeration guard — signUp "User already registered" must + // surface as the generic credential error, not leak the registration leak. + it('masks "User already registered" as generic credential error', async () => { + mockSignInWithPassword.mockResolvedValue({ + data: {}, + error: { message: 'Invalid login credentials' }, + }); + mockSignUp.mockResolvedValue({ + data: {}, + error: { message: 'User already registered' }, + }); + + render(); + typeValidCreds(); + const form = screen.getByRole('button', { name: /entrar/i }).closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('E-mail ou senha inválidos. Por favor, tente novamente.'); + expect(alert).not.toHaveTextContent('User already registered'); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + it('masks "User has already been registered" variant', async () => { + mockSignInWithPassword.mockResolvedValue({ + data: {}, + error: { message: 'Invalid login credentials' }, + }); + mockSignUp.mockResolvedValue({ + data: {}, + error: { message: 'User has already been registered' }, + }); + + render(); + typeValidCreds(); + const form = screen.getByRole('button', { name: /entrar/i }).closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('E-mail ou senha inválidos. Por favor, tente novamente.'); + expect(alert).not.toHaveTextContent('registered'); + }); + }); }); diff --git a/src/app/aluno/login/page.tsx b/src/app/aluno/login/page.tsx index ecfc6fd5..5bc7a93e 100644 --- a/src/app/aluno/login/page.tsx +++ b/src/app/aluno/login/page.tsx @@ -22,12 +22,18 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; -import { Dumbbell } from 'lucide-react'; +import { Dumbbell, Eye, EyeOff } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import Link from 'next/link'; import { Separator } from '@/components/ui/separator'; import { createClient } from '@/utils/supabase/client'; +import { + signInWithGoogle, + signInWithGitHub, + signInWithApple, + signInWithMagicLink, +} from '@/lib/actions/auth'; const formSchema = z.object({ email: z.string().email('Por favor, insira um email válido.'), @@ -40,6 +46,12 @@ export default function AlunoLoginPage() { const notify = useAppNotification(); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [authError, setAuthError] = useState(null); + const [magicLinkEmail, setMagicLinkEmail] = useState(''); + const [magicLinkSent, setMagicLinkSent] = useState(false); + const [magicLinkError, setMagicLinkError] = useState(null); + const [magicLinkLoading, setMagicLinkLoading] = useState(false); const supabase = createClient(); const form = useForm({ @@ -52,6 +64,7 @@ export default function AlunoLoginPage() { const handleFormSubmit = async (data: FormValues) => { setIsLoading(true); + setAuthError(null); try { const { error } = await supabase.auth.signInWithPassword({ email: data.email, @@ -83,13 +96,59 @@ export default function AlunoLoginPage() { notify.success('Login bem-sucedido!'); router.push('/aluno/dashboard'); } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido'; - notify.error('Erro de autenticação', errorMessage, error); + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'object' && error && 'message' in error + ? String((error as { message: unknown }).message) + : 'Erro desconhecido'; + // Avoid account enumeration: if the signUp fallback discovers the email + // is already registered, surface the same generic credential error as a + // failed login rather than leaking "User already registered" to callers. + const message = /already\s+(been\s+)?registered|user\s+already/i.test(rawMessage) + ? 'E-mail ou senha inválidos. Por favor, tente novamente.' + : rawMessage; + setAuthError(message); } finally { setIsLoading(false); } }; + const handleOAuthSignIn = async (action: () => Promise<{ url?: string; error?: string }>) => { + setAuthError(null); + const result = await action(); + if (result.url) { + window.location.href = result.url; + } else if (result.error) { + setAuthError(result.error); + } + }; + + const handleGoogleSignIn = () => handleOAuthSignIn(() => signInWithGoogle('/aluno/dashboard')); + const handleGitHubSignIn = () => handleOAuthSignIn(() => signInWithGitHub('/aluno/dashboard')); + const handleAppleSignIn = () => handleOAuthSignIn(() => signInWithApple('/aluno/dashboard')); + + const handleMagicLinkSubmit = async () => { + if (!magicLinkEmail) return; + setMagicLinkLoading(true); + setMagicLinkError(null); + setMagicLinkSent(false); + + const formData = new FormData(); + formData.append('email', magicLinkEmail); + + try { + const result = await signInWithMagicLink(formData); + if (result.error) { + setMagicLinkError(result.error); + } else { + setMagicLinkSent(true); + } + } finally { + setMagicLinkLoading(false); + } + }; + return (
{/* Animated Background Elements */} @@ -119,7 +178,133 @@ export default function AlunoLoginPage() { Acesse seu treino e informações usando sua conta. - + + {authError && ( +
+ {authError} +
+ )} + + {/* Social Login Buttons */} +
+ + + +
+ + {/* Separator */} +
+ + + OU + + +
+ + {/* Magic Link Section */} +
+ { + setMagicLinkEmail(e.target.value); + setMagicLinkSent(false); + setMagicLinkError(null); + }} + className="bg-background/50 border-white/5 focus-visible:ring-primary/50 transition-all" + /> + + {magicLinkSent && ( +

+ Email enviado! Verifique sua caixa de entrada. +

+ )} + {magicLinkError &&

{magicLinkError}

} +
+ + {/* Separator */} +
+ + + OU + + +
+ + {/* Email/Password Login */}
@@ -150,12 +339,29 @@ export default function AlunoLoginPage() { Senha - +
+ + +
diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx index 65f5e30c..56e66799 100644 --- a/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx +++ b/src/app/aluno/meus-treinos/meus-treinos-client.test.tsx @@ -233,4 +233,20 @@ describe('MeusTreinosClient', () => { expect(screen.getByText('Gere um plano com IA ou crie manualmente para começar.')).toBeTruthy(); expect(screen.getByText('Criar primeiro treino')).toBeTruthy(); }); + + // O8Gaq: exercise names render under treino title, sliced to first 3, joined. + it('renders exercise names on treino cards', () => { + render(); + expect(screen.getByText('Supino')).toBeTruthy(); + expect(screen.getByText('Corrida')).toBeTruthy(); + }); + + it('omits exercise name list when treino has no exercises', () => { + const emptyExTreino: Treino[] = [ + { id: 't-empty', alunoId: 'user-1', objetivo: 'Vazio', diaSemana: 2, exercicios: [] }, + ]; + render(); + expect(screen.getByText('Vazio')).toBeTruthy(); + expect(screen.getByText('0 exercícios')).toBeTruthy(); + }); }); diff --git a/src/app/aluno/meus-treinos/meus-treinos-client.tsx b/src/app/aluno/meus-treinos/meus-treinos-client.tsx index 9cd54441..29195edd 100644 --- a/src/app/aluno/meus-treinos/meus-treinos-client.tsx +++ b/src/app/aluno/meus-treinos/meus-treinos-client.tsx @@ -181,6 +181,14 @@ export default function MeusTreinosClient({

{treino.exercicios.length} exercícios + {treino.exercicios.length > 0 && ( + + {treino.exercicios + .slice(0, 3) + .map((e) => e.nomeExercicio) + .join(', ')} + + )}

{allowEditing && ( diff --git a/src/app/aluno/perfil/page.tsx b/src/app/aluno/perfil/page.tsx new file mode 100644 index 00000000..f24c69bc --- /dev/null +++ b/src/app/aluno/perfil/page.tsx @@ -0,0 +1,10 @@ +import { PageHeader } from '@/components/page-header'; + +export default function AlunoPerfilPage() { + return ( + + ); +} diff --git a/src/app/auth/callback/route.test.ts b/src/app/auth/callback/route.test.ts new file mode 100644 index 00000000..3359f4ab --- /dev/null +++ b/src/app/auth/callback/route.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +const mockExchangeCodeForSession = vi.fn(); + +vi.mock('@supabase/ssr', () => ({ + createServerClient: vi.fn(() => ({ + auth: { exchangeCodeForSession: mockExchangeCodeForSession }, + })), +})); + +let capturedRedirectUrl: string | null = null; + +vi.mock('next/server', async () => { + const actual = await vi.importActual('next/server'); + return { + ...actual, + NextResponse: { + redirect: vi.fn((url: string | URL) => { + capturedRedirectUrl = typeof url === 'string' ? url : url.toString(); + return { cookies: { set: vi.fn() }, headers: { set: vi.fn() } }; + }), + }, + }; +}); + +import { GET } from './route'; + +beforeEach(() => { + vi.clearAllMocks(); + capturedRedirectUrl = null; + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY = 'pk_test'; +}); + +function req(url: string) { + const u = new URL(url); + const nextUrl = { + clone: () => new URL(u.toString()), + pathname: u.pathname, + searchParams: u.searchParams, + search: u.search, + }; + return { nextUrl, cookies: { getAll: vi.fn().mockReturnValue([]) } } as unknown as NextRequest; +} + +describe('GET /auth/callback', () => { + it('redirects to login when no code present', async () => { + await GET(req('http://localhost:3001/auth/callback')); + expect(capturedRedirectUrl).toContain('/login'); + expect(capturedRedirectUrl).toContain('error=auth_callback_failed'); + }); + + it('exchanges code and redirects to validated next path', async () => { + mockExchangeCodeForSession.mockResolvedValue({ error: null }); + await GET(req('http://localhost:3001/auth/callback?code=valid&next=/aluno/dashboard')); + expect(mockExchangeCodeForSession).toHaveBeenCalledWith('valid'); + expect(capturedRedirectUrl).toContain('/aluno/dashboard'); + }); + + it('redirects to login on exchange failure', async () => { + mockExchangeCodeForSession.mockResolvedValue({ error: new Error('bad') }); + await GET(req('http://localhost:3001/auth/callback?code=bad&next=/dashboard')); + expect(capturedRedirectUrl).toContain('/login'); + expect(capturedRedirectUrl).toContain('error=auth_callback_failed'); + }); + + it('prevents open redirect to external URL', async () => { + mockExchangeCodeForSession.mockResolvedValue({ error: null }); + await GET(req('http://localhost:3001/auth/callback?code=ok&next=https://evil.com')); + expect(capturedRedirectUrl).not.toContain('evil.com'); + expect(capturedRedirectUrl).toContain('/login'); + }); + + it('defaults to /login when next is missing', async () => { + mockExchangeCodeForSession.mockResolvedValue({ error: null }); + await GET(req('http://localhost:3001/auth/callback?code=ok')); + expect(capturedRedirectUrl).toContain('/login'); + }); +}); diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 00000000..d4777846 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,77 @@ +import * as Sentry from '@sentry/nextjs'; +import { createServerClient } from '@supabase/ssr'; +import { type NextRequest, NextResponse } from 'next/server'; +import { validateNext } from '@/lib/auth-redirect'; + +export async function GET(request: NextRequest) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY; + + if (!supabaseUrl || !supabaseKey) { + const errorUrl = request.nextUrl.clone(); + errorUrl.pathname = '/login'; + errorUrl.searchParams.set('error', 'auth_callback_failed'); + return NextResponse.redirect(errorUrl); + } + + const { searchParams } = request.nextUrl; + const code = searchParams.get('code'); + const next = searchParams.get('next') ?? '/login'; + + // Open-redirect prevention: single shared helper so this route and the auth + // server actions cannot drift. validateNext rejects protocol-relative + // (`//`), path traversal (`/aluno/../admin`), and sibling-prefix spoofing + // (`/alunox`). Falls back to '/login' for OAuth callback context. + const validatedRoot = validateNext(next); + const validatedNext = validatedRoot === '/' ? '/login' : validatedRoot; + + if (!code) { + const errorUrl = request.nextUrl.clone(); + errorUrl.pathname = '/login'; + errorUrl.searchParams.set('error', 'auth_callback_failed'); + return NextResponse.redirect(errorUrl); + } + + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = validatedNext; + redirectUrl.search = ''; + + const response = NextResponse.redirect(redirectUrl); + + const supabase = createServerClient(supabaseUrl, supabaseKey, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet, headers) { + cookiesToSet.forEach(({ name, value, options }) => { + response.cookies.set(name, value, options); + }); + Object.entries(headers).forEach(([k, v]) => { + response.headers.set(k, v); + }); + }, + }, + }); + + const { error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + // Capture before redirect so auth failures are observable — a bare + // redirect here silently drops the Supabase exchange error. Strip the + // query string: request.url carries the OAuth `code`, a bearer-like + // credential that must not persist in third-party Sentry logs. + Sentry.captureException(error, { + extra: { + callbackUrl: request.nextUrl.origin + request.nextUrl.pathname, + next, + }, + }); + const errorUrl = request.nextUrl.clone(); + errorUrl.pathname = '/login'; + errorUrl.searchParams.set('error', 'auth_callback_failed'); + return NextResponse.redirect(errorUrl); + } + + return response; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 31da65cb..78f0cc86 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -15,6 +15,7 @@ import { Dumbbell } from 'lucide-react'; import Link from 'next/link'; import { Separator } from '@/components/ui/separator'; import { createClient } from '@/utils/supabase/client'; +import { signInWithGoogle, signInWithGitHub, signInWithApple } from '@/lib/actions/auth'; export default function LoginPage() { const [error, setError] = useState(''); @@ -56,6 +57,20 @@ export default function LoginPage() { window.location.href = '/dashboard'; }; + const handleOAuthLogin = async (action: () => Promise<{ url?: string; error?: string }>) => { + setError(''); + const result = await action(); + if (result.url) { + window.location.href = result.url; + } else if (result.error) { + setError(result.error); + } + }; + + const handleGoogleLogin = () => handleOAuthLogin(() => signInWithGoogle('/dashboard')); + const handleGitHubLogin = () => handleOAuthLogin(() => signInWithGitHub('/dashboard')); + const handleAppleLogin = () => handleOAuthLogin(() => signInWithApple('/dashboard')); + return (
{/* Animated Background Elements */} @@ -133,6 +148,88 @@ export default function LoginPage() {
+ + {/* Separator above social */} +
+ + + OU + + +
+ + {/* Social login buttons */} +
+ + + +
+ + {/* Separator below social */} +
+ + +
diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index 40a4bc06..4b3c6b3a 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -43,8 +43,7 @@ vi.mock('lucide-react', () => ({ describe('LandingPage', () => { it('renders the academy name', () => { render(); - expect(screen.getByText('Academia')).toBeTruthy(); - expect(screen.getByText('Five Star')).toBeTruthy(); + expect(screen.getByText('Five Star Gym')).toBeTruthy(); }); it('renders the hero description', () => { diff --git a/src/app/page.tsx b/src/app/page.tsx index ce7ac254..2e83a042 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,7 +12,7 @@ export default function LandingPage() {
Academia Five Star - Interior moderno e de alta performance

- Academia Five Star + Five Star Gym

O ambiente definitivo para alcançar seu potencial máximo. Gestão inteligente, treinos diff --git a/src/components/dashboard/aluno/card-treino.tsx b/src/components/dashboard/aluno/card-treino.tsx index 5b51d52c..2e863882 100644 --- a/src/components/dashboard/aluno/card-treino.tsx +++ b/src/components/dashboard/aluno/card-treino.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; -import { CalendarOff, Sparkles, BrainCircuit, Info } from 'lucide-react'; +import { CalendarOff, Sparkles, BrainCircuit, Info, Play } from 'lucide-react'; import { CircularProgress } from '@/components/ui/circular-progress'; import { cn } from '@/lib/utils'; import type { Treino, Exercicio } from '@/lib/definitions'; @@ -20,6 +20,7 @@ interface CardTreinoProps { onFinishTraining: (completedExercises: string[]) => void; isFeedbackLoading: boolean; onViewExercicio: (exercicio: Exercicio) => void; + onStartWorkout?: () => void; } export function CardTreino({ @@ -27,6 +28,7 @@ export function CardTreino({ onFinishTraining, isFeedbackLoading, onViewExercicio, + onStartWorkout, }: Readonly) { const { checkedExercises, handleCheckChange } = useWorkoutTracker(treino); @@ -82,7 +84,7 @@ export function CardTreino({

@@ -124,6 +126,17 @@ export function CardTreino({ ))} + {onStartWorkout && ( + + )}