diff --git a/.env.example b/.env.example
index 1769c19..060002f 100644
--- a/.env.example
+++ b/.env.example
@@ -29,6 +29,12 @@ BACKUP_DIR=
BACKUP_KEEP_DAYS=
MYSQLDUMP_BIN=
+# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa
+# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma
+# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao.
+MAIL_DRIVER=
+MAIL_LOG_FILE=
+
# so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080).
# No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia.
DB_ROOT_PASS=
diff --git a/CLAUDE.md b/CLAUDE.md
index 4862df2..1599541 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,6 +25,7 @@ node tests/js/customer.test.js # conta do valor da compra do public/js/c
tsc # compila src/ts -> public/js (tsconfig.json)
php bin/migrate.php # roda as migrations pendentes (status | baseline NNN)
php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR)
+php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão)
docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md)
```
@@ -36,7 +37,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já
## Arquitetura
-**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503.
+**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503.
**Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto.
@@ -48,12 +49,16 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já
**Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL.
-**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga.
+**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`.
**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59).
**Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`, e a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade.
+**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora.
+
+**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`).
+
**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste.
**Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`.
@@ -69,6 +74,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já
**Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto.
+**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido.
+
**Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra).
**Produção** — `Dockerfile` + `docker-compose.yml` (Apache, PHP 8.3, MySQL 8; `docker/php.ini` desliga `display_errors`), backup por `bin/backup.php` (`App\Support\Backup`) e o guia em `docs/operacao.md`. Mudou variável de ambiente, comando de operação ou volume: atualize esse guia.
diff --git a/bin/create-admin.php b/bin/create-admin.php
new file mode 100644
index 0000000..32e355d
--- /dev/null
+++ b/bin/create-admin.php
@@ -0,0 +1,51 @@
+create($email, $name, $password);
+ fwrite(STDOUT, "\nadmin criado (id $id). entre em /index.php?url=admin/login\n");
+} catch (PDOException $e) {
+ fwrite(STDERR, (($e->errorInfo[1] ?? null) === 1062 ? 'erro: ja existe admin com esse e-mail' : 'erro no banco: ' . $e->getMessage()) . "\n");
+ exit(1);
+} catch (Throwable $e) {
+ fwrite(STDERR, 'erro: ' . $e->getMessage() . "\n");
+ exit(1);
+}
diff --git a/database/migrations/008_password_resets.sql b/database/migrations/008_password_resets.sql
new file mode 100644
index 0000000..33d4798
--- /dev/null
+++ b/database/migrations/008_password_resets.sql
@@ -0,0 +1,27 @@
+-- fidelityx - migration 008 (recuperacao de senha por e-mail, task 21)
+-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
+-- php bin/migrate.php
+-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/008_password_resets.sql)
+
+USE fidelityx;
+
+-- link de redefinicao de senha: o token vai so no e-mail; aqui fica o hash sha-256 dele
+-- (quem ler o banco nao consegue usar). vale 1 hora e uma vez so.
+CREATE TABLE IF NOT EXISTS password_resets (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ merchant_id BIGINT UNSIGNED NOT NULL,
+ token_hash CHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada)
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_password_resets_token (token_hash),
+ KEY idx_password_resets_merchant (merchant_id),
+
+ CONSTRAINT fk_password_resets_merchant
+ FOREIGN KEY (merchant_id) REFERENCES merchants(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/database/migrations/009_email_verifications.sql b/database/migrations/009_email_verifications.sql
new file mode 100644
index 0000000..50ccc90
--- /dev/null
+++ b/database/migrations/009_email_verifications.sql
@@ -0,0 +1,32 @@
+-- fidelityx - migration 009 (confirmacao do e-mail do lojista, task 50)
+-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
+-- php bin/migrate.php
+-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/009_email_verifications.sql)
+
+USE fidelityx;
+
+-- conta nova so usa o painel depois de confirmar o e-mail (NULL = ainda nao confirmou)
+ALTER TABLE merchants ADD COLUMN email_verified_at TIMESTAMP NULL AFTER email;
+
+-- contas que ja existiam contam como confirmadas: nao da pra travar quem ja usa o sistema
+UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL;
+
+-- link de confirmacao: como o de nova senha, so o hash do token fica aqui. vale 24 horas e uma vez.
+CREATE TABLE IF NOT EXISTS email_verifications (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ merchant_id BIGINT UNSIGNED NOT NULL,
+ token_hash CHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL, -- usado ou cancelado (reenvio)
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_email_verifications_token (token_hash),
+ KEY idx_email_verifications_merchant (merchant_id),
+
+ CONSTRAINT fk_email_verifications_merchant
+ FOREIGN KEY (merchant_id) REFERENCES merchants(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/database/migrations/010_admins.sql b/database/migrations/010_admins.sql
new file mode 100644
index 0000000..eaeb566
--- /dev/null
+++ b/database/migrations/010_admins.sql
@@ -0,0 +1,21 @@
+-- fidelityx - migration 010 (painel administrativo, task 30)
+-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
+-- php bin/migrate.php
+-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/010_admins.sql)
+
+USE fidelityx;
+
+-- administradores do FidelityX (nao sao lojistas): login proprio em admin/login, conta criada
+-- pela linha de comando (php bin/create-admin.php). veem as lojas, nunca os clientes delas.
+CREATE TABLE IF NOT EXISTS admins (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ email VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_admins_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/database/schema.sql b/database/schema.sql
index 70c3e0f..20309b9 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS merchants (
owner_name VARCHAR(255) NOT NULL,
store_name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
+ email_verified_at TIMESTAMP NULL, -- conta nova so usa o painel depois de confirmar o e-mail (task 50)
phone VARCHAR(30) NOT NULL,
category VARCHAR(30) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
@@ -182,6 +183,69 @@ CREATE TABLE IF NOT EXISTS rate_limit_hits (
KEY idx_rate_limit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+-- =========================
+-- password_resets // links de redefinicao de senha (task 21)
+-- =========================
+-- o token vai so no e-mail; aqui fica o hash sha-256 dele. vale 1 hora e uma vez so.
+CREATE TABLE IF NOT EXISTS password_resets (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ merchant_id BIGINT UNSIGNED NOT NULL,
+ token_hash CHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada)
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_password_resets_token (token_hash),
+ KEY idx_password_resets_merchant (merchant_id),
+
+ CONSTRAINT fk_password_resets_merchant
+ FOREIGN KEY (merchant_id) REFERENCES merchants(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- =========================
+-- email_verifications // links de confirmacao do e-mail do lojista (task 50)
+-- =========================
+-- como o de nova senha: so o hash do token fica aqui. vale 24 horas e uma vez.
+CREATE TABLE IF NOT EXISTS email_verifications (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ merchant_id BIGINT UNSIGNED NOT NULL,
+ token_hash CHAR(64) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP NULL, -- usado ou cancelado (reenvio)
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_email_verifications_token (token_hash),
+ KEY idx_email_verifications_merchant (merchant_id),
+
+ CONSTRAINT fk_email_verifications_merchant
+ FOREIGN KEY (merchant_id) REFERENCES merchants(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- =========================
+-- admins // administradores do FidelityX (task 30)
+-- =========================
+-- login proprio em admin/login, conta criada por php bin/create-admin.php. veem as lojas, nunca os clientes.
+CREATE TABLE IF NOT EXISTS admins (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+
+ email VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uq_admins_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
-- =========================
-- schema_migrations // quais migrations de database/migrations ja rodaram neste banco
-- =========================
@@ -200,4 +264,7 @@ INSERT IGNORE INTO schema_migrations (version) VALUES
('004_estorno'),
('005_regra_pontos'),
('006_points_log_extrato'),
- ('007_customers_so_telefone');
+ ('007_customers_so_telefone'),
+ ('008_password_resets'),
+ ('009_email_verifications'),
+ ('010_admins');
diff --git a/docs/adr/003-atendentes-com-login-proprio.md b/docs/adr/003-atendentes-com-login-proprio.md
new file mode 100644
index 0000000..e157149
--- /dev/null
+++ b/docs/adr/003-atendentes-com-login-proprio.md
@@ -0,0 +1,81 @@
+# ADR 003 — Atendentes com login próprio
+
+Status: **proposta** (2026-10-06, rascunho para o time decidir) · Task 58 do backlog · Sem código ainda
+
+## Problema encontrado
+
+A loja tem uma senha só. Quem está no balcão usa a conta do dono, então:
+
+1. **Não fica registrado quem fez o quê.** `points_log.responsible_user` existe desde o MVP, mas nunca foi preenchido. Um lançamento errado, um resgate indevido ou um estorno não têm autor.
+2. **Todo mundo pode tudo.** Quem lança pontos também apaga prêmio, muda a regra de pontos, troca a senha da conta e exclui dados de cliente.
+3. **Tirar o acesso de alguém custa caro.** Quando um funcionário sai, o dono precisa trocar a senha e repassar a nova para todos os outros.
+
+## Decisão proposta
+
+### 1. Quem entra vira "usuário"; a loja continua sendo a loja
+
+Tabela nova `users`, com e-mail e senha saindo de `merchants`:
+
+| Coluna | Observação |
+|---|---|
+| `id` | |
+| `merchant_id` | Loja a que o usuário pertence (FK). Um usuário, uma loja. Ver "Filiais" abaixo. |
+| `role` | `owner` (dono) ou `attendant` (atendente). |
+| `name`, `email` | E-mail `UNIQUE` na plataforma inteira: o login continua sendo só e-mail + senha. |
+| `password_hash`, `email_verified_at`, `status` | Os mesmos de hoje em `merchants`, agora por pessoa. `status` = `active`/`inactive`. |
+
+A migration cria um `owner` para cada loja existente, copiando `owner_name`, `email`, `password_hash` e `email_verified_at`, e só depois tira essas colunas de `merchants`. `merchants.status` continua valendo para a loja inteira: o admin (task 30) desativa a loja, e o dono desativa um atendente.
+
+**Por que uma tabela única e não uma tabela `attendants` ao lado de `merchants`:** com duas tabelas, cada login, sessão, limite de tentativas, recuperação de senha (task 21), confirmação de e-mail (task 50) e o autor de cada movimentação precisariam saber "de qual tabela" é a pessoa. Com `users`, tudo isso passa a apontar para um `user_id`, e o caso do dono deixa de ser especial.
+
+### 2. Sessão e permissões
+
+- A sessão guarda `user_id`, `merchant_id` e `role`. O `authGuard` passa a conferir, a cada requisição, se o usuário **e** a loja estão ativos (desativar um atendente derruba a sessão dele na hora, como hoje acontece com a loja).
+- `password_sig` passa a ser do usuário: trocar a senha de um atendente não derruba o dono.
+- Um método `requireOwner()` no controller, chamado no começo das rotas só do dono. Rota nova nasce restrita ao dono; liberar para atendente é decisão explícita.
+
+| Ação | Dono | Atendente |
+|---|---|---|
+| Buscar cliente, cadastro rápido, lançar, resgatar, estornar (24 h) | sim | sim |
+| Extrato do cliente, registrar consentimento | sim | sim |
+| Corrigir nome do cliente (task 43) | sim | sim *(a confirmar)* |
+| Trocar telefone do cliente (task 43) | sim | não *(a confirmar)* |
+| Excluir dados do cliente (LGPD) | sim | não |
+| Prêmios, regra de pontos, perfil da loja, cartaz | sim | não |
+| Relatórios e exportação CSV (task 59) | sim | não *(a confirmar)* |
+| Cadastrar, desativar e reativar atendentes | sim | não |
+
+### 3. Quem fez cada movimentação
+
+`points_log.responsible_user VARCHAR(255)` vira `user_id BIGINT UNSIGNED NULL` com FK para `users` (`ON DELETE SET NULL`). `addPoints`, `redeem` e `reverse` recebem o `user_id` da sessão e gravam na mesma transação. Movimentações antigas ficam com `NULL` e aparecem como "—". O extrato, o histórico dos relatórios e o CSV ganham a coluna "Feito por".
+
+O nome mostrado é o do usuário **hoje** (não uma cópia na hora do lançamento): se o atendente for removido (ver LGPD), o histórico passa a mostrar "Atendente removido".
+
+### 4. Cadastro de atendente
+
+O dono informa nome e e-mail numa tela nova (`merchant/team`). O sistema cria o usuário sem senha e manda por e-mail um link para ele criar a própria senha, reaproveitando o `MerchantTokenModel` (tasks 21 e 50): o dono nunca sabe a senha do atendente, e o e-mail já sai confirmado pelo uso do link. **Depende do provedor de e-mail (task 55)**, que ainda não foi escolhido.
+
+### 5. LGPD
+
+Os atendentes são titulares de dados (nome e e-mail) e a loja é a controladora. Desativar mantém o registro (o histórico precisa do autor). Para atender um pedido de exclusão, o dono "remove" o atendente: o usuário fica `inactive`, com nome trocado por "Atendente removido" e e-mail por um valor inválido único, e o `user_id` continua nas movimentações. Nenhuma tela de uma loja mostra atendente de outra.
+
+## Consequências
+
+- **Migration grande e com cópia de dados** (`merchants` → `users`). Precisa de teste de migration sobre um banco no formato antigo, como a 007.
+- **Login, recuperação de senha, confirmação de e-mail e limite de tentativas** passam a falar de usuário, não de loja. Os buckets do `RateLimiter` continuam por e-mail, então nada muda para quem usa.
+- **Toda rota do painel ganha uma decisão de permissão.** A tabela acima vira teste de fluxo: atendente recebe 403 em cada rota só do dono.
+- **Abre caminho para filiais (task 60):** "um usuário, várias lojas" vira uma tabela de vínculo `user_stores` no lugar de `users.merchant_id`, sem mexer em login nem em `points_log`.
+
+## Alternativas descartadas
+
+- **Tabela `attendants` separada, dono continua em `merchants`:** menos migration agora, mas dois caminhos em tudo que envolve login e autoria (ver item 1).
+- **PIN de 4 dígitos por atendente, sem login:** registra o autor e é rápido no balcão, mas a sessão continua sendo a do dono (o atendente vê e faz tudo) e PIN curto é fácil de adivinhar.
+- **Só um campo "atendente" de texto livre no lançamento:** não controla acesso e qualquer um digita qualquer nome.
+
+## Perguntas em aberto (para o time)
+
+1. Atendente pode corrigir nome, trocar telefone e ver relatórios? (marcados "a confirmar" na tabela)
+2. Limite de atendentes por loja? Liga com os planos Free/Pro (task 32).
+3. Dono pode redefinir a senha de um atendente, ou só reenviar o link para ele criar outra?
+4. A sessão do atendente expira em menos tempo que a do dono (hoje 8 h)?
+5. Sem provedor de e-mail (task 55), aceitamos um caminho provisório (dono define a senha inicial e o atendente é obrigado a trocar no primeiro acesso)?
diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md
index 9927d7e..3b4a7d6 100644
--- a/docs/db/schema-explanation.md
+++ b/docs/db/schema-explanation.md
@@ -29,6 +29,7 @@ merchants 1───N loyalty_cards N───1 customers
| `password_hash` | BCRYPT (`password_hash`). |
| `plan` | `free`/`pro`. Ainda não é usado pelo código (pós-MVP). |
| `status` | Padrão `active`. Contas `inactive` são barradas no login. |
+| `email_verified_at` | Quando o e-mail foi confirmado pelo link (task 50). `NULL` = conta nova ainda sem confirmar: entra, mas só vê a tela de confirmação. A migration 009 marcou as contas antigas como confirmadas. |
| `category`, `state` | Validados contra as listas de `MerchantController::CATEGORIES` e `STATES`. |
### `customers` — clientes
@@ -85,3 +86,22 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p
- **Lançar e resgatar** rodam em transação: o saldo do cartão e a linha do `points_log` são gravados juntos, ou nenhum dos dois é gravado.
- **Resgate** trava a linha do cartão com `SELECT ... FOR UPDATE` antes de checar o saldo. Assim, dois resgates simultâneos não conseguem gastar os mesmos pontos.
- **Cartão novo** é criado com `INSERT ... ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`, que devolve o id existente sem risco de duplicar.
+
+### `password_resets` — links de nova senha (task 21)
+| Coluna | Observação |
+|---|---|
+| `merchant_id` | Dono do link. `ON DELETE CASCADE`. |
+| `token_hash` | `UNIQUE`. SHA-256 do token; o token em si só existe no e-mail. |
+| `expires_at` | 1 hora depois do pedido. |
+| `used_at` | Preenchido quando o link é usado, quando um pedido novo é feito ou quando a senha é trocada por ele: depois disso não vale mais. |
+
+### `email_verifications` — links de confirmação do e-mail (task 50)
+Mesma forma da `password_resets` (`merchant_id`, `token_hash` com `UNIQUE`, `expires_at`, `used_at`), com validade de 24 horas. Reenviar o link cancela o anterior.
+
+### `admins` — administradores do FidelityX (task 30)
+| Coluna | Observação |
+|---|---|
+| `email` | `UNIQUE`. Login do painel `admin/login`. |
+| `password_hash` | bcrypt. A conta é criada por `php bin/create-admin.php`. |
+
+Separada de `merchants` de propósito: um admin não é uma loja e um lojista nunca vira admin por um campo trocado.
diff --git a/docs/operacao.md b/docs/operacao.md
index 236ad14..19a656c 100644
--- a/docs/operacao.md
+++ b/docs/operacao.md
@@ -15,6 +15,7 @@ Toda a configuração vem de **variáveis de ambiente**. O arquivo `.env` na rai
| `TRUSTED_PROXIES` | não | IPs ou faixas CIDR dos proxies confiáveis, separados por vírgula. Ver [Proxy reverso](#proxy-reverso). |
| `LOG_FILE` | não | Arquivo do log de erros. Vazio usa o destino do `php.ini`. |
| `BACKUP_DIR`, `BACKUP_KEEP_DAYS`, `MYSQLDUMP_BIN` | não | Backup: pasta (padrão `storage/backups`), dias guardados (padrão `14`, `0` nunca apaga) e caminho do `mysqldump`. |
+| `MAIL_DRIVER`, `MAIL_LOG_FILE` | não | E-mail transacional (task 55). Vazio: nenhum e-mail sai e o log registra cada envio perdido (sem o conteúdo). `log`: grava os e-mails inteiros em `MAIL_LOG_FILE` (padrão `storage/mail.log`), **só para desenvolvimento** (tem os links com token). O provedor de produção (SMTP/API, SPF e DKIM do domínio) ainda não foi escolhido. |
| `DB_ROOT_PASS`, `APP_PORT` | só no Compose | Senha do root do MySQL do container e porta publicada no host (padrão `8080`). |
## Docker
@@ -61,6 +62,17 @@ TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12
Com o Compose, o proxy no host chega ao container pelo gateway da rede do Docker, normalmente um endereço em `172.16.0.0/12`.
+## Administrador
+
+O painel de administração (`/index.php?url=admin/login`) tem contas próprias, que não são lojistas. Para criar uma (no container, `docker compose exec app php bin/create-admin.php ...`):
+
+```bash
+php bin/create-admin.php admin@seudominio.com.br "Nome da pessoa"
+# a senha (8 a 72 bytes) é pedida em seguida; não passe a senha como argumento
+```
+
+O admin vê todas as lojas e ativa ou desativa um lojista (a desativação derruba a sessão dele na hora). Não há tela para criar ou remover admins: remover é `DELETE FROM admins WHERE email = ...` no banco.
+
## Rota de saúde
`GET /index.php?url=health` responde `200 {"status":"ok"}` quando a aplicação consegue falar com o banco e `503 {"status":"erro"}` quando não (o motivo vai para o log, nunca para a resposta). Ela roda antes da sessão, então checar a cada 30 s não cria arquivo no volume de sessões, e sai com `Cache-Control: no-store`. É o endereço para o monitor externo e para o `HEALTHCHECK` do container (task 56; a troca do healthcheck no `Dockerfile` e o monitor com alerta ainda estão pendentes).
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index f88dfbc..e36a43b 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1,5 +1,17 @@
parameters:
ignoreErrors:
+ -
+ message: '#^Variable \$merchants might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: views/admin/dashboard.php
+
+ -
+ message: '#^Variable \$paginator might not be defined\.$#'
+ identifier: variable.undefined
+ count: 3
+ path: views/admin/dashboard.php
+
-
message: '#^Variable \$categories might not be defined\.$#'
identifier: variable.undefined
@@ -12,6 +24,24 @@ parameters:
count: 1
path: views/auth/merchant/register.php
+ -
+ message: '#^Variable \$maxPassword might not be defined\.$#'
+ identifier: variable.undefined
+ count: 3
+ path: views/auth/merchant/reset.php
+
+ -
+ message: '#^Variable \$minPassword might not be defined\.$#'
+ identifier: variable.undefined
+ count: 3
+ path: views/auth/merchant/reset.php
+
+ -
+ message: '#^Variable \$token might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: views/auth/merchant/reset.php
+
-
message: '#^Variable \$card might not be defined\.$#'
identifier: variable.undefined
@@ -156,6 +186,12 @@ parameters:
count: 1
path: views/errors/default.php
+ -
+ message: '#^Variable \$email might not be defined\.$#'
+ identifier: variable.undefined
+ count: 1
+ path: views/merchant/confirm-email.php
+
-
message: '#^Variable \$phone might not be defined\.$#'
identifier: variable.undefined
diff --git a/public/css/auth.css b/public/css/auth.css
index fb0714a..09ead9f 100644
--- a/public/css/auth.css
+++ b/public/css/auth.css
@@ -100,3 +100,10 @@
.auth-container.auth-container-wide {
max-width: 600px;
}
+
+/* link "Esqueci minha senha" logo abaixo do botao de entrar (task 21) */
+.auth-forgot {
+ margin-top: 1rem;
+ text-align: center;
+ font-size: 0.9rem;
+}
diff --git a/public/index.php b/public/index.php
index a5fe29f..1da89fd 100644
--- a/public/index.php
+++ b/public/index.php
@@ -95,6 +95,19 @@
};
break;
+ case 'admin':
+ // painel administrativo (task 30): login e sessao proprios, separados dos do lojista
+ RequestGuard::sendNoStore();
+ $controller = new \App\Controllers\AdminController($db);
+
+ match ($action ?? 'dashboard') {
+ 'login' => $controller->renderLogin(),
+ 'logout' => $controller->logout(),
+ 'dashboard' => $controller->renderDashboard(),
+ default => (new ErrorController())->handle(404),
+ };
+ break;
+
case 'merchant':
RequestGuard::sendNoStore();
$controller = new \App\Controllers\MerchantController($db);
@@ -102,6 +115,10 @@
match ($action ?? 'dashboard') {
'login' => $controller->renderLogin(),
'register' => $controller->renderRegister(),
+ 'forgot' => $controller->renderForgot(),
+ 'reset' => $controller->renderReset(),
+ 'confirm-email' => $controller->renderConfirmEmail(),
+ 'verify-email' => $controller->renderVerifyEmail(),
'customer-new' => $controller->renderCustomerNew(),
'logout' => $controller->logout(),
'dashboard' => $controller->renderDashboard(),
diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php
new file mode 100644
index 0000000..8afdd22
--- /dev/null
+++ b/src/Controllers/AdminController.php
@@ -0,0 +1,132 @@
+db = $db;
+ $this->admins = new AdminModel($db);
+ }
+
+ public function renderLogin() {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->handleLogin();
+ return;
+ }
+ if (isset($_SESSION['admin_id'])) {
+ redirect('admin/dashboard');
+ }
+ View::render('admin/login');
+ }
+
+ // mesmas protecoes do login do lojista: limite por e-mail + ip e por ip, mesmo tempo pra e-mail inexistente
+ private function handleLogin() {
+ Csrf::verify();
+
+ $email = trim((string)($_POST['email'] ?? ''));
+ $password = (string)($_POST['password'] ?? '');
+ if ($email === '' || $password === '') {
+ redirect('admin/login', ['error' => 'campos_obrigatorios']);
+ }
+
+ $limiter = new RateLimiter($this->db);
+ $ip = RateLimiter::clientKey();
+ $pair = $email . '|' . $ip;
+ if ($limiter->tooMany('admin_login_pair', $pair, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS)
+ || $limiter->tooMany('admin_login_ip', $ip, self::MAX_LOGIN_FAILURES_PER_IP, self::LOGIN_WINDOW_SECONDS)) {
+ (new ErrorController())->handle(429);
+ exit;
+ }
+
+ $admin = $this->admins->findByEmail($email) ?: null;
+ if (!LoginGuard::verify($admin, $password)) {
+ $limiter->hit('admin_login_pair', $pair);
+ $limiter->hit('admin_login_ip', $ip);
+ redirect('admin/login', ['error' => 'credenciais_invalidas']);
+ }
+ $limiter->clear('admin_login_pair', $pair);
+
+ session_regenerate_id(true);
+ Csrf::regenerate();
+ $_SESSION['admin_id'] = (int)$admin['id'];
+ $_SESSION['admin_name'] = $admin['name'];
+ $_SESSION['admin_last_seen'] = time();
+ $_SESSION['admin_sig'] = SessionGuard::passwordSignature($admin['password_hash']);
+
+ redirect('admin/dashboard');
+ }
+
+ public function logout() {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ redirect(isset($_SESSION['admin_id']) ? 'admin/dashboard' : 'admin/login');
+ }
+ Csrf::verify();
+ SessionGuard::destroy();
+ redirect('admin/login', ['success' => 'logout']);
+ }
+
+ // lista das lojas, paginada; POST ativa ou desativa uma
+ public function renderDashboard() {
+ $this->adminGuard();
+ $merchants = new MerchantModel($this->db);
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ Csrf::verify();
+ $merchantId = filter_var($_POST['merchant_id'] ?? '', FILTER_VALIDATE_INT);
+ $status = (string)($_POST['status'] ?? '');
+ $page = filter_var($_POST['page'] ?? '', FILTER_VALIDATE_INT) ?: 1;
+
+ if (!$merchantId || !$merchants->setStatus($merchantId, $status)) {
+ redirect('admin/dashboard', ['page' => $page, 'error' => 'loja_invalida']);
+ }
+ redirect('admin/dashboard', ['page' => $page, 'success' => $status === 'active' ? 'loja_ativada' : 'loja_desativada']);
+ }
+
+ $paginator = new Paginator($merchants->countAll(), $_GET['page'] ?? 1, self::PER_PAGE);
+ View::render('admin/dashboard', [
+ 'merchants' => $merchants->pageForAdmin($paginator->perPage, $paginator->offset()),
+ 'paginator' => $paginator,
+ ]);
+ }
+
+ // barra quem nao e admin logado; a cada requisicao confere se o admin existe, se a sessao nao
+ // ficou parada demais e se a senha nao mudou desde o login
+ private function adminGuard(): int {
+ if (!isset($_SESSION['admin_id'])) {
+ redirect('admin/login', ['error' => 'sessao_expirada']);
+ }
+
+ $admin = $this->admins->findById((int)$_SESSION['admin_id']);
+ if (!$admin
+ || SessionGuard::isExpired($_SESSION['admin_last_seen'] ?? null, time(), self::IDLE_SECONDS)
+ || SessionGuard::passwordChanged($_SESSION['admin_sig'] ?? null, $admin['password_hash'])) {
+ SessionGuard::destroy();
+ redirect('admin/login', ['error' => 'sessao_expirada']);
+ }
+
+ $_SESSION['admin_last_seen'] = time();
+ return (int)$admin['id'];
+ }
+}
diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php
index 7be1792..8ceb1a7 100644
--- a/src/Controllers/MerchantController.php
+++ b/src/Controllers/MerchantController.php
@@ -3,8 +3,12 @@
namespace App\Controllers;
use App\Models\CustomerModel;
+use App\Models\EmailVerificationModel;
use App\Models\LoyaltyCardModel;
+use App\Mail\MailerFactory;
+use App\Mail\Message;
use App\Models\MerchantModel;
+use App\Models\PasswordResetModel;
use App\Models\PointsLogModel;
use App\Models\RewardModel;
use App\Support\Csrf;
@@ -56,6 +60,12 @@ class MerchantController {
// senha atual errada na troca de senha: mesmo limite do login, contado por conta
private const MAX_PASSWORD_FAILURES = 5;
private const PASSWORD_WINDOW_SECONDS = 900;
+ // pedidos de link de senha (task 21): por ip (quem testa varios e-mails) e por e-mail (nao lotar a caixa de ninguem)
+ private const MAX_RESET_REQUESTS_PER_IP = 10;
+ private const MAX_RESET_REQUESTS_PER_EMAIL = 3;
+ private const RESET_WINDOW_SECONDS = 3600;
+ // reenvio do link de confirmacao do e-mail (task 50), por conta
+ private const MAX_VERIFY_RESENDS = 3;
private $db;
private $merchantModel;
@@ -97,6 +107,99 @@ public function renderLogin() {
View::render('auth/merchant/login');
}
+ // esqueci a senha (task 21): pede o e-mail e manda o link. a resposta e sempre a mesma, exista
+ // o e-mail ou nao: a tela nao revela quais e-mails sao lojas.
+ public function renderForgot() {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->handleForgot();
+ return;
+ }
+
+ View::render('auth/merchant/forgot');
+ }
+
+ private function handleForgot() {
+ Csrf::verify();
+
+ $email = trim((string)($_POST['email'] ?? ''));
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ redirect('merchant/forgot', ['error' => 'email_invalido']);
+ }
+
+ $limiter = new RateLimiter($this->db);
+ $ip = RateLimiter::clientKey();
+ if ($limiter->tooMany('password_reset_ip', $ip, self::MAX_RESET_REQUESTS_PER_IP, self::RESET_WINDOW_SECONDS)
+ || $limiter->tooMany('password_reset_email', $email, self::MAX_RESET_REQUESTS_PER_EMAIL, self::RESET_WINDOW_SECONDS)) {
+ redirect('merchant/forgot', ['error' => 'muitos_pedidos']);
+ }
+ $limiter->hit('password_reset_ip', $ip);
+ $limiter->hit('password_reset_email', $email);
+
+ // conta desativada nao recebe link (nao conseguiria entrar mesmo)
+ $merchant = $this->merchantModel->findByEmail($email);
+ if ($merchant && $merchant['status'] === 'active') {
+ $token = (new PasswordResetModel($this->db))->create((int)$merchant['id']);
+ $link = $this->publicBaseUrl() . url('merchant/reset', ['token' => $token]);
+ MailerFactory::fromEnv()->send(new Message(
+ $email,
+ 'FidelityX: criar uma nova senha',
+ "Olá, {$merchant['owner_name']}.\n\n"
+ . "Recebemos um pedido para criar uma nova senha no painel da loja {$merchant['store_name']}.\n"
+ . "Abra o link abaixo em até 1 hora (ele só funciona uma vez):\n\n$link\n\n"
+ . "Se não foi você, ignore este e-mail: a senha atual continua valendo."
+ ));
+ }
+
+ redirect('merchant/forgot', ['success' => 'reset_enviado']);
+ }
+
+ // link do e-mail: escolhe a senha nova. o token vem na url (GET) e volta num campo escondido (POST).
+ public function renderReset() {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $this->handleReset();
+ return;
+ }
+
+ $token = (string)($_GET['token'] ?? '');
+ if (!(new PasswordResetModel($this->db))->isValid($token)) {
+ redirect('merchant/forgot', ['error' => 'link_invalido']);
+ }
+
+ View::render('auth/merchant/reset', [
+ 'token' => $token,
+ 'minPassword' => PasswordPolicy::MIN_BYTES,
+ 'maxPassword' => PasswordPolicy::MAX_BYTES,
+ ]);
+ }
+
+ private function handleReset() {
+ Csrf::verify();
+
+ $token = (string)($_POST['token'] ?? '');
+ $new = (string)($_POST['new_password'] ?? '');
+ $confirm = (string)($_POST['new_password_confirm'] ?? '');
+ $resets = new PasswordResetModel($this->db);
+
+ if (!$resets->isValid($token)) {
+ redirect('merchant/forgot', ['error' => 'link_invalido']);
+ }
+
+ $passwordProblem = PasswordPolicy::problem($new);
+ if ($passwordProblem !== null) {
+ redirect('merchant/reset', ['token' => $token, 'error' => $passwordProblem]);
+ }
+ if (!hash_equals($new, $confirm)) {
+ redirect('merchant/reset', ['token' => $token, 'error' => 'senhas_diferentes']);
+ }
+
+ // a senha nova derruba as sessoes abertas com a antiga (password_sig, ver authGuard)
+ if ($resets->resetPassword($token, password_hash($new, PASSWORD_BCRYPT)) === null) {
+ redirect('merchant/forgot', ['error' => 'link_invalido']);
+ }
+
+ redirect('merchant/login', ['success' => 'senha_redefinida']);
+ }
+
// RENDERS (privadas, exigem login) -----------------------------------
// home do lojista: um campo de telefone que decide o destino.
@@ -550,9 +653,7 @@ public function handleRegister(){
];
$this->merchantModel->create($data);
-
- // se deu certo, redireciona usuario para o login
- redirect('merchant/login', ['success' => 'cadastrado']);
+ $merchantId = (int)$this->db->lastInsertId();
} catch (\PDOException $e) {
$sqlState = $e->errorInfo[0] ?? null;
$driverCode = (int)($e->errorInfo[1] ?? 0);
@@ -588,6 +689,59 @@ public function handleRegister(){
redirect('merchant/register', ['error' => 'erro_servidor']);
}
+ // conta nova so usa o painel depois de confirmar o e-mail (task 50)
+ $this->sendEmailVerification($merchantId, $email, $owner_name);
+
+ // se deu certo, redireciona usuario para o login
+ redirect('merchant/login', ['success' => 'cadastrado']);
+ }
+
+ // manda (ou reenvia) o link de confirmacao do e-mail; o anterior deixa de valer
+ private function sendEmailVerification(int $merchantId, string $email, string $ownerName): void {
+ $token = (new EmailVerificationModel($this->db))->create($merchantId);
+ $link = $this->publicBaseUrl() . url('merchant/verify-email', ['token' => $token]);
+ MailerFactory::fromEnv()->send(new Message(
+ $email,
+ 'FidelityX: confirme seu e-mail',
+ "Olá, $ownerName.\n\n"
+ . "Para começar a usar o painel do FidelityX, confirme que este e-mail é seu abrindo o link abaixo\n"
+ . "(ele vale por 24 horas):\n\n$link\n\n"
+ . "Se você não criou uma conta no FidelityX, ignore este e-mail."
+ ));
+ }
+
+ // tela de quem entrou mas ainda nao confirmou o e-mail: explica e reenvia o link (task 50)
+ public function renderConfirmEmail() {
+ $merchantId = $this->authGuard(true);
+ if ($this->merchant['email_verified_at'] !== null) {
+ redirect('merchant/dashboard');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ Csrf::verify();
+ $limiter = new RateLimiter($this->db);
+ $key = 'merchant:' . $merchantId;
+ if ($limiter->tooMany('email_verify_resend', $key, self::MAX_VERIFY_RESENDS, self::RESET_WINDOW_SECONDS)) {
+ redirect('merchant/confirm-email', ['error' => 'muitos_pedidos']);
+ }
+ $limiter->hit('email_verify_resend', $key);
+ $this->sendEmailVerification($merchantId, $this->merchant['email'], $this->merchant['owner_name']);
+ redirect('merchant/confirm-email', ['success' => 'confirmacao_reenviada']);
+ }
+
+ View::render('merchant/confirm-email', ['email' => $this->merchant['email']]);
+ }
+
+ // link do e-mail de confirmacao (nao exige login: pode ser aberto no celular, fora da sessao do painel)
+ public function renderVerifyEmail() {
+ $verified = (new EmailVerificationModel($this->db))->verify((string)($_GET['token'] ?? ''));
+ $logged = isset($_SESSION['merchant_id']);
+
+ if ($verified === null) {
+ redirect($logged ? 'merchant/confirm-email' : 'merchant/login', ['error' => 'confirmacao_invalida']);
+ }
+
+ redirect($logged ? 'merchant/dashboard' : 'merchant/login', ['success' => 'email_confirmado']);
}
public function handleLogin(){
@@ -973,7 +1127,8 @@ private function readRewardFields(): ?array {
// o id SEMPRE vem da sessao, nunca do formulario.
// a cada requisicao: sessao parada demais expira, e a conta e conferida no banco
// (lojista desativado perde o acesso na hora, nao so no proximo login).
- private function authGuard(): int {
+ // $allowUnverified: so a tela de confirmar o e-mail aceita conta que ainda nao confirmou (task 50)
+ private function authGuard(bool $allowUnverified = false): int {
if(!isset($_SESSION['merchant_id'])){
redirect('merchant/login', ['error' => 'sessao_expirada']);
}
@@ -998,6 +1153,11 @@ private function authGuard(): int {
// a loja da requisicao fica a mao (ex.: regra de pontos), sem consultar o banco de novo
$this->merchant = $merchant;
+ // conta nova que ainda nao confirmou o e-mail so ve a tela de confirmar
+ if (!$allowUnverified && $merchant['email_verified_at'] === null) {
+ redirect('merchant/confirm-email');
+ }
+
// nome da loja sempre atualizado no menu (pode ter mudado desde o login)
$_SESSION['last_seen'] = time();
$_SESSION['merchant_name'] = $merchant['owner_name'];
diff --git a/src/Mail/LogMailer.php b/src/Mail/LogMailer.php
new file mode 100644
index 0000000..4af3e8d
--- /dev/null
+++ b/src/Mail/LogMailer.php
@@ -0,0 +1,38 @@
+ date('c'),
+ 'to' => $message->to,
+ 'subject' => $message->subject,
+ 'body' => $message->body,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
+
+ $dir = dirname($this->file);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0750, true);
+ }
+ if (@file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX) === false) {
+ error_log('[mail] nao deu pra gravar em MAIL_LOG_FILE: ' . $this->file);
+ return false;
+ }
+ return true;
+ }
+
+ // mensagens gravadas no arquivo, da mais antiga pra mais nova (usado pelos testes)
+ public static function read(string $file): array {
+ $messages = [];
+ foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
+ $messages[] = json_decode($line, true);
+ }
+ return $messages;
+ }
+}
diff --git a/src/Mail/Mailer.php b/src/Mail/Mailer.php
new file mode 100644
index 0000000..8533aac
--- /dev/null
+++ b/src/Mail/Mailer.php
@@ -0,0 +1,9 @@
+ new LogMailer(Env::get('MAIL_LOG_FILE', dirname(__DIR__, 2) . '/storage/mail.log')),
+ default => new NullMailer(),
+ };
+ }
+}
diff --git a/src/Mail/Message.php b/src/Mail/Message.php
new file mode 100644
index 0000000..14f1176
--- /dev/null
+++ b/src/Mail/Message.php
@@ -0,0 +1,13 @@
+subject,
+ substr(hash('sha256', mb_strtolower(trim($message->to))), 0, 12)
+ ));
+ return false;
+ }
+}
diff --git a/src/Models/AdminModel.php b/src/Models/AdminModel.php
new file mode 100644
index 0000000..cd48203
--- /dev/null
+++ b/src/Models/AdminModel.php
@@ -0,0 +1,52 @@
+db = $db;
+ }
+
+ public function findByEmail(string $email) {
+ $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE email = :email');
+ $stmt->execute([':email' => $email]);
+ return $stmt->fetch(\PDO::FETCH_ASSOC);
+ }
+
+ public function findById(int $id) {
+ $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE id = :id');
+ $stmt->execute([':id' => $id]);
+ return $stmt->fetch(\PDO::FETCH_ASSOC);
+ }
+
+ // o que esta errado nos dados de um admin novo (texto pra linha de comando) ou null se esta tudo certo
+ public static function problem(string $email, string $name, string $password): ?string {
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 255) {
+ return 'e-mail invalido';
+ }
+ if (trim($name) === '' || mb_strlen($name) > 255) {
+ return 'informe o nome (ate 255 caracteres)';
+ }
+ if (PasswordPolicy::problem($password) !== null) {
+ return 'a senha precisa ter de ' . PasswordPolicy::MIN_BYTES . ' a ' . PasswordPolicy::MAX_BYTES . ' bytes';
+ }
+ return null;
+ }
+
+ // cria o admin e devolve o id. e-mail repetido sobe como PDOException (UNIQUE).
+ public function create(string $email, string $name, string $password): int {
+ $problem = self::problem($email, $name, $password);
+ if ($problem !== null) {
+ throw new \InvalidArgumentException($problem);
+ }
+
+ $stmt = $this->db->prepare('INSERT INTO admins (email, name, password_hash) VALUES (:email, :name, :hash)');
+ $stmt->execute([':email' => $email, ':name' => trim($name), ':hash' => password_hash($password, PASSWORD_BCRYPT)]);
+ return (int)$this->db->lastInsertId();
+ }
+}
diff --git a/src/Models/EmailVerificationModel.php b/src/Models/EmailVerificationModel.php
new file mode 100644
index 0000000..b5a6d5a
--- /dev/null
+++ b/src/Models/EmailVerificationModel.php
@@ -0,0 +1,17 @@
+consume($token, function (int $merchantId) {
+ $this->db->prepare('UPDATE merchants SET email_verified_at = CURRENT_TIMESTAMP WHERE id = :id AND email_verified_at IS NULL')
+ ->execute([':id' => $merchantId]);
+ });
+ }
+}
diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php
index bb530a7..04dfd15 100644
--- a/src/Models/MerchantModel.php
+++ b/src/Models/MerchantModel.php
@@ -42,7 +42,7 @@ public function findByEmail($email){
// dados que o authGuard confere a cada requisicao (o hash da senha serve pra derrubar
// as sessoes antigas quando a senha e trocada, ver SessionGuard::passwordSignature)
public function findById($merchantId) {
- $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status, points_rule_cents, password_hash FROM merchants WHERE id = :id');
+ $stmt = $this->db->prepare('SELECT id, owner_name, store_name, email, email_verified_at, status, points_rule_cents, password_hash FROM merchants WHERE id = :id');
$stmt->execute([':id' => $merchantId]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
@@ -107,4 +107,30 @@ public function findActiveByPublicCode(string $code) {
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
+
+ // painel administrativo (task 30): as lojas, da mais nova pra mais antiga, com o numero de clientes.
+ // so dados da loja: nada de cliente (nome, telefone) sai daqui.
+ public function countAll(): int {
+ return (int)$this->db->query('SELECT COUNT(*) FROM merchants')->fetchColumn();
+ }
+
+ public function pageForAdmin(int $limit, int $offset): array {
+ $sql = 'SELECT m.id, m.store_name, m.owner_name, m.email, m.cpf, m.cnpj, m.status, m.created_at,
+ (SELECT COUNT(*) FROM loyalty_cards lc WHERE lc.merchant_id = m.id AND lc.anonymized_at IS NULL) AS customers
+ FROM merchants m
+ ORDER BY m.created_at DESC, m.id DESC
+ LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset;
+ return $this->db->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ // ativa ou desativa a loja. desativada perde o acesso na hora (o authGuard confere o status a cada requisicao).
+ // devolve false se a loja nao existe.
+ public function setStatus(int $merchantId, string $status): bool {
+ if (!in_array($status, ['active', 'inactive'], true)) {
+ return false;
+ }
+ $stmt = $this->db->prepare('UPDATE merchants SET status = :status WHERE id = :id');
+ $stmt->execute([':status' => $status, ':id' => $merchantId]);
+ return $stmt->rowCount() > 0 || $this->findById($merchantId) !== false;
+ }
}
diff --git a/src/Models/MerchantTokenModel.php b/src/Models/MerchantTokenModel.php
new file mode 100644
index 0000000..087d960
--- /dev/null
+++ b/src/Models/MerchantTokenModel.php
@@ -0,0 +1,91 @@
+db = $db;
+ }
+
+ public static function hash(string $token): string {
+ return hash('sha256', $token);
+ }
+
+ // cria um link novo e devolve o token (o que vai no e-mail). os anteriores ainda abertos deixam de valer.
+ public function create(int $merchantId): string {
+ $token = bin2hex(random_bytes(32));
+
+ $this->db->beginTransaction();
+ try {
+ $this->cancelOpen($merchantId);
+ $this->db->prepare(
+ 'INSERT INTO ' . static::TABLE . ' (merchant_id, token_hash, expires_at)
+ VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . static::TTL_SECONDS . ' SECOND)'
+ )->execute([':merchant_id' => $merchantId, ':token_hash' => self::hash($token)]);
+ $this->db->commit();
+ } catch (\Throwable $e) {
+ $this->db->rollBack();
+ throw $e;
+ }
+
+ return $token;
+ }
+
+ // o link ainda vale (existe, nao foi usado, nao venceu e a conta esta ativa)
+ public function isValid(string $token): bool {
+ return $token !== '' && $this->findValid(self::hash($token), false) !== false;
+ }
+
+ // usa o link, tudo ou nada: $apply recebe o id do lojista e faz a mudanca; depois o link (e qualquer
+ // outro aberto da conta) deixa de valer. FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao
+ // aplicam duas vezes. devolve o id do lojista ou null se o link nao vale.
+ protected function consume(string $token, callable $apply): ?int {
+ if ($token === '') {
+ return null;
+ }
+
+ $this->db->beginTransaction();
+ try {
+ $merchantId = $this->findValid(self::hash($token), true);
+ if ($merchantId === false) {
+ $this->db->rollBack();
+ return null;
+ }
+
+ $apply((int)$merchantId);
+ $this->cancelOpen((int)$merchantId);
+
+ $this->db->commit();
+ return (int)$merchantId;
+ } catch (\Throwable $e) {
+ $this->db->rollBack();
+ throw $e;
+ }
+ }
+
+ private function cancelOpen(int $merchantId): void {
+ $this->db->prepare('UPDATE ' . static::TABLE . ' SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL')
+ ->execute([':id' => $merchantId]);
+ }
+
+ // id do lojista dono do link valido, ou false
+ private function findValid(string $tokenHash, bool $lock) {
+ $stmt = $this->db->prepare(
+ 'SELECT t.merchant_id FROM ' . static::TABLE . " t
+ JOIN merchants m ON m.id = t.merchant_id
+ WHERE t.token_hash = :token_hash AND t.used_at IS NULL AND t.expires_at > NOW()
+ AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '')
+ );
+ $stmt->execute([':token_hash' => $tokenHash]);
+ return $stmt->fetchColumn();
+ }
+}
diff --git a/src/Models/PasswordResetModel.php b/src/Models/PasswordResetModel.php
new file mode 100644
index 0000000..108578a
--- /dev/null
+++ b/src/Models/PasswordResetModel.php
@@ -0,0 +1,17 @@
+consume($token, function (int $merchantId) use ($passwordHash) {
+ $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id')
+ ->execute([':hash' => $passwordHash, ':id' => $merchantId]);
+ });
+ }
+}
diff --git a/src/Support/Env.php b/src/Support/Env.php
index d980fcd..1a61d42 100644
--- a/src/Support/Env.php
+++ b/src/Support/Env.php
@@ -12,6 +12,7 @@ final class Env {
'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS',
'APP_URL', 'APP_TIMEZONE', 'TRUSTED_PROXIES', 'LOG_FILE',
'BACKUP_DIR', 'BACKUP_KEEP_DAYS', 'MYSQLDUMP_BIN',
+ 'MAIL_DRIVER', 'MAIL_LOG_FILE',
];
// sem estas o banco nao conecta (DB_PASS pode ser vazia, DB_PORT tem padrao)
diff --git a/tests/Feature/AdminTest.php b/tests/Feature/AdminTest.php
new file mode 100644
index 0000000..ced5348
--- /dev/null
+++ b/tests/Feature/AdminTest.php
@@ -0,0 +1,158 @@
+db))->create($email, 'Admin Teste', $password);
+ }
+
+ private function loginAdmin(string $email = 'admin@fidelityx.test', string $password = 'admin-senha-1'): ?string {
+ return $this->post('admin/login', ['email' => $email, 'password' => $password])[1];
+ }
+
+ private function merchantStatus(int $merchantId): string {
+ return (string)$this->scalar('SELECT status FROM merchants WHERE id = ?', [$merchantId]);
+ }
+
+ public function testPainelExigeLoginDeAdmin(): void {
+ [$status, $location] = $this->get('admin/dashboard');
+ $this->assertSame(302, $status);
+ $this->assertSame('admin/login&error=sessao_expirada', $location);
+ $this->assertSame('no-store', $this->lastHeaders['cache-control'][0]);
+
+ // lojista logado nao e admin
+ $this->createMerchant('loja@teste.test');
+ $this->loginAs('loja@teste.test');
+ $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]);
+ }
+
+ public function testLoginDeAdmin(): void {
+ $this->createAdmin();
+ // senha de lojista nao vale no admin (outra tabela)
+ $this->createMerchant('admin@fidelityx.test');
+
+ $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'teste123'));
+ $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('ninguem@fidelityx.test'));
+ $this->assertSame('admin/dashboard', $this->loginAdmin());
+ $this->assertSame(200, $this->get('admin/dashboard')[0]);
+ $this->assertStringContainsString('Administração · Admin Teste', $this->lastBody);
+ }
+
+ public function testListaAsLojasSemDadosDosClientes(): void {
+ $this->createAdmin();
+ $loja = $this->createMerchant('loja@teste.test');
+ $this->createCard($loja, 'Bia Cliente', '11922220002');
+ $this->createCard($loja, 'Caio Cliente', '11933330003');
+ $this->createMerchant('outra@teste.test');
+
+ $this->loginAdmin();
+ $this->get('admin/dashboard');
+
+ $this->assertStringContainsString('2 lojas', $this->lastBody);
+ $this->assertStringContainsString('loja@teste.test', $this->lastBody);
+ $this->assertStringContainsString('outra@teste.test', $this->lastBody);
+ $this->assertMatchesRegularExpression('#loja@teste\.test\s*
2
#', $this->lastBody);
+ $this->assertStringNotContainsString('Bia Cliente', $this->lastBody);
+ $this->assertStringNotContainsString('92222', $this->lastBody);
+ }
+
+ public function testDesativarDerrubaOLojistaNaHoraEAtivarDevolveOAcesso(): void {
+ $this->createAdmin();
+ $loja = $this->createMerchant('loja@teste.test');
+
+ // o lojista esta logado em outro aparelho
+ $this->loginAs('loja@teste.test');
+ $merchantSession = $this->cookie('PHPSESSID');
+
+ $this->newSession();
+ $this->loginAdmin();
+ [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive', 'page' => 1]);
+ $this->assertSame('admin/dashboard&page=1&success=loja_desativada', $location);
+ $this->assertSame('inactive', $this->merchantStatus($loja));
+
+ $this->newSession();
+ $this->setCookie('PHPSESSID', (string)$merchantSession);
+ $this->assertSame('merchant/login&error=conta_inativa', $this->get('merchant/dashboard')[1]);
+
+ $this->newSession();
+ $this->loginAdmin();
+ [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'active', 'page' => 1]);
+ $this->assertSame('admin/dashboard&page=1&success=loja_ativada', $location);
+ $this->newSession();
+ $this->loginAs('loja@teste.test');
+ }
+
+ public function testAcaoInvalidaOuSemCsrfNaoMudaNada(): void {
+ $this->createAdmin();
+ $loja = $this->createMerchant('loja@teste.test');
+ $this->loginAdmin();
+
+ $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'apagada'])[1]);
+ $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => 999999, 'status' => 'inactive'])[1]);
+
+ [$status] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive'], false);
+ $this->assertSame(403, $status);
+ $this->assertSame('active', $this->merchantStatus($loja));
+ }
+
+ public function testSessaoDeAdminCaiQuandoASenhaMudaENoLogout(): void {
+ $id = $this->createAdmin();
+ $this->loginAdmin();
+
+ [, $location] = $this->post('admin/logout', [], true, 'admin/dashboard');
+ $this->assertSame('admin/login&success=logout', $location);
+ $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]);
+
+ $this->loginAdmin();
+ $this->db->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([password_hash('outra-senha-1', PASSWORD_BCRYPT), $id]);
+ $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]);
+ }
+
+ public function testErrosDemaisNoLoginDeAdminDao429(): void {
+ $this->createAdmin();
+ for ($i = 0; $i < 5; $i++) {
+ $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'errada-123'));
+ }
+ [$status] = $this->post('admin/login', ['email' => 'admin@fidelityx.test', 'password' => 'admin-senha-1']);
+ $this->assertSame(429, $status);
+ }
+
+ // o script de linha de comando cria o admin (aqui, no banco de teste)
+ public function testScriptCriaOAdmin(): void {
+ $env = getenv();
+ foreach (['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS'] as $key) {
+ $env[$key] = (string)($_ENV[$key] ?? '');
+ }
+ $run = function (array $args, string $input) use ($env): array {
+ $process = proc_open(
+ array_merge([PHP_BINARY, dirname(__DIR__, 2) . '/bin/create-admin.php'], $args),
+ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $pipes,
+ null,
+ $env
+ );
+ fwrite($pipes[0], $input);
+ fclose($pipes[0]);
+ $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
+ return [proc_close($process), $out];
+ };
+
+ [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "curta\n");
+ $this->assertSame(1, $code);
+ $this->assertStringContainsString('a senha precisa ter de 8 a 72 bytes', $out);
+
+ [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n");
+ $this->assertSame(0, $code, $out);
+ $this->assertSame('Nova Admin', $this->scalar("SELECT name FROM admins WHERE email = 'novo@fidelityx.test'"));
+ $this->assertSame('admin/dashboard', $this->loginAdmin('novo@fidelityx.test', 'senha-forte-1'));
+
+ [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n");
+ $this->assertSame(1, $code);
+ $this->assertStringContainsString('ja existe admin com esse e-mail', $out);
+ }
+}
diff --git a/tests/Feature/BalcaoFlowTest.php b/tests/Feature/BalcaoFlowTest.php
index 7da6958..d6c8e03 100644
--- a/tests/Feature/BalcaoFlowTest.php
+++ b/tests/Feature/BalcaoFlowTest.php
@@ -16,6 +16,7 @@ public function testFluxoCompletoDoCadastroDaLojaAoResgate(): void {
]);
$this->assertSame('merchant/login&success=cadastrado', $location);
$this->loginAs('padaria@teste.test');
+ $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test'));
// premio
[, $location] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 20]);
diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php
new file mode 100644
index 0000000..b5e3d5c
--- /dev/null
+++ b/tests/Feature/EmailVerificationTest.php
@@ -0,0 +1,113 @@
+post('merchant/register', [
+ 'owner_name' => 'Dona Ana', 'shop_name' => 'Padaria da Ana', 'address' => 'Rua 1',
+ 'state' => 'SP', 'city' => 'Sao Paulo', 'email' => $email,
+ 'phone' => '11988887777', 'category' => 'alimentacao', 'document' => '52998224725',
+ 'password' => 'teste123', 'password_confirm' => 'teste123',
+ ]);
+ $this->assertSame('merchant/login&success=cadastrado', $location);
+ }
+
+ private function lastToken(): string {
+ $mails = $this->sentMails();
+ preg_match('#verify-email&token=([a-f0-9]{64})#', (string)end($mails)['body'], $m);
+ $this->assertNotEmpty($m, 'sem link de confirmacao');
+ return $m[1];
+ }
+
+ public function testCadastroMandaOLinkEOPainelEsperaAConfirmacao(): void {
+ $this->register();
+
+ $mails = $this->sentMails();
+ $this->assertCount(1, $mails);
+ $this->assertSame('padaria@teste.test', $mails[0]['to']);
+ $this->assertStringContainsString('24 horas', $mails[0]['body']);
+ $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants'));
+
+ // entra, mas toda tela do painel leva pra confirmacao
+ $this->loginAs('padaria@teste.test');
+ foreach (['merchant/dashboard', 'merchant/customers', 'merchant/rewards', 'merchant/profile', 'merchant/export&tipo=clientes'] as $route) {
+ [$status, $location] = $this->get($route);
+ $this->assertSame(302, $status, $route);
+ $this->assertSame('merchant/confirm-email', $location, $route);
+ }
+ [$status] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 10], true, 'merchant/confirm-email');
+ $this->assertSame(302, $status);
+ $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM rewards'));
+
+ $this->get('merchant/confirm-email');
+ $this->assertStringContainsString('padaria@teste.test', $this->lastBody);
+
+ // confirma pelo link (logado: volta pro painel)
+ $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test'));
+ $this->assertNotNull($this->scalar('SELECT email_verified_at FROM merchants'));
+ $this->assertSame(200, $this->get('merchant/dashboard')[0]);
+ [, $location] = $this->get('merchant/confirm-email');
+ $this->assertSame('merchant/dashboard', $location, 'ja confirmado nao ve mais a tela');
+ }
+
+ public function testLinkAbertoSemLoginConfirmaEUmaVezSo(): void {
+ $this->register();
+ $token = $this->lastToken();
+
+ $this->newSession();
+ [, $location] = $this->get('merchant/verify-email&token=' . $token);
+ $this->assertSame('merchant/login&success=email_confirmado', $location);
+ [, $location] = $this->get('merchant/verify-email&token=' . $token);
+ $this->assertSame('merchant/login&error=confirmacao_invalida', $location);
+ }
+
+ public function testLinkVencidoOuInventadoNaoConfirma(): void {
+ $this->register();
+ $token = $this->lastToken();
+
+ $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . str_repeat('a', 64))[1]);
+ $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email')[1]);
+
+ $this->db->exec('UPDATE email_verifications SET expires_at = NOW() - INTERVAL 1 SECOND');
+ $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $token)[1]);
+ $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants'));
+ }
+
+ public function testReenviarCancelaOLinkAnteriorETemLimite(): void {
+ $this->register();
+ $first = $this->lastToken();
+ $this->loginAs('padaria@teste.test');
+
+ [, $location] = $this->post('merchant/confirm-email', []);
+ $this->assertSame('merchant/confirm-email&success=confirmacao_reenviada', $location);
+ $second = $this->lastToken();
+ $this->assertNotSame($first, $second);
+ $this->assertSame('merchant/confirm-email&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $first)[1]);
+
+ $this->post('merchant/confirm-email', []);
+ $this->post('merchant/confirm-email', []);
+ [, $location] = $this->post('merchant/confirm-email', []);
+ $this->assertSame('merchant/confirm-email&error=muitos_pedidos', $location);
+ $this->assertCount(4, $this->sentMails(), 'cadastro + 3 reenvios');
+
+ [$status] = $this->post('merchant/confirm-email', [], false);
+ $this->assertSame(403, $status, 'reenvio exige csrf');
+ }
+
+ // contas criadas antes da regra (e as de teste, criadas direto no banco) ja contam como confirmadas
+ public function testContaJaConfirmadaUsaOPainelNormalmente(): void {
+ $this->createMerchant('loja@teste.test');
+ $this->loginAs('loja@teste.test');
+ $this->assertSame(200, $this->get('merchant/dashboard')[0]);
+ $this->assertSame([], $this->sentMails());
+ }
+
+ public function testMigration009MarcaContasExistentesComoConfirmadas(): void {
+ $sql = (string)file_get_contents(dirname(__DIR__, 2) . '/database/migrations/009_email_verifications.sql');
+ $this->assertStringContainsString('UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL', $sql);
+ }
+}
diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php
new file mode 100644
index 0000000..f3702a5
--- /dev/null
+++ b/tests/Feature/PasswordResetTest.php
@@ -0,0 +1,147 @@
+post('merchant/forgot', ['email' => $email])[1];
+ }
+
+ // token do link do ultimo e-mail enviado
+ private function tokenFromMail(): string {
+ $mails = $this->sentMails();
+ $this->assertNotEmpty($mails, 'nenhum e-mail enviado');
+ $this->assertMatchesRegularExpression('#http://127\.0\.0\.1:\d+/index\.php\?url=merchant%2Freset&token=([a-f0-9]{64})#', end($mails)['body']);
+ preg_match('#token=([a-f0-9]{64})#', end($mails)['body'], $m);
+ return $m[1];
+ }
+
+ private function reset(string $token, string $password, ?string $confirm = null): ?string {
+ return $this->post(
+ 'merchant/reset',
+ ['token' => $token, 'new_password' => $password, 'new_password_confirm' => $confirm ?? $password],
+ true,
+ 'merchant/reset&token=' . $token
+ )[1];
+ }
+
+ public function testLoginTemOLinkDeEsqueciASenha(): void {
+ $this->get('merchant/login');
+ $this->assertStringContainsString('merchant%2Fforgot', $this->lastBody);
+ $this->assertSame(200, $this->get('merchant/forgot')[0]);
+ }
+
+ public function testFluxoCompletoTrocaASenhaEOLinkSoValeUmaVez(): void {
+ $this->createMerchant('loja@teste.test');
+
+ $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test'));
+ $mail = $this->sentMails()[0];
+ $this->assertSame('loja@teste.test', $mail['to']);
+ $this->assertStringContainsString('1 hora', $mail['body']);
+ $token = $this->tokenFromMail();
+
+ // o banco guarda so o hash do token
+ $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [$token]));
+ $this->assertSame(1, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [hash('sha256', $token)]));
+
+ [$status] = $this->get('merchant/reset&token=' . $token);
+ $this->assertSame(200, $status);
+ $this->assertStringContainsString('name="token" value="' . $token . '"', $this->lastBody);
+
+ $this->assertSame('merchant/reset&token=' . $token . '&error=senha_curta', $this->reset($token, '1234567'));
+ $this->assertSame('merchant/reset&token=' . $token . '&error=senhas_diferentes', $this->reset($token, 'nova-senha-1', 'outra-senha-1'));
+ $this->assertSame('merchant/login&success=senha_redefinida', $this->reset($token, 'nova-senha-1'));
+
+ $this->newSession();
+ [, $location] = $this->post('merchant/login', ['email' => 'loja@teste.test', 'password' => 'teste123']);
+ $this->assertSame('merchant/login&error=credenciais_invalidas', $location);
+ $this->loginAs('loja@teste.test', 'nova-senha-1');
+
+ // de novo: o link ja foi usado
+ $this->newSession();
+ [, $location] = $this->get('merchant/reset&token=' . $token);
+ $this->assertSame('merchant/forgot&error=link_invalido', $location);
+ }
+
+ public function testEmailQueNaoELojaRespondeIgualENaoMandaNada(): void {
+ $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('ninguem@teste.test'));
+ $this->assertSame([], $this->sentMails());
+ $this->assertSame('merchant/forgot&error=email_invalido', $this->requestLink('nao-e-email'));
+ }
+
+ public function testContaDesativadaNaoRecebeLink(): void {
+ $id = $this->createMerchant('loja@teste.test');
+ $this->db->exec("UPDATE merchants SET status = 'inactive' WHERE id = $id");
+
+ $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test'));
+ $this->assertSame([], $this->sentMails());
+ }
+
+ public function testLinkVencidoOuInventadoNaoVale(): void {
+ $this->createMerchant('loja@teste.test');
+ $this->requestLink('loja@teste.test');
+ $token = $this->tokenFromMail();
+
+ foreach (['', 'abc', str_repeat('a', 64)] as $fake) {
+ [, $location] = $this->get('merchant/reset&token=' . $fake);
+ $this->assertSame('merchant/forgot&error=link_invalido', $location, $fake);
+ }
+
+ $this->db->exec('UPDATE password_resets SET expires_at = NOW() - INTERVAL 1 SECOND');
+ [, $location] = $this->get('merchant/reset&token=' . $token);
+ $this->assertSame('merchant/forgot&error=link_invalido', $location);
+
+ // POST direto com o token vencido tambem nao troca
+ $this->get('merchant/forgot');
+ [, $location] = $this->post('merchant/reset', ['token' => $token, 'new_password' => 'nova-senha-1', 'new_password_confirm' => 'nova-senha-1'], true, 'merchant/forgot');
+ $this->assertSame('merchant/forgot&error=link_invalido', $location);
+ $this->loginAs('loja@teste.test');
+ }
+
+ public function testSoOUltimoLinkPedidoVale(): void {
+ $this->createMerchant('loja@teste.test');
+ $this->requestLink('loja@teste.test');
+ $first = $this->tokenFromMail();
+ $this->requestLink('loja@teste.test');
+ $second = $this->tokenFromMail();
+
+ $this->assertNotSame($first, $second);
+ $this->assertSame('merchant/forgot&error=link_invalido', $this->get('merchant/reset&token=' . $first)[1]);
+ $this->assertSame(200, $this->get('merchant/reset&token=' . $second)[0]);
+ }
+
+ // a senha nova derruba quem estava logado com a antiga
+ public function testRedefinirDerrubaAsSessoesAbertas(): void {
+ $this->createMerchant('loja@teste.test');
+ $this->loginAs('loja@teste.test');
+ $logged = $this->cookie('PHPSESSID');
+
+ $this->newSession();
+ $this->requestLink('loja@teste.test');
+ $this->reset($this->tokenFromMail(), 'nova-senha-1');
+
+ $this->newSession();
+ $this->setCookie('PHPSESSID', (string)$logged);
+ [, $location] = $this->get('merchant/dashboard');
+ $this->assertSame('merchant/login&error=sessao_expirada', $location);
+ }
+
+ public function testPedidosDemaisParaOMesmoEmailSaoBarrados(): void {
+ $this->createMerchant('loja@teste.test');
+ for ($i = 0; $i < 3; $i++) {
+ $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test'));
+ }
+ $this->assertSame('merchant/forgot&error=muitos_pedidos', $this->requestLink('loja@teste.test'));
+ $this->assertCount(3, $this->sentMails());
+ }
+
+ public function testPedidoExigeCsrf(): void {
+ $this->createMerchant('loja@teste.test');
+ [$status] = $this->post('merchant/forgot', ['email' => 'loja@teste.test'], false);
+ $this->assertSame(403, $status);
+ $this->assertSame([], $this->sentMails());
+ }
+}
diff --git a/tests/Feature/RegisterTest.php b/tests/Feature/RegisterTest.php
index 207fc68..757163a 100644
--- a/tests/Feature/RegisterTest.php
+++ b/tests/Feature/RegisterTest.php
@@ -35,6 +35,7 @@ public function testCadastraCnpjAlfanumerico(): void {
$this->assertNull($this->scalar('SELECT cpf FROM merchants'));
$this->loginAs('padaria@teste.test');
+ $this->confirmEmailFromMail('padaria@teste.test');
$this->get('merchant/profile');
$this->assertStringContainsString('value="12.ABC.345/01DE-35"', $this->lastBody);
$this->assertStringContainsString('>CNPJ<', $this->lastBody);
diff --git a/tests/Support/DatabaseTestCase.php b/tests/Support/DatabaseTestCase.php
index 9d62e80..ed5fa68 100644
--- a/tests/Support/DatabaseTestCase.php
+++ b/tests/Support/DatabaseTestCase.php
@@ -19,8 +19,8 @@ protected function setUp(): void {
protected function createMerchant(string $email = 'loja@teste.test'): int {
$stmt = $this->db->prepare(
- "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, phone, category, cpf, password_hash)
- VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, '11988887777', 'varejo', :cpf, :hash)"
+ "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, email_verified_at, phone, category, cpf, password_hash)
+ VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, CURRENT_TIMESTAMP, '11988887777', 'varejo', :cpf, :hash)"
);
$stmt->execute([
':code' => strtoupper(substr(md5($email), 0, 8)),
diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php
index c199be6..b7ad01f 100644
--- a/tests/Support/HttpTestCase.php
+++ b/tests/Support/HttpTestCase.php
@@ -9,6 +9,8 @@
abstract class HttpTestCase extends DatabaseTestCase {
private static $server = null;
private static string $baseUrl;
+ // e-mails "enviados" pelo servidor de teste (MAIL_DRIVER=log), uma linha json por mensagem
+ private static string $mailLog;
private string $cookieJar;
protected string $lastBody = '';
// cabecalhos da ultima resposta: nome em minusculas => lista de valores (set-cookie pode vir repetido)
@@ -30,6 +32,10 @@ public static function setUpBeforeClass(): void {
// o teste e o "proxy confiavel" do servidor: com isso um teste pode se passar por clientes de
// ips diferentes mandando X-Forwarded-For (ver fromIp). sem o cabecalho nada muda: vale o 127.0.0.1.
$env['TRUSTED_PROXIES'] = '127.0.0.1';
+ // e-mail transacional vai pra um arquivo que o teste le (sentMails), nunca pra fora
+ self::$mailLog = tempnam(sys_get_temp_dir(), 'fx-mail');
+ $env['MAIL_DRIVER'] = 'log';
+ $env['MAIL_LOG_FILE'] = self::$mailLog;
// comando em array: roda o php direto, sem shell no meio (assim o proc_terminate mata o servidor mesmo)
self::$server = proc_open(
@@ -59,12 +65,14 @@ public static function tearDownAfterClass(): void {
proc_close(self::$server);
self::$server = null;
}
+ @unlink(self::$mailLog);
}
protected function setUp(): void {
parent::setUp();
$this->cookieJar = tempnam(sys_get_temp_dir(), 'fx-cookie');
$this->clientIp = null;
+ file_put_contents(self::$mailLog, '');
$this->extraHeaders = [];
}
@@ -84,6 +92,21 @@ protected function tearDown(): void {
@unlink($this->cookieJar);
}
+ // e-mails que o sistema mandou neste teste: [['to' =>, 'subject' =>, 'body' =>, 'at' =>], ...]
+ protected function sentMails(): array {
+ return \App\Mail\LogMailer::read(self::$mailLog);
+ }
+
+ // abre o link do ultimo e-mail de confirmacao mandado para $email (task 50) e devolve o destino do redirect
+ protected function confirmEmailFromMail(string $email): ?string {
+ foreach (array_reverse($this->sentMails()) as $mail) {
+ if ($mail['to'] === $email && preg_match('#url=merchant%2Fverify-email&token=([a-f0-9]{64})#', $mail['body'], $m)) {
+ return $this->get('merchant/verify-email&token=' . $m[1])[1];
+ }
+ }
+ $this->fail("nenhum e-mail de confirmacao para $email");
+ }
+
// sessao nova (como apagar o cookie no navegador)
protected function newSession(): void {
@unlink($this->cookieJar);
diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php
index 7f45796..33732f5 100644
--- a/tests/Support/TestDatabase.php
+++ b/tests/Support/TestDatabase.php
@@ -7,7 +7,7 @@
// banco de teste: recriado do schema.sql no inicio da rodada e esvaziado antes de cada teste
final class TestDatabase {
// ordem respeita as chaves estrangeiras (filhos antes dos pais)
- private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'merchants', 'rate_limit_hits'];
+ private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'email_verifications', 'merchants', 'rate_limit_hits', 'admins'];
public static function name(): string {
return $_ENV['DB_NAME'];
diff --git a/tests/Unit/MailerTest.php b/tests/Unit/MailerTest.php
new file mode 100644
index 0000000..cd6254e
--- /dev/null
+++ b/tests/Unit/MailerTest.php
@@ -0,0 +1,83 @@
+envBackup = $_ENV;
+ $this->log = sys_get_temp_dir() . '/fx-mailer-' . bin2hex(random_bytes(4)) . '.log';
+ // o que o codigo manda pro error_log vem pra ca (e nao pra saida do teste)
+ $this->previousErrorLog = ini_set('error_log', $this->log . '.err');
+ }
+
+ protected function tearDown(): void {
+ $_ENV = $this->envBackup;
+ ini_set('error_log', (string)$this->previousErrorLog);
+ @unlink($this->log);
+ @unlink($this->log . '.err');
+ }
+
+ private function message(): Message {
+ return new Message('ana@teste.test', 'Redefinir senha', "Abra o link:\nhttps://x/?token=segredo");
+ }
+
+ public function testSemDriverNenhumEmailSaiEOLogNaoTemOConteudo(): void {
+ unset($_ENV['MAIL_DRIVER']);
+ $mailer = MailerFactory::fromEnv();
+
+ $this->assertInstanceOf(NullMailer::class, $mailer);
+ $this->assertFalse($mailer->send($this->message()));
+
+ $errors = (string)file_get_contents($this->log . '.err');
+ $this->assertStringContainsString('[mail] e-mail nao enviado', $errors);
+ $this->assertStringContainsString('Redefinir senha', $errors);
+ $this->assertStringNotContainsString('segredo', $errors);
+ $this->assertStringNotContainsString('ana@teste.test', $errors);
+ }
+
+ public function testDriverDesconhecidoNaoEnvia(): void {
+ $_ENV['MAIL_DRIVER'] = 'smtp';
+ $this->assertInstanceOf(NullMailer::class, MailerFactory::fromEnv());
+ }
+
+ public function testDriverLogGravaUmaLinhaPorMensagem(): void {
+ $_ENV['MAIL_DRIVER'] = 'log';
+ $_ENV['MAIL_LOG_FILE'] = $this->log;
+ $mailer = MailerFactory::fromEnv();
+
+ $this->assertInstanceOf(LogMailer::class, $mailer);
+ $this->assertTrue($mailer->send($this->message()));
+ $this->assertTrue($mailer->send(new Message('bia@teste.test', 'Outro', 'Corpo')));
+
+ $sent = LogMailer::read($this->log);
+ $this->assertCount(2, $sent);
+ $this->assertSame('ana@teste.test', $sent[0]['to']);
+ $this->assertSame('Redefinir senha', $sent[0]['subject']);
+ $this->assertSame("Abra o link:\nhttps://x/?token=segredo", $sent[0]['body']);
+ $this->assertSame('bia@teste.test', $sent[1]['to']);
+ }
+
+ public function testArquivoQueNaoDaPraGravarDevolveFalseEAvisa(): void {
+ // a "pasta" do arquivo e um arquivo comum: nao da pra criar nem gravar
+ file_put_contents($this->log, '');
+ $mailer = new LogMailer($this->log . '/mail.log');
+
+ $this->assertFalse($mailer->send($this->message()));
+ $this->assertStringContainsString('[mail] nao deu pra gravar', (string)file_get_contents($this->log . '.err'));
+ }
+
+ public function testArquivoSemMensagens(): void {
+ $this->assertSame([], LogMailer::read($this->log . '-nao-existe'));
+ }
+}
diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php
new file mode 100644
index 0000000..179c238
--- /dev/null
+++ b/views/admin/dashboard.php
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+ Lojas | Administração FidelityX
+
+
+
+
+
+
+
+
+
+