Uttara

Integração · Tutorial de adoção

Do lovable.dev/new ao ecossistema Uttara

Tutorial completo: criar a app no Lovable, conectá-la ao GitHub, vendoriar o uttara-ui via subtree, padronizar secrets de Supabase, fixar o tema da marca em build-time e plugar autenticação real via CAS (OIDC + PKCE). É o caminho que toda app interna do ecossistema deve seguir.

  1. Criar o projeto no Lovable e conectar ao GitHub

    Vá em lovable.dev/new e descreva a app em uma frase — o Lovable já entrega um projeto TanStack Start + Vite 7 + Tailwind v4, que é exatamente a stack canônica do ecossistema.

    Em seguida, no menu do projeto, escolha GitHub → Connect to GitHub e crie um repo na organização uttara-dev (ou na org da empresa cliente, em white-label). Sem o repo conectado, não dá pra usar git subtree no passo 2.

    Por quê: o ecossistema padroniza versionamento via Git. Lovable lida com o ciclo de iteração visual; GitHub é a fonte da verdade para releases, CI e o subtree do uttara-ui.

  2. Vendoriar o uttara-ui via git subtree (recomendado)

    O caminho preferido hoje é vendoriar o pacote dentro do repo da app, em src/lib/uttara-ui. Isso elimina a dependência de registry privado no momento do build, deixa o código navegável direto pelo IDE e simplifica forks white-label. É exatamente o que esta vitrine faz — todos os exemplos importam de @/lib/uttara-ui/*.

    bash
    # A partir da raiz do repo da app, com main checked-out e clean.
    # Vendoriza o pacote dentro de src/lib/uttara-ui — mesmo caminho que a vitrine usa.
    git remote add uttara-ui git@github.com:uttara-dev/uttara-ui.git
    git fetch uttara-ui
    git subtree add --prefix=src/lib/uttara-ui uttara-ui main --squash

    Para atualizar quando sair uma nova versão:

    bash
    # Atualizar para a última versão publicada do pacote
    git fetch uttara-ui
    git subtree pull --prefix=src/lib/uttara-ui uttara-ui main --squash

    Implicação: commits do upstream entram squashed no histórico da app. Modificações locais em src/lib/uttara-ui conflitam no próximo pull — se precisar customizar, levante a mudança no repo do uttara-ui primeiro.

  3. (Alternativa) Consumir como pacote npm publicado

    Se preferir consumir como dependência npm, o pacote vive no GitHub Packages da org uttara-dev. Crie .npmrc na raiz e configure um fine-grained PAT com permissão Packages: Read-only como GITHUB_PACKAGES_TOKEN em Workspace Settings → Build Secrets (build secret, não runtime).

    .npmrcini
    @uttara-dev:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}
    bash
    # Pacote + peers obrigatórios
    bun add @uttara-dev/ui
    
    # Peers opcionais (para forms enriquecidos)
    bun add react-hook-form @hookform/resolvers zod sonner

    Quando usar esta via: apps muito pequenas que não querem nem o diretório vendoriado no repo. Para tudo que vai virar produto interno, prefira o subtree do passo 2.

  4. Carregar o CSS do tema

    Um único @import traz todos os tokens semânticos em oklch (light/dark, primárias, surfaces, sidebar, charts) — sem precisar tocar em tailwind.config (que aliás não existe em Tailwind v4).

    src/styles.csscss
    /* src/styles.css — tema Uttara já com tokens light/dark */
    @import "tailwindcss";
    @import "@uttara-dev/ui/styles.css";

    Se usou subtree (passo 2), o import vira relativo:

    src/styles.csscss
    /* src/styles.css — quando consumindo via subtree */
    @import "tailwindcss";
    @import "./lib/uttara-ui/styles.css";
  5. Plugar os providers e o AppShell

    A ordem importa: ThemeProvider aplica os CSS vars antes do primeiro paint; AuthProvider precisa estar disponível para o UserMenu dentro do AppShell.

    src/components/AppProviders.tsxtsx
    // src/components/AppProviders.tsx
    import { ThemeProvider } from "@uttara-dev/ui/providers";
    import { I18nProvider } from "@uttara-dev/ui/i18n";
    import { AuthProvider } from "@uttara-dev/ui/auth";
    import { createCasServerAuthAdapter } from "@uttara-dev/cas-client/react/adapters";
    import { ConfirmDialogProvider } from "@uttara-dev/ui/composite";
    import { Toaster } from "@uttara-dev/ui/primitives";
    import { uttaraTheme } from "@uttara-dev/ui/tokens";
    import { previewUsers } from "@/preview-users";
    
    // O adapter REAL do CAS é o mesmo em dev e em produção. O Preview
    // Mode do SDK (cas-client v1.1.0+) emite uma sessão local válida
    // no iframe do Lovable e em localhost, sem precisar de mock.
    const auth = createCasServerAuthAdapter({
      preview: {
        enabled: import.meta.env.DEV,
        users: previewUsers,
      },
    });
    
    export function AppProviders({ children }: { children: React.ReactNode }) {
      return (
        // brand={uttaraTheme} é hardcoded de propósito: cada app do
        // ecossistema escolhe seu tema em build-time. Para uma instância
        // white-label, troque por createBrandTheme({...}) ou outro tema.
        <ThemeProvider brand={uttaraTheme} defaultMode="system">
          <I18nProvider defaultLocale="pt-BR">
            <AuthProvider adapter={auth}>
              <ConfirmDialogProvider>
                {children}
                <Toaster richColors />
              </ConfirmDialogProvider>
            </AuthProvider>
          </I18nProvider>
        </ThemeProvider>
      );
    }
    src/routes/__root.tsxtsx
    // src/routes/__root.tsx — AppShell envolvendo as rotas
    import { Outlet, createRootRoute } from "@tanstack/react-router";
    import { AppShell } from "@uttara-dev/ui/layout";
    import { Home, Users, Settings } from "lucide-react";
    import { AppProviders } from "@/components/AppProviders";
    
    const nav = [
      { label: "Início",   to: "/",         icon: Home },
      { label: "Clientes", to: "/clientes", icon: Users,    requiredPermission: "clientes.view" },
      { label: "Ajustes",  to: "/ajustes",  icon: Settings },
    ];
    
    export const Route = createRootRoute({
      component: () => (
        <AppProviders>
          <AppShell nav={nav}>
            <Outlet />
          </AppShell>
        </AppProviders>
      ),
    });
  6. Padronizar secrets do projeto (Supabase + CAS)

    Apps do ecossistema usam nomes de secret idênticos entre si. Isso permite copiar adapters, server functions e até templates de migration entre apps sem renomear nada. Configure no Lovable Cloud (runtime secrets) — não em Build Secrets.

    • SUPABASE_URL — URL do projeto Supabase desta app
    • SUPABASE_PUBLISHABLE_KEY — chave anon, segura no client
    • SUPABASE_SERVICE_ROLE_KEYsomente em edge functions / server fns
    • SUPABASE_SCHEMA — schema dedicado da app (ex: vendas)
    • VITE_CAS_ISSUER — URL do CAS (ex: https://cas.uttara.com)
    • VITE_CAS_CLIENT_ID — recebido do admin do CAS ao registrar a app
    src/lib/supabase.tsts
    // src/lib/supabase.ts — cliente único da app
    import { createClient } from "@supabase/supabase-js";
    
    // Padrão do ecossistema (ver seção "Secrets do projeto" acima):
    //   SUPABASE_URL                — URL do projeto Supabase desta app
    //   SUPABASE_PUBLISHABLE_KEY    — chave anon, segura no client
    //   SUPABASE_SCHEMA             — schema dedicado da app (ex: "vendas")
    // SUPABASE_SERVICE_ROLE_KEY fica APENAS em edge functions / server fns.
    
    export const supabase = createClient(
      import.meta.env.VITE_SUPABASE_URL!,
      import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!,
      {
        db: { schema: import.meta.env.VITE_SUPABASE_SCHEMA ?? "public" },
      },
    );

    Schema dedicado: cada app vive em seu próprio schema do Supabase (não em public). Isso permite múltiplas apps compartilharem um mesmo projeto Supabase sem colisão de tabelas, e simplifica o backup/restore por domínio. A app nunca lê dos schemas auth ou cas — para dados do usuário, use a App KV API do CAS.

  7. Tema hardcoded em build-time (e o caminho white-label)

    O tema é fixado em AppProviders.tsx via brand={uttaraTheme} — não vem de runtime nem de configuração do Supabase. Cada app declara em build-time qual marca ela serve.

    Por quê?

    Apesar de o ecossistema ser da Uttara, ele foi desenhado para ser clonável e implantável em outras empresas com identidade visual distinta e isolamento total — só compartilhando tecnologia. Para uma instância white-label, o fork muda o tema (e o CAS, e o Supabase) em build-time e fica completamente independente. Tema dinâmico em runtime quebraria essa promessa de isolamento sem ganho real, já que cada empresa só tem uma marca.

  8. Renderizar a primeira tela

    Três compostos do pacote — PageHeader, StatCard e EmptyState — entregam uma tela consistente com o resto do ecossistema, sem nenhum CSS custom.

    src/routes/clientes.tsxtsx
    // src/routes/clientes.tsx — primeira tela "de verdade"
    import { createFileRoute } from "@tanstack/react-router";
    import { PageHeader, StatCard, EmptyState } from "@uttara-dev/ui/composite";
    import { Button } from "@uttara-dev/ui/primitives";
    import { Plus, Users, TrendingUp } from "lucide-react";
    
    export const Route = createFileRoute("/clientes")({
      component: ClientesPage,
    });
    
    function ClientesPage() {
      const clientes: unknown[] = []; // virá da sua API
    
      return (
        <div className="space-y-6 p-6">
          <PageHeader
            title="Clientes"
            description="Sua base ativa, em um só lugar."
            actions={
              <Button>
                <Plus className="mr-2 h-4 w-4" /> Novo cliente
              </Button>
            }
          />
    
          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
            <StatCard label="Total"    value="0"   icon={Users} />
            <StatCard label="Ativos"   value="0"   icon={TrendingUp} delta={{ value: "+0%", trend: "up" }} />
            <StatCard label="Inativos" value="0"   icon={Users} />
          </div>
    
          {clientes.length === 0 && (
            <EmptyState
              icon={Users}
              title="Nenhum cliente ainda"
              description="Cadastre o primeiro para começar."
              action={<Button><Plus className="mr-2 h-4 w-4" /> Novo cliente</Button>}
            />
          )}
        </div>
      );
    }
  9. Conectar ao CAS via OIDC + PKCE

    Use o createCasServerAuthAdapter do @uttara-dev/cas-client em dev e em produção — o Preview Mode (v1.1.0+) cobre o iframe do Lovable e localhost sem precisar de adapter mock. O fluxo OIDC é: start login (PKCE + state + nonce) → callback (troca code por tokens, valida id_token) → adapter (espelha o usuário corrente no contrato da UI).

    src/auth/cas-start-login.tsts
    // src/auth/cas-start-login.ts — disparo do fluxo OIDC + PKCE
    const CAS = import.meta.env.VITE_CAS_ISSUER!;            // ex: "https://cas.uttara.com"
    const CLIENT_ID = import.meta.env.VITE_CAS_CLIENT_ID!;   // registrado no CAS
    const REDIRECT_URI = `${window.location.origin}/auth/callback`;
    
    function b64url(buf: ArrayBuffer): string {
      return btoa(String.fromCharCode(...new Uint8Array(buf)))
        .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
    }
    const sha256 = (s: string) => crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
    
    export async function startLogin(returnTo = "/") {
      const verifier  = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
      const challenge = b64url(await sha256(verifier));
      const state = crypto.randomUUID();
      const nonce = crypto.randomUUID();
    
      sessionStorage.setItem("cas_pkce_verifier", verifier);
      sessionStorage.setItem("cas_state", state);
      sessionStorage.setItem("cas_nonce", nonce);
      sessionStorage.setItem("cas_return_to", returnTo);
    
      const params = new URLSearchParams({
        response_type: "code",
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        // Princípio do menor privilégio: peça só os scopes que a app vai usar.
        scope: "openid profile email",
        state, nonce,
        code_challenge: challenge,
        code_challenge_method: "S256",
      });
      window.location.href = `${CAS}/api/authorize?${params}`;
    }
    src/routes/auth.callback.tsxtsx
    // src/routes/auth.callback.tsx — troca code por tokens, valida id_token
    import { createFileRoute, useNavigate } from "@tanstack/react-router";
    import * as jose from "jose";
    
    const CAS = import.meta.env.VITE_CAS_ISSUER!;
    const CLIENT_ID = import.meta.env.VITE_CAS_CLIENT_ID!;
    const REDIRECT_URI = typeof window !== "undefined"
      ? `${window.location.origin}/auth/callback`
      : "";
    const JWKS = jose.createRemoteJWKSet(new URL(`${CAS}/api/jwks`));
    
    export const Route = createFileRoute("/auth/callback")({
      component: CallbackPage,
    });
    
    function CallbackPage() {
      const navigate = useNavigate();
      // ... efeitos de troca de code, validação de state/nonce, persistência.
      // Para SPA pura, mantenha access_token em memória e refresh_token em
      // cookie httpOnly setado por uma server function (padrão BFF).
      return <p>Entrando…</p>;
    }
    src/auth/cas-adapter.tsts
    // src/auth/cas-adapter.ts — encaixe entre CAS e o AuthAdapter da UI
    import type { AuthAdapter, AuthUser } from "@uttara-dev/ui/auth";
    import { startLogin } from "./cas-start-login";
    
    /**
     * Camada fina: o CAS resolve OIDC/PKCE/refresh; este adapter apenas
     * espelha o usuário corrente para o ThemeProvider/AppShell/UserMenu.
     */
    export function createCasAuthAdapter(): AuthAdapter {
      let user: AuthUser | null = null;
      let loading = true;
      const listeners = new Set<() => void>();
      const notify = () => listeners.forEach((l) => l());
    
      // Bootstrap: se já há sessão (refresh cookie válido), troca por access
      // token e baixa /userinfo. Se 401, fica deslogado e silencioso.
      void hydrateFromSession().then((u) => {
        user = u;
        loading = false;
        notify();
      });
    
      return {
        getUser: () => user,
        isLoading: () => loading,
        hasPermission: (perm) => {
          const p = user?.permissions ?? [];
          return p.includes("*") || p.includes(perm);
        },
        signIn: () => startLogin(window.location.pathname),
        signOut: async () => { await casLogout(); user = null; notify(); },
        subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); },
      };
    }
    
    // hydrateFromSession() / casLogout() ficam no arquivo cas-session.ts
    // — separados porque envolvem fetch ao backend BFF da app.
    Princípio do menor privilégio

    Peça apenas os scopes que a app realmente vai usar. openid é obrigatório; profile email é o mínimo prático. Adicione phone, document, identities ou offline_access apenas quando houver caso de uso. O CAS só libera o que estiver na whitelist da sua app.

    Para gestão de conta (avatar, e-mails, telefone, documento, conexões OAuth, senha), nunca implemente o CRUD na sua app — redirecione para cas.uttara.com/account/* e re-busque /userinfo no retorno.

Resultado esperado

Sidebar colapsável à esquerda, header com theme toggle e user menu, breadcrumb automático no topo do main, área de conteúdo com PageHeader, três StatCard em grid e um EmptyState. Light/dark já funcionando, login real contra o CAS (ou mock em dev), e secrets Supabase prontas para a primeira query.

É a mesma identidade desta vitrine — porque ela renderiza usando o mesmo subtree.

Prompt inicial sugerido para Lovable

Cole este prompt como primeira mensagem ao iniciar uma nova app Lovable do ecossistema. Ele contextualiza o ambiente e fixa as convenções antes que o agente comece a tomar decisões de design ou stack. Substitua os placeholders {NOME_DA_APP} e {DESCREVA AQUI...}.

prompt-inicial.md
Crie uma nova aplicação web interna chamada "{NOME_DA_APP}" para
o ecossistema Uttara. A app deve seguir 100% as convenções abaixo desde
o primeiro commit — não invente design system próprio.

## Stack
- TanStack Start (React 19 + Vite 7).
- Tailwind CSS v4 via @import em src/styles.css (sem tailwind.config.js).
- @uttara-dev/ui como única fonte de componentes UI e tokens visuais
  (vendoriado via git subtree em src/lib/uttara-ui — ver instruções).
- Supabase como backend (Postgres + Storage + Edge Functions). Auth
  vem do CAS Uttara, NÃO do Supabase Auth.
- TanStack Query para data fetching.
- react-hook-form + zod para formulários.

## Setup obrigatório do Uttara UI (via subtree)
1. Adicione o remote e vendorize em src/lib/uttara-ui:
     git remote add uttara-ui git@github.com:uttara-dev/uttara-ui.git
     git fetch uttara-ui
     git subtree add --prefix=src/lib/uttara-ui uttara-ui main --squash

2. Em src/styles.css, importe nesta ordem:
     @import "tailwindcss";
     @import "./lib/uttara-ui/styles.css";

3. Crie src/components/AppProviders.tsx envolvendo:
     ThemeProvider (brand={uttaraTheme}) > I18nProvider > AuthProvider >
     ConfirmDialogProvider. Use createCasServerAuthAdapter({ preview })
     do @uttara-dev/cas-client — Preview Mode cobre dev/iframe.

4. Use AppShell de @/lib/uttara-ui/layout como esqueleto, com nav
   declarado em src/components/nav.ts.

## Secrets do projeto (no Lovable Cloud)
Configure estes nomes EXATOS — todo o ecossistema os assume:
- SUPABASE_URL
- SUPABASE_PUBLISHABLE_KEY    (anon, ok no client)
- SUPABASE_SERVICE_ROLE_KEY   (SOMENTE em edge functions / server fns)
- SUPABASE_SCHEMA             (schema dedicado da app, ex: "vendas")
- VITE_CAS_ISSUER             (URL do CAS, ex: "https://cas.uttara.com")
- VITE_CAS_CLIENT_ID          (recebido do admin do CAS ao registrar a app)

## Regras de código
- NUNCA use cores literais (text-white, bg-blue-500, #fff). Apenas tokens
  semânticos: bg-background, text-foreground, bg-primary, text-muted-foreground,
  border-border. Para tons novos, adicione tokens em src/styles.css em oklch.
- Importe componentes do pacote, não dos primitivos shadcn diretamente,
  sempre que houver equivalente Uttara (ex.: FormInput em vez de <Input> cru).
- Use imports granulares para preservar tree-shaking:
    @/lib/uttara-ui/primitives | /forms | /composite | /layout | /providers | /auth | /i18n
- Formulários: react-hook-form + zod. Spread ...register() nos enriquecidos.
  Para FormSelect, use <Controller>.
- A app NUNCA acessa diretamente o schema do CAS. Para dados do usuário
  fora do domínio da app, use a App KV API do CAS.
- Externalidades (Supabase/Auth/Storage) ficam SEMPRE na app, nunca dentro
  do uttara-ui — injetadas via adapters.

## Primeiro entregável
Crie só o esqueleto: layout com AppShell + sidebar + 1 rota inicial /
contendo um PageHeader e um EmptyState. Pare aí e me peça a próxima tela.

## Objetivo do produto
{DESCREVA AQUI O QUE A APP FAZ}

Dica: o arquivo src/lib/uttara-ui/README.md já vem junto no subtree e contém os padrões completos (catálogo, receitas, regras de ouro). Anexe-o ao Knowledge da app no Lovable para que o agente tenha contexto sem precisar repeti-los no prompt.

Em construção — leia antes de adotar

O CAS está em fase de estabilização — o protocolo OIDC e a App KV API descritos acima refletem o estado atual mas podem evoluir. As secrets Supabase padronizadas ainda estão sendo testadas em produção; reporte fricções para iterarmos juntos.

A receita do subtree é a recomendação atual; assim que o pacote npm estabilizar em CI, a via 3 (registry) se torna equivalente.

Próximos passos
  • Aprofundar no contrato do AuthAdapter — ciclo de vida do subscribe, tratamento de isLoading, guards de rota.
  • Estruturar queries com TanStack Query + o cliente Supabase do passo 6.
  • Adicionar DataTable com syncToUrl assim que houver listas.