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 + + + + + + + + +
+

Lojas

+ + +
+ +

Nenhuma loja cadastrada.

+ +

total ?> total === 1 ? 'loja' : 'lojas' ?>

+
+ + + + + + + + + + + + + + + + + + + +
LojaResponsávelCPF/CNPJE-mailClientesCadastroSituaçãoAções
+
+ + + + + +
+
+
+ + + +
+
+ + + diff --git a/views/admin/login.php b/views/admin/login.php new file mode 100644 index 0000000..1db1814 --- /dev/null +++ b/views/admin/login.php @@ -0,0 +1,43 @@ + + + + + + + + Administração | FidelityX + + + + + + + +
+
+ +

Administração

+
+ + + +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + + + 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.',