diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cd86f68..17e3f01 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,12 @@ "Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" ! -name \"Dashboard.yml\" -exec sed -i 's| url: \\\\\\({{.*}}\\\\\\)| url: \"\"\\\\1\"\"|g' {} +)", "Bash(find /var/home/felipecn/DEV/MeowSpool/backend/bruno-routes -name \"*.yml\" ! -name \"route.yml\" -exec sed -i 's| url: \"\"\\\\?\\\\\\(.*\\\\\\)\"\"\\\\?$| url: \"\"\\\\1\"\"|g; s| url: \"\"\\\\\\(.*\\\\\\)\"\"\"\"\\\\?$| url: \"\"\\\\1\"\"|g' {} +)", "Bash(cargo check:*)", - "Bash(ls /var/home/felipecn/DEV/MeowSpool/mobile/app/\\\\\\(auth\\\\\\))" + "Bash(ls /var/home/felipecn/DEV/MeowSpool/mobile/app/\\\\\\(auth\\\\\\))", + "Bash(cd:*)", + "Bash(source:*)", + "Bash(npm install:*)", + "Bash(npx tsc:*)", + "Bash(npx expo:*)" ] } } diff --git a/PRD.md b/PRD.md index 1e97962..422b84e 100644 --- a/PRD.md +++ b/PRD.md @@ -1,48 +1,62 @@ -# Product Requirements Document (PRD): MeowSpool +# MeowSpool — Documento de Referência do Projeto -# Paper Canva -https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY +**Canvas de Design:** https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY + +--- ## 1. Visão Geral -MeowSpool é uma solução para entusiastas e profissionais de impressão 3D gerenciarem seu estoque de filamentos. O diferencial está na precisão do cálculo de peso líquido (descontando o peso do carretel via presets), na identidade visual inspirada em gatos siameses, e na integração com etiquetas físicas exportáveis (SVG/QR Code/NFC). +MeowSpool é uma solução para entusiastas e profissionais de impressão 3D gerenciarem seu estoque de filamentos. O diferencial está na precisão do cálculo de peso líquido (descontando o peso do carretel via presets), na identidade visual inspirada em gatos siameses, e na integração com etiquetas físicas exportáveis (SVG/PDF/QR Code/NFC). -**Foco do desenvolvimento atual:** App Mobile (React Native) + Backend em Rust. A interface Web é planejada para uma fase posterior, após a estabilização do mobile e da API. +**Foco do desenvolvimento atual:** App Mobile (React Native + Expo) + Backend em Rust. A interface Web é planejada para uma fase posterior, após a estabilização do mobile e da API. --- ## 2. Objetivos -* **Precisão:** Eliminar o "chute" de quanto filamento resta no rolo. -* **Agilidade:** Facilitar o cadastro e a consulta via dispositivos móveis, mesmo offline. -* **Organização:** Centralizar parâmetros técnicos (temperatura/fluxo) para consulta rápida. +- **Precisão:** Eliminar o "chute" de quanto filamento resta no rolo. +- **Agilidade:** Facilitar o cadastro e a consulta via dispositivos móveis, mesmo offline. +- **Organização:** Centralizar parâmetros técnicos (temperatura/fluxo) para consulta rápida. --- ## 3. Público-Alvo -* Hobbistas de impressão 3D. -* Donos de "Print Farms" (fazendas de impressão). -* Projetistas que trabalham com diversos materiais (PLA, ABS, PETG, etc). +- Hobbistas de impressão 3D. +- Donos de "Print Farms" (fazendas de impressão). +- Projetistas que trabalham com diversos materiais (PLA, ABS, PETG, etc). --- ## 4. Identidade Visual ### 4.1 Nome e Conceito + **MeowSpool** — fusão de "Meow" (gato) e "Spool" (carretel). A identidade visual é inspirada no gato siamês: tons quentes e escuros com detalhes em azul-aço que remetem aos olhos característicos da raça. +App é **dark mode exclusivo** — nenhum suporte a light mode. + ### 4.2 Paleta de Cores -| Token | Hex | Função na Interface | -|---------------|-----------|----------------------------------------------| -| `bg-base` | `#1E1B18` | Fundo geral | -| `bg-surface` | `#2A2622` | Cards, contêineres, widgets | -| `bg-hover` | `#332F2B` | Estados de hover em cards e itens de lista | -| `text-primary`| `#F5EEDC` | Títulos, peso líquido (texto importante) | -| `text-secondary`| `#C9C1B0`| Descrições, labels (material, marca) | -| `accent` | `#38BCC2` | Botões de ação, elementos ativos | -| `accent-muted`| `#38BCC226`| Backgrounds de badges e status (10% opacidade)| +| Token | Hex | Função na Interface | +|-----------------|-------------|-------------------------------------------------| +| `bgBase` | `#1E1B18` | Fundo geral | +| `bgSurface` | `#2A2622` | Cards, contêineres, widgets | +| `bgHover` | `#332F2B` | Estados de hover em cards e itens de lista | +| `textPrimary` | `#F5EEDC` | Títulos, peso líquido (texto importante) | +| `textSecondary` | `#C9C1B0` | Descrições, labels (material, marca) | +| `accent` | `#38BCC2` | Botões de ação, elementos ativos | +| `accentMuted` | `#38BCC226` | Backgrounds de badges e status (10% opacidade) | +| `stockLow` | `#FF6B6B` | ≤ 15% — vermelho | +| `stockMedium` | `#FF9F43` | ≤ 35% — laranja | +| `stockOk` | `#38BCC2` | > 35% — accent | +| `border` | `#3D3830` | Bordas sutis | +| `error` | `#FF6B6B` | Mensagens de erro | + +### 4.3 Tipografia + +- UI geral: **Inter** +- Slugs, hex, URLs, código: **JetBrains Mono** --- @@ -50,196 +64,510 @@ MeowSpool é uma solução para entusiastas e profissionais de impressão 3D ger ### 5.1 Autenticação -* **Métodos suportados:** Email/senha e OAuth via Google. -* **Plataformas:** Mobile consome a API de auth do backend Rust. Web utilizará o mesmo sistema em fase futura. -* **Sessão:** Token JWT com refresh token. O app mobile persiste a sessão localmente para acesso offline. +- **Métodos suportados:** Email/senha e OAuth via Google. +- **Verificação de e-mail obrigatória** antes do primeiro login. +- **Sessão:** Token JWT com refresh token. O app mobile persiste a sessão localmente para acesso offline. +- **Fluxos:** Login, Cadastro, Verificar E-mail, Reenviar Verificação, Esqueci a Senha, Redefinir Senha. ### 5.2 Gestão de Inventário -* **Cadastro de Filamento:** Campos obrigatórios e opcionais: - * Material (tipo): PLA, ABS, PETG, TPU, ASA, PA, PC, etc. - * Marca (texto livre) - * Modelo (texto livre) - * Cor (seletor hex — armazena valor hexadecimal, ex: `#FF5733`) - * Temperatura Hotend (°C) - * Temperatura Mesa (°C) - * Fator de Fluxo/Extrusão (%) - -* **Calculadora de Peso Líquido:** - * Seleção de **Preset de Carretel** (ver 5.3). - * Input de **Peso Total** (leitura da balança, em gramas). - * Cálculo automático: `Peso_Líquido = Peso_Total - Peso_Carretel`. +- **Cadastro de Filamento:** Material, Marca, Modelo, Cor (hex), Temperatura Hotend, Temperatura Mesa, Fator de Fluxo/Extrusão, Notas. +- **Calculadora de Peso Líquido:** `Peso_Líquido = Peso_Total - Peso_Carretel` (via preset selecionado). +- **Filtros:** Material, marca, busca textual, nível de estoque (`low`/`medium`/`ok`). +- **Ordenação:** Peso líquido asc/desc, criado em desc (padrão). ### 5.3 Presets de Carretéis -* **Presets do Sistema (built-in):** Um conjunto de presets pré-cadastrados com as principais marcas do mercado. Exemplos: +**Presets do Sistema (built-in):** - | Nome do Preset | Peso do Carretel | - |------------------------|-----------------| - | Bambu Lab (Plástico) | 250g | - | Elegoo (Papelão) | 200g | - | Creality (Plástico) | 230g | - | Prusament (Plástico) | 201g | - | Sunlu (Papelão) | 200g | - | Polymaker (Plástico) | 220g | - | Genérico Papelão 1kg | 200g | - | Genérico Plástico 1kg | 250g | +| Nome do Preset | Peso do Carretel | +|------------------------|-----------------| +| Bambu Lab (Plástico) | 250g | +| Elegoo (Papelão) | 200g | +| Creality (Plástico) | 230g | +| Prusament (Plástico) | 201g | +| Sunlu (Papelão) | 200g | +| Polymaker (Plástico) | 220g | +| Genérico Papelão 1kg | 200g | +| Genérico Plástico 1kg | 250g | -* **Presets Customizados:** O usuário pode criar, editar e excluir seus próprios presets (nome + peso em gramas). Presets customizados são vinculados à conta do usuário e sincronizados. -* **Presets do sistema são somente leitura** — não podem ser editados ou excluídos pelo usuário. +**Presets Customizados:** O usuário pode criar, editar e excluir seus próprios presets. Presets do sistema são somente leitura. ### 5.4 Identificação e Etiquetas -* **Geração de QR Code:** Cada filamento cadastrado gera um QR Code único apontando para sua página de detalhes no app/web. -* **Exportação de Etiqueta em SVG:** O sistema gera um arquivo SVG com os dados do filamento (nome, material, cor, peso líquido, QR Code) para que o usuário importe e imprima na plataforma de sua preferência (Inkscape, Canva, impressoras de etiqueta com suporte a SVG, etc). - * Layout padrão: 50mm x 30mm (ajustável via parâmetro). - * Conteúdo: cor visual do filamento, nome, material, marca, peso líquido, QR Code. +- **QR Code:** Cada filamento gera QR Code com deep link `meowspool://filament/`. +- **Etiqueta SVG:** `GET /filaments/:id/label.svg` — parâmetros `width_mm`, `height_mm`, `fields`, `dpi`. +- **Etiqueta PDF:** `GET /filaments/:id/label.pdf` — mesmos parâmetros. +- **Impressoras suportadas:** Padrão (50×30mm), Niimbot D11/D110 (22×14mm, dpi=203), Brother/Dymo (dpi=300). +- **Campos selecionáveis:** `color`, `name`, `material_brand`, `net_weight`, `print_temp`, `qrcode`. +- **NFC:** Leitura e gravação de tags NTAG215 com URI `meowspool://filament/`. -### 5.5 Interface (UI/UX) +### 5.5 Dashboard -* **Design:** Dark mode exclusivo, paleta siamês (ver 4.2). -* **Plataforma atual:** Mobile (React Native). -* **Filtros e Busca:** Filtragem por material, marca, cor e nível de estoque. -* **Dashboard:** Visão geral do inventário com totais por material e alertas visuais de estoque baixo. +- Total de estoque em kg. +- Contagem de filamentos com estoque baixo. +- Resumo por material (contagem + peso total). +- Filamentos com estoque baixo (≤15%). +- Filamentos adicionados recentemente. --- -## 6. Requisitos Técnicos (Stack) +## 6. Arquitetura do Sistema -### 6.1 Fase 1 — Mobile + Backend (foco atual) +Ambos os lados seguem **Arquitetura Hexagonal (Ports & Adapters)**: -| Camada | Tecnologia | Justificativa | -|-------------------|----------------------|------------------------------------------------------------------------------| -| **Mobile** | React Native | Performance nativa, suporte offline e acesso a APIs do dispositivo (câmera, NFC). | -| **Back-end** | Rust (Axum ou Actix) | Alta performance, segurança de memória e baixo consumo de recursos. | -| **Banco de Dados**| PostgreSQL | Robustez para relações entre usuários, marcas, materiais e presets. | -| **Auth** | JWT + OAuth (Google) | Padrão seguro, compatível com mobile e preparado para web futura. | -| **Banco Local** | SQLite (SQLCipher) | Armazenamento offline seguro no dispositivo móvel. | +``` +adapters/inbound (HTTP / Tela) + | + v + application (Use Cases / Services) + | + v + domain (Entidades puras) + | + ^ + ports (Interfaces/traits) + | + ^ +adapters/outbound (PostgreSQL / SQLite / NFC / HTTP) +``` -### 6.2 Fase 2 — Web (planejado, pós-estabilização) - -| Camada | Tecnologia | Justificativa | -|-------------------|----------------|------------------------------------------------------------| -| **Front-end Web** | Next.js | SEO, performance, rotas e suporte a cache via ISR/SSR. | -| **Cache (Web)** | Redis / HTTP Cache | Redução de latência nas listagens e dados de inventário.| - -### 6.3 Estratégia de Sincronização (Offline-First Mobile) - -1. O app mobile utiliza um banco de dados local (SQLite via SQLCipher para segurança). -2. Todas as operações são escritas localmente primeiro (write-ahead). -3. Quando há conexão, o app sincroniza via API REST com o backend Rust (estratégia: last-write-wins com timestamp, com resolução de conflitos simples). -4. A Web (fase 2) consumirá a mesma API REST, sem necessidade de alterações no backend. +**Regra fundamental:** O `domain` e os `ports` nunca importam de `adapters` ou `application`. --- -## 7. Estrutura de Dados (Entidades Principais) +## 7. Backend (Rust) + +### 7.1 Stack + +| Componente | Biblioteca | +|------------------|----------------------------------------| +| HTTP Framework | `axum` 0.7 | +| Async Runtime | `tokio` (full) | +| ORM / Query | `sqlx` (postgres, uuid, time, macros) | +| Autenticação JWT | `jsonwebtoken` 9.x | +| Hash de senha | `argon2` 0.5 (Argon2id) | +| OAuth Google | `oauth2` 4.x | +| Serialização | `serde`, `serde_json` | +| Erros | `thiserror` | +| Env vars | `dotenvy` | +| Logging | `tracing`, `tracing-subscriber` | +| Validação | `validator` | +| HTTP Client | `reqwest` | +| Email (SMTP) | `lettre` (STARTTLS) | +| Geração QR Code | `qrcode` + `image` | +| Geração PDF | `printpdf` 0.7 | + +### 7.2 Estrutura de Pastas + +``` +backend/ +├── Cargo.toml +├── migrations/ +│ ├── 20240101000001_create_users.sql +│ ├── 20240101000002_create_spool_presets.sql +│ ├── 20240101000003_create_filaments.sql +│ └── 20240101000004_create_token_tables.sql ← password_reset_tokens + email_verification_tokens +└── src/ + ├── main.rs ← entry point: config, DB, router, servidor + ├── config.rs ← struct Config lida de variáveis de ambiente + ├── error.rs ← AppError unificado com IntoResponse + ├── router.rs ← composição de todas as rotas Axum + ├── domain/ ← entidades puras (User, Filament, SpoolPreset) + ├── ports/ ← traits: UserRepository, FilamentRepository, SpoolPresetRepository + ├── application/ + │ ├── auth_service.rs ← login, register, OAuth, refresh, logout + │ ├── email_token_service.rs← forgot_password, verify_email, reset_password + │ ├── filament_service.rs ← CRUD, peso líquido, QR Code, SVG, PDF + │ └── spool_preset_service.rs + ├── infrastructure/ + │ └── email_service.rs ← SMTP via lettre + └── adapters/ + ├── inbound/ ← handlers Axum + middleware JWT + └── outbound/ ← implementações PostgreSQL dos ports +``` + +### 7.3 Rotas da API + +Base: `/api/v1` + +**Auth — `/api/v1/auth`** + +| Método | Rota | Acesso | +|--------|-------------------------|--------------------------------| +| POST | `/register` | Público — retorna 201 sem token| +| POST | `/login` | Público | +| POST | `/oauth/google` | Público | +| POST | `/refresh` | Público (requer refresh token) | +| POST | `/logout` | Autenticado | +| POST | `/resend-verification` | Público — sempre retorna 200 | +| POST | `/forgot-password` | Público | +| POST | `/verify-email` | Público | +| POST | `/reset-password` | Público (requer token) | + +**Redirects para Deep Links** + +| Método | Rota | Descrição | +|--------|-------------------|------------------------------------------------------------| +| GET | `/verify-email` | Redireciona 302 → `{APP_SCHEME}://verify-email?token=xxx` | +| GET | `/reset-password` | Redireciona 302 → `{APP_SCHEME}://reset-password?token=xxx`| + +**Filaments — `/api/v1/filaments`** + +| Método | Rota | Acesso | +|--------|------------------|-------------| +| GET | `/` | Autenticado | +| POST | `/` | Autenticado | +| GET | `/:id` | Autenticado | +| PUT | `/:id` | Autenticado | +| DELETE | `/:id` | Autenticado | +| GET | `/:id/qrcode` | Autenticado | +| GET | `/:id/label.svg` | Autenticado | +| GET | `/:id/label.pdf` | Autenticado | + +**Spool Presets — `/api/v1/spool-presets`** + +| Método | Rota | Acesso | +|--------|--------|--------------------------------------| +| GET | `/` | Autenticado | +| POST | `/` | Autenticado | +| PUT | `/:id` | Autenticado (apenas presets do user) | +| DELETE | `/:id` | Autenticado (apenas presets do user) | + +**Outros** + +| Método | Rota | Acesso | +|--------|--------------|-------------| +| GET | `/dashboard` | Autenticado | +| GET | `/users/me` | Autenticado | +| PUT | `/users/me` | Autenticado | + +### 7.4 Padrão de Erros + +```json +{ "error": "not found", "code": "NOT_FOUND" } +``` + +| Variante AppError | HTTP | Code | +|------------------------|------|------------------------| +| `NotFound` | 404 | `NOT_FOUND` | +| `Unauthorized` | 401 | `UNAUTHORIZED` | +| `Forbidden` | 403 | `FORBIDDEN` | +| `EmailNotVerified` | 403 | `EMAIL_NOT_VERIFIED` | +| `Conflict` | 409 | `CONFLICT` | +| `BadRequest` | 400 | `BAD_REQUEST` | +| `Validation` | 422 | `VALIDATION_ERROR` | +| `Internal` | 500 | `INTERNAL_ERROR` | + +### 7.5 Variáveis de Ambiente + +| Variável | Obrigatória | Padrão | +|---------------------------|-------------|-------------------------| +| `DATABASE_URL` | ✅ | — | +| `JWT_SECRET` | ✅ | — | +| `JWT_EXPIRY_SECS` | ❌ | `3600` | +| `JWT_REFRESH_EXPIRY_SECS` | ❌ | `2592000` | +| `GOOGLE_CLIENT_ID` | ❌ | — | +| `GOOGLE_CLIENT_SECRET` | ❌ | — | +| `HOST` / `PORT` | ❌ | `0.0.0.0` / `8080` | +| `SMTP_HOST` / `SMTP_PORT` | ❌ | `smtp.gmail.com` / `587`| +| `SMTP_USER` / `SMTP_PASS` | ❌ | — | +| `EMAIL_FROM` | ❌ | `noreply@meowspool.app` | +| `APP_BASE_URL` | ❌ | `http://localhost:8080` | +| `APP_SCHEME` | ❌ | `meowspool` | + +> Em produção: `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` + +### 7.6 Como Rodar Localmente + +```bash +cd backend/ +cp .env.example .env + +# PostgreSQL via Docker +docker run -d --name meowspool-db \ + -e POSTGRES_USER=meowspool \ + -e POSTGRES_PASSWORD=meowspool \ + -e POSTGRES_DB=meowspool \ + -p 5432:5432 postgres:16-alpine + +sqlx migrate run +cargo watch -x run +``` + +--- + +## 8. Mobile (React Native + Expo) + +### 8.1 Stack + +| Componente | Biblioteca | +|-------------------|-------------------------------------| +| Framework | `expo` ~54 (Bare Workflow) | +| Navigation | `expo-router` ~4 (file-system) | +| Linguagem | TypeScript 5.x (strict) | +| Node Version | 22 LTS (via `mise`) | +| Java Version | 17 (obrigatório — Java 24 quebra CMake) | +| Forms | `react-hook-form` + `zod` | +| Estado global | `zustand` | +| HTTP | `axios` (interceptor Bearer + refresh) | +| DB local | `expo-sqlite` + SQLCipher | +| Auth persistência | `expo-secure-store` | +| UI | React Native + Ionicons | +| QR Code render | `react-native-qrcode-svg` | +| SVG inline | `react-native-svg` (SvgXml) | +| Câmera / Scanner | `expo-camera` ~17 (ML Kit) | +| NFC | `react-native-nfc-manager` | +| File system | `expo-file-system/legacy` | +| Compartilhamento | `expo-sharing` (iOS) | +| Abrir arquivo | `expo-intent-launcher` (Android) | +| Animações | `react-native-reanimated` | + +> **Bare Workflow:** o diretório `android/` é comitado. Qualquer pacote com módulo nativo exige `npx expo prebuild --platform android` + rebuild do APK. + +### 8.2 Estrutura de Pastas + +``` +mobile/ +├── .mise.toml ← node 22 LTS, java 17 +├── app.json ← Expo config, scheme "meowspool" +├── app/ ← expo-router (file-system routes) +│ ├── _layout.tsx ← Root layout (auth guard + sessão) +│ ├── (auth)/ +│ │ ├── login.tsx ← G3-0 +│ │ ├── register.tsx ← IX-0 +│ │ ├── forgot-password.tsx ← L2-0 +│ │ ├── check-email.tsx ← RU-0 (pós-cadastro / e-mail não verificado) +│ │ ├── reset-password.tsx ← 117-0 +│ │ └── password-reset-done.tsx ← 135-0 +│ ├── filament/[id].tsx ← Deep link: meowspool://filament/ → /(app)/inventory/ +│ ├── verify-email.tsx ← Deep link: meowspool://verify-email?token=xxx +│ └── (app)/ +│ ├── (tabs)/ +│ │ ├── home.tsx ← 1-0 Dashboard +│ │ ├── inventory.tsx ← CW-0 Inventário +│ │ ├── add.tsx ← FAB → /inventory/new +│ │ ├── config.tsx ← M4-0 Presets de Carretéis +│ │ └── profile.tsx ← 1LQ-0 Perfil +│ ├── inventory/ +│ │ ├── new.tsx ← 2X-0 Cadastro +│ │ ├── filters.tsx ← 17H-0 Filtros (bottom sheet) +│ │ ├── [id].tsx ← 6L-0 Detalhe +│ │ └── [id]/edit.tsx ← 13O-0 Editar +│ ├── config/presets/ +│ │ ├── new.tsx ← QM-0 Novo Preset +│ │ └── [id]/edit.tsx ← 1KD-0 Editar Preset +│ ├── scanner.tsx ← Scanner QR Code (ML Kit) +│ ├── nfc-reader.tsx ← 1RY-0 Leitura NFC (bottom sheet modal) +│ └── filaments/[id]/ +│ ├── qrcode.tsx ← Ver QR Code +│ ├── label.tsx ← Exportar Etiqueta (PDF/SVG) +│ └── write-nfc.tsx ← Gravar tag NTAG215 +└── src/ + ├── domain/ ← Entidades puras (Filament, SpoolPreset, User, Dashboard) + ├── ports/ ← Interfaces (IFilamentRepository, ISpoolPresetRepository, IAuthRepository, INFCRepository) + ├── application/ ← Use Cases (filament/, preset/, auth/, nfc/) + ├── adapters/ + │ ├── remote/ ← Axios → API Rust (ApiFilamentRepository, etc.) + │ ├── local/ ← SQLite (LocalFilamentRepository, LocalSpoolPresetRepository) + │ └── nfc/ ← react-native-nfc-manager (NFCManagerRepository) + ├── store/ ← Zustand: authStore, filamentStore, presetStore + ├── shared/ ← theme.ts, constants.ts, utils/ + └── presentation/components/ + ├── ui/ ← Button, Input, Card, Badge + ├── layout/ ← Screen, Header + └── filament/ ← StockBar, ColorSwatch, FilamentCard +``` + +### 8.3 Navegação e Deep Links + +- Scheme: `meowspool://` (configurado em `app.json`) +- Grupos: `(auth)` — sem sessão | `(app)` — autenticado | `(app)/(tabs)` — bottom tabs + +| Deep Link | Handler | Ação | +|----------------------------------------|--------------------------------|---------------------------------------------| +| `meowspool://filament/` | `app/filament/[id].tsx` | Redireciona para `/(app)/inventory/` | +| `meowspool://verify-email?token=` | `app/verify-email.tsx` | Verifica e-mail via POST /auth/verify-email | +| `meowspool://reset-password?token=`| `app/(auth)/reset-password.tsx`| Exibe formulário de nova senha | + +> **Links de e-mail:** o backend gera `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx` (aceito por clientes de e-mail). O backend redireciona 302 → `meowspool://...`. O OS abre o app. + +### 8.4 Estratégia Offline-First + +1. **Escrita:** toda mutação persiste primeiro no SQLite (`synced = 0`) e enfileira em `sync_queue`. +2. **Leitura:** sempre do SQLite; a API é usada apenas para sync. +3. **Sync:** background job consome `sync_queue` e envia para o backend. +4. **Conflito:** last-write-wins via `updatedAt`. O backend é a fonte de verdade. +5. **Auth offline:** token no `expo-secure-store`; refresh automático via interceptor Axios. + +**Schema SQLite:** + +```sql +-- filaments +id TEXT PRIMARY KEY, user_id TEXT, material TEXT, brand TEXT, model TEXT, +color_hex TEXT, spool_preset_id TEXT, total_weight_g REAL, net_weight_g REAL, +temp_hotend_c REAL, temp_bed_c REAL, flow_factor_pct REAL, notes TEXT, +synced INTEGER DEFAULT 0, updated_at TEXT, created_at TEXT + +-- spool_presets +id TEXT PRIMARY KEY, user_id TEXT, name TEXT, spool_weight_g REAL, +is_system INTEGER DEFAULT 0, synced INTEGER DEFAULT 0, created_at TEXT + +-- sync_queue +id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT, entity_id TEXT, +operation TEXT, payload TEXT, created_at TEXT +``` + +### 8.5 Como Rodar Localmente + +```bash +cd mobile/ +mise install # garante Node 22 e Java 17 +npm install + +# Criar .env +echo "EXPO_PUBLIC_API_URL=http://localhost:3000/api/v1" > .env + +# Dev (Expo Go — sem câmera ML Kit / NFC) +npx expo start + +# Build nativo Android (necessário para câmera ML Kit e NFC) +mise exec -- npx expo run:android +``` + +> **Atenção ao build Android:** o projeto usa `namespace "com.meowspool"` no `build.gradle`, mas o código fonte fica em `com.meowspool.app`. Use sempre nomes totalmente qualificados no `AndroidManifest.xml`. + +--- + +## 9. Estrutura de Dados (Entidades Principais) ### `users` -| Campo | Tipo | Descrição | -|--------------|-----------|----------------------------------| -| id | UUID | Identificador único | -| email | string | Email do usuário | -| password_hash| string | Hash bcrypt (nulo se OAuth) | -| google_id | string? | ID OAuth Google (opcional) | -| created_at | timestamp | | +| Campo | Tipo | Descrição | +|------------------|-----------|-------------------------------------| +| id | UUID | Identificador único | +| email | string | Email do usuário | +| password_hash | string? | Hash Argon2id (nulo se OAuth) | +| google_id | string? | ID OAuth Google (opcional) | +| email_verified | boolean | Obrigatório para login | +| created_at | timestamp | | ### `spool_presets` -| Campo | Tipo | Descrição | -|--------------|-----------|------------------------------------------------| -| id | UUID | Identificador único | -| name | string | Nome do preset (ex: "Bambu Lab Plástico") | -| spool_weight_g| integer | Peso do carretel vazio em gramas | -| is_system | boolean | `true` = preset built-in, `false` = customizado| -| user_id | UUID? | Nulo para presets do sistema | -| created_at | timestamp | | +| Campo | Tipo | Descrição | +|----------------|-----------|-------------------------------------------------| +| id | UUID | Identificador único | +| name | string | Nome do preset | +| spool_weight_g | integer | Peso do carretel vazio em gramas | +| is_system | boolean | `true` = built-in (read-only para usuários) | +| user_id | UUID? | Nulo para presets do sistema | +| created_at | timestamp | | ### `filaments` -| Campo | Tipo | Descrição | -|------------------|-----------|----------------------------------------| -| id | UUID | Identificador único | -| user_id | UUID | Dono do filamento | -| material | string | PLA, ABS, PETG, etc. | -| brand | string | Marca | -| model | string? | Modelo/linha | -| color_hex | string | Cor em hex (ex: `#FF5733`) | -| spool_preset_id | UUID | Preset de carretel usado | -| total_weight_g | integer | Peso total medido na balança (gramas) | -| net_weight_g | integer | Calculado: total - preset | -| temp_hotend_c | integer? | Temperatura do hotend (°C) | -| temp_bed_c | integer? | Temperatura da mesa (°C) | -| flow_factor_pct | float? | Fator de fluxo (%) | -| notes | text? | Observações livres | -| updated_at | timestamp | Usado para sync offline | -| created_at | timestamp | | +| Campo | Tipo | Descrição | +|-----------------|-----------|-----------------------------------------| +| id | UUID | Identificador único | +| user_id | UUID | Dono do filamento | +| material | string | PLA, ABS, PETG, TPU, ASA, PA, PC… | +| brand | string | Marca | +| model | string? | Modelo/linha | +| color_hex | string | Cor em hex (`#FF5733`) | +| spool_preset_id | UUID | Preset de carretel usado | +| total_weight_g | real | Peso total medido na balança (gramas) | +| net_weight_g | real | Calculado: total − peso do carretel | +| temp_hotend_c | real? | Temperatura do hotend (°C) | +| temp_bed_c | real? | Temperatura da mesa (°C) | +| flow_factor_pct | real? | Fator de fluxo (%) | +| notes | text? | Observações livres | +| updated_at | timestamp | Usado para resolução de conflitos sync | +| created_at | timestamp | | + +### `password_reset_tokens` / `email_verification_tokens` +| Campo | Tipo | Descrição | +|------------|-------------|--------------------------------------| +| id | UUID | PK | +| token | TEXT UNIQUE | Token único (indexado) | +| user_id | UUID | FK para users | +| expires_at | TIMESTAMPTZ | TTL: 1h (reset) / 24h (verificação) | +| used_at | TIMESTAMPTZ | NULL = não usado; preenchido ao usar | --- -## 8. Fluxo do Usuário (User Flow) +## 10. Fluxo do Usuário -### Cadastro de Filamento (Mobile) -1. Usuário abre o App Mobile (autenticado). -2. Clica em "Novo Filamento". -3. Insere Marca, Material, Modelo e seleciona a Cor via seletor hex. -4. Seleciona o Preset do Carretel (ex: "Bambu Lab Plástico — 250g") ou cria um customizado. -5. Coloca o carretel na balança e digita o peso total (ex: "850g"). -6. O sistema calcula e exibe: **600g de filamento disponível**. -7. Salva localmente (offline-first) e sincroniza com o backend quando online. -8. O sistema gera o QR Code e disponibiliza o SVG da etiqueta para exportação. +### Cadastro e Login +1. Usuário se cadastra → backend envia e-mail de verificação (background). +2. Usuário clica no link `https://...` → backend redireciona 302 → deep link → app verifica. +3. Login com e-mail não verificado → `403 EMAIL_NOT_VERIFIED` → app sugere reenvio. +4. Login com e-mail verificado → tokens JWT emitidos → sessão persistida no SecureStore. -### Consulta via Web *(Fase 2 — planejado)* -1. Usuário acessa a web autenticado. -2. Dashboard exibe inventário completo com totais e alertas. -3. Dados são servidos via API com cache, garantindo baixa latência. +### Cadastro de Filamento +1. Usuário abre o App (autenticado) → "Novo Filamento". +2. Preenche marca, material, modelo, seleciona cor hex. +3. Seleciona preset de carretel ou cria um novo. +4. Digita o peso total (ex: 850g) → app calcula: **600g disponível**. +5. Salva localmente (offline-first) e sincroniza quando online. +6. QR Code e etiqueta ficam disponíveis imediatamente. + +### Identificação via QR Code / NFC +1. Usuário aponta câmera para QR Code **ou** aproxima tag NFC do carretel. +2. App lê `meowspool://filament/` → navega direto para o detalhe do filamento. --- -## 9. Status do Design (Paper) +## 11. Artboards de Design -**Canvas:** https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY - -Todas as telas do MVP foram criadas. 18 artboards cobrindo 100% dos fluxos definidos nas seções 5.1–5.5. - -### Artboards - -| ID | Tela | Seção PRD | -|----|------|-----------| -| `G3-0` | Login | 5.1 | -| `IX-0` | Cadastro | 5.1 | -| `L2-0` | Recuperação de Senha | 5.1 | -| `RU-0` | Verificação de Email | 5.1 | -| `117-0` | Redefinir Senha | 5.1 | -| `135-0` | Senha Redefinida | 5.1 | -| `1-0` | Home / Dashboard | 5.5 | -| `CW-0` | Inventário | 5.2 / 5.5 | -| `17H-0` | Filtros (bottom sheet) | 5.5 | -| `2X-0` | Cadastro de Filamento | 5.2 | -| `13O-0` | Editar Filamento | 5.2 | -| `6L-0` | Detalhe do Filamento | 5.2 | -| `M4-0` | Presets de Carretéis | 5.3 | -| `QM-0` | Novo Preset | 5.3 | -| `1KD-0` | Editar Preset | 5.3 | -| `1CF-0` | Ver QR Code | 5.4 | -| `1FZ-0` | Exportar Etiqueta | 5.4 | -| `1LQ-0` | Perfil / Conta | 5.1 / 5.5 | - -### Notas de design - -- Dark mode exclusivo, paleta siamês (`#1E1B18` base, `#38BCC2` accent). -- Fontes: Inter (UI geral) e JetBrains Mono (URLs/código). -- A aba "Config" da nav bar corresponde à tela Presets de Carretéis (`M4-0`). -- Home expandida (1260px) com scroll — inclui seção "Por Material" e "Estoque Baixo". -- Alertas de estoque: vermelho (`#FF6B6B`) para ≤15%, laranja (`#FF9F43`) para ≤35%. -- QR Codes representados por SVGs geométricos simulados. -- Etiqueta SVG na tela de Exportar: proporção 50×30mm escalada para ~350×210px. +| ID | Tela | Seção | +|---------|-----------------------------|-------| +| `G3-0` | Login | 5.1 | +| `IX-0` | Cadastro | 5.1 | +| `L2-0` | Recuperação de Senha | 5.1 | +| `RU-0` | Verificação de Email | 5.1 | +| `117-0` | Redefinir Senha | 5.1 | +| `135-0` | Senha Redefinida | 5.1 | +| `1-0` | Home / Dashboard | 5.5 | +| `CW-0` | Inventário | 5.2 | +| `17H-0` | Filtros (bottom sheet) | 5.2 | +| `2X-0` | Cadastro de Filamento | 5.2 | +| `13O-0` | Editar Filamento | 5.2 | +| `6L-0` | Detalhe do Filamento | 5.2 | +| `M4-0` | Presets de Carretéis | 5.3 | +| `QM-0` | Novo Preset | 5.3 | +| `1KD-0` | Editar Preset | 5.3 | +| `1CF-0` | Ver QR Code | 5.4 | +| `1FZ-0` | Exportar Etiqueta | 5.4 | +| `1RY-0` | Leitura NFC (modal) | 5.4 | +| `1LQ-0` | Perfil / Conta | 5.1 | --- -## 10. Roadmap de Funcionalidades Futuras +## 12. Roadmap -* **Front-end Web (Next.js):** Dashboard e inventário acessível via browser, consumindo a mesma API do backend. -* **Escrita de Tags NFC:** Gravação de dados diretamente em tags NFC coladas nos carretéis (via app mobile). -* **Histórico de Uso:** Log de gramas consumidas por projeto/impressão. -* **Alerta de Estoque Baixo:** Notificações push quando um filamento estiver abaixo de X gramas. -* **Custo por Grama:** Registro do custo de cada rolo para cálculo de custo por impressão. -* **Importação em Lote:** Upload CSV/JSON para cadastro massivo (útil para print farms). -* **Compartilhamento de Presets:** Pool público de presets de carretéis contribuídos pela comunidade. +### Pendente — Mobile +- [ ] Sync background com `sync_queue` SQLite → API +- [ ] Google OAuth com `expo-auth-session` +- [ ] NFC suporte iOS (entitlement `com.apple.developer.nfc.readwrite`) +- [ ] Expo Notifications para alertas de estoque baixo +- [ ] Testes de integração com Jest + Testing Library + +### Pendente — Infraestrutura +- [ ] DI container formal (instanciar e injetar repositórios concretos) + +### Fase 2 — Web (planejado) +- [ ] Front-end Web com Next.js (dashboard + inventário) +- [ ] Cache com Redis / HTTP Cache +- [ ] Histórico de uso por projeto/impressão +- [ ] Custo por grama +- [ ] Importação em lote (CSV/JSON) +- [ ] Pool público de presets compartilhados pela comunidade + +### Concluído +- [x] CRUD de filamentos (mobile + API) +- [x] CRUD de presets (mobile + API) +- [x] Dashboard (mobile + API) +- [x] QR Code geração e scanner (ML Kit) +- [x] Exportação de etiqueta SVG e PDF +- [x] Suporte Niimbot D11/D110 (22×14mm) com layout adaptativo +- [x] NFC leitura e gravação NTAG215 (Android) +- [x] Deep links (filamento, verificação de e-mail, reset de senha) +- [x] Verificação de e-mail obrigatória + reenvio +- [x] Fluxo completo de reset de senha +- [x] Redirect HTTP → deep link (compatibilidade com clientes de e-mail) diff --git a/backend/agent.md b/backend/agent.md index 712ecd2..d014f07 100644 --- a/backend/agent.md +++ b/backend/agent.md @@ -487,6 +487,84 @@ GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,q --- +## Mudanças Recentes (19/03/2026) — segunda entrada + +### ✅ Google OAuth — suporte a client IDs Android e iOS + +**Arquivos modificados**: `src/config.rs`, `src/adapters/inbound/auth_handler.rs` + +**Problema**: o backend lia apenas `GOOGLE_CLIENT_ID` (Android). Tokens emitidos pelo fluxo iOS tinham `aud` diferente e eram rejeitados. + +**Mudanças**: + +- `Config` ganhou campo `google_client_id_ios: String` lido de `GOOGLE_CLIENT_ID_APPLE` +- `verify_google_id_token` aceita agora dois client IDs e valida `aud` contra ambos: + +```rust +let valid_ids = [client_id_android, client_id_ios]; +let audience_valid = valid_ids.iter().any(|id| !id.is_empty() && *id == aud); +if !audience_valid { return Err(AppError::Unauthorized); } +``` + +- Assinatura atualizada: `verify_google_id_token(id_token, client_id_android, client_id_ios)` +- Call site em `google_oauth_handler` passa `state.config.google_client_id` e `state.config.google_client_id_ios` + +**Variável de ambiente adicionada**: + +| Variável | Valor no .env | +| ---------------------- | ------------------------------------------------------------------ | +| `GOOGLE_CLIENT_ID` | `724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com` | +| `GOOGLE_CLIENT_ID_APPLE` | `724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com` | + +--- + +## Mudanças Recentes (19/03/2026) + +### ✅ Hardening de segurança — Google OAuth e JWT + +**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/application/auth_service.rs` + +#### Google OAuth — validação de `aud` e `email_verified` + +**Problema**: a função `verify_google_id_token` recebia `_client_id` como parâmetro mas nunca o usava. Qualquer portador de um token Google válido — emitido para qualquer app — podia autenticar no MeowSpool. + +**Correção** (`auth_handler.rs`): + +```rust +// Valida audience: o token deve ter sido emitido para este app +let aud = payload["aud"].as_str().ok_or(AppError::Unauthorized)?; +if !client_id.is_empty() && aud != client_id { + tracing::warn!(aud, client_id, "Google id_token audience mismatch"); + return Err(AppError::Unauthorized); +} + +// Rejeita contas Google com e-mail não verificado +let email_verified = payload["email_verified"].as_str().unwrap_or("false"); +if email_verified != "true" { + tracing::warn!("Google account email not verified"); + return Err(AppError::Unauthorized); +} +``` + +- Parâmetro renomeado de `_client_id` para `client_id` +- Rejeições geram `tracing::warn!` para auditoria + +#### JWT — algoritmo explícito + +**Problema**: `Validation::default()` não fixava o algoritmo permitido, abrindo margem para ataques com algoritmos inesperados. + +**Correção** (`auth_service.rs`): substituído `Validation::default()` por `Validation::new(Algorithm::HS256)` em ambos os pontos de decodificação: + +```rust +// validate_access_token e decode_refresh_token: +let validation = Validation::new(Algorithm::HS256); +decode::(token, &key, &validation) +``` + +`Algorithm` importado de `jsonwebtoken`. + +--- + ## Mudanças Recentes (14/03/2026) ### ✅ Reenvio de e-mail de verificação diff --git a/backend/src/adapters/inbound/auth_handler.rs b/backend/src/adapters/inbound/auth_handler.rs index 893cde7..02c82a3 100644 --- a/backend/src/adapters/inbound/auth_handler.rs +++ b/backend/src/adapters/inbound/auth_handler.rs @@ -111,7 +111,11 @@ pub async fn google_oauth_handler( Json(req): Json, ) -> Result { // Verificar id_token com a API do Google e extrair sub + email - let (google_id, email) = verify_google_id_token(&req.id_token, &state.config.google_client_id).await?; + let (google_id, email) = verify_google_id_token( + &req.id_token, + &state.config.google_client_id, + &state.config.google_client_id_ios, + ).await?; let tokens = state.auth_service.google_oauth(google_id, email).await?; Ok(Json(AuthResponse { access_token: tokens.access_token, @@ -203,9 +207,15 @@ pub async fn reset_password_redirect_handler( // ------------------------------------------------------------------------- /// Verifica o id_token do Google chamando o endpoint tokeninfo e retorna (sub, email). +/// +/// Validações obrigatórias: +/// - token reconhecido pelo Google (status 2xx) +/// - `aud` bate com um dos client_ids registrados (Android ou iOS) +/// - `email_verified` = true (previne emails não verificados no Google) async fn verify_google_id_token( id_token: &str, - _client_id: &str, + client_id_android: &str, + client_id_ios: &str, ) -> Result<(String, String), AppError> { let url = format!( "https://oauth2.googleapis.com/tokeninfo?id_token={id_token}" @@ -218,6 +228,7 @@ async fn verify_google_id_token( .map_err(|e| AppError::Internal(e.into()))?; if !resp.status().is_success() { + tracing::warn!("Google tokeninfo rejected id_token"); return Err(AppError::Unauthorized); } @@ -226,6 +237,23 @@ async fn verify_google_id_token( .await .map_err(|e| AppError::Internal(e.into()))?; + // Valida audience: aceita tokens emitidos para o client Android ou iOS do app. + // Um token emitido para outro app (aud diferente) é rejeitado. + let aud = payload["aud"].as_str().ok_or(AppError::Unauthorized)?; + let valid_ids = [client_id_android, client_id_ios]; + let audience_valid = valid_ids.iter().any(|id| !id.is_empty() && *id == aud); + if !audience_valid { + tracing::warn!(aud, "Google id_token audience mismatch"); + return Err(AppError::Unauthorized); + } + + // Rejeita contas Google com e-mail não verificado + let email_verified = payload["email_verified"].as_str().unwrap_or("false"); + if email_verified != "true" { + tracing::warn!("Google account email not verified"); + return Err(AppError::Unauthorized); + } + let sub = payload["sub"] .as_str() .ok_or(AppError::Unauthorized)? diff --git a/backend/src/application/auth_service.rs b/backend/src/application/auth_service.rs index 18e636f..ac5d22f 100644 --- a/backend/src/application/auth_service.rs +++ b/backend/src/application/auth_service.rs @@ -4,7 +4,7 @@ use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; @@ -137,10 +137,11 @@ impl AuthService { // ------------------------------------------------------------------------- pub fn validate_access_token(&self, token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); decode::( token, &DecodingKey::from_secret(self.config.jwt_secret.as_bytes()), - &Validation::default(), + &validation, ) .map(|data| data.claims) .map_err(|_| AppError::Unauthorized) @@ -180,10 +181,11 @@ impl AuthService { } fn decode_refresh_token(&self, token: &str) -> Result { + let validation = Validation::new(Algorithm::HS256); decode::( token, &DecodingKey::from_secret(self.config.jwt_secret.as_bytes()), - &Validation::default(), + &validation, ) .map(|data| data.claims) .map_err(|_| AppError::Unauthorized) diff --git a/backend/src/config.rs b/backend/src/config.rs index 5c5248e..843194a 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -7,7 +7,8 @@ pub struct Config { pub jwt_secret: String, pub jwt_expiry_secs: u64, pub jwt_refresh_expiry_secs: u64, - pub google_client_id: String, + pub google_client_id: String, // Android OAuth 2.0 client ID + pub google_client_id_ios: String, // iOS OAuth 2.0 client ID (GOOGLE_CLIENT_ID_APPLE) pub google_client_secret: String, pub host: String, pub port: u16, @@ -36,6 +37,7 @@ impl Config { jwt_expiry_secs: optional("JWT_EXPIRY_SECS", 3600)?, jwt_refresh_expiry_secs: optional("JWT_REFRESH_EXPIRY_SECS", 2_592_000)?, google_client_id: optional_string("GOOGLE_CLIENT_ID"), + google_client_id_ios: optional_string("GOOGLE_CLIENT_ID_APPLE"), google_client_secret: optional_string("GOOGLE_CLIENT_SECRET"), host: optional_string_default("HOST", "0.0.0.0"), port: optional("PORT", 8080)?, diff --git a/mobile/agent.md b/mobile/agent.md index 54b217b..6ef464a 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -33,7 +33,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi | Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build | | NFC | `react-native-nfc-manager` | Leitura e gravação NDEF (NTAG215); requer build nativo | | File system | `expo-file-system/legacy` | Salvar arquivos no cache; usar import `/legacy` | -| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos | +| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos (iOS) | +| Abrir arquivo | `expo-intent-launcher` | ACTION_VIEW no Android — abre apps de impressão (Niimbot) | --- @@ -370,11 +371,139 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) - [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`) - [ ] Expo Notifications para alertas de estoque baixo - [x] Deep link `verify-email?token=` com handler e integração ao backend -- [ ] Google OAuth com `expo-auth-session` +- [x] Google OAuth com `expo-auth-session` (Android + iOS, `Google.useAuthRequest` + `promptAsync`) - [ ] Testes de integração com Jest + Testing Library --- +## Mudanças Recentes (19/03/2026) — segunda entrada + +### ✅ Google OAuth — implementação completa + +**Arquivos modificados**: `app/(auth)/login.tsx`, `src/infrastructure/container.ts`, `src/adapters/remote/ApiAuthRepository.ts` + +**Pacotes instalados**: `expo-auth-session`, `expo-web-browser` + +#### Fluxo implementado + +``` +Usuário toca "Entrar com Google" + → promptAsync() abre browser via expo-web-browser + → Google OAuth consent screen + → redirect de volta ao app (scheme meowspool://) + → useEffect detecta response.type === 'success' + → authentication.idToken enviado para GoogleLoginUseCase + → POST /auth/oauth/google { id_token } + → backend valida aud, email_verified, cria/encontra usuário + → retorna JWT pair → setSession → navega para home +``` + +#### Detalhes técnicos + +**`login.tsx`** — hook `Google.useAuthRequest` + `useEffect` no `response`: + +```tsx +WebBrowser.maybeCompleteAuthSession(); // fora do componente + +const [_request, response, promptAsync] = Google.useAuthRequest({ + androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID, + iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS, +}); + +useEffect(() => { + if (response?.type === 'success') { + const idToken = response.authentication?.idToken; + googleLoginUseCase.execute({ idToken }) + .then(session => setSession(session)) + .then(() => router.replace('/(app)/(tabs)/home')) + .finally(() => setIsGoogleLoading(false)); + } +}, [response]); +``` + +- `onGoogleLogin` agora é síncrono e apenas chama `promptAsync()` +- Tratamento dos três estados: `success`, `error`, `dismiss` + +**`ApiAuthRepository.ts`** — bug corrigido: o campo era enviado como `idToken` (camelCase) mas o backend espera `id_token` (snake_case): + +```ts +// Antes (quebrado): +await httpClient.post('/auth/oauth/google', input); // enviava { idToken } + +// Depois (correto): +await httpClient.post('/auth/oauth/google', { id_token: input.idToken }); +``` + +**`container.ts`** — adicionado export de `googleLoginUseCase`: + +```ts +import { GoogleLoginUseCase } from '@application/auth/AuthUseCases'; +export const googleLoginUseCase = new GoogleLoginUseCase(authRepository); +``` + +#### Variáveis de ambiente necessárias (`mobile/.env`) + +``` +EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID=724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com +EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS=724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com +``` + +#### Configuração no Google Cloud Console (obrigatória) + +O redirect URI do `expo-auth-session` usa o scheme do app. Adicionar nos OAuth 2.0 Clients: + +- **Android** (client `GOOGLE_CLIENT_ID_ANDROID`): SHA-1 fingerprint da keystore + package name `com.meowspool.app` +- **iOS** (client `GOOGLE_CLIENT_ID_IOS`): bundle ID `com.meowspool.app` + +> **Nota**: o `expo-auth-session` com `Google.useAuthRequest` funciona em development build (não no Expo Go). Requer `mise exec -- npx expo run:android` após instalar os pacotes. + +--- + +## Mudanças Recentes (19/03/2026) + +### ✅ Hardening de segurança — bypass do interceptor de refresh + +**Arquivo modificado**: `src/store/authStore.ts` + +**Problema**: `loadStoredSession` usava `axios.get` diretamente com o access token lido do SecureStore, bypassando o interceptor de refresh do `httpClient`. Consequência: se o access token expirava, a sessão era destruída mesmo quando o refresh token ainda era válido, forçando login desnecessário. + +**Correção**: + +```ts +// Antes (bypass do interceptor): +const { data } = await axios.get(`${API_BASE_URL}/users/me`, { + headers: { Authorization: `Bearer ${accessToken}` }, +}); + +// Depois (usa httpClient → interceptor de refresh ativo): +const { data } = await httpClient.get('/users/me'); +// Lê tokens após possível refresh (interceptor pode ter renovado o SecureStore) +const [accessToken, refreshToken] = await Promise.all([ + SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN), + SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN), +]); +``` + +**Melhorias adicionais no `catch`**: + +```ts +const isAuthError = + axios.isAxiosError(err) && + (err.response?.status === 401 || err.response?.status === 403); +if (isAuthError || !axios.isAxiosError(err)) { + await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN); + await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN); +} +set({ isLoading: false }); +``` + +- Erros de rede (sem conexão) não forçam logout — tokens são preservados para quando a conectividade voltar +- Somente erros 401/403 limpam a sessão +- Import `API_BASE_URL` removido (não mais necessário) +- Import `httpClient` adicionado de `@adapters/remote/httpClient` + +--- + ## Mudanças Recentes (14/03/2026) ### ✅ Reenvio de e-mail de verificação @@ -606,8 +735,9 @@ mise exec -- npx expo run:android - **Pacotes instalados**: `expo-file-system`, `expo-sharing`, `react-native-worklets` - **Atenção**: importar `expo-file-system` como `expo-file-system/legacy` — a API padrão do SDK 54 não exporta `EncodingType` nem `writeAsStringAsync` diretamente -- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → `Sharing.shareAsync` -- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → `Sharing.shareAsync` +- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → Android: `IntentLauncher.startActivityAsync('android.intent.action.VIEW', ...)` com `content://` URI via `FileSystem.getContentUriAsync` | iOS: `Sharing.shareAsync` +- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → mesmo padrão Android/iOS acima +- **Por que ACTION_VIEW no Android**: `expo-sharing` usa `ACTION_SEND`, que não é declarado pelo Niimbot (e outros apps de impressão). Esses apps declaram apenas `ACTION_VIEW`. Usar `IntentLauncher` com `ACTION_VIEW` + `content://` URI mostra o mesmo seletor que o Gmail usa ao abrir um PDF. - **Seletor de formato**: chips PDF/SVG no footer; padrão é PDF - **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção diff --git a/mobile/app.json b/mobile/app.json index cec1abe..45dfc2d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,7 +11,9 @@ "resizeMode": "contain", "backgroundColor": "#1E1B18" }, - "assetBundlePatterns": ["**/*"], + "assetBundlePatterns": [ + "**/*" + ], "ios": { "supportsTablet": false, "bundleIdentifier": "com.meowspool.app" @@ -36,7 +38,9 @@ "cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos." } ], - "react-native-nfc-manager" + "react-native-nfc-manager", + "expo-web-browser", + "./plugins/withGoogleOAuth" ], "experiments": { "typedRoutes": true diff --git a/mobile/app/(app)/filaments/[id]/label.tsx b/mobile/app/(app)/filaments/[id]/label.tsx index a1ab91e..8ee3fcf 100644 --- a/mobile/app/(app)/filaments/[id]/label.tsx +++ b/mobile/app/(app)/filaments/[id]/label.tsx @@ -1,12 +1,13 @@ import React, { useState } from 'react'; import { - View, Text, TouchableOpacity, StyleSheet, Alert, + View, Text, TouchableOpacity, StyleSheet, Alert, Platform, } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as FileSystem from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; +import * as IntentLauncher from 'expo-intent-launcher'; import { useFilamentStore } from '@store/filamentStore'; import { usePresetStore } from '@store/presetStore'; import { calcFilamentPercentage } from '@domain/Filament'; @@ -111,7 +112,14 @@ export default function LabelScreen(): React.ReactElement { await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64, }); - if (await Sharing.isAvailableAsync()) { + if (Platform.OS === 'android') { + const contentUri = await FileSystem.getContentUriAsync(fileUri); + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + flags: 1, // FLAG_GRANT_READ_URI_PERMISSION + type: 'application/pdf', + }); + } else if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' }); } else { Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.'); @@ -123,7 +131,14 @@ export default function LabelScreen(): React.ReactElement { ); const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`; await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' }); - if (await Sharing.isAvailableAsync()) { + if (Platform.OS === 'android') { + const contentUri = await FileSystem.getContentUriAsync(fileUri); + await IntentLauncher.startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + flags: 1, // FLAG_GRANT_READ_URI_PERMISSION + type: 'image/svg+xml', + }); + } else if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' }); } else { Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.'); diff --git a/mobile/app/(auth)/login.tsx b/mobile/app/(auth)/login.tsx index 3fcb754..0c40805 100644 --- a/mobile/app/(auth)/login.tsx +++ b/mobile/app/(auth)/login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; import axios from 'axios'; import { Link, useRouter } from 'expo-router'; @@ -6,14 +6,19 @@ import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Ionicons } from '@expo/vector-icons'; +import * as Google from 'expo-auth-session/providers/google'; +import * as WebBrowser from 'expo-web-browser'; import { Screen } from '@presentation/components/layout/Screen'; import { Input } from '@presentation/components/ui/Input'; import { Button } from '@presentation/components/ui/Button'; import { colors, typography, spacing } from '@shared/theme'; -import { loginUseCase } from '@infrastructure/container'; +import { loginUseCase, googleLoginUseCase } from '@infrastructure/container'; import { useAuthStore } from '@store/authStore'; import { CatIcon } from '@presentation/components/icons/CatIcon'; +// Necessário para fechar o browser após redirect OAuth no Android/iOS +WebBrowser.maybeCompleteAuthSession(); + const schema = z.object({ email: z.string().email('E-mail inválido'), password: z.string().min(1, 'Senha obrigatória'), @@ -31,6 +36,36 @@ export default function LoginScreen(): React.ReactElement { const [isLoading, setIsLoading] = useState(false); const [isGoogleLoading, setIsGoogleLoading] = useState(false); + // Hook do expo-auth-session para Google OAuth. + // Usa os client IDs de Android e iOS configurados via variáveis de ambiente. + const [_request, response, promptAsync] = Google.useAuthRequest({ + androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID, + iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS, + }); + + // Reage ao resultado do fluxo OAuth assim que o browser fecha + useEffect(() => { + if (response?.type === 'success') { + const idToken = response.authentication?.idToken; + if (!idToken) { + Alert.alert('Erro', 'Não foi possível obter o token do Google.'); + setIsGoogleLoading(false); + return; + } + googleLoginUseCase + .execute({ idToken }) + .then((session) => setSession(session)) + .then(() => router.replace('/(app)/(tabs)/home')) + .catch(() => Alert.alert('Erro', 'Não foi possível entrar com o Google.')) + .finally(() => setIsGoogleLoading(false)); + } else if (response?.type === 'error') { + Alert.alert('Erro', 'Autenticação com Google cancelada ou falhou.'); + setIsGoogleLoading(false); + } else if (response?.type === 'dismiss') { + setIsGoogleLoading(false); + } + }, [response]); + const { control, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' }, @@ -57,16 +92,10 @@ export default function LoginScreen(): React.ReactElement { } } - async function onGoogleLogin(): Promise { + function onGoogleLogin(): void { setIsGoogleLoading(true); - try { - // TODO: Google Sign-In + GoogleLoginUseCase - router.replace('/(app)/(tabs)/home'); - } catch { - Alert.alert('Erro', 'Não foi possível entrar com o Google.'); - } finally { - setIsGoogleLoading(false); - } + // promptAsync abre o browser; o resultado chega via useEffect no `response` + promptAsync(); } return ( diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 93dcce7..cb5bf60 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -15,10 +15,13 @@ "@react-navigation/native-stack": "^7.3.16", "axios": "^1.7.7", "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.8", "expo-file-system": "~19.0.21", "expo-font": "~14.0.11", + "expo-intent-launcher": "^55.0.8", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", @@ -27,6 +30,7 @@ "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.53.0", @@ -6024,6 +6028,33 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz", + "integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.11", + "expo-crypto": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-web-browser": "~15.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-application": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", + "integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-camera": { "version": "17.0.10", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", @@ -6058,6 +6089,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "19.0.21", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", @@ -6082,6 +6125,15 @@ "react-native": "*" } }, + "node_modules/expo-intent-launcher": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-55.0.8.tgz", + "integrity": "sha512-MrgQoHC+2AGMDfwg+X+zF/lp0N2tIa7zVtMmPJksyAvOUpgZXN8B5IZaW8JLGSnFhNCYF3eEzJkQiUzXcgX7CA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -6474,6 +6526,16 @@ } } }, + "node_modules/expo-web-browser": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.23", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", diff --git a/mobile/package.json b/mobile/package.json index 880ca56..d7ac469 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,10 +18,13 @@ "@react-navigation/native-stack": "^7.3.16", "axios": "^1.7.7", "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.8", "expo-file-system": "~19.0.21", "expo-font": "~14.0.11", + "expo-intent-launcher": "^55.0.8", "expo-linking": "~8.0.11", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", @@ -30,6 +33,7 @@ "expo-sqlite": "~16.0.10", "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.53.0", diff --git a/mobile/plugins/withGoogleOAuth.js b/mobile/plugins/withGoogleOAuth.js new file mode 100644 index 0000000..894239a --- /dev/null +++ b/mobile/plugins/withGoogleOAuth.js @@ -0,0 +1,50 @@ +/** + * Plugin local: injeta o intent filter do Google OAuth no AndroidManifest.xml + * durante o `expo prebuild`. + * + * Sem esse intent filter, o Android não sabe que o MeowSpool deve interceptar + * o redirect do Google OAuth (scheme com.googleusercontent.apps.*), fazendo o + * Chrome tratar a URL como pesquisa. + * + * O scheme é o reverse do Android OAuth 2.0 client ID registrado no Google Cloud Console. + */ +const { withAndroidManifest } = require('@expo/config-plugins'); + +const GOOGLE_REVERSE_CLIENT_ID = + 'com.googleusercontent.apps.724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v'; + +module.exports = function withGoogleOAuth(config) { + return withAndroidManifest(config, (config) => { + const manifest = config.modResults; + const activities = manifest.manifest.application?.[0]?.activity ?? []; + + const mainActivity = activities.find( + (a) => a.$?.['android:name'] === '.MainActivity', + ); + + if (!mainActivity) return config; + + // Evita duplicar o intent filter em rebuilds consecutivos + const alreadyAdded = (mainActivity['intent-filter'] ?? []).some((filter) => + (filter.data ?? []).some( + (d) => d.$?.['android:scheme'] === GOOGLE_REVERSE_CLIENT_ID, + ), + ); + + if (alreadyAdded) return config; + + mainActivity['intent-filter'] = [ + ...(mainActivity['intent-filter'] ?? []), + { + action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }], + category: [ + { $: { 'android:name': 'android.intent.category.DEFAULT' } }, + { $: { 'android:name': 'android.intent.category.BROWSABLE' } }, + ], + data: [{ $: { 'android:scheme': GOOGLE_REVERSE_CLIENT_ID } }], + }, + ]; + + return config; + }); +}; diff --git a/mobile/src/adapters/remote/ApiAuthRepository.ts b/mobile/src/adapters/remote/ApiAuthRepository.ts index 28fde76..2246865 100644 --- a/mobile/src/adapters/remote/ApiAuthRepository.ts +++ b/mobile/src/adapters/remote/ApiAuthRepository.ts @@ -17,7 +17,8 @@ export class ApiAuthRepository implements AuthRepository { } async loginWithGoogle(input: GoogleOAuthInput): Promise { - const { data } = await httpClient.post('/auth/oauth/google', input); + // Backend espera snake_case: { id_token } + const { data } = await httpClient.post('/auth/oauth/google', { id_token: input.idToken }); return this.fetchSession(data.access_token as string, data.refresh_token as string); } diff --git a/mobile/src/infrastructure/container.ts b/mobile/src/infrastructure/container.ts index edefe0e..7d7f791 100644 --- a/mobile/src/infrastructure/container.ts +++ b/mobile/src/infrastructure/container.ts @@ -7,6 +7,7 @@ import { ApiSpoolPresetRepository } from '@adapters/remote/ApiSpoolPresetReposit import { LoginUseCase, RegisterUseCase, + GoogleLoginUseCase, ForgotPasswordUseCase, ResendVerificationUseCase, ResetPasswordUseCase, @@ -32,6 +33,7 @@ const presetRepository = new ApiSpoolPresetRepository(); // Use cases de autenticação export const loginUseCase = new LoginUseCase(authRepository); export const registerUseCase = new RegisterUseCase(authRepository); +export const googleLoginUseCase = new GoogleLoginUseCase(authRepository); export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository); export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository); export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository); diff --git a/mobile/src/store/authStore.ts b/mobile/src/store/authStore.ts index 47532cb..31afc40 100644 --- a/mobile/src/store/authStore.ts +++ b/mobile/src/store/authStore.ts @@ -2,7 +2,8 @@ import { create } from 'zustand'; import * as SecureStore from 'expo-secure-store'; import axios from 'axios'; import type { User, AuthSession } from '@domain/User'; -import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN, API_BASE_URL } from '@shared/constants'; +import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN } from '@shared/constants'; +import { httpClient } from '@adapters/remote/httpClient'; interface AuthState { user: User | null; @@ -56,27 +57,38 @@ export const useAuthStore = create((set) => ({ loadStoredSession: async () => { set({ isLoading: true }); try { - const accessToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN); - const refreshToken = await SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN); - if (accessToken) { - const { data } = await axios.get(`${API_BASE_URL}/users/me`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - const user: User = { - id: String(data.id), - email: data.email as string, - name: (data.name as string | null) ?? null, - googleId: (data.google_id as string | null) ?? null, - createdAt: data.created_at as string, - }; - set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false }); - } else { + const storedToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN); + if (!storedToken) { set({ isLoading: false }); + return; + } + // Usa httpClient para que o interceptor de refresh atue automaticamente: + // se o access token expirou mas o refresh token é válido, a sessão é renovada + // em vez de forçar logout desnecessariamente. + const { data } = await httpClient.get('/users/me'); + // Lê tokens após possível refresh (o interceptor pode ter atualizado o SecureStore) + const [accessToken, refreshToken] = await Promise.all([ + SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN), + SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN), + ]); + const user: User = { + id: String(data.id), + email: data.email as string, + name: (data.name as string | null) ?? null, + googleId: (data.google_id as string | null) ?? null, + createdAt: data.created_at as string, + }; + set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false }); + } catch (err) { + // Limpa sessão apenas em falha de autenticação (401/403). + // Erros de rede (sem conexão) não forçam logout — o usuário pode estar offline. + const isAuthError = + axios.isAxiosError(err) && + (err.response?.status === 401 || err.response?.status === 403); + if (isAuthError || !axios.isAxiosError(err)) { + await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN); + await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN); } - } catch { - // Token inválido ou expirado — limpa a sessão - await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN); - await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN); set({ isLoading: false }); } },