Uttara UI · Contrato de autenticação
AuthAdapter
O Uttara UI não embute nenhum SDK de autenticação. Em vez disso, define o contrato AuthAdapter que sua app implementa. O encaixe canônico — em dev e em produção — é o adapter do @uttara-dev/cas-client, que conversa com o CAS Uttara via OIDC e usa o Preview Mode do SDK para emitir uma sessão local válida no iframe do Lovable e em localhost (sem precisar de adapter mock próprio). O setup completo está em /integracao.
Demo ao vivo (Preview Mode do CAS)
Esta página consome o mesmo useAuth() que uma app real usaria. O AppProviders da vitrine carrega o createCasServerAuthAdapter com Preview Mode habilitado em dev — a sessão é emitida pelo backend, indistinguível de uma sessão real.
Observe o UserMenu na sidebar reagir ao estado em tempo real — mesmo adapter, mesmo provider.
1. O contrato
Tudo que o Uttara UI precisa saber sobre o usuário cabe nesta interface. Componentes como UserMenu, AppShell e qualquer item de navegação com requiredPermission consultam o adapter via useAuth().
// @uttara-dev/ui/auth — contrato que sua app implementa
export interface AuthUser {
id: string;
name: string;
email: string;
avatarUrl?: string;
permissions?: string[];
}
export interface AuthAdapter {
getUser(): AuthUser | null;
isLoading(): boolean;
hasPermission(permission: string): boolean;
signIn(): Promise<void> | void;
signOut(): Promise<void> | void;
subscribe(listener: () => void): () => void;
}2. Preview Mode do CAS (em dev)
Até a v1.5.0 o pacote oferecia um MockAuthAdapter síncrono em memória. A partir de v1.6.0 ele foi removido — o @uttara-dev/cas-client@1.1.0+ traz o Preview Mode, que cobre os mesmos cenários (dev local, iframe do Lovable, smoke tests) emitindo uma sessão real assinada com o mesmo CAS_SESSION_SECRET. A app declara uma única lista de previewUsers, passada para o server (createCasRouteHandlers) e para o client (createCasServerAuthAdapter).
// A vitrine usa o adapter REAL do CAS — sem mock.
// Em dev e no iframe do Lovable, o Preview Mode do SDK (cas-client
// v1.1.0+) emite uma sessão local válida com claims de um perfil
// pré-declarado. Em produção, o mesmo adapter dispara o fluxo OIDC.
import { AuthProvider } from "@uttara-dev/ui/auth";
import { createCasServerAuthAdapter } from "@uttara-dev/cas-client/react/adapters";
import { previewUsers } from "@/preview-users";
const adapter = createCasServerAuthAdapter({
preview: {
enabled: import.meta.env.DEV,
users: previewUsers,
},
});
<AuthProvider adapter={adapter}>
<App />
</AuthProvider>Detalhes de motivação, gates de segurança e custom detectors: vendor/uttara/cas-client/docs/09-preview-mode.md.
3. Consumindo no componente
// Consumindo o usuário em qualquer componente
import { useAuth } from "@uttara-dev/ui/auth";
export function BotaoExcluir({ id }: { id: string }) {
const { user, hasPermission } = useAuth();
if (!hasPermission("clientes.delete")) return null;
return (
<Button onClick={() => excluir(id, user!.id)} variant="destructive">
Excluir
</Button>
);
}Pontos delicados
Três armadilhas que aparecem quando você implementa um adapter real (CAS, Auth0, Clerk ou qualquer outro): o ciclo de vida do subscribe, o tratamento de isLoading no componente, e o mesmo isLoading no guard de rota.
Contrato do subscribe()
O AuthProvider não guarda o estado — ele apenas força um re-render quando o adapter notifica. Se o seu adapter esquecer de chamar os listeners após uma mudança, a UI fica congelada mesmo com o estado interno correto.
// Como o AuthProvider consome subscribe()
// (referência — você não precisa reimplementar isso na sua app)
import * as React from "react";
import type { AuthAdapter } from "@uttara-dev/ui/auth";
export function AuthProvider({ adapter, children }: {
adapter: AuthAdapter;
children: React.ReactNode;
}) {
// useReducer só para forçar re-render — não guardamos estado aqui:
// a fonte de verdade é o adapter.
const [, force] = React.useReducer((x: number) => x + 1, 0);
React.useEffect(() => {
const unsubscribe = adapter.subscribe(() => force());
return unsubscribe; // cleanup desinscreve quando o adapter troca
}, [adapter]);
// ...monta o contexto a partir de adapter.getUser() / isLoading() ...
}
// CONTRATO QUE SEU ADAPTER PRECISA HONRAR:
// • subscribe(fn) deve chamar fn() SEMPRE que getUser() ou isLoading()
// mudarem de valor — caso contrário a UI fica congelada.
// • subscribe(fn) deve retornar uma função de unsubscribe síncrona.
// • Múltiplos subscribers podem coexistir (use Set, não variável única).
// • Não chame fn() de dentro do próprio subscribe — chame depois das
// mutações de estado para refletir o valor já atualizado.Tratamento de isLoading no componente
Três estados explícitos: bootstrap em curso → spinner; bootstrap terminou sem usuário → convite ao login; autenticado → conteúdo. Tratar só dois estados (logado vs deslogado) causa o "flash de deslogado" durante o boot.
// Tratamento de loading no consumidor
import { useAuth } from "@uttara-dev/ui/auth";
import { Spinner, EmptyState } from "@uttara-dev/ui/composite";
import { Button } from "@uttara-dev/ui";
export function PrivateArea({ children }: { children: React.ReactNode }) {
const { user, isLoading, signIn } = useAuth();
// 1. Bootstrap do adapter ainda em curso — evita flash de "deslogado".
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner label="Verificando sessão…" />
</div>
);
}
// 2. Loading terminou e não há usuário — convida ao login.
if (!user) {
return (
<EmptyState
title="Sessão necessária"
description="Entre com sua conta Uttara para continuar."
action={<Button onClick={() => signIn()}>Entrar</Button>}
/>
);
}
// 3. Autenticado — renderiza o conteúdo protegido.
return <>{children}</>;
}Tratamento de isLoading no guard de rota
O beforeLoad roda antes da árvore montar — se rodar enquanto o adapter ainda está em bootstrap, getUser() retorna null e o usuário é redirecionado pra login mesmo tendo sessão válida. A solução é aguardar o loading terminar (com timeout de segurança).
// Tratamento de loading no guard de rota
import { createFileRoute, redirect, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated")({
// beforeLoad NÃO deve rodar enquanto isLoading() === true.
// Se rodar, getUser() retorna null por bootstrap e o usuário vai ser
// chutado para /login mesmo já tendo sessão válida.
beforeLoad: async ({ context, location }) => {
await waitUntil(() => !context.auth.isLoading(), 3000);
if (!context.auth.getUser()) {
throw redirect({
to: "/login",
search: { redirect: location.href },
});
}
},
component: () => <Outlet />,
});
function waitUntil(predicate: () => boolean, timeoutMs: number) {
return new Promise<void>((resolve) => {
if (predicate()) return resolve();
const start = Date.now();
const id = setInterval(() => {
if (predicate() || Date.now() - start > timeoutMs) {
clearInterval(id);
resolve();
}
}, 50);
});
}Permissões são strings livres. O pacote nunca interpreta o formato — você decide se usa "clientes.write", "crm:write" ou enums. A convenção de "*" como super-permissão é aplicada pelo extractPermissionsFromClaims do cas-client quando a claim contém esse valor.
subscribe() é obrigatório. O AuthProvider ouve mudanças do adapter para re-renderizar a árvore. Esqueça e o UserMenu não atualiza após signIn.
Nada de Supabase nem CAS aqui dentro. Auth é externalidade — fica na app, atrás do adapter. O pacote permanece backend-agnostic. Para o encaixe concreto com o CAS Uttara (OIDC, PKCE, scopes, deep links de gestão de conta), veja /integracao.