feat: implement email verification and password reset features
- Add EmailTokenService for handling email verification and password reset tokens. - Create EmailService for sending verification and reset emails via SMTP. - Update AuthService to handle email verification status during login. - Modify user registration to redirect to a check email screen instead of issuing a token. - Implement resend verification email functionality. - Add deep link handling for email verification and password reset in the mobile app. - Update mobile app routes and components to support new email verification flow. - Enhance error handling for unverified emails during login attempts. - Update configuration to include SMTP settings for email service.
This commit is contained in:
Generated
+141
@@ -8,6 +8,18 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -56,6 +68,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -433,6 +454,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -672,6 +703,22 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -754,6 +801,12 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fax"
|
name = "fax"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -992,6 +1045,16 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1522,6 +1585,34 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.183"
|
version = "0.2.183"
|
||||||
@@ -1685,6 +1776,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"image",
|
"image",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"lettre",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
@@ -1884,6 +1976,15 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "object"
|
||||||
|
version = "0.37.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -2151,6 +2252,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -2245,6 +2356,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
@@ -2601,6 +2718,7 @@ version = "0.23.37"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -3091,6 +3209,20 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3931,6 +4063,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ base64 = "0.22"
|
|||||||
# PDF generation
|
# PDF generation
|
||||||
printpdf = "0.7"
|
printpdf = "0.7"
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
lettre = { version = "0.11", features = ["smtp-transport", "tokio1-rustls-tls", "builder"], default-features = false }
|
||||||
|
|
||||||
# Async trait
|
# Async trait
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
|||||||
+169
-2
@@ -23,6 +23,7 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
|
|||||||
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
|
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
|
||||||
| Validação | `validator` | 0.18 |
|
| Validação | `validator` | 0.18 |
|
||||||
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
|
| 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 QR Code | `qrcode` | 0.14 |
|
||||||
| Geração de imagem| `image` | 0.25 |
|
| Geração de imagem| `image` | 0.25 |
|
||||||
| Encode Base64 | `base64` | 0.22 |
|
| Encode Base64 | `base64` | 0.22 |
|
||||||
@@ -41,7 +42,8 @@ backend/
|
|||||||
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
|
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
|
||||||
│ ├── 20240101000001_create_users.sql
|
│ ├── 20240101000001_create_users.sql
|
||||||
│ ├── 20240101000002_create_spool_presets.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/
|
└── src/
|
||||||
├── main.rs <- entry point: inicializa config, DB, router e servidor
|
├── main.rs <- entry point: inicializa config, DB, router e servidor
|
||||||
├── config.rs <- struct Config lida de variáveis de ambiente
|
├── config.rs <- struct Config lida de variáveis de ambiente
|
||||||
@@ -63,9 +65,14 @@ backend/
|
|||||||
├── application/ <- CASOS DE USO: orquestram domínio + ports
|
├── application/ <- CASOS DE USO: orquestram domínio + ports
|
||||||
│ ├── mod.rs
|
│ ├── mod.rs
|
||||||
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
|
│ ├── 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
|
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
|
||||||
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
|
│ └── 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/
|
└── adapters/
|
||||||
├── inbound/ <- HTTP: recebe requisições, delega ao application
|
├── inbound/ <- HTTP: recebe requisições, delega ao application
|
||||||
│ ├── mod.rs
|
│ ├── mod.rs
|
||||||
@@ -119,10 +126,20 @@ Todas as rotas são prefixadas com `/api/v1`.
|
|||||||
| POST | `/oauth/google` | `google_oauth_handler` | Público |
|
| POST | `/oauth/google` | `google_oauth_handler` | Público |
|
||||||
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
|
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
|
||||||
| POST | `/logout` | `logout_handler` | Autenticado |
|
| POST | `/logout` | `logout_handler` | Autenticado |
|
||||||
|
| POST | `/resend-verification` | `resend_verification_handler` | Público (sempre 200) |
|
||||||
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
| POST | `/forgot-password` | `forgot_password_handler` | Público |
|
||||||
| POST | `/verify-email` | `verify_email_handler` | Público |
|
| POST | `/verify-email` | `verify_email_handler` | Público |
|
||||||
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
|
| 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`
|
### Users — `/api/v1/users`
|
||||||
|
|
||||||
| Método | Rota | Handler | Acesso |
|
| Método | Rota | Handler | Acesso |
|
||||||
@@ -204,9 +221,15 @@ pub enum AppError {
|
|||||||
#[error("validation error: {0}")]
|
#[error("validation error: {0}")]
|
||||||
Validation(String),
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String), // token inválido, expirado, já utilizado
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("unprocessable entity: {0}")]
|
||||||
|
UnprocessableEntity(String),
|
||||||
|
|
||||||
#[error("internal error")]
|
#[error("internal error")]
|
||||||
Internal(#[from] anyhow::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
|
## 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)
|
## 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
|
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
|
||||||
|
|
||||||
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
|
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Forgot Password
|
name: Forgot Password
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 6
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ http:
|
|||||||
"password": "12345678"
|
"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:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
info:
|
info:
|
||||||
name: Logout
|
name: Logout
|
||||||
type: http
|
type: http
|
||||||
seq: 2
|
seq: 5
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
url: "{{base_url}}/api/v1/auth/logout"
|
url: "{{base_url}}/api/v1/auth/logout"
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
data: |-
|
|
||||||
{
|
|
||||||
"email": "felipe@felipecncloud.com",
|
|
||||||
"password": "l11f06c10"
|
|
||||||
}
|
|
||||||
auth:
|
auth:
|
||||||
type: bearer
|
type: bearer
|
||||||
token: "{{access_token}}"
|
token: "{{access_token}}"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: OAuth Google
|
name: OAuth Google
|
||||||
type: http
|
type: http
|
||||||
seq: 8
|
seq: 3
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
@@ -13,6 +13,13 @@ http:
|
|||||||
"id_token": "{{google_id_token}}"
|
"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:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ http:
|
|||||||
"refresh_token": "{{refresh_token}}"
|
"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:
|
settings:
|
||||||
encodeUrl: true
|
encodeUrl: true
|
||||||
timeout: 0
|
timeout: 0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ http:
|
|||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
{
|
{
|
||||||
"email": "felipe@felipecncloud.com",
|
"email": "felipecaninn18@gmail.com",
|
||||||
"password": "12345678"
|
"password": "12345678"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Reset Password
|
name: Reset Password
|
||||||
type: http
|
type: http
|
||||||
seq: 7
|
seq: 8
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
info:
|
info:
|
||||||
name: Verify Email
|
name: Verify Email
|
||||||
type: http
|
type: http
|
||||||
seq: 6
|
seq: 7
|
||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ http:
|
|||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
{
|
{
|
||||||
"material": "PLA",
|
"material": "PETG",
|
||||||
"brand": "Bambu",
|
"brand": "Elegoo",
|
||||||
"model": "PLA Basic",
|
"model": "Rapid",
|
||||||
"color_hex": "#FF5733",
|
"color_hex": "#FF5733",
|
||||||
"spool_preset_id": "{{preset_id}}",
|
"spool_preset_id": "{{preset_id}}",
|
||||||
"total_weight_g": 1250,
|
"total_weight_g": 527,
|
||||||
"temp_hotend_c": 220,
|
"temp_hotend_c": 250,
|
||||||
"temp_bed_c": 60,
|
"temp_bed_c": 70,
|
||||||
"flow_factor_pct": 1.0,
|
"flow_factor_pct": 0.0033,
|
||||||
"notes": "Filamento de teste"
|
"notes": "Filamento"
|
||||||
}
|
}
|
||||||
auth:
|
auth:
|
||||||
type: bearer
|
type: bearer
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,3 +10,9 @@ variables:
|
|||||||
value: 07188f3b-2b74-4251-88bc-037de7628d65
|
value: 07188f3b-2b74-4251-88bc-037de7628d65
|
||||||
- name: filament_id
|
- name: filament_id
|
||||||
value: ""
|
value: ""
|
||||||
|
- name: google_id_token
|
||||||
|
value: ""
|
||||||
|
- name: email_verify_token
|
||||||
|
value: 0351fde6cc2546499c2910fe109528fc
|
||||||
|
- name: reset_token
|
||||||
|
value: ""
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 serde::{Deserialize, Serialize};
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
@@ -40,11 +40,22 @@ pub struct ForgotPasswordRequest {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct ResendVerificationRequest {
|
||||||
|
#[validate(email)]
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct VerifyEmailRequest {
|
pub struct VerifyEmailRequest {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TokenQueryParams {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub struct ResetPasswordRequest {
|
pub struct ResetPasswordRequest {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
@@ -68,15 +79,18 @@ pub async fn register_handler(
|
|||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
let tokens = state.auth_service.register(req.email, req.password).await?;
|
let email = req.email.clone();
|
||||||
Ok((
|
let user = state.auth_service.register(req.email, req.password).await?;
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(AuthResponse {
|
// Envia email de verificação em background para não bloquear a resposta
|
||||||
access_token: tokens.access_token,
|
let ets = state.email_token_service.clone();
|
||||||
refresh_token: tokens.refresh_token,
|
tokio::spawn(async move {
|
||||||
token_type: "Bearer",
|
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(
|
pub async fn login_handler(
|
||||||
@@ -124,30 +138,66 @@ pub async fn logout_handler() -> impl IntoResponse {
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn resend_verification_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<ResendVerificationRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
req.validate()?;
|
||||||
|
state.email_token_service.resend_verification_email(&req.email).await?;
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn forgot_password_handler(
|
pub async fn forgot_password_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
Json(req): Json<ForgotPasswordRequest>,
|
Json(req): Json<ForgotPasswordRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
// TODO: gerar token de reset e enviar email
|
// Sempre retorna 200 para não vazar informação sobre emails cadastrados
|
||||||
// Retornamos sempre 200 para não vazar informação sobre emails cadastrados
|
state.email_token_service.forgot_password(&req.email).await?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify_email_handler(
|
pub async fn verify_email_handler(
|
||||||
Json(_req): Json<VerifyEmailRequest>,
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<VerifyEmailRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
// TODO: validar token e marcar email_verified = true
|
state.email_token_service.verify_email(&req.token).await?;
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reset_password_handler(
|
pub async fn reset_password_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
Json(req): Json<ResetPasswordRequest>,
|
Json(req): Json<ResetPasswordRequest>,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
req.validate()?;
|
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)
|
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<AppState>,
|
||||||
|
Query(params): Query<TokenQueryParams>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Query(params): Query<TokenQueryParams>,
|
||||||
|
) -> 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
|
// Helper: verificação de Google ID Token
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -68,5 +68,18 @@ pub async fn update_me_handler(
|
|||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
// TODO: implementar atualização de perfil
|
// TODO: implementar atualização de perfil
|
||||||
Ok(axum::http::StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_me_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(current_user): Extension<CurrentUser>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
state
|
||||||
|
.auth_service
|
||||||
|
.user_repo_ref()
|
||||||
|
.delete(current_user.id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -60,16 +60,14 @@ impl AuthService {
|
|||||||
// Registro com email/senha
|
// Registro com email/senha
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
pub async fn register(&self, email: String, password: String) -> Result<TokenPair, AppError> {
|
pub async fn register(&self, email: String, password: String) -> Result<User, AppError> {
|
||||||
if let Some(_) = self.user_repo.find_by_email(&email).await? {
|
if self.user_repo.find_by_email(&email).await?.is_some() {
|
||||||
return Err(AppError::Conflict("email already registered".into()));
|
return Err(AppError::Conflict("email already registered".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let password_hash = hash_password(&password)?;
|
let password_hash = hash_password(&password)?;
|
||||||
let user = User::new_with_password(email, password_hash);
|
let new_user = User::new_with_password(email, password_hash);
|
||||||
let user = self.user_repo.create(&user).await?;
|
Ok(self.user_repo.create(&new_user).await?)
|
||||||
|
|
||||||
self.issue_token_pair(&user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -86,6 +84,10 @@ impl AuthService {
|
|||||||
let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?;
|
let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?;
|
||||||
verify_password(&password, hash)?;
|
verify_password(&password, hash)?;
|
||||||
|
|
||||||
|
if !user.email_verified {
|
||||||
|
return Err(AppError::EmailNotVerified);
|
||||||
|
}
|
||||||
|
|
||||||
self.issue_token_pair(&user)
|
self.issue_token_pair(&user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<PgPool>,
|
||||||
|
user_repo: Arc<dyn UserRepository>,
|
||||||
|
email_svc: Arc<EmailService>,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailTokenService {
|
||||||
|
pub fn new(
|
||||||
|
db: Arc<PgPool>,
|
||||||
|
user_repo: Arc<dyn UserRepository>,
|
||||||
|
email_svc: Arc<EmailService>,
|
||||||
|
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<time::OffsetDateTime>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
|
pub mod email_token_service;
|
||||||
pub mod filament_service;
|
pub mod filament_service;
|
||||||
pub mod spool_preset_service;
|
pub mod spool_preset_service;
|
||||||
|
|
||||||
pub use auth_service::AuthService;
|
pub use auth_service::AuthService;
|
||||||
|
pub use email_token_service::EmailTokenService;
|
||||||
pub use filament_service::FilamentService;
|
pub use filament_service::FilamentService;
|
||||||
pub use spool_preset_service::SpoolPresetService;
|
pub use spool_preset_service::SpoolPresetService;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ pub struct Config {
|
|||||||
pub host: String,
|
pub host: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub app_env: AppEnv,
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -32,6 +40,13 @@ impl Config {
|
|||||||
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)?,
|
||||||
app_env: parse_app_env(),
|
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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,15 @@ pub enum AppError {
|
|||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("email not verified")]
|
||||||
|
EmailNotVerified,
|
||||||
|
|
||||||
#[error("validation error: {0}")]
|
#[error("validation error: {0}")]
|
||||||
Validation(String),
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
#[error("conflict: {0}")]
|
#[error("conflict: {0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
@@ -37,7 +43,9 @@ impl AppError {
|
|||||||
Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"),
|
Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"),
|
||||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
|
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
|
Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
|
||||||
|
Self::EmailNotVerified => (StatusCode::FORBIDDEN, "EMAIL_NOT_VERIFIED"),
|
||||||
Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"),
|
Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"),
|
||||||
|
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "BAD_REQUEST"),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"),
|
Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"),
|
||||||
Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"),
|
Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"),
|
||||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"),
|
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"),
|
||||||
|
|||||||
@@ -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<Tokio1Executor>,
|
||||||
|
from: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailService {
|
||||||
|
pub fn new(config: &Config) -> Result<Self, AppError> {
|
||||||
|
let creds = Credentials::new(config.smtp_user.clone(), config.smtp_pass.clone());
|
||||||
|
|
||||||
|
let mailer = AsyncSmtpTransport::<Tokio1Executor>::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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod email_service;
|
||||||
|
|
||||||
|
pub use email_service::EmailService;
|
||||||
@@ -8,6 +8,7 @@ mod application;
|
|||||||
mod config;
|
mod config;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod infrastructure;
|
||||||
mod ports;
|
mod ports;
|
||||||
mod router;
|
mod router;
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn create(&self, user: &User) -> Result<User, AppError>;
|
async fn create(&self, user: &User) -> Result<User, AppError>;
|
||||||
async fn update(&self, user: &User) -> Result<User, AppError>;
|
async fn update(&self, user: &User) -> Result<User, AppError>;
|
||||||
async fn verify_email(&self, id: Uuid) -> Result<(), AppError>;
|
async fn verify_email(&self, id: Uuid) -> Result<(), AppError>;
|
||||||
|
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-2
@@ -18,8 +18,9 @@ use crate::{
|
|||||||
PostgresUserRepository,
|
PostgresUserRepository,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
application::{AuthService, FilamentService, SpoolPresetService},
|
application::{AuthService, EmailTokenService, FilamentService, SpoolPresetService},
|
||||||
config::Config,
|
config::Config,
|
||||||
|
infrastructure::EmailService,
|
||||||
ports::{FilamentRepository, SpoolPresetRepository, UserRepository},
|
ports::{FilamentRepository, SpoolPresetRepository, UserRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ use crate::{
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth_service: Arc<AuthService>,
|
pub auth_service: Arc<AuthService>,
|
||||||
|
pub email_token_service: Arc<EmailTokenService>,
|
||||||
pub filament_service: Arc<FilamentService>,
|
pub filament_service: Arc<FilamentService>,
|
||||||
pub spool_preset_service: Arc<SpoolPresetService>,
|
pub spool_preset_service: Arc<SpoolPresetService>,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
@@ -46,6 +48,15 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
|||||||
Arc::clone(&user_repo),
|
Arc::clone(&user_repo),
|
||||||
config.clone(),
|
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(
|
let filament_service = Arc::new(FilamentService::new(
|
||||||
Arc::clone(&filament_repo),
|
Arc::clone(&filament_repo),
|
||||||
Arc::clone(&preset_repo),
|
Arc::clone(&preset_repo),
|
||||||
@@ -54,6 +65,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
|||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
auth_service,
|
auth_service,
|
||||||
|
email_token_service,
|
||||||
filament_service,
|
filament_service,
|
||||||
spool_preset_service,
|
spool_preset_service,
|
||||||
config: config.clone(),
|
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/login", post(auth_handler::login_handler))
|
||||||
.route("/auth/oauth/google", post(auth_handler::google_oauth_handler))
|
.route("/auth/oauth/google", post(auth_handler::google_oauth_handler))
|
||||||
.route("/auth/refresh", post(auth_handler::refresh_token_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/forgot-password", post(auth_handler::forgot_password_handler))
|
||||||
.route("/auth/verify-email", post(auth_handler::verify_email_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)
|
// Rotas protegidas (requerem JWT válido)
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
@@ -75,6 +91,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
|
|||||||
// Users
|
// Users
|
||||||
.route("/users/me", get(user_handler::get_me_handler))
|
.route("/users/me", get(user_handler::get_me_handler))
|
||||||
.route("/users/me", put(user_handler::update_me_handler))
|
.route("/users/me", put(user_handler::update_me_handler))
|
||||||
|
.route("/users/me", delete(user_handler::delete_me_handler))
|
||||||
// Dashboard
|
// Dashboard
|
||||||
.route("/dashboard", get(filament_handler::dashboard_handler))
|
.route("/dashboard", get(filament_handler::dashboard_handler))
|
||||||
// Filaments
|
// Filaments
|
||||||
|
|||||||
+120
-7
@@ -85,11 +85,12 @@ mobile/
|
|||||||
│ │ ├── login.tsx ← G3-0
|
│ │ ├── login.tsx ← G3-0
|
||||||
│ │ ├── register.tsx ← IX-0
|
│ │ ├── register.tsx ← IX-0
|
||||||
│ │ ├── forgot-password.tsx ← L2-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
|
│ │ ├── reset-password.tsx ← 117-0
|
||||||
│ │ └── password-reset-done.tsx ← 135-0
|
│ │ └── password-reset-done.tsx ← 135-0
|
||||||
│ ├── filament/
|
│ ├── filament/
|
||||||
│ │ └── [id].tsx ← Deep link handler: redireciona meowspool://filament/{id} → /(app)/inventory/{id}
|
│ │ └── [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)/
|
│ └── (app)/
|
||||||
│ ├── _layout.tsx ← Stack autenticado
|
│ ├── _layout.tsx ← Stack autenticado
|
||||||
│ ├── (tabs)/
|
│ ├── (tabs)/
|
||||||
@@ -138,7 +139,7 @@ mobile/
|
|||||||
│ ├── preset/
|
│ ├── preset/
|
||||||
│ │ └── PresetUseCases.ts ← List, Create, Update, Delete
|
│ │ └── PresetUseCases.ts ← List, Create, Update, Delete
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc.
|
│ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, ForgotPassword, ResendVerification, VerifyEmail, ResetPassword
|
||||||
│ └── nfc/
|
│ └── nfc/
|
||||||
│ ├── ReadNFCTagUseCase.ts ← lê URI, valida schema meowspool://, extrai filament ID
|
│ ├── ReadNFCTagUseCase.ts ← lê URI, valida schema meowspool://, extrai filament ID
|
||||||
│ └── WriteNFCTagUseCase.ts ← monta meowspool://filament/{id} e grava na tag
|
│ └── WriteNFCTagUseCase.ts ← monta meowspool://filament/{id} e grava na tag
|
||||||
@@ -222,12 +223,22 @@ Arquivo: `src/shared/theme.ts`
|
|||||||
|
|
||||||
### Deep links
|
### Deep links
|
||||||
|
|
||||||
- Scheme: `meowspool://`
|
- Scheme: `meowspool://` (configurado em `app.json` como `scheme: "meowspool"`)
|
||||||
- Filamento: `meowspool://filament/<id>` → `/(app)/inventory/<id>` (singular, alinhado com backend)
|
- **Filamento**: `meowspool://filament/<id>` → `/(app)/inventory/<id>`
|
||||||
- O redirecionamento é feito pela rota `app/filament/[id].tsx` com `<Redirect>` do expo-router — garante que o deep link abrido externamente (QR Code, NFC, link compartilhado) sempre chegue na tela correta
|
- Handler: `app/filament/[id].tsx` com `<Redirect>` do expo-router
|
||||||
|
- Mesmo link usado em QR Code, NFC e links compartilhados
|
||||||
|
- **Verificação de e-mail**: `meowspool://verify-email?token=<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=<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/<id>`
|
- O scanner (`scanner.tsx`) faz match via `/meowspool:\/\/filament\/([^/]+)/` e navega para `/(app)/inventory/<id>`
|
||||||
- O QR Code de cada filamento exibe `meowspool://filament/<id>` usando `react-native-qrcode-svg`
|
- As tags NFC NTAG215 gravam a URI `meowspool://filament/<id>` como NDEF URI record
|
||||||
- As tags NFC NTAG215 gravam a mesma URI `meowspool://filament/<id>` como NDEF URI record — mesmo deep link, infraestrutura compartilhada
|
|
||||||
|
> **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/google` | OAuth Google |
|
||||||
| POST | `/auth/refresh` | Refresh token |
|
| POST | `/auth/refresh` | Refresh token |
|
||||||
| POST | `/auth/logout` | Logout |
|
| POST | `/auth/logout` | Logout |
|
||||||
|
| POST | `/auth/resend-verification` | Reenviar e-mail de verificação |
|
||||||
| POST | `/auth/forgot-password` | Solicitar reset de senha |
|
| POST | `/auth/forgot-password` | Solicitar reset de senha |
|
||||||
| POST | `/auth/reset-password` | Confirmar reset com token |
|
| POST | `/auth/reset-password` | Confirmar reset com token |
|
||||||
| POST | `/auth/verify-email` | Verificar e-mail com código |
|
| 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`
|
- [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx`
|
||||||
- [ ] 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
|
||||||
- [ ] Google OAuth com `expo-auth-session`
|
- [ ] Google OAuth com `expo-auth-session`
|
||||||
- [ ] Testes de integração com Jest + Testing Library
|
- [ ] 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)
|
## 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=<token>` após o registro
|
||||||
|
2. Usuário toca no link → browser abre → backend redireciona 302 → `meowspool://verify-email?token=<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<void> {
|
||||||
|
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`
|
### ✅ 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.
|
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.
|
||||||
|
|||||||
@@ -1,15 +1,43 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import { Screen } from '@presentation/components/layout/Screen';
|
import { Screen } from '@presentation/components/layout/Screen';
|
||||||
import { Header } from '@presentation/components/layout/Header';
|
import { Header } from '@presentation/components/layout/Header';
|
||||||
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
import { colors, typography, spacing, radius } from '@shared/theme';
|
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
|
* Tela de Verificação de E-mail — RU-0
|
||||||
* Mostrada após o cadastro bem-sucedido.
|
* 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<void> {
|
||||||
|
if (!email || cooldown > 0) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await resendVerificationUseCase.execute(email);
|
||||||
|
setSent(true);
|
||||||
|
setCooldown(COOLDOWN_SECS);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<Header title="Verificar e-mail" showBack />
|
<Header title="Verificar e-mail" showBack />
|
||||||
@@ -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.
|
Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{email && (
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
cooldown > 0
|
||||||
|
? `Reenviar em ${cooldown}s`
|
||||||
|
: sent
|
||||||
|
? 'Reenviar e-mail'
|
||||||
|
: 'Reenviar e-mail'
|
||||||
|
}
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleResend}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={cooldown > 0}
|
||||||
|
style={styles.resendButton}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sent && cooldown > 0 && (
|
||||||
|
<Text style={styles.sentText}>E-mail reenviado! Verifique sua caixa de entrada.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.hint}>
|
<View style={styles.hint}>
|
||||||
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
|
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
|
||||||
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
|
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
|
||||||
@@ -64,11 +113,20 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: typography.fontSize.base * 1.6,
|
lineHeight: typography.fontSize.base * 1.6,
|
||||||
},
|
},
|
||||||
|
resendButton: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
sentText: {
|
||||||
|
fontFamily: typography.fontFamily.ui,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.accent,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
hint: {
|
hint: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing[2],
|
gap: spacing[2],
|
||||||
marginTop: spacing[4],
|
marginTop: spacing[2],
|
||||||
},
|
},
|
||||||
hintText: {
|
hintText: {
|
||||||
fontFamily: typography.fontFamily.ui,
|
fontFamily: typography.fontFamily.ui,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { 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 { Link, useRouter } from 'expo-router';
|
import { Link, useRouter } from 'expo-router';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -42,8 +43,15 @@ export default function LoginScreen(): React.ReactElement {
|
|||||||
await setSession(session);
|
await setSession(session);
|
||||||
router.replace('/(app)/(tabs)/home');
|
router.replace('/(app)/(tabs)/home');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('login error', err);
|
if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') {
|
||||||
|
Alert.alert(
|
||||||
|
'E-mail não verificado',
|
||||||
|
'Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada.',
|
||||||
|
[{ text: 'OK', onPress: () => router.push(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`) }],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
Alert.alert('Erro', 'E-mail ou senha incorretos.');
|
Alert.alert('Erro', 'E-mail ou senha incorretos.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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 { registerUseCase } from '@infrastructure/container';
|
import { registerUseCase } from '@infrastructure/container';
|
||||||
import { useAuthStore } from '@store/authStore';
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email('E-mail inválido'),
|
email: z.string().email('E-mail inválido'),
|
||||||
@@ -31,7 +30,6 @@ type FormData = z.infer<typeof schema>;
|
|||||||
*/
|
*/
|
||||||
export default function RegisterScreen(): React.ReactElement {
|
export default function RegisterScreen(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setSession = useAuthStore((s) => s.setSession);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
|
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||||
@@ -42,9 +40,8 @@ export default function RegisterScreen(): React.ReactElement {
|
|||||||
async function onSubmit(data: FormData): Promise<void> {
|
async function onSubmit(data: FormData): Promise<void> {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const session = await registerUseCase.execute({ email: data.email, password: data.password });
|
await registerUseCase.execute({ email: data.email, password: data.password });
|
||||||
await setSession(session);
|
router.replace(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`);
|
||||||
router.replace('/(auth)/verify-email');
|
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Erro', 'Não foi possível criar sua conta.');
|
Alert.alert('Erro', 'Não foi possível criar sua conta.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ export default function RootLayout(): React.ReactElement | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
const inAuthGroup = segments[0] === '(auth)';
|
const inAuthGroup = segments[0] === '(auth)';
|
||||||
|
const inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament';
|
||||||
if (isAuthenticated && inAuthGroup) {
|
if (isAuthenticated && inAuthGroup) {
|
||||||
router.replace('/(app)/(tabs)/home');
|
router.replace('/(app)/(tabs)/home');
|
||||||
} else if (!isAuthenticated && !inAuthGroup) {
|
} else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) {
|
||||||
router.replace('/(auth)/login');
|
router.replace('/(auth)/login');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, segments, router]);
|
}, [isAuthenticated, isLoading, segments, router]);
|
||||||
@@ -35,6 +36,8 @@ export default function RootLayout(): React.ReactElement | null {
|
|||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="(auth)" />
|
<Stack.Screen name="(auth)" />
|
||||||
<Stack.Screen name="(app)" />
|
<Stack.Screen name="(app)" />
|
||||||
|
<Stack.Screen name="verify-email" />
|
||||||
|
<Stack.Screen name="filament/[id]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Screen } from '@presentation/components/layout/Screen';
|
||||||
|
import { Button } from '@presentation/components/ui/Button';
|
||||||
|
import { colors, typography, spacing, radius } from '@shared/theme';
|
||||||
|
import { verifyEmailUseCase } from '@infrastructure/container';
|
||||||
|
|
||||||
|
type Status = 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler de deep link: meowspool://verify-email?token=xxx
|
||||||
|
* Chama o backend para confirmar o e-mail e exibe o resultado.
|
||||||
|
*/
|
||||||
|
export default function VerifyEmailDeepLink(): React.ReactElement {
|
||||||
|
const { token } = useLocalSearchParams<{ token: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState<Status>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('Não foi possível verificar o e-mail.');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setErrorMessage('Link inválido ou expirado.');
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyEmailUseCase.execute(token)
|
||||||
|
.then(() => setStatus('success'))
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Erro desconhecido.';
|
||||||
|
setErrorMessage(msg);
|
||||||
|
setStatus('error');
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{status === 'loading' && (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator size="large" color={colors.accent} />
|
||||||
|
<Text style={styles.message}>Verificando e-mail…</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<View style={[styles.iconWrapper, styles.iconSuccess]}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={64} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>E-mail verificado!</Text>
|
||||||
|
<Text style={styles.message}>Sua conta está ativa. Faça login para continuar.</Text>
|
||||||
|
<Button
|
||||||
|
label="Ir para o login"
|
||||||
|
onPress={() => router.replace('/(auth)/login')}
|
||||||
|
style={styles.cta}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<View style={[styles.iconWrapper, styles.iconError]}>
|
||||||
|
<Ionicons name="close-circle-outline" size={64} color={colors.error ?? '#FF6B6B'} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Falha na verificação</Text>
|
||||||
|
<Text style={styles.message}>{errorMessage}</Text>
|
||||||
|
<Button
|
||||||
|
label="Voltar ao login"
|
||||||
|
onPress={() => router.replace('/(auth)/login')}
|
||||||
|
style={styles.cta}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing[4],
|
||||||
|
paddingHorizontal: spacing[6],
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
backgroundColor: colors.bgSurface,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing[2],
|
||||||
|
},
|
||||||
|
iconSuccess: {},
|
||||||
|
iconError: {},
|
||||||
|
title: {
|
||||||
|
fontFamily: typography.fontFamily.ui,
|
||||||
|
fontSize: typography.fontSize.xl,
|
||||||
|
fontWeight: typography.fontWeight.bold,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontFamily: typography.fontFamily.ui,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: typography.fontSize.base * 1.6,
|
||||||
|
},
|
||||||
|
cta: { marginTop: spacing[2], width: '100%' },
|
||||||
|
});
|
||||||
@@ -12,9 +12,8 @@ export class ApiAuthRepository implements AuthRepository {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(input: RegisterInput): Promise<AuthSession> {
|
async register(input: RegisterInput): Promise<void> {
|
||||||
const { data } = await httpClient.post('/auth/register', input);
|
await httpClient.post('/auth/register', input);
|
||||||
return this.fetchSession(data.access_token as string, data.refresh_token as string);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
|
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
|
||||||
@@ -36,6 +35,10 @@ export class ApiAuthRepository implements AuthRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resendVerification(email: string): Promise<void> {
|
||||||
|
await httpClient.post('/auth/resend-verification', { email });
|
||||||
|
}
|
||||||
|
|
||||||
async forgotPassword(email: string): Promise<void> {
|
async forgotPassword(email: string): Promise<void> {
|
||||||
await httpClient.post('/auth/forgot-password', { email });
|
await httpClient.post('/auth/forgot-password', { email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export class LoginUseCase {
|
|||||||
export class RegisterUseCase {
|
export class RegisterUseCase {
|
||||||
constructor(private readonly authRepo: AuthRepository) {}
|
constructor(private readonly authRepo: AuthRepository) {}
|
||||||
|
|
||||||
async execute(input: RegisterInput): Promise<AuthSession> {
|
async execute(input: RegisterInput): Promise<void> {
|
||||||
if (!input.email || !input.password) {
|
if (!input.email || !input.password) {
|
||||||
throw new Error('E-mail e senha são obrigatórios.');
|
throw new Error('E-mail e senha são obrigatórios.');
|
||||||
}
|
}
|
||||||
if (input.password.length < 8) {
|
if (input.password.length < 8) {
|
||||||
throw new Error('A senha deve ter ao menos 8 caracteres.');
|
throw new Error('A senha deve ter ao menos 8 caracteres.');
|
||||||
}
|
}
|
||||||
return this.authRepo.register(input);
|
await this.authRepo.register(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,18 @@ export class LogoutUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caso de uso: Reenviar e-mail de verificação de conta.
|
||||||
|
*/
|
||||||
|
export class ResendVerificationUseCase {
|
||||||
|
constructor(private readonly authRepo: AuthRepository) {}
|
||||||
|
|
||||||
|
async execute(email: string): Promise<void> {
|
||||||
|
if (!email) throw new Error('E-mail obrigatório.');
|
||||||
|
await this.authRepo.resendVerification(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caso de uso: Solicitar redefinição de senha.
|
* Caso de uso: Solicitar redefinição de senha.
|
||||||
*/
|
*/
|
||||||
@@ -65,6 +77,18 @@ export class ForgotPasswordUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caso de uso: Verificar e-mail com token recebido por deep link.
|
||||||
|
*/
|
||||||
|
export class VerifyEmailUseCase {
|
||||||
|
constructor(private readonly authRepo: AuthRepository) {}
|
||||||
|
|
||||||
|
async execute(token: string): Promise<void> {
|
||||||
|
if (!token) throw new Error('Token inválido.');
|
||||||
|
await this.authRepo.verifyEmail(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caso de uso: Redefinir senha com token.
|
* Caso de uso: Redefinir senha com token.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
LoginUseCase,
|
LoginUseCase,
|
||||||
RegisterUseCase,
|
RegisterUseCase,
|
||||||
ForgotPasswordUseCase,
|
ForgotPasswordUseCase,
|
||||||
|
ResendVerificationUseCase,
|
||||||
ResetPasswordUseCase,
|
ResetPasswordUseCase,
|
||||||
|
VerifyEmailUseCase,
|
||||||
LogoutUseCase,
|
LogoutUseCase,
|
||||||
} from '@application/auth/AuthUseCases';
|
} from '@application/auth/AuthUseCases';
|
||||||
import { CreateFilamentUseCase } from '@application/filament/CreateFilamentUseCase';
|
import { CreateFilamentUseCase } from '@application/filament/CreateFilamentUseCase';
|
||||||
@@ -30,8 +32,10 @@ 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 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);
|
||||||
|
export const verifyEmailUseCase = new VerifyEmailUseCase(authRepository);
|
||||||
export const logoutUseCase = new LogoutUseCase(authRepository);
|
export const logoutUseCase = new LogoutUseCase(authRepository);
|
||||||
|
|
||||||
// Use cases de filamento
|
// Use cases de filamento
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import type { AuthSession, LoginInput, RegisterInput, GoogleOAuthInput } from '@
|
|||||||
*/
|
*/
|
||||||
export interface AuthRepository {
|
export interface AuthRepository {
|
||||||
login(input: LoginInput): Promise<AuthSession>;
|
login(input: LoginInput): Promise<AuthSession>;
|
||||||
register(input: RegisterInput): Promise<AuthSession>;
|
register(input: RegisterInput): Promise<void>;
|
||||||
loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession>;
|
loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession>;
|
||||||
refreshToken(refreshToken: string): Promise<Pick<AuthSession, 'accessToken' | 'refreshToken'>>;
|
refreshToken(refreshToken: string): Promise<Pick<AuthSession, 'accessToken' | 'refreshToken'>>;
|
||||||
logout(accessToken: string): Promise<void>;
|
logout(accessToken: string): Promise<void>;
|
||||||
|
resendVerification(email: string): Promise<void>;
|
||||||
forgotPassword(email: string): Promise<void>;
|
forgotPassword(email: string): Promise<void>;
|
||||||
verifyEmail(token: string): Promise<void>;
|
verifyEmail(token: string): Promise<void>;
|
||||||
resetPassword(token: string, newPassword: string): Promise<void>;
|
resetPassword(token: string, newPassword: string): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user