426 lines
21 KiB
Markdown
426 lines
21 KiB
Markdown
# Plano — Auth real v0.2 (pré-piloto)
|
||
|
||
> **ESTADO: IMPLEMENTADO (2026-05-30).** Os 8 passos foram concluídos e verificados (typecheck limpo, `scripts/auth-smoke.ts` 11/11, E2E do MAI CALL verde). Endurecimentos pré-piloto ainda diferidos (não-bugs): enumeração por timing, rate-limit por IP, prefixo `__Secure-` dos cookies, PINs/segredo de demo, HTTPS em produção. Ver §10 e a memory `project-phase`.
|
||
>
|
||
> Autor: Opus 4.8 (sessão de design, 2026-05-30). Implementado pelo Sonnet.
|
||
> Pré-requisito: MAI CALL v0.1 completo (ver [`mai-call-v0.1.md`](./mai-call-v0.1.md)). Estado do código no momento do design verificado contra o repo.
|
||
|
||
## Objetivo numa frase
|
||
|
||
Fechar a porta das traseiras (`AUTH_DEV_AUTOLOGIN`) e dar a cada tipo de utilizador o login certo:
|
||
**admin/supervisor → email + password**; **operador → escolha na lista + PIN de 4–6 dígitos**.
|
||
Tudo o resto do produto (MAI CALL) continua a funcionar igual.
|
||
|
||
## Decisões fixadas (não revisitar sem motivo forte)
|
||
|
||
1. **Um único segredo por utilizador, guardado em `User.passwordHash`** (já existe, nullable). Password (admin) e PIN (operador) são ambos "um segredo que o utilizador prova" — mesmo campo, mesma função de hash. O `role` decide qual UI/validação se aplica.
|
||
2. **Hashing com `crypto.scrypt` nativo do Node 22** — zero dependências, sem build nativo (evita atrito no Windows + pnpm 11 que bloqueia postinstall). NÃO instalar bcrypt/argon nativos.
|
||
3. **`AUTH_DEV_AUTOLOGIN` não desaparece — passa a ser ignorado quando `NODE_ENV === 'production'`.** Em dev/test continua a funcionar (conveniência local + E2E). A porta das traseiras fica fechada **no código**, não só por honestidade do `.env`.
|
||
4. **Operador entra por lista + PIN** (não password). O picker que já existe ganha um segundo passo (teclado de PIN). Isto mantém o caminho para o RFID limpo: trocar o teclado por um leitor de cartão, sem mexer no resto.
|
||
5. **Admin-web ganha Auth.js a sério** (hoje não tem nenhum). Mesmo padrão da operator-pwa: Credentials provider + JWT + middleware + página de login.
|
||
6. **Lockout mínimo** (5 tentativas falhadas → bloqueio de 5 min) porque um PIN numérico exposto na rede sem qualquer travão a força-bruta é irresponsável para um piloto. É a única adição de schema.
|
||
7. **Continua single-tenant na prática.** O login procura o utilizador por email com `findFirst` (o email só é único *por tenant*, `@@unique([tenantId, email])`). Para o piloto (uma fábrica = um tenant) é suficiente. Multi-tenant login (selector de tenant ou email globalmente único) fica para quando entrar o 2º cliente.
|
||
|
||
---
|
||
|
||
## 1. Modelo de dados
|
||
|
||
Sem modelos novos. Só **dois campos** em `User`, para o lockout:
|
||
|
||
```prisma
|
||
model User {
|
||
// ... campos existentes (id, tenantId, email, passwordHash, role, createdAt, relations) ...
|
||
|
||
failedAttempts Int @default(0)
|
||
lockedUntil DateTime?
|
||
}
|
||
```
|
||
|
||
`passwordHash` já existe e é `String?` — passa a ser efetivamente usado. **Não** mudar o tipo nem a nulidade (um utilizador sem segredo definido simplesmente não consegue entrar — `verifySecret` devolve `false`).
|
||
|
||
`tenant-extension.ts` não precisa de alteração (`User` já está em `TENANT_SCOPED_MODELS`), mas atenção: o login corre **antes** de haver tenant, por isso usa o `prisma` não-scoped (ver §3).
|
||
|
||
**Migration:** após editar o schema, correr `pnpm db:migrate` (nome sugerido: `auth_v0_2_lockout`).
|
||
|
||
---
|
||
|
||
## 2. Função de hashing — `@repo/db`
|
||
|
||
Cria-se `packages/db/src/crypto.ts` e exporta-se de `packages/db/src/index.ts`.
|
||
Fica em `@repo/db` (e não em `@repo/api`) **de propósito**: o `seed.ts` vive em `packages/db` e precisa de `hashSecret`; como `api` depende de `db` (e não o contrário), só `db` pode ser importado por todos sem ciclo.
|
||
|
||
```ts
|
||
// packages/db/src/crypto.ts
|
||
import { randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto';
|
||
import { promisify } from 'node:util';
|
||
|
||
const scrypt = promisify(_scrypt);
|
||
const KEYLEN = 64;
|
||
|
||
/** Devolve "scrypt$<saltHex>$<hashHex>". Serve para password (admin) e PIN (operador). */
|
||
export async function hashSecret(plain: string): Promise<string> {
|
||
const salt = randomBytes(16);
|
||
const derived = (await scrypt(plain, salt, KEYLEN)) as Buffer;
|
||
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`;
|
||
}
|
||
|
||
/** Verificação em tempo constante. `stored` null/malformado → false (nunca lança). */
|
||
export async function verifySecret(plain: string, stored: string | null): Promise<boolean> {
|
||
if (!stored) return false;
|
||
const [scheme, saltHex, hashHex] = stored.split('$');
|
||
if (scheme !== 'scrypt' || !saltHex || !hashHex) return false;
|
||
const salt = Buffer.from(saltHex, 'hex');
|
||
const expected = Buffer.from(hashHex, 'hex');
|
||
const derived = (await scrypt(plain, salt, expected.length)) as Buffer;
|
||
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
||
}
|
||
```
|
||
|
||
Export em `packages/db/src/index.ts`: `export { hashSecret, verifySecret } from './crypto';`
|
||
|
||
---
|
||
|
||
## 3. Verificação de credenciais — `@repo/api`
|
||
|
||
Lógica única partilhada pelas duas apps (lookup + verify + lockout). Vai em `@repo/api` porque toca Prisma + regra de negócio, e ambas as apps já dependem de `@repo/api`.
|
||
|
||
```ts
|
||
// packages/api/src/auth.ts
|
||
import { prisma, verifySecret } from '@repo/db';
|
||
import type { SessionUser } from './context';
|
||
|
||
const MAX_ATTEMPTS = 5;
|
||
const LOCK_MS = 5 * 60_000;
|
||
|
||
/**
|
||
* Autentica por email + segredo (PIN ou password), restringindo a roles permitidos.
|
||
* Usa o prisma NÃO-scoped: o login acontece antes de existir tenant.
|
||
* Devolve o SessionUser em sucesso, null em qualquer falha (credenciais, role, lockout).
|
||
*/
|
||
export async function authenticateCredential(opts: {
|
||
email: string;
|
||
secret: string;
|
||
allowedRoles: SessionUser['role'][];
|
||
}): Promise<SessionUser | null> {
|
||
const user = await prisma.user.findFirst({ where: { email: opts.email } });
|
||
if (!user) return null;
|
||
if (!opts.allowedRoles.includes(user.role)) return null;
|
||
if (user.lockedUntil && user.lockedUntil > new Date()) return null;
|
||
|
||
const ok = await verifySecret(opts.secret, user.passwordHash);
|
||
if (!ok) {
|
||
const attempts = user.failedAttempts + 1;
|
||
await prisma.user.update({
|
||
where: { id: user.id },
|
||
data: {
|
||
failedAttempts: attempts,
|
||
lockedUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_MS) : null,
|
||
},
|
||
});
|
||
return null;
|
||
}
|
||
|
||
if (user.failedAttempts !== 0 || user.lockedUntil) {
|
||
await prisma.user.update({
|
||
where: { id: user.id },
|
||
data: { failedAttempts: 0, lockedUntil: null },
|
||
});
|
||
}
|
||
|
||
return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId };
|
||
}
|
||
```
|
||
|
||
Export em `packages/api/src/index.ts`: `export { authenticateCredential } from './auth';`
|
||
(`SessionUser` já é exportado de `@repo/api` — confirmado em `context.ts`.)
|
||
|
||
---
|
||
|
||
## 4. Fechar a porta das traseiras (`NODE_ENV` gating)
|
||
|
||
A regra única: **autologin só é honrado se `AUTH_DEV_AUTOLOGIN` E `NODE_ENV !== 'production'`.**
|
||
|
||
**operator-pwa** — `apps/operator-pwa/lib/auth.ts`, em `resolveUser()`:
|
||
```ts
|
||
const autologinAllowed = env.AUTH_DEV_AUTOLOGIN && process.env.NODE_ENV !== 'production';
|
||
if (autologinAllowed) {
|
||
// ... fallback admin@demo.local (igual ao atual)
|
||
}
|
||
```
|
||
|
||
**operator-pwa** — `apps/operator-pwa/middleware.ts`:
|
||
```ts
|
||
const isAutologin =
|
||
process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production';
|
||
```
|
||
O redirect passa a apontar para `/select-operator` na operator-pwa (já aponta) — sem mudança de destino.
|
||
|
||
**admin-web** — passa a ter `resolveUser()` real (ver §6), com o mesmo gating no fallback.
|
||
|
||
> **Porque é que isto não parte o E2E:** o Playwright corre `next dev` (logo `NODE_ENV === 'development'`) e injecta `AUTH_DEV_AUTOLOGIN=true` nos webServers. O gating só desliga o autologin em `production`. O happy-path continua a entrar por autologin sem mexer no teste.
|
||
|
||
---
|
||
|
||
## 5. operator-pwa — login do operador (lista + PIN)
|
||
|
||
### 5a. Credentials provider (`apps/operator-pwa/lib/auth.ts`)
|
||
|
||
Substituir o provider placeholder (que aceitava email sem password) por:
|
||
|
||
```ts
|
||
Credentials({
|
||
name: 'Operador (PIN)',
|
||
credentials: { email: { type: 'text' }, pin: { type: 'password' } },
|
||
async authorize(credentials) {
|
||
const email = credentials?.email;
|
||
const pin = credentials?.pin;
|
||
if (typeof email !== 'string' || typeof pin !== 'string') return null;
|
||
const u = await authenticateCredential({ email, secret: pin, allowedRoles: ['OPERATOR'] });
|
||
if (!u) return null;
|
||
return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId };
|
||
},
|
||
}),
|
||
```
|
||
`allowedRoles: ['OPERATOR']` garante que admins não entram pela app do operador.
|
||
|
||
### 5b. Cookie name distinto (evita colisão com admin-web)
|
||
|
||
Em `apps/operator-pwa/lib/auth.config.ts`, dentro do objeto `authConfig`, acrescentar:
|
||
```ts
|
||
cookies: {
|
||
sessionToken: { name: 'fieldops-op.session-token' },
|
||
callbackUrl: { name: 'fieldops-op.callback-url' },
|
||
csrfToken: { name: 'fieldops-op.csrf-token' },
|
||
},
|
||
```
|
||
> **Porquê:** cookies não são isolados por porto. Sem nomes distintos, abrir admin-web (:3001) e operator-pwa (:3000) na mesma máquina sobrepõe a sessão. Cada app tem de ter o seu prefixo.
|
||
|
||
### 5c. Ecrã de PIN
|
||
|
||
`/select-operator` mantém-se como entrada, mas o `OperatorPicker` ganha **dois estados**:
|
||
|
||
1. **Lista** (como hoje): mostra operadores; tap selecciona em vez de fazer `signIn` imediato.
|
||
2. **Teclado de PIN**: depois de escolher, mostra o nome do operador + um teclado numérico (botões 0–9, apagar) + indicador de dígitos. Ao atingir o comprimento (aceitar 4 a 6 dígitos; submeter no botão "Entrar") chama:
|
||
```ts
|
||
const result = await signIn('credentials', { email, pin, redirect: false });
|
||
```
|
||
- sucesso (`!result?.error`): `router.push('/'); router.refresh();`
|
||
- erro: limpar dígitos e mostrar **"PIN incorreto ou conta bloqueada. Tente novamente."** (não distinguir os dois casos — não dar pistas a quem ataca).
|
||
- botão "Voltar" regressa à lista.
|
||
|
||
Mobile-first, botões grandes (target dedo com luva). Reaproveitar as classes Tailwind já usadas no picker atual.
|
||
|
||
`apps/operator-pwa/app/select-operator/page.tsx` não muda a query (continua a listar operadores via `prisma` direto). Só passa a precisar do `id`+`email` (já passa).
|
||
|
||
`sign-out-button.tsx` (botão "Trocar") não muda — `signOut({ callbackUrl: '/select-operator' })` continua correto.
|
||
|
||
---
|
||
|
||
## 6. admin-web — login do admin (email + password)
|
||
|
||
A admin-web **não tem Auth.js**. Adiciona-se o stack completo, espelhando a operator-pwa.
|
||
|
||
### 6a. Dependência
|
||
|
||
Em `apps/admin-web/package.json`, acrescentar a `dependencies` (mesma versão exata da operator-pwa, para não haver drift):
|
||
```json
|
||
"next-auth": "5.0.0-beta.25"
|
||
```
|
||
Correr `pnpm install`.
|
||
|
||
### 6b. Ficheiros novos
|
||
|
||
- **`apps/admin-web/lib/auth.config.ts`** — cópia do edge-safe config da operator-pwa, com:
|
||
- `pages: { signIn: '/login' }`
|
||
- os mesmos callbacks `jwt`/`session` (copiar tal e qual).
|
||
- `cookies` com prefixo **`fieldops-admin.`** (sessionToken / callbackUrl / csrfToken) — ver §5b.
|
||
|
||
- **`apps/admin-web/lib/auth.ts`** — substitui o `resolveUser` placeholder atual. Estrutura igual à operator-pwa:
|
||
```ts
|
||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||
...authConfig,
|
||
secret: env.AUTH_SECRET,
|
||
providers: [
|
||
Credentials({
|
||
name: 'Email + password',
|
||
credentials: { email: { type: 'email' }, password: { type: 'password' } },
|
||
async authorize(c) {
|
||
if (typeof c?.email !== 'string' || typeof c?.password !== 'string') return null;
|
||
const u = await authenticateCredential({
|
||
email: c.email, secret: c.password, allowedRoles: ['ADMIN', 'SUPERVISOR'],
|
||
});
|
||
if (!u) return null;
|
||
return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId };
|
||
},
|
||
}),
|
||
],
|
||
});
|
||
|
||
export async function resolveUser(): Promise<SessionUser | null> {
|
||
const session = await auth();
|
||
const u = session?.user as any;
|
||
if (u?.id && u?.tenantId) return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId };
|
||
|
||
const autologinAllowed =
|
||
process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production';
|
||
if (autologinAllowed) {
|
||
const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } });
|
||
if (admin) return { id: admin.id, email: admin.email, role: admin.role as any, tenantId: admin.tenantId };
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
- **`apps/admin-web/app/api/auth/[...nextauth]/route.ts`** — idêntico ao da operator-pwa:
|
||
```ts
|
||
import { handlers } from '@/lib/auth';
|
||
export const { GET, POST } = handlers;
|
||
export const runtime = 'nodejs';
|
||
```
|
||
|
||
- **`apps/admin-web/middleware.ts`** — espelha o da operator-pwa, mas redireciona para `/login`:
|
||
```ts
|
||
import NextAuth from 'next-auth';
|
||
import { authConfig } from './lib/auth.config';
|
||
const { auth } = NextAuth(authConfig);
|
||
export default auth((req) => {
|
||
const isLoggedIn = !!req.auth?.user;
|
||
const isAutologin =
|
||
process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production';
|
||
const { pathname } = req.nextUrl;
|
||
if (pathname === '/login') {
|
||
if (isLoggedIn) return Response.redirect(new URL('/maintenance', req.url));
|
||
return;
|
||
}
|
||
if (!isLoggedIn && !isAutologin) return Response.redirect(new URL('/login', req.url));
|
||
});
|
||
export const config = {
|
||
matcher: ['/((?!api/auth|api/trpc|_next/static|_next/image|favicon.ico).*)'],
|
||
};
|
||
```
|
||
|
||
- **`apps/admin-web/app/login/page.tsx`** + **`login-form.tsx`** (client component):
|
||
- form com email + password, botão "Entrar".
|
||
- `const result = await signIn('credentials', { email, password, redirect: false });`
|
||
- sucesso → `router.push('/maintenance')`; erro → "Email ou password incorretos. Tente novamente."
|
||
- Estilo: reaproveitar componentes `@repo/ui` (Card/Button) já usados na queue.
|
||
|
||
> `signIn`/`signOut` de `next-auth/react` funcionam **sem** `SessionProvider`, por isso `apps/admin-web/app/providers.tsx` não precisa de mudar.
|
||
|
||
### 6c. env da admin-web
|
||
|
||
`apps/admin-web/env.ts` — acrescentar ao bloco `server` (hoje só tem `DATABASE_URL`, `AUTH_DEV_AUTOLOGIN`, `LOG_LEVEL`):
|
||
```ts
|
||
AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'),
|
||
```
|
||
e no `runtimeEnv`: `AUTH_SECRET: process.env.AUTH_SECRET,`
|
||
**Não** adicionar `AUTH_URL`: o `.env` partilhado tem `AUTH_URL=http://localhost:3000` (da operator-pwa) e isso seria errado para a admin. Com `trustHost: true` (já no authConfig), o Auth.js infere o host a partir do request — não precisa de `AUTH_URL` em dev.
|
||
|
||
---
|
||
|
||
## 7. Seed — definir PINs e password demo
|
||
|
||
`packages/db/prisma/seed.ts`:
|
||
|
||
- importar `hashSecret` de `@repo/db` (ou diretamente de `./crypto` — mas o seed já importa `@prisma/client` dinamicamente; usar import estático de `../src/crypto.js`/`@repo/db` conforme o que o build resolver; preferir `@repo/db`).
|
||
- admin: `passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD)`.
|
||
- cada operador: `passwordHash: await hashSecret(pin)` com PINs distintos. Como `createMany` não permite valores assíncronos por linha de forma limpa, trocar o `createMany` dos operadores por um loop `for ... create`.
|
||
- constantes no topo:
|
||
```ts
|
||
const DEMO_ADMIN_PASSWORD = 'admin1234';
|
||
const OPERATORS = [
|
||
{ email: 'op1@demo.local', pin: '1111' },
|
||
{ email: 'op2@demo.local', pin: '2222' },
|
||
{ email: 'op3@demo.local', pin: '3333' },
|
||
];
|
||
```
|
||
- imprimir as credenciais demo no final (`console.warn`) para Pedro saber o que digitar:
|
||
```
|
||
Seed: admin=admin@demo.local / admin1234 | operadores: op1=1111 op2=2222 op3=3333
|
||
```
|
||
|
||
`failedAttempts`/`lockedUntil` ficam nos defaults (0/null), não precisam de set no seed.
|
||
|
||
---
|
||
|
||
## 8. `.env.example` + README
|
||
|
||
- **`.env.example`**: na nota do `AUTH_DEV_AUTOLOGIN`, acrescentar que **mesmo a `true` é ignorado em produção** (`NODE_ENV=production`). Manter default `"false"`.
|
||
- **README** — secção "Known limitations": remover/atualizar a linha "No real authentication" (agora há), e a linha "Operator picker, not TAG/card" mantém-se mas passa a "picker + PIN". Acrescentar as credenciais demo ao "Demo flow" (admin: `admin@demo.local`/`admin1234`; operadores: PINs). Atualizar o aviso da porta das traseiras para refletir o gating por `NODE_ENV`.
|
||
|
||
---
|
||
|
||
## 9. Verificação — smoke script (segue o padrão do repo)
|
||
|
||
Criar `scripts/auth-smoke.ts` (como os outros `scripts/*-smoke.ts`):
|
||
|
||
1. corre contra a BD seeded.
|
||
2. `authenticateCredential({ email:'op1@demo.local', secret:'1111', allowedRoles:['OPERATOR'] })` → devolve user. ✓
|
||
3. PIN errado 5× → 6ª chamada com PIN **certo** devolve `null` (lockout ativo). ✓
|
||
4. role errado: `authenticateCredential({ email:'admin@demo.local', secret:'admin1234', allowedRoles:['OPERATOR'] })` → `null`. ✓
|
||
5. admin com password certa e `allowedRoles:['ADMIN','SUPERVISOR']` → user. ✓
|
||
6. no fim, repor `failedAttempts=0, lockedUntil=null` do op1 para não deixar a BD num estado bloqueado (ou re-seed).
|
||
|
||
Adicionar ao README a linha `pnpm tsx scripts/auth-smoke.ts`.
|
||
|
||
O E2E `mai-call.spec.ts` **não muda** (continua via autologin em dev, §4).
|
||
|
||
---
|
||
|
||
## 10. Cortes propositados — o que NÃO entra na v0.2
|
||
|
||
| Cortado | Porquê | Quando volta |
|
||
|---|---|---|
|
||
| RFID/cartão do operador | PIN é a ponte; arquitetura já preparada | quando MY QUALITY entrar |
|
||
| Reset de password / "esqueci o PIN" self-service | Sem email/SMS infra; admin re-seed ou futura UI | v0.3 |
|
||
| UI de gestão de utilizadores (criar/editar/role) | Cria-se via seed/SQL no piloto | com onboarding multi-tenant |
|
||
| Selector de tenant no login | Single-tenant no piloto; email `findFirst` chega | 2º cliente |
|
||
| SSO / MFA | Não pedido pelo piloto | se cliente exigir |
|
||
| Rate-limit por IP (além do lockout por conta) | Lockout por conta cobre o PIN; IP precisa de infra | se necessário |
|
||
| Rotação de `AUTH_SECRET` / política de sessão curta | Default JWT chega para piloto | endurecimento pós-piloto |
|
||
| Expiração/rotação de PIN | Sem valor demonstrado no piloto | quando pedirem |
|
||
|
||
---
|
||
|
||
## 11. Plano de implementação (passos pequenos, ordenados)
|
||
|
||
Cada passo é mergeable e tem critério de aceitação testável.
|
||
|
||
### Passo 1 — Schema + crypto util
|
||
**Faz:** §1 (campos `failedAttempts`/`lockedUntil` + migration) e §2 (`packages/db/src/crypto.ts` + export).
|
||
**AC:** `pnpm db:migrate` corre sem erro; um script ad-hoc consegue `hashSecret('1234')` e `verifySecret('1234', hash) === true`, `verifySecret('9999', hash) === false`, `verifySecret('x', null) === false`.
|
||
|
||
### Passo 2 — `authenticateCredential` em `@repo/api`
|
||
**Faz:** §3 (`packages/api/src/auth.ts` + export).
|
||
**AC:** importável de `@repo/api`; `pnpm typecheck` verde. (Comportamento real testado no Passo 7.)
|
||
|
||
### Passo 3 — Seed com PINs/password
|
||
**Faz:** §7.
|
||
**AC:** `pnpm db:seed` corre e imprime as credenciais; em Prisma Studio, `passwordHash` dos 4 utilizadores está preenchido (formato `scrypt$...`).
|
||
|
||
### Passo 4 — Fechar a porta das traseiras
|
||
**Faz:** §4 (gating `NODE_ENV` na operator-pwa: `resolveUser` + `middleware`).
|
||
**AC:** com `AUTH_DEV_AUTOLOGIN=true` e `next dev`, a operator-pwa continua a entrar (dev). Simular produção (`NODE_ENV=production` num build) → abrir `/` redireciona para `/select-operator` em vez de auto-entrar.
|
||
|
||
### Passo 5 — operator-pwa: provider PIN + cookie name
|
||
**Faz:** §5a + §5b (authorize com PIN + `allowedRoles:['OPERATOR']`; cookies `fieldops-op.*`).
|
||
**AC:** `signIn('credentials',{email:'op1@demo.local',pin:'1111'})` cria sessão; `pin:'0000'` falha. Cookie no browser chama-se `fieldops-op.session-token`.
|
||
|
||
### Passo 6 — operator-pwa: ecrã de PIN
|
||
**Faz:** §5c (picker em 2 estados: lista → teclado PIN).
|
||
**AC:** Demo: limpar cookies → `/` redireciona ao picker → escolher op1 → teclar `1111` → "Entrar" → home mostra `op1@demo.local`. PIN errado mostra a mensagem e limpa os dígitos.
|
||
|
||
### Passo 7 — admin-web: Auth.js completo + login
|
||
**Faz:** §6 inteiro (dep, auth.config, auth.ts, route handler, middleware, /login, env `AUTH_SECRET`, cookies `fieldops-admin.*`).
|
||
**AC:** com autologin **off** (ou prod), abrir :3001 redireciona a `/login`; entrar com `admin@demo.local`/`admin1234` chega à fila `/maintenance`; password errada falha; um OPERATOR não consegue entrar na admin-web (role bloqueado).
|
||
|
||
### Passo 8 — Smoke + docs
|
||
**Faz:** §9 (`scripts/auth-smoke.ts`) + §8 (`.env.example` + README).
|
||
**AC:** `pnpm tsx scripts/auth-smoke.ts` passa todos os casos (incl. lockout às 5 tentativas); `pnpm test:e2e` continua verde; outro dev segue o README e consegue fazer login real nas duas apps.
|
||
|
||
---
|
||
|
||
**Sequência crítica:** Passos 1→3 são fundação (schema, crypto, credenciais). Passo 4 fecha o buraco de segurança e pode ir cedo. Passos 5–6 (operador) e 7 (admin) são independentes — podem ir em paralelo se houver 2 devs. Passo 8 fecha.
|
||
|
||
**Risco principal:** a colisão de cookies entre :3000 e :3001 (§5b/§6b) — se os nomes de cookie não forem distintos, fazer login numa app desloga a outra de forma confusa. Verificar explicitamente os nomes dos cookies no DevTools no Passo 5 e 7.
|