Portal do cliente
Login público + área restrita reativa via MadSitePage.
Um portal de cliente é uma área pública (fora de /app/*)
onde quem faz login não é um usuário do admin — é um cliente. Hoje não existem dois
sistemas de autenticação (um "admin" e um "portal"): existe um único mecanismo
de sessão nativo do Laravel, e cada área decide sua própria chave de sessão e
seu próprio middleware. Este exemplo cobre landing pública, login, área restrita
reativa e download de PDF com verificação de posse — tudo via
Mad\Site\MadSitePage, a mesma base que renderiza este portal de
documentação que você está lendo agora.
session('logged') para clientes
session('logged')/session('userid') são a identidade do
staff, lidas por Mad\Security\PermissionGate e pelo
middleware mad.auth do admin. Um portal de cliente é uma identidade
diferente — use uma chave de sessão própria
(portal_cliente_id abaixo) para nunca misturar as duas. Ver
Auth no portal público
para como a sessão do staff funciona.
Estrutura
app/
├── Models/Cliente.php
├── Http/Middleware/PortalAuthenticate.php # gate da área restrita (GET)
├── Http/Controllers/PortalPedidoPdfController.php
├── Control/Portal/
│ ├── PortalPage.php # base abstrata — boot() resolve $cliente
│ ├── PortalHome.php # landing pública
│ ├── PortalLogin.php # login (form reativo)
│ └── PortalPedidos.php # área restrita — "meus pedidos"
└── resources/views/public/portal/
├── _layout.blade.php
├── home.blade.php
├── login.blade.php
└── pedidos.blade.php
routes/modules/portal.php
1. Model
// app/Models/Cliente.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Cliente extends Model
{
protected $connection = 'business';
protected $table = 'cliente';
protected $fillable = ['nome', 'email', 'password', 'ativo'];
protected $hidden = ['password'];
public static function rules($id = null): array
{
return [
'nome' => 'required|max:200',
'email' => 'required|email|unique:cliente,email,' . ($id ?? 'NULL') . ',id',
];
}
}
2. Rotas
Landing e login ficam fora de qualquer middleware de auth (público de verdade); a área
restrita fica atrás de um middleware dedicado. O endpoint reativo
POST /public/_mad-wire é global e único — já está
registrado em routes/web.php para todo MadSitePage do app
(inclusive este portal e a documentação que você está lendo); não se registra de novo
por módulo.
// routes/modules/portal.php
use Illuminate\Support\Facades\Route;
use App\Control\Portal\PortalHome;
use App\Control\Portal\PortalLogin;
use App\Control\Portal\PortalPedidos;
use App\Http\Middleware\PortalAuthenticate;
// Públicas — sem middleware de auth nenhum.
Route::get('/portal', [PortalHome::class, 'showPage']);
Route::get('/portal/entrar', [PortalLogin::class, 'showPage']);
// Restrita — exige sessão de cliente.
Route::middleware([PortalAuthenticate::class])->group(function () {
Route::get('/portal/pedidos', [PortalPedidos::class, 'showPage']);
});
// routes/web.php — no final, junto dos outros módulos
require base_path('routes/modules/portal.php');
3. Middleware da área restrita
Modelado no mesmo espírito do Mad\Http\Middleware\MadAuthenticate do admin
(checagem de sessão + redirect), mas lendo a chave de sessão própria do portal — nunca
PermissionGate::isLogged(), que é do staff:
// app/Http/Middleware/PortalAuthenticate.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class PortalAuthenticate
{
public function handle(Request $request, Closure $next)
{
if (session('portal_cliente_id')) {
return $next($request);
}
return redirect(site_url('/portal/entrar'));
}
}
POST /public/_mad-wire é o endpoint reativo de todo
MadSitePage do site — ele não tem (nem pode ter) o
PortalAuthenticate aplicado, porque o mesmo endpoint também atende
páginas públicas como esta documentação. Toda ação reativa de uma página
restrita precisa revalidar a sessão sozinha — ver a seção
Defesa em camadas abaixo.
4. Login (MadSitePage reativo)
// app/control/Portal/PortalLogin.php
namespace App\Control\Portal;
use App\Models\Cliente;
use Mad\Form\MadForm;
use Mad\Form\MadValidationException;
use Mad\Http\MadResponse;
use Mad\Site\MadSitePage;
class PortalLogin extends MadSitePage
{
protected static string $siteTitle = 'Entrar — Portal do Cliente';
protected static string $siteLayout = 'public.portal._layout';
public MadForm $form;
public function mount(): void
{
$this->form = new MadForm('form');
}
public function onLogin(): MadResponse
{
try {
$data = $this->form->getData();
$this->form->validate([
'email' => 'required|email',
'password' => 'required',
]);
$cliente = Cliente::where('email', $data->email)->first();
if (!$cliente || !password_verify((string) $data->password, $cliente->password)) {
throw new \Exception('Credenciais inválidas.');
}
if ($cliente->ativo !== '1') {
throw new \Exception('Conta desativada.');
}
session()->regenerate(); session()->save();
session(['portal_cliente_id' => $cliente->id]);
return (new MadResponse())->redirectUrl(site_url('/portal/pedidos'));
} catch (MadValidationException $e) {
return $e->asInline();
} catch (\Throwable $e) {
return \Mad\Ui\MadMessage::error('Não foi possível entrar', $e->getMessage());
}
}
protected function view(): string|array
{
return 'public.portal.login';
}
}
<div style="max-width:400px;margin:60px auto;">
<h1>Entrar</h1>
<mad-form submit="onLogin">
<mad-input-field name="email" label="Email" type="email" required />
<mad-password-field name="password" label="Senha" required />
<mad-form-actions>
<mad-btn type="submit" variant="primary">Entrar</mad-btn>
</mad-form-actions>
</mad-form>
</div>
session()->regenerate() antes de gravar a identidade evita fixação de
sessão (um id de sessão emitido antes do login nunca deve sobreviver ao login). Para
produção, adicione um rate-limit por IP+email no onLogin — o mesmo padrão
que App\Control\Iam\LoginForm usa no admin (RateLimiter nativo
do Laravel), omitido aqui por brevidade.
5. Defesa em camadas — boot(), não mount()
No ciclo reativo do MadComponent, mount() só roda no
carregamento inicial (GET via showPage()); a partir daí, toda ação
(onClick, onSave...) chega via
POST /public/_mad-wire e roda boot() +
hydrate() — mount() não é chamado de novo.
Por isso a sessão do cliente é revalidada em boot() (que roda em toda
requisição, GET e wire), nunca só em mount():
// app/control/Portal/PortalPage.php — base das páginas restritas
namespace App\Control\Portal;
use App\Models\Cliente;
use Mad\Http\MadResponse;
use Mad\Site\MadSitePage;
abstract class PortalPage extends MadSitePage
{
protected ?Cliente $cliente = null;
public function boot(): void
{
$id = session('portal_cliente_id');
$this->cliente = $id ? Cliente::find($id) : null;
}
/** Chame no topo de toda action que devolve dado do cliente logado. */
protected function requireLogin(): ?MadResponse
{
if ($this->cliente) {
return null;
}
return (new MadResponse())->redirectUrl(site_url('/portal/entrar'));
}
}
6. Área restrita — "Meus pedidos"
Sempre filtre pelo cliente_id do cliente logado — nunca
por um id recebido do cliente (URL, parâmetro de ação). Essa é a regra de ouro de
qualquer portal: trocar um id na URL nunca pode expor pedido de outra pessoa.
// app/control/Portal/PortalPedidos.php
namespace App\Control\Portal;
use App\Models\PedidoVenda;
use Mad\Http\MadResponse;
class PortalPedidos extends PortalPage
{
protected static string $siteTitle = 'Meus pedidos — Portal do Cliente';
protected static string $siteLayout = 'public.portal._layout';
public function mount(): void
{
// boot() já populou $this->cliente — se vazio, PortalAuthenticate já
// bloqueou o GET antes de chegar aqui.
}
/** Action reativa — ex.: botão "Atualizar" na página. */
public function onRecarregar(): MadResponse
{
if ($deny = $this->requireLogin()) {
return $deny;
}
return \Mad\Ui\MadToast::success('Atualizado.');
}
/** Builder, não Collection — o <mad-grid :query="..."> pagina sozinho. */
public function queryMeusPedidos(): \Illuminate\Database\Eloquent\Builder
{
// SEGURANÇA: filtro por cliente_id do usuário da SESSÃO, nunca de input.
return PedidoVenda::where('cliente_id', $this->cliente->id)->orderByDesc('id');
}
protected function view(): string|array
{
return ['public.portal.pedidos', ['cliente' => $this->cliente]];
}
}
<h1>Olá, {{ $cliente->nome }}</h1>
<mad-grid model="PedidoVenda" :query="$that->queryMeusPedidos()" per-page="20" no-export>
<mad-columns>
<mad-col field="id" label="Número" width="100" />
<mad-col field="dt_pedido" label="Data" date="d/m/Y" />
<mad-col field="status" label="Status"
badge="aprovado:success:Aprovado|pendente:warning:Pendente|cancelado:danger:Cancelado" />
<mad-col field="valor_total" label="Valor" right money="R$" />
<mad-col field="id" label="" width="60" transform="PortalPedidos::pdfLink" html />
</mad-columns>
</mad-grid>
// PortalPedidos — transform de coluna usado acima (2º arg = stdClass da linha)
public static function pdfLink(mixed $id, object $row): string
{
$url = route('portal.pedidos.pdf', ['id' => $id]);
return '<a href="' . e($url) . '" target="_blank" class="mad-docs-card-arrow">'
. '<i data-lucide="file-text"></i></a>';
}
7. Download de PDF — verificação de posse
O download roda numa rota HTTP comum (não no wire — ver
por quê),
protegida pelo mesmo PortalAuthenticate — e o controller confirma de novo
que o pedido pertence ao cliente da sessão antes de gerar o PDF:
// app/Http/Controllers/PortalPedidoPdfController.php
namespace App\Http\Controllers;
use App\Models\PedidoVenda;
use Illuminate\Http\Response;
use Mad\Doc\MadDocPdf;
class PortalPedidoPdfController extends Controller
{
public function show(int $id): Response
{
$clienteId = session('portal_cliente_id');
$pedido = PedidoVenda::where('id', $id)
->where('cliente_id', $clienteId) // posse — não confia só no {id} da URL
->firstOrFail();
$pdf = MadDocPdf::fromView('pdf.pedido', ['pedido' => $pedido]);
return response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="pedido-' . $pedido->id . '.pdf"',
]);
}
}
// routes/modules/portal.php — dentro do grupo PortalAuthenticate
Route::get('/portal/pedidos/{id}/pdf', [\App\Http\Controllers\PortalPedidoPdfController::class, 'show'])
->name('portal.pedidos.pdf');
8. Logout
// PortalPedidos (ou qualquer página da área restrita)
public function onLogout(): MadResponse
{
session()->forget('portal_cliente_id');
session()->save();
return (new MadResponse())->redirectUrl(site_url('/portal'));
}