Docs›Exemplos›Portal do cliente
Exemplos

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.

Nunca reuse 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'));
    }
}
Esse middleware só protege o carregamento inicial (GET)

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'));
}

Próximos