feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS. - Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`. - Modified `google_oauth_handler` to accept and process the new client IDs. - Enhanced security by enforcing explicit JWT algorithm validation. - Updated mobile app to handle Google OAuth flow using `expo-auth-session`. - Fixed API request to send `id_token` in snake_case as expected by the backend. - Added necessary environment variables for Google client IDs in mobile app. - Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
This commit is contained in:
@@ -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\" ! -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(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(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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,62 @@
|
|||||||
# Product Requirements Document (PRD): MeowSpool
|
# MeowSpool — Documento de Referência do Projeto
|
||||||
|
|
||||||
# Paper Canva
|
**Canvas de Design:** https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY
|
||||||
https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Visão Geral
|
## 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
|
## 2. Objetivos
|
||||||
|
|
||||||
* **Precisão:** Eliminar o "chute" de quanto filamento resta no rolo.
|
- **Precisão:** Eliminar o "chute" de quanto filamento resta no rolo.
|
||||||
* **Agilidade:** Facilitar o cadastro e a consulta via dispositivos móveis, mesmo offline.
|
- **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.
|
- **Organização:** Centralizar parâmetros técnicos (temperatura/fluxo) para consulta rápida.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Público-Alvo
|
## 3. Público-Alvo
|
||||||
|
|
||||||
* Hobbistas de impressão 3D.
|
- Hobbistas de impressão 3D.
|
||||||
* Donos de "Print Farms" (fazendas de impressão).
|
- Donos de "Print Farms" (fazendas de impressão).
|
||||||
* Projetistas que trabalham com diversos materiais (PLA, ABS, PETG, etc).
|
- Projetistas que trabalham com diversos materiais (PLA, ABS, PETG, etc).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Identidade Visual
|
## 4. Identidade Visual
|
||||||
|
|
||||||
### 4.1 Nome e Conceito
|
### 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.
|
**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
|
### 4.2 Paleta de Cores
|
||||||
|
|
||||||
| Token | Hex | Função na Interface |
|
| Token | Hex | Função na Interface |
|
||||||
|---------------|-----------|----------------------------------------------|
|
|-----------------|-------------|-------------------------------------------------|
|
||||||
| `bg-base` | `#1E1B18` | Fundo geral |
|
| `bgBase` | `#1E1B18` | Fundo geral |
|
||||||
| `bg-surface` | `#2A2622` | Cards, contêineres, widgets |
|
| `bgSurface` | `#2A2622` | Cards, contêineres, widgets |
|
||||||
| `bg-hover` | `#332F2B` | Estados de hover em cards e itens de lista |
|
| `bgHover` | `#332F2B` | Estados de hover em cards e itens de lista |
|
||||||
| `text-primary`| `#F5EEDC` | Títulos, peso líquido (texto importante) |
|
| `textPrimary` | `#F5EEDC` | Títulos, peso líquido (texto importante) |
|
||||||
| `text-secondary`| `#C9C1B0`| Descrições, labels (material, marca) |
|
| `textSecondary` | `#C9C1B0` | Descrições, labels (material, marca) |
|
||||||
| `accent` | `#38BCC2` | Botões de ação, elementos ativos |
|
| `accent` | `#38BCC2` | Botões de ação, elementos ativos |
|
||||||
| `accent-muted`| `#38BCC226`| Backgrounds de badges e status (10% opacidade)|
|
| `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,159 +64,459 @@ MeowSpool é uma solução para entusiastas e profissionais de impressão 3D ger
|
|||||||
|
|
||||||
### 5.1 Autenticação
|
### 5.1 Autenticação
|
||||||
|
|
||||||
* **Métodos suportados:** Email/senha e OAuth via Google.
|
- **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.
|
- **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.
|
- **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
|
### 5.2 Gestão de Inventário
|
||||||
|
|
||||||
* **Cadastro de Filamento:** Campos obrigatórios e opcionais:
|
- **Cadastro de Filamento:** Material, Marca, Modelo, Cor (hex), Temperatura Hotend, Temperatura Mesa, Fator de Fluxo/Extrusão, Notas.
|
||||||
* Material (tipo): PLA, ABS, PETG, TPU, ASA, PA, PC, etc.
|
- **Calculadora de Peso Líquido:** `Peso_Líquido = Peso_Total - Peso_Carretel` (via preset selecionado).
|
||||||
* Marca (texto livre)
|
- **Filtros:** Material, marca, busca textual, nível de estoque (`low`/`medium`/`ok`).
|
||||||
* Modelo (texto livre)
|
- **Ordenação:** Peso líquido asc/desc, criado em desc (padrão).
|
||||||
* 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`.
|
|
||||||
|
|
||||||
### 5.3 Presets de Carretéis
|
### 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 |
|
| Nome do Preset | Peso do Carretel |
|
||||||
|------------------------|-----------------|
|
|------------------------|-----------------|
|
||||||
| Bambu Lab (Plástico) | 250g |
|
| Bambu Lab (Plástico) | 250g |
|
||||||
| Elegoo (Papelão) | 200g |
|
| Elegoo (Papelão) | 200g |
|
||||||
| Creality (Plástico) | 230g |
|
| Creality (Plástico) | 230g |
|
||||||
| Prusament (Plástico) | 201g |
|
| Prusament (Plástico) | 201g |
|
||||||
| Sunlu (Papelão) | 200g |
|
| Sunlu (Papelão) | 200g |
|
||||||
| Polymaker (Plástico) | 220g |
|
| Polymaker (Plástico) | 220g |
|
||||||
| Genérico Papelão 1kg | 200g |
|
| Genérico Papelão 1kg | 200g |
|
||||||
| Genérico Plástico 1kg | 250g |
|
| 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 Customizados:** O usuário pode criar, editar e excluir seus próprios presets. Presets do sistema são somente leitura.
|
||||||
* **Presets do sistema são somente leitura** — não podem ser editados ou excluídos pelo usuário.
|
|
||||||
|
|
||||||
### 5.4 Identificação e Etiquetas
|
### 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.
|
- **QR Code:** Cada filamento gera QR Code com deep link `meowspool://filament/<id>`.
|
||||||
* **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).
|
- **Etiqueta SVG:** `GET /filaments/:id/label.svg` — parâmetros `width_mm`, `height_mm`, `fields`, `dpi`.
|
||||||
* Layout padrão: 50mm x 30mm (ajustável via parâmetro).
|
- **Etiqueta PDF:** `GET /filaments/:id/label.pdf` — mesmos parâmetros.
|
||||||
* Conteúdo: cor visual do filamento, nome, material, marca, peso líquido, QR Code.
|
- **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/<id>`.
|
||||||
|
|
||||||
### 5.5 Interface (UI/UX)
|
### 5.5 Dashboard
|
||||||
|
|
||||||
* **Design:** Dark mode exclusivo, paleta siamês (ver 4.2).
|
- Total de estoque em kg.
|
||||||
* **Plataforma atual:** Mobile (React Native).
|
- Contagem de filamentos com estoque baixo.
|
||||||
* **Filtros e Busca:** Filtragem por material, marca, cor e nível de estoque.
|
- Resumo por material (contagem + peso total).
|
||||||
* **Dashboard:** Visão geral do inventário com totais por material e alertas visuais de estoque baixo.
|
- 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 |
|
```
|
||||||
|-------------------|----------------------|------------------------------------------------------------------------------|
|
adapters/inbound (HTTP / Tela)
|
||||||
| **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. |
|
v
|
||||||
| **Banco de Dados**| PostgreSQL | Robustez para relações entre usuários, marcas, materiais e presets. |
|
application (Use Cases / Services)
|
||||||
| **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. |
|
v
|
||||||
|
domain (Entidades puras)
|
||||||
|
|
|
||||||
|
^
|
||||||
|
ports (Interfaces/traits)
|
||||||
|
|
|
||||||
|
^
|
||||||
|
adapters/outbound (PostgreSQL / SQLite / NFC / HTTP)
|
||||||
|
```
|
||||||
|
|
||||||
### 6.2 Fase 2 — Web (planejado, pós-estabilização)
|
**Regra fundamental:** O `domain` e os `ports` nunca importam de `adapters` ou `application`.
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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/<id> → /(app)/inventory/<id>
|
||||||
|
│ ├── 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/<id>` | `app/filament/[id].tsx` | Redireciona para `/(app)/inventory/<id>` |
|
||||||
|
| `meowspool://verify-email?token=<tok>` | `app/verify-email.tsx` | Verifica e-mail via POST /auth/verify-email |
|
||||||
|
| `meowspool://reset-password?token=<tok>`| `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`
|
### `users`
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
|--------------|-----------|----------------------------------|
|
|------------------|-----------|-------------------------------------|
|
||||||
| id | UUID | Identificador único |
|
| id | UUID | Identificador único |
|
||||||
| email | string | Email do usuário |
|
| email | string | Email do usuário |
|
||||||
| password_hash| string | Hash bcrypt (nulo se OAuth) |
|
| password_hash | string? | Hash Argon2id (nulo se OAuth) |
|
||||||
| google_id | string? | ID OAuth Google (opcional) |
|
| google_id | string? | ID OAuth Google (opcional) |
|
||||||
|
| email_verified | boolean | Obrigatório para login |
|
||||||
| created_at | timestamp | |
|
| created_at | timestamp | |
|
||||||
|
|
||||||
### `spool_presets`
|
### `spool_presets`
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
|--------------|-----------|------------------------------------------------|
|
|----------------|-----------|-------------------------------------------------|
|
||||||
| id | UUID | Identificador único |
|
| id | UUID | Identificador único |
|
||||||
| name | string | Nome do preset (ex: "Bambu Lab Plástico") |
|
| name | string | Nome do preset |
|
||||||
| spool_weight_g| integer | Peso do carretel vazio em gramas |
|
| spool_weight_g | integer | Peso do carretel vazio em gramas |
|
||||||
| is_system | boolean | `true` = preset built-in, `false` = customizado|
|
| is_system | boolean | `true` = built-in (read-only para usuários) |
|
||||||
| user_id | UUID? | Nulo para presets do sistema |
|
| user_id | UUID? | Nulo para presets do sistema |
|
||||||
| created_at | timestamp | |
|
| created_at | timestamp | |
|
||||||
|
|
||||||
### `filaments`
|
### `filaments`
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
|------------------|-----------|----------------------------------------|
|
|-----------------|-----------|-----------------------------------------|
|
||||||
| id | UUID | Identificador único |
|
| id | UUID | Identificador único |
|
||||||
| user_id | UUID | Dono do filamento |
|
| user_id | UUID | Dono do filamento |
|
||||||
| material | string | PLA, ABS, PETG, etc. |
|
| material | string | PLA, ABS, PETG, TPU, ASA, PA, PC… |
|
||||||
| brand | string | Marca |
|
| brand | string | Marca |
|
||||||
| model | string? | Modelo/linha |
|
| model | string? | Modelo/linha |
|
||||||
| color_hex | string | Cor em hex (ex: `#FF5733`) |
|
| color_hex | string | Cor em hex (`#FF5733`) |
|
||||||
| spool_preset_id | UUID | Preset de carretel usado |
|
| spool_preset_id | UUID | Preset de carretel usado |
|
||||||
| total_weight_g | integer | Peso total medido na balança (gramas) |
|
| total_weight_g | real | Peso total medido na balança (gramas) |
|
||||||
| net_weight_g | integer | Calculado: total - preset |
|
| net_weight_g | real | Calculado: total − peso do carretel |
|
||||||
| temp_hotend_c | integer? | Temperatura do hotend (°C) |
|
| temp_hotend_c | real? | Temperatura do hotend (°C) |
|
||||||
| temp_bed_c | integer? | Temperatura da mesa (°C) |
|
| temp_bed_c | real? | Temperatura da mesa (°C) |
|
||||||
| flow_factor_pct | float? | Fator de fluxo (%) |
|
| flow_factor_pct | real? | Fator de fluxo (%) |
|
||||||
| notes | text? | Observações livres |
|
| notes | text? | Observações livres |
|
||||||
| updated_at | timestamp | Usado para sync offline |
|
| updated_at | timestamp | Usado para resolução de conflitos sync |
|
||||||
| created_at | timestamp | |
|
| created_at | timestamp | |
|
||||||
|
|
||||||
---
|
### `password_reset_tokens` / `email_verification_tokens`
|
||||||
|
| Campo | Tipo | Descrição |
|
||||||
## 8. Fluxo do Usuário (User Flow)
|
|------------|-------------|--------------------------------------|
|
||||||
|
| id | UUID | PK |
|
||||||
### Cadastro de Filamento (Mobile)
|
| token | TEXT UNIQUE | Token único (indexado) |
|
||||||
1. Usuário abre o App Mobile (autenticado).
|
| user_id | UUID | FK para users |
|
||||||
2. Clica em "Novo Filamento".
|
| expires_at | TIMESTAMPTZ | TTL: 1h (reset) / 24h (verificação) |
|
||||||
3. Insere Marca, Material, Modelo e seleciona a Cor via seletor hex.
|
| used_at | TIMESTAMPTZ | NULL = não usado; preenchido ao usar |
|
||||||
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.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Status do Design (Paper)
|
## 10. Fluxo do Usuário
|
||||||
|
|
||||||
**Canvas:** https://app.paper.design/file/01KKM0HA6NBV44VCQDFQHC2MRY
|
### 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.
|
||||||
|
|
||||||
Todas as telas do MVP foram criadas. 18 artboards cobrindo 100% dos fluxos definidos nas seções 5.1–5.5.
|
### 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.
|
||||||
|
|
||||||
### Artboards
|
### 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/<id>` → navega direto para o detalhe do filamento.
|
||||||
|
|
||||||
| ID | Tela | Seção PRD |
|
---
|
||||||
|----|------|-----------|
|
|
||||||
|
## 11. Artboards de Design
|
||||||
|
|
||||||
|
| ID | Tela | Seção |
|
||||||
|
|---------|-----------------------------|-------|
|
||||||
| `G3-0` | Login | 5.1 |
|
| `G3-0` | Login | 5.1 |
|
||||||
| `IX-0` | Cadastro | 5.1 |
|
| `IX-0` | Cadastro | 5.1 |
|
||||||
| `L2-0` | Recuperação de Senha | 5.1 |
|
| `L2-0` | Recuperação de Senha | 5.1 |
|
||||||
@@ -210,8 +524,8 @@ Todas as telas do MVP foram criadas. 18 artboards cobrindo 100% dos fluxos defin
|
|||||||
| `117-0` | Redefinir Senha | 5.1 |
|
| `117-0` | Redefinir Senha | 5.1 |
|
||||||
| `135-0` | Senha Redefinida | 5.1 |
|
| `135-0` | Senha Redefinida | 5.1 |
|
||||||
| `1-0` | Home / Dashboard | 5.5 |
|
| `1-0` | Home / Dashboard | 5.5 |
|
||||||
| `CW-0` | Inventário | 5.2 / 5.5 |
|
| `CW-0` | Inventário | 5.2 |
|
||||||
| `17H-0` | Filtros (bottom sheet) | 5.5 |
|
| `17H-0` | Filtros (bottom sheet) | 5.2 |
|
||||||
| `2X-0` | Cadastro de Filamento | 5.2 |
|
| `2X-0` | Cadastro de Filamento | 5.2 |
|
||||||
| `13O-0` | Editar Filamento | 5.2 |
|
| `13O-0` | Editar Filamento | 5.2 |
|
||||||
| `6L-0` | Detalhe do Filamento | 5.2 |
|
| `6L-0` | Detalhe do Filamento | 5.2 |
|
||||||
@@ -220,26 +534,40 @@ Todas as telas do MVP foram criadas. 18 artboards cobrindo 100% dos fluxos defin
|
|||||||
| `1KD-0` | Editar Preset | 5.3 |
|
| `1KD-0` | Editar Preset | 5.3 |
|
||||||
| `1CF-0` | Ver QR Code | 5.4 |
|
| `1CF-0` | Ver QR Code | 5.4 |
|
||||||
| `1FZ-0` | Exportar Etiqueta | 5.4 |
|
| `1FZ-0` | Exportar Etiqueta | 5.4 |
|
||||||
| `1LQ-0` | Perfil / Conta | 5.1 / 5.5 |
|
| `1RY-0` | Leitura NFC (modal) | 5.4 |
|
||||||
|
| `1LQ-0` | Perfil / Conta | 5.1 |
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
### Pendente — Mobile
|
||||||
* **Escrita de Tags NFC:** Gravação de dados diretamente em tags NFC coladas nos carretéis (via app mobile).
|
- [ ] Sync background com `sync_queue` SQLite → API
|
||||||
* **Histórico de Uso:** Log de gramas consumidas por projeto/impressão.
|
- [ ] Google OAuth com `expo-auth-session`
|
||||||
* **Alerta de Estoque Baixo:** Notificações push quando um filamento estiver abaixo de X gramas.
|
- [ ] NFC suporte iOS (entitlement `com.apple.developer.nfc.readwrite`)
|
||||||
* **Custo por Grama:** Registro do custo de cada rolo para cálculo de custo por impressão.
|
- [ ] Expo Notifications para alertas de estoque baixo
|
||||||
* **Importação em Lote:** Upload CSV/JSON para cadastro massivo (útil para print farms).
|
- [ ] Testes de integração com Jest + Testing Library
|
||||||
* **Compartilhamento de Presets:** Pool público de presets de carretéis contribuídos pela comunidade.
|
|
||||||
|
### 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)
|
||||||
|
|||||||
@@ -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::<Claims>(token, &key, &validation)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Algorithm` importado de `jsonwebtoken`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mudanças Recentes (14/03/2026)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
### ✅ Reenvio de e-mail de verificação
|
### ✅ Reenvio de e-mail de verificação
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ pub async fn google_oauth_handler(
|
|||||||
Json(req): Json<GoogleOAuthRequest>,
|
Json(req): Json<GoogleOAuthRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// Verificar id_token com a API do Google e extrair sub + email
|
// 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?;
|
let tokens = state.auth_service.google_oauth(google_id, email).await?;
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
access_token: tokens.access_token,
|
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).
|
/// 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(
|
async fn verify_google_id_token(
|
||||||
id_token: &str,
|
id_token: &str,
|
||||||
_client_id: &str,
|
client_id_android: &str,
|
||||||
|
client_id_ios: &str,
|
||||||
) -> Result<(String, String), AppError> {
|
) -> Result<(String, String), AppError> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://oauth2.googleapis.com/tokeninfo?id_token={id_token}"
|
"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()))?;
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
|
tracing::warn!("Google tokeninfo rejected id_token");
|
||||||
return Err(AppError::Unauthorized);
|
return Err(AppError::Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +237,23 @@ async fn verify_google_id_token(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Internal(e.into()))?;
|
.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"]
|
let sub = payload["sub"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or(AppError::Unauthorized)?
|
.ok_or(AppError::Unauthorized)?
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use argon2::{
|
|||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||||
Argon2,
|
Argon2,
|
||||||
};
|
};
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -137,10 +137,11 @@ impl AuthService {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
pub fn validate_access_token(&self, token: &str) -> Result<AccessClaims, AppError> {
|
pub fn validate_access_token(&self, token: &str) -> Result<AccessClaims, AppError> {
|
||||||
|
let validation = Validation::new(Algorithm::HS256);
|
||||||
decode::<AccessClaims>(
|
decode::<AccessClaims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
||||||
&Validation::default(),
|
&validation,
|
||||||
)
|
)
|
||||||
.map(|data| data.claims)
|
.map(|data| data.claims)
|
||||||
.map_err(|_| AppError::Unauthorized)
|
.map_err(|_| AppError::Unauthorized)
|
||||||
@@ -180,10 +181,11 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decode_refresh_token(&self, token: &str) -> Result<RefreshClaims, AppError> {
|
fn decode_refresh_token(&self, token: &str) -> Result<RefreshClaims, AppError> {
|
||||||
|
let validation = Validation::new(Algorithm::HS256);
|
||||||
decode::<RefreshClaims>(
|
decode::<RefreshClaims>(
|
||||||
token,
|
token,
|
||||||
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
&DecodingKey::from_secret(self.config.jwt_secret.as_bytes()),
|
||||||
&Validation::default(),
|
&validation,
|
||||||
)
|
)
|
||||||
.map(|data| data.claims)
|
.map(|data| data.claims)
|
||||||
.map_err(|_| AppError::Unauthorized)
|
.map_err(|_| AppError::Unauthorized)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ pub struct Config {
|
|||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub jwt_expiry_secs: u64,
|
pub jwt_expiry_secs: u64,
|
||||||
pub jwt_refresh_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 google_client_secret: String,
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
@@ -36,6 +37,7 @@ impl Config {
|
|||||||
jwt_expiry_secs: optional("JWT_EXPIRY_SECS", 3600)?,
|
jwt_expiry_secs: optional("JWT_EXPIRY_SECS", 3600)?,
|
||||||
jwt_refresh_expiry_secs: optional("JWT_REFRESH_EXPIRY_SECS", 2_592_000)?,
|
jwt_refresh_expiry_secs: optional("JWT_REFRESH_EXPIRY_SECS", 2_592_000)?,
|
||||||
google_client_id: optional_string("GOOGLE_CLIENT_ID"),
|
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"),
|
google_client_secret: optional_string("GOOGLE_CLIENT_SECRET"),
|
||||||
host: optional_string_default("HOST", "0.0.0.0"),
|
host: optional_string_default("HOST", "0.0.0.0"),
|
||||||
port: optional("PORT", 8080)?,
|
port: optional("PORT", 8080)?,
|
||||||
|
|||||||
+134
-4
@@ -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 |
|
| 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 |
|
| 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` |
|
| 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`)
|
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
|
||||||
- [ ] Expo Notifications para alertas de estoque baixo
|
- [ ] Expo Notifications para alertas de estoque baixo
|
||||||
- [x] Deep link `verify-email?token=` com handler e integração ao backend
|
- [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
|
- [ ] 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)
|
## Mudanças Recentes (14/03/2026)
|
||||||
|
|
||||||
### ✅ Reenvio de e-mail de verificação
|
### ✅ 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`
|
- **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
|
- **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 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'` → `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
|
- **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
|
- **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -11,7 +11,9 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#1E1B18"
|
"backgroundColor": "#1E1B18"
|
||||||
},
|
},
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.meowspool.app"
|
"bundleIdentifier": "com.meowspool.app"
|
||||||
@@ -36,7 +38,9 @@
|
|||||||
"cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos."
|
"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": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, TouchableOpacity, StyleSheet, Alert,
|
View, Text, TouchableOpacity, StyleSheet, Alert, Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import * as IntentLauncher from 'expo-intent-launcher';
|
||||||
import { useFilamentStore } from '@store/filamentStore';
|
import { useFilamentStore } from '@store/filamentStore';
|
||||||
import { usePresetStore } from '@store/presetStore';
|
import { usePresetStore } from '@store/presetStore';
|
||||||
import { calcFilamentPercentage } from '@domain/Filament';
|
import { calcFilamentPercentage } from '@domain/Filament';
|
||||||
@@ -111,7 +112,14 @@ export default function LabelScreen(): React.ReactElement {
|
|||||||
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
||||||
encoding: FileSystem.EncodingType.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' });
|
await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' });
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
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`;
|
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
|
||||||
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
|
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' });
|
await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' });
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
||||||
|
|||||||
+40
-11
@@ -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 { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Link, useRouter } from 'expo-router';
|
import { Link, useRouter } from 'expo-router';
|
||||||
@@ -6,14 +6,19 @@ import { useForm, Controller } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 { Screen } from '@presentation/components/layout/Screen';
|
||||||
import { Input } from '@presentation/components/ui/Input';
|
import { Input } from '@presentation/components/ui/Input';
|
||||||
import { Button } from '@presentation/components/ui/Button';
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { colors, typography, spacing } from '@shared/theme';
|
import { colors, typography, spacing } from '@shared/theme';
|
||||||
import { loginUseCase } from '@infrastructure/container';
|
import { loginUseCase, googleLoginUseCase } from '@infrastructure/container';
|
||||||
import { useAuthStore } from '@store/authStore';
|
import { useAuthStore } from '@store/authStore';
|
||||||
import { CatIcon } from '@presentation/components/icons/CatIcon';
|
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({
|
const schema = z.object({
|
||||||
email: z.string().email('E-mail inválido'),
|
email: z.string().email('E-mail inválido'),
|
||||||
password: z.string().min(1, 'Senha obrigatória'),
|
password: z.string().min(1, 'Senha obrigatória'),
|
||||||
@@ -31,6 +36,36 @@ export default function LoginScreen(): React.ReactElement {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = 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<FormData>({
|
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { email: '', password: '' },
|
defaultValues: { email: '', password: '' },
|
||||||
@@ -57,16 +92,10 @@ export default function LoginScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onGoogleLogin(): Promise<void> {
|
function onGoogleLogin(): void {
|
||||||
setIsGoogleLoading(true);
|
setIsGoogleLoading(true);
|
||||||
try {
|
// promptAsync abre o browser; o resultado chega via useEffect no `response`
|
||||||
// TODO: Google Sign-In + GoogleLoginUseCase
|
promptAsync();
|
||||||
router.replace('/(app)/(tabs)/home');
|
|
||||||
} catch {
|
|
||||||
Alert.alert('Erro', 'Não foi possível entrar com o Google.');
|
|
||||||
} finally {
|
|
||||||
setIsGoogleLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Generated
+62
@@ -15,10 +15,13 @@
|
|||||||
"@react-navigation/native-stack": "^7.3.16",
|
"@react-navigation/native-stack": "^7.3.16",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
|
"expo-auth-session": "~7.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-intent-launcher": "^55.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
@@ -6024,6 +6028,33 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-camera": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||||
@@ -6058,6 +6089,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.21",
|
"version": "19.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||||
@@ -6082,6 +6125,15 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
"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": {
|
"node_modules/expo/node_modules/@expo/cli": {
|
||||||
"version": "54.0.23",
|
"version": "54.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
|
||||||
|
|||||||
@@ -18,10 +18,13 @@
|
|||||||
"@react-navigation/native-stack": "^7.3.16",
|
"@react-navigation/native-stack": "^7.3.16",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
|
"expo-auth-session": "~7.0.10",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-intent-launcher": "^55.0.8",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-system-ui": "~6.0.9",
|
"expo-system-ui": "~6.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -17,7 +17,8 @@ export class ApiAuthRepository implements AuthRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
|
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
|
||||||
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);
|
return this.fetchSession(data.access_token as string, data.refresh_token as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ApiSpoolPresetRepository } from '@adapters/remote/ApiSpoolPresetReposit
|
|||||||
import {
|
import {
|
||||||
LoginUseCase,
|
LoginUseCase,
|
||||||
RegisterUseCase,
|
RegisterUseCase,
|
||||||
|
GoogleLoginUseCase,
|
||||||
ForgotPasswordUseCase,
|
ForgotPasswordUseCase,
|
||||||
ResendVerificationUseCase,
|
ResendVerificationUseCase,
|
||||||
ResetPasswordUseCase,
|
ResetPasswordUseCase,
|
||||||
@@ -32,6 +33,7 @@ const presetRepository = new ApiSpoolPresetRepository();
|
|||||||
// Use cases de autenticação
|
// Use cases de autenticação
|
||||||
export const loginUseCase = new LoginUseCase(authRepository);
|
export const loginUseCase = new LoginUseCase(authRepository);
|
||||||
export const registerUseCase = new RegisterUseCase(authRepository);
|
export const registerUseCase = new RegisterUseCase(authRepository);
|
||||||
|
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);
|
||||||
export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository);
|
export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository);
|
||||||
export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository);
|
export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository);
|
||||||
export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository);
|
export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { User, AuthSession } from '@domain/User';
|
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 {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -56,12 +57,20 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
loadStoredSession: async () => {
|
loadStoredSession: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const accessToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||||
const refreshToken = await SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
if (!storedToken) {
|
||||||
if (accessToken) {
|
set({ isLoading: false });
|
||||||
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
|
return;
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
}
|
||||||
});
|
// 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 = {
|
const user: User = {
|
||||||
id: String(data.id),
|
id: String(data.id),
|
||||||
email: data.email as string,
|
email: data.email as string,
|
||||||
@@ -70,13 +79,16 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
createdAt: data.created_at as string,
|
createdAt: data.created_at as string,
|
||||||
};
|
};
|
||||||
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
|
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
|
||||||
} else {
|
} catch (err) {
|
||||||
set({ isLoading: false });
|
// 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.
|
||||||
} catch {
|
const isAuthError =
|
||||||
// Token inválido ou expirado — limpa a sessão
|
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_ACCESS_TOKEN);
|
||||||
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
||||||
|
}
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user