diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f5a5337..41bfde7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -56,6 +68,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -433,6 +454,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -672,6 +703,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -754,6 +801,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fax" version = "0.2.6" @@ -992,6 +1045,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1522,6 +1585,34 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna 1.1.0", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls 0.23.37", + "socket2 0.6.3", + "tokio", + "tokio-rustls 0.26.4", + "url", + "webpki-roots 1.0.6", +] + [[package]] name = "libc" version = "0.2.183" @@ -1685,6 +1776,7 @@ dependencies = [ "dotenvy", "image", "jsonwebtoken", + "lettre", "oauth2", "printpdf", "qrcode", @@ -1884,6 +1976,15 @@ dependencies = [ "url", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2151,6 +2252,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -2245,6 +2356,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -2601,6 +2718,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3091,6 +3209,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3931,6 +4063,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 131be09..95ad76b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -65,6 +65,9 @@ base64 = "0.22" # PDF generation printpdf = "0.7" +# Email (SMTP) +lettre = { version = "0.11", features = ["smtp-transport", "tokio1-rustls-tls", "builder"], default-features = false } + # Async trait async-trait = "0.1" diff --git a/backend/agent.md b/backend/agent.md index 47c7005..712ecd2 100644 --- a/backend/agent.md +++ b/backend/agent.md @@ -23,6 +23,7 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f | Logging | `tracing`, `tracing-subscriber` | 0.1 | | Validação | `validator` | 0.18 | | HTTP Client | `reqwest` (json, rustls-tls) | 0.12 | +| Email (SMTP) | `lettre` (smtp-transport, tokio1-rustls-tls, builder) | 0.11 | | Geração QR Code | `qrcode` | 0.14 | | Geração de imagem| `image` | 0.25 | | Encode Base64 | `base64` | 0.22 | @@ -41,7 +42,8 @@ backend/ ├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI │ ├── 20240101000001_create_users.sql │ ├── 20240101000002_create_spool_presets.sql -│ └── 20240101000003_create_filaments.sql +│ ├── 20240101000003_create_filaments.sql +│ └── 20240101000004_create_token_tables.sql <- password_reset_tokens + email_verification_tokens └── src/ ├── main.rs <- entry point: inicializa config, DB, router e servidor ├── config.rs <- struct Config lida de variáveis de ambiente @@ -63,9 +65,14 @@ backend/ ├── application/ <- CASOS DE USO: orquestram domínio + ports │ ├── mod.rs │ ├── auth_service.rs <- login, register, OAuth, refresh, logout + │ ├── email_token_service.rs <- forgot_password, verify_email, reset_password (tokens DB + email) │ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG │ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD) │ + ├── infrastructure/ <- Serviços externos (SMTP, etc.) + │ ├── mod.rs + │ └── email_service.rs <- EmailService: SMTP via lettre (STARTTLS) + │ └── adapters/ ├── inbound/ <- HTTP: recebe requisições, delega ao application │ ├── mod.rs @@ -119,9 +126,19 @@ Todas as rotas são prefixadas com `/api/v1`. | POST | `/oauth/google` | `google_oauth_handler` | Público | | POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) | | POST | `/logout` | `logout_handler` | Autenticado | -| POST | `/forgot-password` | `forgot_password_handler` | Público | -| POST | `/verify-email` | `verify_email_handler` | Público | -| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) | +| POST | `/resend-verification` | `resend_verification_handler` | Público (sempre 200) | +| POST | `/forgot-password` | `forgot_password_handler` | Público | +| POST | `/verify-email` | `verify_email_handler` | Público | +| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) | + +### Redirects para Deep Links — `/api/v1` + +| Método | Rota | Handler | Descrição | +| ------ | ----------------- | --------------------------------- | ---------------------------------------------------------- | +| GET | `/verify-email` | `verify_email_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://verify-email?token=xxx` | +| GET | `/reset-password` | `reset_password_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://reset-password?token=xxx`| + +> Esses endpoints são os **destinos dos links nos e-mails**. Clientes de e-mail (Gmail etc.) aceitam URLs `https://` normalmente; o backend redireciona para o deep link do app. O OS reconhece o scheme e abre o MeowSpool. ### Users — `/api/v1/users` @@ -204,9 +221,15 @@ pub enum AppError { #[error("validation error: {0}")] Validation(String), + #[error("bad request: {0}")] + BadRequest(String), // token inválido, expirado, já utilizado + #[error("conflict: {0}")] Conflict(String), + #[error("unprocessable entity: {0}")] + UnprocessableEntity(String), + #[error("internal error")] Internal(#[from] anyhow::Error), } @@ -311,7 +334,26 @@ Arquivos ficam em `backend/migrations/`. Nomeie com timestamp e descrição clar ## Variáveis de Ambiente -Copie `.env.example` para `.env` antes de rodar. Veja o arquivo `.env.example` para a lista completa. +Copie `.env.example` para `.env` antes de rodar. Variáveis principais: + +| Variável | Obrigatória | Padrão | Descrição | +| ------------------------- | ----------- | ----------------------- | ---------------------------------------------- | +| `DATABASE_URL` | ✅ | — | Connection string PostgreSQL | +| `JWT_SECRET` | ✅ | — | Segredo de assinatura JWT | +| `JWT_EXPIRY_SECS` | ❌ | `3600` | TTL do access token (segundos) | +| `JWT_REFRESH_EXPIRY_SECS` | ❌ | `2592000` | TTL do refresh token (segundos) | +| `GOOGLE_CLIENT_ID` | ❌ | — | Client ID OAuth Google | +| `GOOGLE_CLIENT_SECRET` | ❌ | — | Client Secret OAuth Google | +| `HOST` | ❌ | `0.0.0.0` | Endereço de bind do servidor | +| `PORT` | ❌ | `8080` | Porta do servidor | +| `APP_ENV` | ❌ | `development` | `development` ou `production` | +| `SMTP_HOST` | ❌ | `smtp.gmail.com` | Servidor SMTP | +| `SMTP_PORT` | ❌ | `587` | Porta SMTP (STARTTLS) | +| `SMTP_USER` | ❌ | — | Usuário SMTP (e-mail) | +| `SMTP_PASS` | ❌ | — | Senha / App Password SMTP | +| `EMAIL_FROM` | ❌ | `noreply@meowspool.app` | Endereço remetente dos e-mails | +| `APP_BASE_URL` | ❌ | `http://localhost:8080` | URL base usada nos links de e-mail. Em produção: `https://meowspool.felipecncloud.com/api/v1` | +| `APP_SCHEME` | ❌ | `meowspool` | Scheme do deep link do app mobile. Usado nos redirects de e-mail | --- @@ -447,6 +489,131 @@ GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,q ## Mudanças Recentes (14/03/2026) +### ✅ Reenvio de e-mail de verificação + +**Arquivos modificados**: `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/router.rs` + +**Novo endpoint**: `POST /api/v1/auth/resend-verification` — body: `{ "email": "..." }` +- Sempre retorna `200 OK` (não vaza se e-mail existe ou já foi verificado) +- Ignora silenciosamente se: e-mail não cadastrado, conta já verificada +- Reutiliza `send_verification_email` internamente (gera novo token com TTL 24h) + +**Novo método** (`email_token_service.rs`): +```rust +pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> { + let Some(user) = self.user_repo.find_by_email(email).await? else { return Ok(()); }; + if user.email_verified { return Ok(()); } + self.send_verification_email(user.id, email).await +} +``` + +--- + +### ✅ Registro não emite tokens — login bloqueado sem verificação de e-mail + +**Arquivos modificados**: `src/application/auth_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/error.rs` + +**Problema**: após o registro, o app autenticava o usuário imediatamente (emitia tokens) sem exigir verificação de e-mail. Login também aceitava contas não verificadas. + +**Mudanças**: + +- `AppError::EmailNotVerified` adicionado → HTTP 403, code `"EMAIL_NOT_VERIFIED"` +- `AuthService::register` passa a retornar apenas `User` (sem `TokenPair`) — o handler responde `201` sem body de auth +- `AuthService::login` verifica `user.email_verified` antes de emitir tokens: + ```rust + if !user.email_verified { + return Err(AppError::EmailNotVerified); + } + ``` + +**Fluxo resultante**: +1. Registro → `201 Created` (sem tokens) → backend envia e-mail de verificação em background +2. Login com e-mail não verificado → `403 { "code": "EMAIL_NOT_VERIFIED" }` +3. Após verificar e-mail → login funciona normalmente + +--- + +### ✅ Redirect HTTP → Deep Link para links de e-mail + +**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/router.rs`, `src/config.rs`, `.env` + +**Problema**: Clientes de e-mail (Gmail, etc.) bloqueiam links com scheme customizado (`meowspool://`). O link no e-mail não abria o app. + +**Solução**: O backend agora gera links `https://` nos e-mails. Ao clicar, o backend redireciona (302) para o deep link do app. + +**Fluxo completo**: +``` +E-mail → https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx + ↓ GET (browser abre normalmente) + Backend responde 302 Location: meowspool://verify-email?token=xxx + ↓ OS reconhece o scheme + App MeowSpool abre → app/verify-email.tsx + ↓ + POST /auth/verify-email { token } → verifica no banco +``` + +**Novos handlers** (`auth_handler.rs`): +- `verify_email_redirect_handler` — `GET /api/v1/verify-email?token=xxx` +- `reset_password_redirect_handler` — `GET /api/v1/reset-password?token=xxx` + +**Novo campo Config** (`config.rs`): +- `app_scheme: String` — lido de `APP_SCHEME` (padrão: `meowspool`) + +**`.env` atualizado**: +- `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` (antes: `meowspool:/`) +- `APP_SCHEME=meowspool` (novo) + +--- + +### ✅ Fluxo completo de e-mail: verificação e reset de senha + +**Arquivos criados/modificados**: `migrations/20240101000004_create_token_tables.sql`, `src/infrastructure/email_service.rs`, `src/infrastructure/mod.rs`, `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/config.rs`, `src/error.rs`, `src/main.rs`, `src/router.rs`, `Cargo.toml` + +#### Migrations + +Criadas as tabelas `password_reset_tokens` e `email_verification_tokens` com: +- UUID como PK (gen_random_uuid) +- `token TEXT UNIQUE` — indexado para lookup rápido +- `used_at TIMESTAMPTZ` — `NULL` = não usado; preenchido na validação para invalidar após uso +- `expires_at TIMESTAMPTZ` — TTL: 1h para reset de senha, 24h para verificação de e-mail + +#### EmailService (`src/infrastructure/email_service.rs`) + +Envia e-mails via SMTP com STARTTLS usando `lettre`. Métodos: +- `send_password_reset(to, reset_link)` — e-mail de redefinição de senha +- `send_email_verification(to, verify_link)` — e-mail de confirmação de conta + +#### EmailTokenService (`src/application/email_token_service.rs`) + +Orquestra tokens no banco + envio de e-mail. Métodos: +- `forgot_password(email)` — cria token em `password_reset_tokens`, envia e-mail. Sempre retorna `Ok` (não vaza se o e-mail existe) +- `verify_email(token)` — valida token em `email_verification_tokens`, chama `user_repo.verify_email()`, marca token como usado +- `reset_password(token, new_password)` — valida token em `password_reset_tokens`, re-hash da senha com Argon2id, atualiza usuário, marca token como usado +- `send_verification_email(user_id, email)` — cria token em `email_verification_tokens` e envia e-mail. Chamado após o registro + +#### Handlers implementados + +Anteriormente retornavam `200 OK` sem lógica. Agora delegam ao `EmailTokenService`: +- `forgot_password_handler` — POST `/auth/forgot-password` +- `verify_email_handler` — POST `/auth/verify-email` +- `reset_password_handler` — POST `/auth/reset-password` + +#### Registro envia e-mail de verificação + +`register_handler` chama `email_token_service.send_verification_email()` via `tokio::spawn` (background) após criar o usuário — o cadastro responde imediatamente sem esperar o SMTP. + +#### AppError::BadRequest adicionado + +Nova variante para erros previsíveis do usuário (token inválido, expirado, já usado) → HTTP 400. + +#### Configuração `APP_BASE_URL` + +- Em produção: `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` + - Gera links nos e-mails como `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx` + - O backend redireciona esse GET para o deep link via `APP_SCHEME` + +--- + ### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo **Arquivos alterados**: `filament_handler.rs`, `filament_service.rs` diff --git a/backend/bruno-routes/MewoSpool/Auth/Forgot Password.yml b/backend/bruno-routes/MewoSpool/Auth/Forgot Password.yml index da66f60..ea79268 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Forgot Password.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Forgot Password.yml @@ -1,7 +1,7 @@ info: name: Forgot Password type: http - seq: 5 + seq: 6 http: method: POST diff --git a/backend/bruno-routes/MewoSpool/Auth/Login.yml b/backend/bruno-routes/MewoSpool/Auth/Login.yml index 8a02fdb..db62868 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Login.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Login.yml @@ -14,6 +14,13 @@ http: "password": "12345678" } +script: + res: | + if (res.status === 200) { + bru.setEnvVar("access_token", res.getBody().access_token); + bru.setEnvVar("refresh_token", res.getBody().refresh_token); + } + settings: encodeUrl: true timeout: 0 diff --git a/backend/bruno-routes/MewoSpool/Auth/Logout.yml b/backend/bruno-routes/MewoSpool/Auth/Logout.yml index f0f0e64..feda625 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Logout.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Logout.yml @@ -1,18 +1,11 @@ info: name: Logout type: http - seq: 2 + seq: 5 http: method: POST url: "{{base_url}}/api/v1/auth/logout" - body: - type: json - data: |- - { - "email": "felipe@felipecncloud.com", - "password": "l11f06c10" - } auth: type: bearer token: "{{access_token}}" diff --git a/backend/bruno-routes/MewoSpool/Auth/OAuth Google.yml b/backend/bruno-routes/MewoSpool/Auth/OAuth Google.yml index 0567aeb..6bbc39c 100644 --- a/backend/bruno-routes/MewoSpool/Auth/OAuth Google.yml +++ b/backend/bruno-routes/MewoSpool/Auth/OAuth Google.yml @@ -1,7 +1,7 @@ info: name: OAuth Google type: http - seq: 8 + seq: 3 http: method: POST @@ -13,6 +13,13 @@ http: "id_token": "{{google_id_token}}" } +script: + res: | + if (res.status === 200) { + bru.setEnvVar("access_token", res.getBody().access_token); + bru.setEnvVar("refresh_token", res.getBody().refresh_token); + } + settings: encodeUrl: true timeout: 0 diff --git a/backend/bruno-routes/MewoSpool/Auth/Refresh Token.yml b/backend/bruno-routes/MewoSpool/Auth/Refresh Token.yml index ea5925e..8965c54 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Refresh Token.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Refresh Token.yml @@ -13,6 +13,13 @@ http: "refresh_token": "{{refresh_token}}" } +script: + res: | + if (res.status === 200) { + bru.setEnvVar("access_token", res.getBody().access_token); + bru.setEnvVar("refresh_token", res.getBody().refresh_token); + } + settings: encodeUrl: true timeout: 0 diff --git a/backend/bruno-routes/MewoSpool/Auth/Register.yml b/backend/bruno-routes/MewoSpool/Auth/Register.yml index 4156377..5fc878d 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Register.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Register.yml @@ -10,7 +10,7 @@ http: type: json data: |- { - "email": "felipe@felipecncloud.com", + "email": "felipecaninn18@gmail.com", "password": "12345678" } diff --git a/backend/bruno-routes/MewoSpool/Auth/Reset Password.yml b/backend/bruno-routes/MewoSpool/Auth/Reset Password.yml index 67bab7a..0520d58 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Reset Password.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Reset Password.yml @@ -1,7 +1,7 @@ info: name: Reset Password type: http - seq: 7 + seq: 8 http: method: POST diff --git a/backend/bruno-routes/MewoSpool/Auth/Verify Email.yml b/backend/bruno-routes/MewoSpool/Auth/Verify Email.yml index 2cb6f69..3028800 100644 --- a/backend/bruno-routes/MewoSpool/Auth/Verify Email.yml +++ b/backend/bruno-routes/MewoSpool/Auth/Verify Email.yml @@ -1,7 +1,7 @@ info: name: Verify Email type: http - seq: 6 + seq: 7 http: method: POST diff --git a/backend/bruno-routes/MewoSpool/Filaments/Create Filament.yml b/backend/bruno-routes/MewoSpool/Filaments/Create Filament.yml index f431d31..4b7bc7f 100644 --- a/backend/bruno-routes/MewoSpool/Filaments/Create Filament.yml +++ b/backend/bruno-routes/MewoSpool/Filaments/Create Filament.yml @@ -10,16 +10,16 @@ http: type: json data: |- { - "material": "PLA", - "brand": "Bambu", - "model": "PLA Basic", + "material": "PETG", + "brand": "Elegoo", + "model": "Rapid", "color_hex": "#FF5733", "spool_preset_id": "{{preset_id}}", - "total_weight_g": 1250, - "temp_hotend_c": 220, - "temp_bed_c": 60, - "flow_factor_pct": 1.0, - "notes": "Filamento de teste" + "total_weight_g": 527, + "temp_hotend_c": 250, + "temp_bed_c": 70, + "flow_factor_pct": 0.0033, + "notes": "Filamento" } auth: type: bearer diff --git a/backend/bruno-routes/MewoSpool/Users/Delete Me.yml b/backend/bruno-routes/MewoSpool/Users/Delete Me.yml new file mode 100644 index 0000000..81fcea6 --- /dev/null +++ b/backend/bruno-routes/MewoSpool/Users/Delete Me.yml @@ -0,0 +1,17 @@ +info: + name: Delete Me + type: http + seq: 3 + +http: + method: DELETE + url: "{{base_url}}/api/v1/users/me" + auth: + type: bearer + token: "{{access_token}}" + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/backend/bruno-routes/MewoSpool/environments/route.yml b/backend/bruno-routes/MewoSpool/environments/route.yml index 425ab60..4963724 100644 --- a/backend/bruno-routes/MewoSpool/environments/route.yml +++ b/backend/bruno-routes/MewoSpool/environments/route.yml @@ -10,3 +10,9 @@ variables: value: 07188f3b-2b74-4251-88bc-037de7628d65 - name: filament_id value: "" + - name: google_id_token + value: "" + - name: email_verify_token + value: 0351fde6cc2546499c2910fe109528fc + - name: reset_token + value: "" diff --git a/backend/migrations/20240101000004_create_token_tables.sql b/backend/migrations/20240101000004_create_token_tables.sql new file mode 100644 index 0000000..4a665fa --- /dev/null +++ b/backend/migrations/20240101000004_create_token_tables.sql @@ -0,0 +1,19 @@ +CREATE TABLE password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON password_reset_tokens (token); + +CREATE TABLE email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON email_verification_tokens (token); diff --git a/backend/src/adapters/inbound/auth_handler.rs b/backend/src/adapters/inbound/auth_handler.rs index 2e08ce1..893cde7 100644 --- a/backend/src/adapters/inbound/auth_handler.rs +++ b/backend/src/adapters/inbound/auth_handler.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use axum::{extract::{Query, State}, http::{header, StatusCode}, response::IntoResponse, Json}; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -40,11 +40,22 @@ pub struct ForgotPasswordRequest { pub email: String, } +#[derive(Debug, Deserialize, Validate)] +pub struct ResendVerificationRequest { + #[validate(email)] + pub email: String, +} + #[derive(Debug, Deserialize)] pub struct VerifyEmailRequest { pub token: String, } +#[derive(Debug, Deserialize)] +pub struct TokenQueryParams { + pub token: String, +} + #[derive(Debug, Deserialize, Validate)] pub struct ResetPasswordRequest { pub token: String, @@ -68,15 +79,18 @@ pub async fn register_handler( Json(req): Json, ) -> Result { req.validate()?; - let tokens = state.auth_service.register(req.email, req.password).await?; - Ok(( - StatusCode::CREATED, - Json(AuthResponse { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - token_type: "Bearer", - }), - )) + let email = req.email.clone(); + let user = state.auth_service.register(req.email, req.password).await?; + + // Envia email de verificação em background para não bloquear a resposta + let ets = state.email_token_service.clone(); + tokio::spawn(async move { + if let Err(e) = ets.send_verification_email(user.id, &email).await { + tracing::warn!(error = %e, "Failed to send verification email"); + } + }); + + Ok(StatusCode::CREATED) } pub async fn login_handler( @@ -124,30 +138,66 @@ pub async fn logout_handler() -> impl IntoResponse { StatusCode::NO_CONTENT } +pub async fn resend_verification_handler( + State(state): State, + Json(req): Json, +) -> Result { + req.validate()?; + state.email_token_service.resend_verification_email(&req.email).await?; + Ok(StatusCode::OK) +} + pub async fn forgot_password_handler( + State(state): State, Json(req): Json, ) -> Result { req.validate()?; - // TODO: gerar token de reset e enviar email - // Retornamos sempre 200 para não vazar informação sobre emails cadastrados + // Sempre retorna 200 para não vazar informação sobre emails cadastrados + state.email_token_service.forgot_password(&req.email).await?; Ok(StatusCode::OK) } pub async fn verify_email_handler( - Json(_req): Json, + State(state): State, + Json(req): Json, ) -> Result { - // TODO: validar token e marcar email_verified = true + state.email_token_service.verify_email(&req.token).await?; Ok(StatusCode::OK) } pub async fn reset_password_handler( + State(state): State, Json(req): Json, ) -> Result { req.validate()?; - // TODO: validar token de reset e atualizar senha + state.email_token_service.reset_password(&req.token, &req.new_password).await?; Ok(StatusCode::OK) } +/// GET /verify-email?token=xxx → redireciona para o deep link do app +pub async fn verify_email_redirect_handler( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let location = format!( + "{}://verify-email?token={}", + state.config.app_scheme, params.token + ); + (StatusCode::FOUND, [(header::LOCATION, location)]) +} + +/// GET /reset-password?token=xxx → redireciona para o deep link do app +pub async fn reset_password_redirect_handler( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + let location = format!( + "{}://reset-password?token={}", + state.config.app_scheme, params.token + ); + (StatusCode::FOUND, [(header::LOCATION, location)]) +} + // ------------------------------------------------------------------------- // Helper: verificação de Google ID Token // ------------------------------------------------------------------------- diff --git a/backend/src/adapters/inbound/user_handler.rs b/backend/src/adapters/inbound/user_handler.rs index d6af0da..0dd37d2 100644 --- a/backend/src/adapters/inbound/user_handler.rs +++ b/backend/src/adapters/inbound/user_handler.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, response::IntoResponse, Extension, Json}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Extension, Json}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; @@ -68,5 +68,18 @@ pub async fn update_me_handler( ) -> Result { req.validate()?; // TODO: implementar atualização de perfil - Ok(axum::http::StatusCode::OK) + Ok(StatusCode::OK) +} + +pub async fn delete_me_handler( + State(state): State, + Extension(current_user): Extension, +) -> Result { + state + .auth_service + .user_repo_ref() + .delete(current_user.id) + .await?; + + Ok(StatusCode::NO_CONTENT) } diff --git a/backend/src/adapters/outbound/postgres_user_repo.rs b/backend/src/adapters/outbound/postgres_user_repo.rs index 526e31e..d82c7da 100644 --- a/backend/src/adapters/outbound/postgres_user_repo.rs +++ b/backend/src/adapters/outbound/postgres_user_repo.rs @@ -100,6 +100,15 @@ impl UserRepository for PostgresUserRepository { Ok(()) } + + async fn delete(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(self.db.as_ref()) + .await?; + + Ok(()) + } } // ------------------------------------------------------------------------- diff --git a/backend/src/application/auth_service.rs b/backend/src/application/auth_service.rs index b535ea3..18e636f 100644 --- a/backend/src/application/auth_service.rs +++ b/backend/src/application/auth_service.rs @@ -60,16 +60,14 @@ impl AuthService { // Registro com email/senha // ------------------------------------------------------------------------- - pub async fn register(&self, email: String, password: String) -> Result { - if let Some(_) = self.user_repo.find_by_email(&email).await? { + pub async fn register(&self, email: String, password: String) -> Result { + if self.user_repo.find_by_email(&email).await?.is_some() { return Err(AppError::Conflict("email already registered".into())); } let password_hash = hash_password(&password)?; - let user = User::new_with_password(email, password_hash); - let user = self.user_repo.create(&user).await?; - - self.issue_token_pair(&user) + let new_user = User::new_with_password(email, password_hash); + Ok(self.user_repo.create(&new_user).await?) } // ------------------------------------------------------------------------- @@ -86,6 +84,10 @@ impl AuthService { let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?; verify_password(&password, hash)?; + if !user.email_verified { + return Err(AppError::EmailNotVerified); + } + self.issue_token_pair(&user) } diff --git a/backend/src/application/email_token_service.rs b/backend/src/application/email_token_service.rs new file mode 100644 index 0000000..9ca617a --- /dev/null +++ b/backend/src/application/email_token_service.rs @@ -0,0 +1,189 @@ +use std::sync::Arc; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::{ + application::auth_service::hash_password, + config::Config, + error::AppError, + infrastructure::EmailService, + ports::UserRepository, +}; + +pub struct EmailTokenService { + db: Arc, + user_repo: Arc, + email_svc: Arc, + config: Config, +} + +impl EmailTokenService { + pub fn new( + db: Arc, + user_repo: Arc, + email_svc: Arc, + config: Config, + ) -> Self { + Self { db, user_repo, email_svc, config } + } + + // ------------------------------------------------------------------------- + // Forgot password: gera token e envia email + // ------------------------------------------------------------------------- + + pub async fn forgot_password(&self, email: &str) -> Result<(), AppError> { + // Não revelamos se o email existe ou não + let Some(user) = self.user_repo.find_by_email(email).await? else { + return Ok(()); + }; + + let token = generate_token(); + let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(1); + + sqlx::query( + "INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)", + ) + .bind(user.id) + .bind(&token) + .bind(expires_at) + .execute(self.db.as_ref()) + .await?; + + let reset_link = format!( + "{}/reset-password?token={}", + self.config.app_base_url, token + ); + self.email_svc.send_password_reset(email, &reset_link).await?; + + Ok(()) + } + + // ------------------------------------------------------------------------- + // Verify email: valida token e marca email_verified = true + // ------------------------------------------------------------------------- + + pub async fn verify_email(&self, token: &str) -> Result<(), AppError> { + let row = sqlx::query_as::<_, TokenRow>( + "SELECT id, user_id, used_at, expires_at FROM email_verification_tokens WHERE token = $1", + ) + .bind(token) + .fetch_optional(self.db.as_ref()) + .await? + .ok_or(AppError::BadRequest("Token inválido".into()))?; + + validate_token(&row)?; + + self.user_repo.verify_email(row.user_id).await?; + + sqlx::query( + "UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1", + ) + .bind(row.id) + .execute(self.db.as_ref()) + .await?; + + Ok(()) + } + + // ------------------------------------------------------------------------- + // Reset password: valida token e atualiza senha + // ------------------------------------------------------------------------- + + pub async fn reset_password(&self, token: &str, new_password: &str) -> Result<(), AppError> { + let row = sqlx::query_as::<_, TokenRow>( + "SELECT id, user_id, used_at, expires_at FROM password_reset_tokens WHERE token = $1", + ) + .bind(token) + .fetch_optional(self.db.as_ref()) + .await? + .ok_or(AppError::BadRequest("Token inválido".into()))?; + + validate_token(&row)?; + + let mut user = self + .user_repo + .find_by_id(row.user_id) + .await? + .ok_or(AppError::BadRequest("Usuário não encontrado".into()))?; + + user.password_hash = Some(hash_password(new_password)?); + self.user_repo.update(&user).await?; + + sqlx::query( + "UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1", + ) + .bind(row.id) + .execute(self.db.as_ref()) + .await?; + + Ok(()) + } + + // ------------------------------------------------------------------------- + // Geração de token de verificação de email (chamado no registro) + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // Reenvio de e-mail de verificação (endpoint público) + // ------------------------------------------------------------------------- + + pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> { + // Não revela se o e-mail existe ou se já foi verificado + let Some(user) = self.user_repo.find_by_email(email).await? else { + return Ok(()); + }; + if user.email_verified { + return Ok(()); + } + self.send_verification_email(user.id, email).await + } + + pub async fn send_verification_email(&self, user_id: Uuid, email: &str) -> Result<(), AppError> { + let token = generate_token(); + let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(24); + + sqlx::query( + "INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)", + ) + .bind(user_id) + .bind(&token) + .bind(expires_at) + .execute(self.db.as_ref()) + .await?; + + let verify_link = format!( + "{}/verify-email?token={}", + self.config.app_base_url, token + ); + self.email_svc.send_email_verification(email, &verify_link).await?; + + Ok(()) + } +} + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +fn generate_token() -> String { + Uuid::new_v4().to_string().replace('-', "") +} + +#[derive(sqlx::FromRow)] +struct TokenRow { + id: Uuid, + user_id: Uuid, + used_at: Option, + expires_at: time::OffsetDateTime, +} + +fn validate_token(row: &TokenRow) -> Result<(), AppError> { + if row.used_at.is_some() { + return Err(AppError::BadRequest("Token já utilizado".into())); + } + if time::OffsetDateTime::now_utc() > row.expires_at { + return Err(AppError::BadRequest("Token expirado".into())); + } + Ok(()) +} diff --git a/backend/src/application/mod.rs b/backend/src/application/mod.rs index b3e2352..a43ef78 100644 --- a/backend/src/application/mod.rs +++ b/backend/src/application/mod.rs @@ -1,7 +1,9 @@ pub mod auth_service; +pub mod email_token_service; pub mod filament_service; pub mod spool_preset_service; pub use auth_service::AuthService; +pub use email_token_service::EmailTokenService; pub use filament_service::FilamentService; pub use spool_preset_service::SpoolPresetService; diff --git a/backend/src/config.rs b/backend/src/config.rs index cec7032..5c5248e 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -12,6 +12,14 @@ pub struct Config { pub host: String, pub port: u16, pub app_env: AppEnv, + // Email (SMTP) + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_user: String, + pub smtp_pass: String, + pub email_from: String, + pub app_base_url: String, + pub app_scheme: String, } #[derive(Debug, Clone, PartialEq)] @@ -32,6 +40,13 @@ impl Config { host: optional_string_default("HOST", "0.0.0.0"), port: optional("PORT", 8080)?, app_env: parse_app_env(), + smtp_host: optional_string_default("SMTP_HOST", "smtp.gmail.com"), + smtp_port: optional("SMTP_PORT", 587)?, + smtp_user: optional_string("SMTP_USER"), + smtp_pass: optional_string("SMTP_PASS"), + email_from: optional_string_default("EMAIL_FROM", "noreply@meowspool.app"), + app_base_url: optional_string_default("APP_BASE_URL", "http://localhost:8080"), + app_scheme: optional_string_default("APP_SCHEME", "meowspool"), }) } diff --git a/backend/src/error.rs b/backend/src/error.rs index dc6b288..803309f 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -18,9 +18,15 @@ pub enum AppError { #[error("forbidden")] Forbidden, + #[error("email not verified")] + EmailNotVerified, + #[error("validation error: {0}")] Validation(String), + #[error("bad request: {0}")] + BadRequest(String), + #[error("conflict: {0}")] Conflict(String), @@ -37,7 +43,9 @@ impl AppError { Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"), Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"), + Self::EmailNotVerified => (StatusCode::FORBIDDEN, "EMAIL_NOT_VERIFIED"), Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"), + Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "BAD_REQUEST"), Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"), Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"), diff --git a/backend/src/infrastructure/email_service.rs b/backend/src/infrastructure/email_service.rs new file mode 100644 index 0000000..c196925 --- /dev/null +++ b/backend/src/infrastructure/email_service.rs @@ -0,0 +1,67 @@ +use lettre::{ + message::header::ContentType, + transport::smtp::authentication::Credentials, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; + +use crate::{config::Config, error::AppError}; + +pub struct EmailService { + mailer: AsyncSmtpTransport, + from: String, +} + +impl EmailService { + pub fn new(config: &Config) -> Result { + let creds = Credentials::new(config.smtp_user.clone(), config.smtp_pass.clone()); + + let mailer = AsyncSmtpTransport::::starttls_relay(&config.smtp_host) + .map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP config error: {e}")))? + .port(config.smtp_port) + .credentials(creds) + .build(); + + Ok(Self { + mailer, + from: config.email_from.clone(), + }) + } + + pub async fn send_password_reset(&self, to: &str, reset_link: &str) -> Result<(), AppError> { + let body = format!( + "Você solicitou a redefinição de senha do MeowSpool.\n\nClique no link abaixo para redefinir sua senha (válido por 1 hora):\n\n{reset_link}\n\nSe você não solicitou isso, ignore este email." + ); + + self.send(to, "Redefinição de senha — MeowSpool", &body).await + } + + pub async fn send_email_verification(&self, to: &str, verify_link: &str) -> Result<(), AppError> { + let body = format!( + "Bem-vindo ao MeowSpool! Confirme seu endereço de email clicando no link abaixo:\n\n{verify_link}\n\nO link expira em 24 horas." + ); + + self.send(to, "Confirme seu email — MeowSpool", &body).await + } + + async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), AppError> { + let email = Message::builder() + .from( + self.from + .parse() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid from address: {e}")))?, + ) + .to(to + .parse() + .map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid to address: {e}")))?) + .subject(subject) + .header(ContentType::TEXT_PLAIN) + .body(body.to_string()) + .map_err(|e| AppError::Internal(anyhow::anyhow!("Email build error: {e}")))?; + + AsyncTransport::send(&self.mailer, email) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP send error: {e}")))?; + + Ok(()) + } +} diff --git a/backend/src/infrastructure/mod.rs b/backend/src/infrastructure/mod.rs new file mode 100644 index 0000000..22a11cf --- /dev/null +++ b/backend/src/infrastructure/mod.rs @@ -0,0 +1,3 @@ +pub mod email_service; + +pub use email_service::EmailService; diff --git a/backend/src/main.rs b/backend/src/main.rs index 355fd29..f69b532 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,6 +8,7 @@ mod application; mod config; mod domain; mod error; +mod infrastructure; mod ports; mod router; diff --git a/backend/src/ports/user_repository.rs b/backend/src/ports/user_repository.rs index a2597c1..50332ce 100644 --- a/backend/src/ports/user_repository.rs +++ b/backend/src/ports/user_repository.rs @@ -13,4 +13,5 @@ pub trait UserRepository: Send + Sync { async fn create(&self, user: &User) -> Result; async fn update(&self, user: &User) -> Result; async fn verify_email(&self, id: Uuid) -> Result<(), AppError>; + async fn delete(&self, id: Uuid) -> Result<(), AppError>; } diff --git a/backend/src/router.rs b/backend/src/router.rs index bc93732..39537e2 100644 --- a/backend/src/router.rs +++ b/backend/src/router.rs @@ -18,8 +18,9 @@ use crate::{ PostgresUserRepository, }, }, - application::{AuthService, FilamentService, SpoolPresetService}, + application::{AuthService, EmailTokenService, FilamentService, SpoolPresetService}, config::Config, + infrastructure::EmailService, ports::{FilamentRepository, SpoolPresetRepository, UserRepository}, }; @@ -27,6 +28,7 @@ use crate::{ #[derive(Clone)] pub struct AppState { pub auth_service: Arc, + pub email_token_service: Arc, pub filament_service: Arc, pub spool_preset_service: Arc, pub config: Config, @@ -46,6 +48,15 @@ pub fn build(db: PgPool, config: Config) -> Router { Arc::clone(&user_repo), config.clone(), )); + let email_svc = Arc::new( + EmailService::new(&config).expect("Failed to configure SMTP"), + ); + let email_token_service = Arc::new(EmailTokenService::new( + Arc::clone(&db), + Arc::clone(&user_repo), + Arc::clone(&email_svc), + config.clone(), + )); let filament_service = Arc::new(FilamentService::new( Arc::clone(&filament_repo), Arc::clone(&preset_repo), @@ -54,6 +65,7 @@ pub fn build(db: PgPool, config: Config) -> Router { let state = AppState { auth_service, + email_token_service, filament_service, spool_preset_service, config: config.clone(), @@ -65,9 +77,13 @@ pub fn build(db: PgPool, config: Config) -> Router { .route("/auth/login", post(auth_handler::login_handler)) .route("/auth/oauth/google", post(auth_handler::google_oauth_handler)) .route("/auth/refresh", post(auth_handler::refresh_token_handler)) + .route("/auth/resend-verification", post(auth_handler::resend_verification_handler)) .route("/auth/forgot-password", post(auth_handler::forgot_password_handler)) .route("/auth/verify-email", post(auth_handler::verify_email_handler)) - .route("/auth/reset-password", post(auth_handler::reset_password_handler)); + .route("/auth/reset-password", post(auth_handler::reset_password_handler)) + // Redirects para deep links (usados nos links de email) + .route("/verify-email", get(auth_handler::verify_email_redirect_handler)) + .route("/reset-password", get(auth_handler::reset_password_redirect_handler)); // Rotas protegidas (requerem JWT válido) let protected_routes = Router::new() @@ -75,6 +91,7 @@ pub fn build(db: PgPool, config: Config) -> Router { // Users .route("/users/me", get(user_handler::get_me_handler)) .route("/users/me", put(user_handler::update_me_handler)) + .route("/users/me", delete(user_handler::delete_me_handler)) // Dashboard .route("/dashboard", get(filament_handler::dashboard_handler)) // Filaments diff --git a/mobile/agent.md b/mobile/agent.md index 843a805..54b217b 100644 --- a/mobile/agent.md +++ b/mobile/agent.md @@ -85,11 +85,12 @@ mobile/ │ │ ├── login.tsx ← G3-0 │ │ ├── register.tsx ← IX-0 │ │ ├── forgot-password.tsx ← L2-0 -│ │ ├── verify-email.tsx ← RU-0 +│ │ ├── check-email.tsx ← RU-0 Tela informativa pós-cadastro ("Confirme seu e-mail") │ │ ├── reset-password.tsx ← 117-0 │ │ └── password-reset-done.tsx ← 135-0 │ ├── filament/ │ │ └── [id].tsx ← Deep link handler: redireciona meowspool://filament/{id} → /(app)/inventory/{id} +│ ├── verify-email.tsx ← Deep link handler: meowspool://verify-email?token=xxx → chama API e exibe resultado │ └── (app)/ │ ├── _layout.tsx ← Stack autenticado │ ├── (tabs)/ @@ -138,7 +139,7 @@ mobile/ │ ├── preset/ │ │ └── PresetUseCases.ts ← List, Create, Update, Delete │ ├── auth/ - │ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc. + │ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, ForgotPassword, ResendVerification, VerifyEmail, ResetPassword │ └── nfc/ │ ├── ReadNFCTagUseCase.ts ← lê URI, valida schema meowspool://, extrai filament ID │ └── WriteNFCTagUseCase.ts ← monta meowspool://filament/{id} e grava na tag @@ -222,12 +223,22 @@ Arquivo: `src/shared/theme.ts` ### Deep links -- Scheme: `meowspool://` -- Filamento: `meowspool://filament/` → `/(app)/inventory/` (singular, alinhado com backend) -- O redirecionamento é feito pela rota `app/filament/[id].tsx` com `` do expo-router — garante que o deep link abrido externamente (QR Code, NFC, link compartilhado) sempre chegue na tela correta +- Scheme: `meowspool://` (configurado em `app.json` como `scheme: "meowspool"`) +- **Filamento**: `meowspool://filament/` → `/(app)/inventory/` + - Handler: `app/filament/[id].tsx` com `` do expo-router + - Mesmo link usado em QR Code, NFC e links compartilhados +- **Verificação de e-mail**: `meowspool://verify-email?token=` + - Handler: `app/verify-email.tsx` — chama `VerifyEmailUseCase.execute(token)` automaticamente + - Exibe estados: carregando → sucesso → erro (token inválido/expirado) + - Redireciona para `/(auth)/login` ao concluir +- **Reset de senha**: `meowspool://reset-password?token=` + - Handler: `app/(auth)/reset-password.tsx` — lê `token` via `useLocalSearchParams` - O scanner (`scanner.tsx`) faz match via `/meowspool:\/\/filament\/([^/]+)/` e navega para `/(app)/inventory/` -- O QR Code de cada filamento exibe `meowspool://filament/` usando `react-native-qrcode-svg` -- As tags NFC NTAG215 gravam a mesma URI `meowspool://filament/` como NDEF URI record — mesmo deep link, infraestrutura compartilhada +- As tags NFC NTAG215 gravam a URI `meowspool://filament/` como NDEF URI record + +> **Links de e-mail**: o backend gera links `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx` (aceitos por clientes de e-mail). O backend redireciona (302) para `meowspool://verify-email?token=xxx`. O OS reconhece o scheme e abre o app. +> - `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` +> - `APP_SCHEME=meowspool` --- @@ -309,6 +320,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) | POST | `/auth/google` | OAuth Google | | POST | `/auth/refresh` | Refresh token | | POST | `/auth/logout` | Logout | +| POST | `/auth/resend-verification` | Reenviar e-mail de verificação | | POST | `/auth/forgot-password` | Solicitar reset de senha | | POST | `/auth/reset-password` | Confirmar reset com token | | POST | `/auth/verify-email` | Verificar e-mail com código | @@ -357,6 +369,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) - [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx` - [ ] 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` - [ ] Testes de integração com Jest + Testing Library @@ -364,6 +377,106 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`) ## Mudanças Recentes (14/03/2026) +### ✅ Reenvio de e-mail de verificação + +**Arquivos modificados**: `src/ports/AuthRepository.ts`, `src/adapters/remote/ApiAuthRepository.ts`, `src/application/auth/AuthUseCases.ts`, `src/infrastructure/container.ts`, `app/(auth)/check-email.tsx`, `app/(auth)/register.tsx`, `app/(auth)/login.tsx` + +**Fluxo**: +- `register.tsx` e `login.tsx` passam o email como query param: `/(auth)/check-email?email=xxx` +- `check-email.tsx` lê `email` via `useLocalSearchParams` e exibe botão "Reenviar e-mail" +- Cooldown de 60s após cada envio para evitar spam +- Feedback visual: "E-mail reenviado! Verifique sua caixa de entrada." +- Botão só aparece se `email` estiver disponível nos params + +**Cadeia adicionada**: +- `AuthRepository.resendVerification(email)` — novo método no port +- `ApiAuthRepository.resendVerification` — `POST /auth/resend-verification` +- `ResendVerificationUseCase` — valida email não vazio e delega ao repo +- `resendVerificationUseCase` exportado do `container.ts` + +--- + +### ✅ Verificação de e-mail obrigatória antes do login + +**Arquivos modificados**: `src/ports/AuthRepository.ts`, `src/adapters/remote/ApiAuthRepository.ts`, `src/application/auth/AuthUseCases.ts`, `app/(auth)/register.tsx`, `app/(auth)/login.tsx`, `app/_layout.tsx` + +**Renomeação**: `app/(auth)/verify-email.tsx` → `app/(auth)/check-email.tsx` +- **Motivo**: no expo-router, grupos `(auth)` são transparentes na URL. `app/(auth)/verify-email.tsx` e `app/verify-email.tsx` competiam pelo mesmo path `/verify-email`, fazendo o deep link cair na tela informativa em vez do handler de verificação. + +**Mudanças**: + +- `RegisterUseCase.execute` / `ApiAuthRepository.register` / `AuthRepository.register` — retornam `void` (backend não emite mais tokens no registro) +- `register.tsx` — remove `setSession`, navega para `/(auth)/check-email` após cadastro (usuário fica não autenticado) +- `login.tsx` — detecta `EMAIL_NOT_VERIFIED` (403 do backend) e exibe alerta com link para `/(auth)/check-email`: + ```ts + if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') { + Alert.alert('E-mail não verificado', '...', [ + { text: 'OK', onPress: () => router.push('/(auth)/check-email') }, + ]); + } + ``` +- `app/_layout.tsx` — auth guard agora exclui rotas públicas da raiz do redirect para login: + ```ts + const inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament'; + // ... + } else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) { + router.replace('/(auth)/login'); + } + ``` + **Sem esse fix**, o guard redirecionava `verify-email` para login antes de o usuário ver a tela de sucesso. + +**Rotas públicas da raiz** (não sofrem redirect do auth guard): +| Rota | Arquivo | Função | +|---|---|---| +| `/verify-email` | `app/verify-email.tsx` | Handler deep link — verifica e-mail e exibe sucesso/erro | +| `/filament` | `app/filament/[id].tsx` | Handler deep link — redireciona para detalhe do filamento | + +--- + +### ✅ Redirect HTTP → Deep Link (fix compatibilidade com clientes de e-mail) + +**Contexto**: clientes de e-mail (Gmail) bloqueiam links com scheme customizado (`meowspool://`). O link não abria o app. + +**Solução implementada no backend**: o e-mail agora contém link `https://`. O backend (GET `/api/v1/verify-email?token=xxx`) redireciona 302 → `meowspool://verify-email?token=xxx`. O OS reconhece o scheme e abre o app normalmente. + +**Impacto no mobile**: nenhuma alteração necessária no app — `app/verify-email.tsx` continua recebendo o deep link e chamando o backend via POST como antes. + +--- + +### ✅ Deep Link de Verificação de E-mail — `app/verify-email.tsx` + +**Arquivos criados/modificados**: `app/verify-email.tsx`, `app/_layout.tsx`, `src/application/auth/AuthUseCases.ts`, `src/infrastructure/container.ts` + +#### Fluxo + +1. Backend envia e-mail com link `https://meowspool.felipecncloud.com/api/v1/verify-email?token=` após o registro +2. Usuário toca no link → browser abre → backend redireciona 302 → `meowspool://verify-email?token=` +3. OS reconhece o scheme → expo-router roteia para `app/verify-email.tsx` +4. A tela lê `token` via `useLocalSearchParams` e chama `VerifyEmailUseCase.execute(token)` em `useEffect` +5. Exibe estado **loading** durante a chamada, **sucesso** ou **erro** (token inválido / expirado / já usado) +6. Botão redireciona para `/(auth)/login` + +#### `VerifyEmailUseCase` + +Adicionado em `src/application/auth/AuthUseCases.ts`: + +```ts +export class VerifyEmailUseCase { + constructor(private readonly authRepo: AuthRepository) {} + + async execute(token: string): Promise { + if (!token) throw new Error('Token inválido.'); + await this.authRepo.verifyEmail(token); + } +} +``` + +- `ApiAuthRepository.verifyEmail(token)` já existia (`POST /auth/verify-email` com `{ token }`) +- `verifyEmailUseCase` exportado do `container.ts` +- Rota `verify-email` registrada no `Stack` do `app/_layout.tsx` + +--- + ### ✅ Deep Link Handler — `app/filament/[id].tsx` Criada rota `app/filament/[id].tsx` para tratar deep links abertos externamente (QR Code, NFC, mensagem compartilhada). Sem essa rota, o expo-router exibia "Unmatched Route" ao abrir `meowspool://filament/{id}` fora do app. diff --git a/mobile/app/(auth)/verify-email.tsx b/mobile/app/(auth)/check-email.tsx similarity index 54% rename from mobile/app/(auth)/verify-email.tsx rename to mobile/app/(auth)/check-email.tsx index 471f0f9..b69773e 100644 --- a/mobile/app/(auth)/verify-email.tsx +++ b/mobile/app/(auth)/check-email.tsx @@ -1,15 +1,43 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useLocalSearchParams } from 'expo-router'; import { Screen } from '@presentation/components/layout/Screen'; import { Header } from '@presentation/components/layout/Header'; +import { Button } from '@presentation/components/ui/Button'; import { colors, typography, spacing, radius } from '@shared/theme'; +import { resendVerificationUseCase } from '@infrastructure/container'; + +const COOLDOWN_SECS = 60; /** * Tela de Verificação de E-mail — RU-0 * Mostrada após o cadastro bem-sucedido. */ -export default function VerifyEmailScreen(): React.ReactElement { +export default function CheckEmailScreen(): React.ReactElement { + const { email } = useLocalSearchParams<{ email: string }>(); + const [isLoading, setIsLoading] = useState(false); + const [cooldown, setCooldown] = useState(0); + const [sent, setSent] = useState(false); + + useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + async function handleResend(): Promise { + if (!email || cooldown > 0) return; + setIsLoading(true); + try { + await resendVerificationUseCase.execute(email); + setSent(true); + setCooldown(COOLDOWN_SECS); + } finally { + setIsLoading(false); + } + } + return (
@@ -24,6 +52,27 @@ export default function VerifyEmailScreen(): React.ReactElement { Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login. + {email && ( +