Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/adr/0005-token-bearer-no-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 0005 · Token Bearer no client, não cookie httpOnly

**Status:** aceito

## Contexto

A issue A04 pedia guardar o token de sessão em cookie `httpOnly` via rota de servidor do Nuxt, nunca acessível a JavaScript do navegador — o jeito mais seguro contra roubo de token por XSS.

## Decisão

O `front-admin` continua com o padrão do template `base_nuxt_modular`: token Bearer guardado em `authStore` (Pinia) e persistido em `localStorage`, enviado pelo `HttpClient` como `Authorization: Bearer <token>`.

## Por quê

O app do participante (Flutter, `base_clean_arch_bloc`) vai consumir a mesma API com o mesmo token Sanctum. Cookie `httpOnly` só existe no navegador — um app mobile não tem como usá-lo. Ter dois mecanismos de autenticação (cookie pro admin web, Bearer pro app) duplicaria a lógica de emissão/revogação de sessão no backend e a superfície de teste, sem benefício pro mobile. Unificar em Bearer mantém web e app no mesmo fluxo de login/logout/expiração.

## Risco aceito e mitigação

Token em `localStorage` fica exposto a um XSS bem-sucedido no admin. Mitigações até agora: CSP e sanitização de entrada estão no escopo de B05 (segurança base, ainda não feita); o token tem TTL configurável no Sanctum; `EnsureIdempotencyKey`/rate limiting (B04) reduzem o dano de um token roubado em endpoints sensíveis. Revisitar esta decisão se o admin passar a lidar com dado de cartão ou algo de risco equivalente.

## Consequências

- `HttpClient`/`authStore` do `front-admin` não mudam de mecanismo nesta issue.
- Todo endpoint de autenticação do backend (`Identity`, quando existir) deve devolver `{user, token}` — formato que `useAuthSubmit` já espera.
26 changes: 16 additions & 10 deletions front-admin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ app/
composables/ # cross-cutting, not owned by one module (useLogout, useAuthSubmit)
services/ # backend calls not owned by one module (authService)
infrastructure/
http/HttpClient.ts # the only place that calls fetch() — Bearer auth, Laravel-shaped errors
http/HttpClient.ts # the only place that calls fetch() — Bearer auth, API-envelope errors
http/api-types.d.ts # gerado por `npm run api:gen` a partir do OpenAPI do Scramble — não editar à mão
modules/
login/ # reference module
types/index.ts
Expand All @@ -33,7 +34,8 @@ app/
locales/pt-br/index.ts
router/index.ts # documental — Nuxt file-based routing resolves navigation today
stores/
authStore.ts # the one legitimate global (Pinia) store — session data
authStore.ts # session data
editionStore.ts # edição ativa do painel — lido em quase toda tela
pages/
login.vue # wires the login module into Nuxt's file-based router
index.vue
Expand Down Expand Up @@ -76,15 +78,19 @@ Tokens oficiais em `../docs/design/tema-sertao-tokens.png`, aplicados em `app/as

[DaisyUI](https://daisyui.com/) is wired in `app/assets/css/main.css` via `@plugin 'daisyui';` (Tailwind v4 CSS-first config, no JS config file). `LoginForm.vue` demonstrates the intended usage: `fieldset`/`fieldset-legend`/`label` for form fields, `input`/`input-error`, `btn`/`btn-primary`/`btn-ghost`, `alert`/`alert-error` — component classes, not raw utilities, for anything that's a form control, button, or alert. See `.claude/rules/architecture.md`, "UI kit: DaisyUI".

## Why the backend error shape is opinionated
## Cliente de API

`HttpClient.ts` expects a Laravel-style `{message, errors}` JSON body on non-2xx responses, and Bearer-token auth matching a Sanctum-style `{user, token}` login response. This isn't generic REST client boilerplate — it's built to pair with `base_laravel_modular`'s `apps/backend`. See `.claude/rules/architecture.md` for what that backend still needs to build (`POST /auth/login`, `POST /auth/logout`) before this frontend's `login` module is more than a working shape.
`HttpClient.ts` espera o envelope de erro da API (`../docs/api.md`): `{code, message, errors, request_id}` em toda resposta não-2xx, e autenticação Bearer batendo com login estilo Sanctum (`{user, token}`). `HttpClientError` carrega `statusCode`, `fieldErrors`, `code` e `requestId`.

## Roadmap (not built yet)
- **Tipos gerados do contrato**: `npm run api:gen` lê `http://localhost:8000/docs/api.json` (backend rodando) e escreve `app/infrastructure/http/api-types.d.ts`, commitado — mudar o contrato da API sem rodar `api:gen` de novo quebra o typecheck do front. Ver `app/application/shared/services/pingService.ts` como referência de serviço tipado a partir de `operations['<nome>']`.
- **401 em requisição autenticada** chama o handler registrado por `httpClient.setUnauthorizedHandler()` — hoje só `app/plugins/auth.client.ts` registra (limpa `authStore`, navega pro `/login`). Um 401 sem token (ex.: login errado) não dispara isso — `HttpClient` só decide que a sessão expirou quando *tinha* token.
- **Token Bearer, não cookie httpOnly** — decisão registrada em `docs/adr/0005-token-bearer-no-client.md` (o app mobile também consome a mesma API com o mesmo token).
- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa — hoje só em memória, sem API por trás; liga no módulo Community quando ele existir.

- No generator/skill scaffolding a new module (`base_laravel_modular` has `make new-module` wrapping a real Composer package generator; there's no equivalent tool for a Nuxt module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`).
- `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built — only documented as the reason `useAuthSubmit` exists as a shared composable rather than living inside `login/`.
- No navigation chrome (sidebar/navbar) yet — the "register every module's view in it" step from `docs/ARCHITECTURE.md`'s checklist has nothing to register into today.
- `apps/backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints (see above) — needed for this app to talk to a real backend instead of just having the client-side shape ready.
## Roadmap (not built yet)

Done: `npm test` passes (40 tests across 7 files, one per layer of the reference module plus the shared composables and `HttpClient`); `npm run test:e2e` passes (6 Playwright cases covering `login`'s full user-facing behavior in a real browser, network mocked at the `fetch` boundary); `npm run typecheck` passes; `npm run build` produces a working production build.
- No generator/skill scaffolding a new module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`.
- `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built.
- No navigation chrome (sidebar/navbar) yet.
- `backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints don't exist yet — needed for this app to talk to a real backend instead of just having the client-side shape ready.
- `useEdition()` não tem fonte de dados real (ver acima).
5 changes: 3 additions & 2 deletions front-admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ Painel da organização da [PHPeste Platform](../README.md). Nuxt 4, módulo-por

> Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https://claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N#page-dcb81aee8d83) · tokens do tema em [`../docs/design/tema-sertao-tokens.png`](../docs/design/tema-sertao-tokens.png).

> **Status:** `login` é o módulo de referência completo (types, service + error class, composable, component, view, testes em cada camada — unitário e e2e). 40 testes unitários e 6 e2e (Playwright) passam, typecheck passa, `npm run build` gera um build de produção funcional.
> **Status:** `login` é o módulo de referência completo (types, service + error class, composable, component, view, testes em cada camada — unitário e e2e). Testes unitários, e2e (Playwright), typecheck e `npm run build` passam.

```bash
npm install
npx playwright install chromium # uma vez — baixa o browser que o Playwright usa
cp .env.example .env
npm run dev
npm run lint # eslint (via @nuxt/eslint)
npm run api:gen # regenera app/infrastructure/http/api-types.d.ts (precisa do backend rodando em :8000)
npm run lint # eslint (via @nuxt/eslint)
npm run typecheck # nuxt typecheck (vue-tsc)
npm test # vitest — mocka a fronteira do composable/service
npm run test:e2e # playwright — browser real, mocka só a rede
Expand Down
25 changes: 25 additions & 0 deletions front-admin/app/application/shared/composables/useEdition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useEdition } from './useEdition'

beforeEach(() => {
setActivePinia(createPinia())
})

describe('useEdition', () => {
it('começa sem edição atual', () => {
const { current } = useEdition()

expect(current.value).toBeNull()
})

it('guarda e limpa a edição atual', () => {
const { current, setCurrent, clear } = useEdition()

setCurrent({ id: 1, slug: 'phpeste-2027', name: 'PHPeste 2027' })
expect(current.value).toEqual({ id: 1, slug: 'phpeste-2027', name: 'PHPeste 2027' })

clear()
expect(current.value).toBeNull()
})
})
19 changes: 19 additions & 0 deletions front-admin/app/application/shared/composables/useEdition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { storeToRefs } from 'pinia'
import { useEditionStore } from '@/stores/editionStore'

/**
* Edição atual do painel. Hoje só guarda em memória o que foi setado
* explicitamente (ex.: seletor de edição) — passa a vir do backend
* (`GET /api/v1/editions/current` ou equivalente) quando o módulo
* Community existir. Ver docs/adr/, decisão de Identity/Community.
*/
export function useEdition() {
const store = useEditionStore()
const { current } = storeToRefs(store)

return {
current,
setCurrent: store.setCurrent,
clear: store.clear,
}
}
19 changes: 19 additions & 0 deletions front-admin/app/application/shared/services/pingService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { httpClient } from '@/infrastructure/http/HttpClient'
import pingService from './pingService'

vi.mock('@/infrastructure/http/HttpClient', () => ({
httpClient: { get: vi.fn() },
}))

describe('pingService.ping', () => {
it('chama GET /ping e devolve o payload tipado pelo contrato OpenAPI', async () => {
const payload = { status: 'ok', request_id: 'abc-123' }
vi.mocked(httpClient.get).mockResolvedValueOnce(payload)

const result = await pingService.ping()

expect(httpClient.get).toHaveBeenCalledWith('/ping')
expect(result).toEqual(payload)
})
})
12 changes: 12 additions & 0 deletions front-admin/app/application/shared/services/pingService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { httpClient } from '@/infrastructure/http/HttpClient'
import type { operations } from '@/infrastructure/http/api-types'

const PING_URL = '/ping'

export type PingPayload = operations['ping']['responses'][200]['content']['application/json']

export default {
async ping(): Promise<PingPayload> {
return httpClient.get<PingPayload>(PING_URL)
},
}
47 changes: 47 additions & 0 deletions front-admin/app/infrastructure/http/HttpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('httpClient', () => {
afterEach(() => {
vi.unstubAllGlobals()
httpClient.setAuthToken(null)
httpClient.setUnauthorizedHandler(null)
})

it('appends a query string when query params are given', async () => {
Expand Down Expand Up @@ -81,4 +82,50 @@ describe('httpClient', () => {
})
await expect(httpClient.get('/profile')).rejects.toBeInstanceOf(HttpClientError)
})

it('reads code and request_id from the API error envelope', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(
JSON.stringify({ code: 'not_found', message: 'Recurso não encontrado.', errors: null, request_id: 'req-1' }),
{ status: 404 },
),
)

await expect(httpClient.get('/editions/404')).rejects.toMatchObject({
code: 'not_found',
requestId: 'req-1',
})
})

it('falls back to the X-Request-Id header when the body has none', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Erro interno.' }), {
status: 500,
headers: { 'X-Request-Id': 'from-header' },
}),
)

await expect(httpClient.get('/boom')).rejects.toMatchObject({ requestId: 'from-header' })
})

it('calls the unauthorized handler on 401 when a token was set', async () => {
const onUnauthorized = vi.fn()
httpClient.setUnauthorizedHandler(onUnauthorized)
httpClient.setAuthToken('expired-token')
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Unauthenticated.' }), { status: 401 }))

await expect(httpClient.get('/profile')).rejects.toBeInstanceOf(HttpClientError)

expect(onUnauthorized).toHaveBeenCalledOnce()
})

it('does not call the unauthorized handler on 401 without a token (e.g. failed login)', async () => {
const onUnauthorized = vi.fn()
httpClient.setUnauthorizedHandler(onUnauthorized)
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Invalid credentials.' }), { status: 401 }))

await expect(httpClient.post('/auth/login', { body: { email: 'x', password: 'y' } })).rejects.toBeInstanceOf(HttpClientError)

expect(onUnauthorized).not.toHaveBeenCalled()
})
})
40 changes: 36 additions & 4 deletions front-admin/app/infrastructure/http/HttpClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
export class HttpClientError extends Error {
statusCode: number | null
fieldErrors: Record<string, string[]> | null

constructor(message: string, statusCode: number | null, fieldErrors: Record<string, string[]> | null = null) {
code: string | null
requestId: string | null

constructor(
message: string,
statusCode: number | null,
fieldErrors: Record<string, string[]> | null = null,
code: string | null = null,
requestId: string | null = null,
) {
super(message)
this.name = 'HttpClientError'
this.statusCode = statusCode
this.fieldErrors = fieldErrors
this.code = code
this.requestId = requestId
}
}

Expand All @@ -18,17 +28,29 @@ interface HttpClientRequestOptions {
query?: Record<string, HttpClientQueryValue>
}

interface LaravelErrorPayload {
interface ApiErrorPayload {
message?: string
errors?: Record<string, string[]>
code?: string
request_id?: string
}

let authToken: string | null = null
let onUnauthorized: (() => void) | null = null

function setAuthToken(token: string | null) {
authToken = token
}

/**
* Chamado quando uma requisição que levava token recebe 401 — sessão
* expirada ou revogada. Quem decide o que fazer (limpar o authStore,
* navegar pro login) é o plugin de auth, não o HttpClient.
*/
function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler
}

// Arrays become `key[]=a&key[]=b` — the query string shape Laravel parses
// natively as an array on the request side.
function buildQueryString(query: HttpClientRequestOptions['query']): string {
Expand Down Expand Up @@ -57,6 +79,7 @@ function buildQueryString(query: HttpClientRequestOptions['query']): string {

async function request<T>(method: string, path: string, options: HttpClientRequestOptions = {}): Promise<T> {
const { apiBase } = useRuntimeConfig().public
const hadToken = authToken !== null
let response: Response

// FormData (file upload) can't be JSON.stringify'd, and Content-Type has
Expand All @@ -82,11 +105,19 @@ async function request<T>(method: string, path: string, options: HttpClientReque
const data = response.status === 204 ? null : await response.json().catch(() => null)

if (!response.ok) {
const payload = data as LaravelErrorPayload | null
const payload = data as ApiErrorPayload | null
const requestId = payload?.request_id ?? response.headers.get('X-Request-Id')

if (response.status === 401 && hadToken) {
onUnauthorized?.()
}

throw new HttpClientError(
payload?.message ?? 'Something went wrong.',
response.status,
payload?.errors ?? null,
payload?.code ?? null,
requestId,
)
}

Expand All @@ -95,6 +126,7 @@ async function request<T>(method: string, path: string, options: HttpClientReque

export const httpClient = {
setAuthToken,
setUnauthorizedHandler,
get: <T>(path: string, options?: HttpClientRequestOptions) => request<T>('GET', path, options),
post: <T>(path: string, options?: HttpClientRequestOptions) => request<T>('POST', path, options),
put: <T>(path: string, options?: HttpClientRequestOptions) => request<T>('PUT', path, options),
Expand Down
57 changes: 57 additions & 0 deletions front-admin/app/infrastructure/http/api-types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/

export interface paths {
"/v1/ping": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["ping"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: never;
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
ping: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
status: string;
request_id: string | null;
};
};
};
};
};
}
9 changes: 8 additions & 1 deletion front-admin/app/plugins/auth.client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useAuthStore } from '@/stores/authStore'
import { httpClient } from '@/infrastructure/http/HttpClient'

export default defineNuxtPlugin(() => {
useAuthStore().hydrate()
const authStore = useAuthStore()
authStore.hydrate()

httpClient.setUnauthorizedHandler(() => {
authStore.clearSession()
navigateTo('/login')
})
})
Loading
Loading