diff --git a/CLAUDE.md b/CLAUDE.md
index 09a4079..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.
@@ -54,6 +55,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já
**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.
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/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 3e7dfeb..20309b9 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -229,6 +229,23 @@ CREATE TABLE IF NOT EXISTS email_verifications (
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
-- =========================
@@ -249,4 +266,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES
('006_points_log_extrato'),
('007_customers_so_telefone'),
('008_password_resets'),
- ('009_email_verifications');
+ ('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 6814e92..3b4a7d6 100644
--- a/docs/db/schema-explanation.md
+++ b/docs/db/schema-explanation.md
@@ -97,3 +97,11 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p
### `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 bd411e4..19a656c 100644
--- a/docs/operacao.md
+++ b/docs/operacao.md
@@ -62,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 22af72b..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
diff --git a/public/index.php b/public/index.php
index ff1c718..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);
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/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/MerchantModel.php b/src/Models/MerchantModel.php
index bc95c63..04dfd15 100644
--- a/src/Models/MerchantModel.php
+++ b/src/Models/MerchantModel.php
@@ -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/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/Support/TestDatabase.php b/tests/Support/TestDatabase.php
index ef214b5..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', 'password_resets', 'email_verifications', '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/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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/partials/flash.php b/views/partials/flash.php
index 46f45be..034b6fb 100644
--- a/views/partials/flash.php
+++ b/views/partials/flash.php
@@ -40,6 +40,7 @@
'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.',
'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.',
'senha_atual_incorreta' => 'A senha atual não confere.',
+ 'loja_invalida' => 'Loja não encontrada.',
'confirmacao_invalida' => 'Este link de confirmação é inválido, já foi usado ou venceu. Entre no painel e peça outro.',
'link_invalido' => 'Este link de nova senha é inválido, já foi usado ou venceu. Peça outro.',
'muitos_pedidos' => 'Muitos pedidos seguidos. Aguarde um pouco e tente de novo.',
@@ -48,6 +49,8 @@
'success' => [
'cadastrado' => 'Cadastro realizado! Enviamos um link de confirmação para o seu e-mail. Faça login para continuar.',
'confirmacao_reenviada' => 'Enviamos um novo link de confirmação. O anterior deixou de valer.',
+ 'loja_ativada' => 'Loja ativada. O lojista já pode entrar.',
+ 'loja_desativada' => 'Loja desativada. O lojista perdeu o acesso na hora.',
'email_confirmado' => 'E-mail confirmado. Bem-vindo ao FidelityX!',
'logged' => 'Bem-vindo de volta!',
'logout' => 'Você saiu da sua conta.',