Uttara

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.

Estado atual
Nenhum usuário autenticado.

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/authts
// @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).

tsx
// 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

tsx
// 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.

tsx
// 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.

src/components/PrivateArea.tsxtsx
// 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).

src/routes/_authenticated.tsxtsx
// 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);
  });
}
Pontos de atenção

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.