Pular para o conteúdo principal

Fluxo de Primeiro Acesso — Emplacamento e Intranet (Next.js Supabase)

Fluxo obrigatório de primeiro acesso (TEC-822) para convidados dos produtos Emplacamento e Intranet. Estende o fluxo genérico de convite do MakerKit (/join/identities) com um wizard de 3 passos — Convite e conta → Senha → Dados — seguido de uma tela de sucesso, antes de liberar o acesso à conta. Segue fielmente o protótipo Figma do onboarding do MeResolve.

Visão geral

Antes desta feature, o fluxo de convite (/join/accept/join/identities) tinha só um gate genérico, comum a todos os produtos do SuperApp: definir senha. CPF e celular já existiam no banco (emplacamento_account_users / intranet_account_users), mas eram preenchidos pelo admin no momento do convite — nunca confirmados pela própria pessoa.

O TEC-822 adiciona, só para Emplacamento e Intranet, um wizard de 3 passos obrigatório:

  1. Convite e conta — tela read-only com quem convidou, os dados do convite e o próprio acesso da pessoa.
  2. Senha — criação de senha, com força ao vivo.
  3. Dados — confirmação de CPF e celular, com checagem de unicidade ao vivo sem revelar de quem é o CPF colidente.

Seguida de uma tela de sucesso ("Acesso ativado") antes de liberar a home da conta.

Só Emplacamento e Intranet

Todo o mecanismo (schema, RPCs, roteamento) é condicionado a requiresPrimeiroAcessoDados(product) (config/products.config.ts). Para qualquer outro produto (Belite, Operacional, Admin), o comportamento é idêntico ao anterior — sem o passo Dados, sem a tela de sucesso.

Por que página por passo, e não um wizard client-side

Duas abordagens foram avaliadas antes de implementar: um wizard 100% client-side (uma única rota com um hook de estado cobrindo os 3 passos) e uma extensão do padrão já existente no kit — cada passo como página própria, com a "conclusão" derivada da verdade do servidor.

A segunda foi escolhida. O problema que o wizard client-side resolveria — "não perder a senha digitada ao voltar de passo" — já é resolvido pelo padrão que a tela /identities original usa: ela deriva se a senha já foi definida a partir do AMR (Authentication Method Reference) da sessão do Supabase Auth, não de estado do navegador. Voltar para essa etapa depois de já ter definido senha simplesmente mostra "concluído" — nunca pede a senha de novo.

Manter o padrão página-por-passo:

  • bate com os frames do Figma sendo telas distintas;
  • tem blast radius muito menor — só estende /join, sem introduzir uma rota e um hook de estado paralelos;
  • deixa o gate de "senha já definida" e "dados já confirmados" inteiramente do lado do servidor, verificável em qualquer momento (voltar, atualizar a página, ou tentar acessar a URL da conta direto).

Estrutura de arquivos

apps/web/
├── app/[locale]/
│ ├── join/
│ │ ├── page.tsx # Convite e conta — orquestra todo o roteamento
│ │ ├── layout.tsx # data-theme="meresolve" (tema não-branded)
│ │ ├── _components/
│ │ │ ├── invite-details-summary.tsx # Card "O convite" + "Seu acesso"
│ │ │ ├── accept-invite-card.tsx # Botão de aceite + "Não é você?"
│ │ │ └── invite-status-message.tsx # Convite vencido / já utilizado / não encontrado
│ │ └── _lib/server/
│ │ └── invite-context.service.ts # resolveInviteReason, getInviterInfo
│ └── identities/
│ ├── layout.tsx # data-theme="meresolve"
│ ├── page.tsx # Senha — fluxo GENÉRICO, inalterado
│ ├── senha/
│ │ ├── page.tsx # Senha — fluxo DEDICADO (primeiro acesso)
│ │ ├── _components/
│ │ │ ├── create-senha-form.tsx
│ │ │ └── senha-strength-bar.tsx
│ │ └── _lib/schema/criar-senha.schema.ts
│ ├── dados/
│ │ ├── page.tsx
│ │ ├── _components/step-dados-form.tsx
│ │ └── _lib/
│ │ ├── schema/primeiro-acesso.schema.ts
│ │ ├── hooks/use-uniqueness-check.ts
│ │ └── server/
│ │ ├── primeiro-acesso.service.ts
│ │ └── primeiro-acesso-actions.ts
│ ├── finalizado/
│ │ ├── page.tsx # Acesso ativado
│ │ └── _components/finalizado-view.tsx
│ ├── _components/
│ │ ├── first-access-stepper.tsx # Stepper com checkmarks (compartilhado)
│ │ └── info-card.tsx # InfoCard/InfoCardRow/InviteBadge (compartilhado)
│ └── _lib/server/
│ └── first-access-context.service.ts # Nome/perfil/vínculo legível (banco + DW)
│ └── _shared/team-workspace/
│ └── team-workspace-layout.tsx # Gate obrigatório (todos os produtos passam aqui)
├── lib/
│ ├── primeiro-acesso-rpc.types.ts # Tipos das RPCs (escritos à mão, ver alerta abaixo)
│ └── server/supabase-dw-client.ts # Cliente do DW, movido de dentro do Emplacamento
├── supabase/
│ ├── schemas/
│ │ ├── 07-invitations.sql # + accepted_at, unique index parcial, 14 dias
│ │ ├── 24-emplacamento-account-users.sql # + dados_confirmados_em
│ │ ├── 25-intranet-account-users.sql # + dados_confirmados_em
│ │ └── 32-primeiro-acesso.sql # As 4 RPCs novas
│ └── migrations/
│ ├── 20260819120000_primeiro_acesso_dados.sql
│ └── 20260819150000_invitations_accepted_at.sql
└── i18n/messages/{pt-BR,en}/first-access.json
packages/features/
├── auth/src/components/password-strength-meter.tsx # Checklist de senha genérico (fora do wizard)
└── team-accounts/src/server/api.ts # getInvitation() + accepted_at/created_at/invited_by

Banco de dados

Coluna de marcação

dados_confirmados_em timestamptz foi adicionada em emplacamento_account_users e intranet_account_users. É distinta de accepted_at (que é automático, disparado pelo aceite do convite genérico) — marca especificamente "esta pessoa confirmou o próprio CPF/celular".

As 4 RPCs (32-primeiro-acesso.sql)

Todas SECURITY DEFINER, com revoke/grant explícitos (nunca deixando EXECUTE aberto para public/anon):

FunçãoPapel
cpf_check_unicidade(p_cpf)Checagem "ao vivo" — retorna só boolean, nunca revela a quem pertence o CPF colidente
confirm_first_access_dados(p_account_id, p_cpf, p_telefone)Escrita atômica — grava CPF/celular com pg_advisory_xact_lock e revalida a unicidade dentro da própria transação (nunca confia só na checagem do client)
product_user_dados_pendente(p_account_id)O gate: "esta conta exige o passo Dados e esta pessoa ainda não confirmou?" — false automático para qualquer produto fora de {emplacamento, intranet}
product_user_self_dados(p_account_id)Leitura da própria linha, para pré-popular o formulário
A âncora de segurança de confirm_first_access_dados

O UPDATE só acontece com WHERE account_id = p_account_id AND user_id = auth.uid(). Mesmo sendo SECURITY DEFINER (que faz bypass de RLS), essa cláusula torna impossível gravar na linha de outra pessoa.

Unicidade de CPF sem vazar identidade

O escopo da checagem é entre Emplacamento e Intranet (não Belite), excluindo a própria identidade de quem está confirmando:

select exists (
select 1 from public.emplacamento_account_users
where cpf = v_cpf and user_id is distinct from v_user_id
union all
select 1 from public.intranet_account_users
where cpf = v_cpf and user_id is distinct from v_user_id
);

Isso permite que a mesma pessoa tenha acesso a qualquer combinação de produtos (a exclusão é por user_id, não por conta) — só impede que duas identidades diferentes usem o mesmo CPF real dentro desse par de produtos.

Nomes de grupo/loja vêm do Data Warehouse

emplacamento_user_grupos / emplacamento_user_concessionarias guardam só os códigos (grupo_empresa_codigo, concessionaria_cnpj) — não há tabela de nomes no banco principal. first-access-context.service.ts resolve os nomes legíveis ("Grupo Vega", "Loja Santo Amaro") consultando dw_emplacamento_mart.emplacamento_kpi_dia via getSupabaseDwClient() (movido para apps/web/lib/server/supabase-dw-client.ts, antes vivia só dentro de emplacamento/reports/por-territorio).

Convites: 14 dias e accepted_at

A tela "Convite já utilizado" do Figma mostra "ativado em {data}" — mas accept_invitation() deletava a linha do convite ao aceitar, então não sobrava informação de quando foi aceito. A correção:

  • accept_invitation() passa a marcar accepted_at = now() em vez de DELETE.
  • O unique (email, account_id) original foi trocado por um índice único parcial: unique (email, account_id) where accepted_at is null — um convite já aceito não trava um reconvite futuro para a mesma pessoa.
  • expires_at passou de 7 para 14 dias (copy do Figma: "Convites valem 14 dias por segurança").
  • get_account_invitations() e getInvitation() (em packages/features/team-accounts/src/server/api.ts) passaram a filtrar accepted_at is null explicitamente, preservando o comportamento anterior (que dependia implicitamente da linha ter sido deletada).
Mudança de mecanismo global

Isso mexe no fluxo de convite usado por todos os produtos, não só Emplacamento/Intranet. O comportamento externo é o mesmo (convite aceito continua "não encontrado" para quem tenta usá-lo de novo pelo fluxo padrão) — a diferença é que agora existe histórico.

Roteamento fim a fim

/join/accept → /auth/confirm → /join
(mostra sempre: cartão do convite)
├─ conta nova + produto elegível → /identities/senha → /identities/dados → /identities/finalizado → home
├─ conta nova + produto normal → /identities (fluxo genérico, inalterado) → home
└─ usuário já existe, novo convite, produto elegível → direto /identities/dados (pula senha, que já existe)

Cada página lê seu próprio parâmetro next da URL e só sabe "para onde ir depois" — nenhuma página sabe nada do passo anterior. É isso que mantém as páginas independentes e o /identities genérico intocado para outros produtos.

Em join/page.tsx:

const afterPasswordPath = requiresDados
? `/identities/dados?accountSlug=${invitation.account.slug}&next=${encodeURIComponent(accountHome)}`
: accountHome;

const nextPath = shouldSetupAccount
? requiresDados
? `/identities/senha?accountSlug=${invitation.account.slug}&next=${encodeURIComponent(afterPasswordPath)}`
: `/identities?next=${encodeURIComponent(afterPasswordPath)}`
: afterPasswordPath;

O gate obrigatório

Redirecionar só depois do convite não bastava — se alguém voltasse ou colasse a URL da conta direto, entraria sem nunca ter confirmado os dados. O gate real vive em team-workspace-layout.tsx, o layout compartilhado pelos 4 produtos: logo depois de resolver a conta, chama product_user_dados_pendente e redireciona para /identities/dados se true.

async function redirectIfDadosPendente(accountId: string, account: string, product: string | null) {
const { data: pendente } = await client.rpc('product_user_dados_pendente', {
p_account_id: accountId,
});

if (pendente) {
const accountHome = getPathsForProduct(product).app.accountHome.replace('[account]', account);
redirect(`/identities/dados?accountSlug=${account}&next=${encodeURIComponent(accountHome)}`);
}
}

Como a RPC já retorna false para produtos fora do escopo, o código do layout não precisa saber quais produtos exigem o passo — a regra vive inteiramente no banco.

As telas

TelaRotaO que faz
Convite e conta/joinCard com 3 seções: quem convidou (nome + vínculo, resolvido via invited_by → conta pessoal), dados do convite (aprovado por, válido até) e o próprio acesso da pessoa (nome, e-mail, perfil, vínculo)
Senha/identities/senhaTela dedicada (não o diálogo genérico do kit) — campo de senha com barra de força de 4 segmentos (Fraca/Média/Boa/Forte) e checklist fixo de 3 regras sempre obrigatórias (8+ caracteres, maiúscula+minúscula, número)
Dados/identities/dadosCPF e celular pré-preenchidos (o que o admin já cadastrou), badges "Do convite", card explicativo "Por que o CPF precisa estar certo", checagem de unicidade ao vivo (debounced) com banner de erro rico quando o CPF já está cadastrado
Acesso ativado/identities/finalizadoCheckmark verde, stepper com os 3 passos concluídos, progress bar que auto-avança após alguns segundos (com botão "Continuar" manual)
Requisitos de senha fixos neste wizard

identities/senha/_lib/schema/criar-senha.schema.ts define regras sempre ativas (8+ caracteres, maiúscula+minúscula, número), diferente do resto do kit — packages/features/auth/src/schemas/password.schema.ts mantém os requisitos extras opt-in por env var (NEXT_PUBLIC_PASSWORD_REQUIRE_*), usados no cadastro genérico e no password-strength-meter.tsx compartilhado.

Bugs reais encontrados durante o desenvolvimento

Dois bugs de lógica só apareceram ao testar o fluxo completo de ponta a ponta (Playwright, convite real → aceite → senha → dados → home) — nenhum dos dois foi pego por typecheck ou lint.

Caminho duplicado (/identities/dados?next=/identities/dados?next=...)identities/senha/page.tsx reconstruía o caminho de /identities/dados a partir do próprio next, mas esse next já vinha pronto de join/page.tsx como o caminho completo do passo Dados. Corrigido removendo a reconstrução: a página de senha só repassa o next recebido, sem envolvê-lo de novo.

Auto-avanço da tela "Acesso ativado" nunca disparava — o useEffect que agenda o setTimeout do auto-avanço tinha um useRef guardando "já iniciei o timer", nunca resetado no cleanup. No modo dev do React (que monta/desmonta/remonta componentes de propósito para pegar bugs de efeito), a segunda montagem via o ref já true e desistia de agendar. Corrigido removendo o guard — o próprio cleanup do useEffect (que cancela o timeout anterior) já evita duplicação de timer num remonte legítimo.

Ajuste de layout: caber sem scroll

O card do wizard usa AuthLayoutShell (h-screen + justify-center), que não tem scroll próprio — conteúdo mais alto que a viewport ficava cortado, sem forma de rolar até o fim. A correção final:

  1. Cada página envolve o conteúdo num container com max-h-[92vh] overflow-y-auto (mesmo padrão já usado pela tela /identities original), garantindo que sempre existe uma via de escape por scroll em telas muito baixas.
  2. Para eliminar o scroll na maioria das janelas, o card "Quem foi convidado" foi mesclado dentro de "Seu acesso" (ambos mostravam nome/e-mail, duplicado) e os espaçamentos/padding dos cards foram reduzidos.

Isso baixou a altura do conteúdo do card de Convite e conta de ~835px para ~660px — cabe sem rolagem em janelas de notebook comuns (testado em 1366×728), com o scroll interno ainda disponível como reserva para janelas bem menores.

Como testar localmente

Pré-requisitos: Supabase local no ar.

# Terminal 1 — Supabase local
pnpm supabase:web:start

# Terminal 2 — app web
pnpm dev
  1. Entre com desenvolvimento@meresolve.com.br / DesenvolvimentoMeResolve e cadastre um usuário novo em /emplacamento/home/abracaf/usuarios.
  2. Abra o Mailpit (http://127.0.0.1:54324), pegue o link do convite e abra-o numa aba anônima.
  3. Percorra Convite e contaSenhaDados. No passo Dados, teste um CPF que já pertença a outra pessoa (deve bloquear com o banner vermelho e desabilitar o botão) e depois um CPF válido e livre.
  4. Confirme a tela Acesso ativado e que ela avança automaticamente para a home da conta.
  5. Tente acessar a URL da conta direto (sem ter passado pelo passo Dados) — deve ser redirecionado de volta para /identities/dados.
  6. Repita o cadastro para uma conta Belite — o fluxo deve pular direto para a home, sem passar por /identities/dados nem /identities/finalizado (regressão zero).
Tipos das RPCs escritos à mão

apps/web/lib/primeiro-acesso-rpc.types.ts não vem de Database['public']['Functions'] — as RPCs nascem nesta feature, e pnpm supabase:web:typegen só pode rodar contra um banco com exatamente as migrations desta branch. Regenerar os tipos reais quando as migrations entrarem em dev.