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.
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.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 --squashPara 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 --squashImplicação: commits do upstream entram squashed no histórico da app. Modificações locais em
src/lib/uttara-uiconflitam no próximo pull — se precisar customizar, levante a mudança no repo douttara-uiprimeiro.(Alternativa) Consumir como pacote npm publicado
Se preferir consumir como dependência npm, o pacote vive no GitHub Packages da org
uttara-dev. Crie.npmrcna raiz e configure um fine-grained PAT com permissão Packages: Read-only comoGITHUB_PACKAGES_TOKENem 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 sonnerQuando 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.
Carregar o CSS do tema
Um único
@importtraz todos os tokens semânticos em oklch (light/dark, primárias, surfaces, sidebar, charts) — sem precisar tocar emtailwind.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";Plugar os providers e o AppShell
A ordem importa:
ThemeProvideraplica os CSS vars antes do primeiro paint;AuthProviderprecisa estar disponível para oUserMenudentro doAppShell.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> ), });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_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
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 schemasauthoucas— para dados do usuário, use a App KV API do CAS.Tema hardcoded em build-time (e o caminho white-label)
O tema é fixado em
AppProviders.tsxviabrand={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.
Renderizar a primeira tela
Três compostos do pacote —
PageHeader,StatCardeEmptyState— 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> ); }Conectar ao CAS via OIDC + PKCE
Use o
createCasServerAuthAdapterdo@uttara-dev/cas-clientem 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, validaid_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égioPeça apenas os scopes que a app realmente vai usar.
openidé obrigatório;profile emailé o mínimo prático. Adicionephone,document,identitiesouoffline_accessapenas 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/userinfono retorno.
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...}.
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.
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.
- Aprofundar no contrato do AuthAdapter — ciclo de vida do
subscribe, tratamento deisLoading, guards de rota. - Estruturar queries com TanStack Query + o cliente Supabase do passo 6.
- Adicionar
DataTablecomsyncToUrlassim que houver listas.