Neuroredes
Plataforma

Autenticação & Papéis

Login por email e senha via Supabase Auth, convite por link, sessão em cookies refrescada pelo middleware do Next.js, e a matriz de quatro papéis que define o que cada usuário vê. O papel não é uma coluna no banco — é derivado de em qual das três tabelas-perfil o supabase_auth_id aparece.

Capítulo 01

Onde mora o módulo

O código de autenticação vive em três camadas: rotas Next.js em app/(auth)/, o middleware da raiz, e edge functions do Supabase que cuidam de criação de usuário e validação de perfil.

Rotas Next.js

app/(auth)/login/{page.tsx, actions.ts}
Página de login. page.tsx é Client Component com form controlado; actions.ts é Server Action que valida com Zod e chama supabase.auth.signInWithPassword.
app/(auth)/verify-user/route.ts
Route Handler GET intermediário: invoca a edge get-profile para descobrir o papel do usuário e grava o cookie user_profile. Redireciona para /surveys em caso de sucesso, ou para /login?error=unauthorized se o perfil for inconsistente.
app/(auth)/confirmar-convite/page.tsx
Página de definição de senha após convite. Lê os tokens do hash fragment da URL, restaura sessão e dispara auth.updateUser({ password }).
app/(auth)/esqueci-senha/{page.tsx, actions.ts}
Solicitação de reset. A action chama auth.resetPasswordForEmail com redirectTo apontando para /auth/callback?next=/redefinir-senha.
app/(auth)/auth/callback/route.ts
Callback do reset. Troca o code da query por sessão (auth.exchangeCodeForSession) e segue para o next recebido (na prática, /redefinir-senha).
app/(auth)/redefinir-senha/page.tsx
Página de redefinição. Confere se há sessão ativa (único uso de auth.getSession() no projeto — ver Capítulo 08) e aplica auth.updateUser({ password }).

Sessão e clientes Supabase

proxy.ts
Entrypoint do middleware do Next.js 16. Roda em toda rota não-estática e delega para updateSession(request).
lib/supabase/{client,server,middleware}.ts
Três variantes do cliente @supabase/ssr: browser, server (Server Components/Actions), e middleware. Detalhes no Capítulo 05.
services/profile.service.ts
fetchUserProfileFromEdge(): valida o JWT com auth.getUser() e invoca a edge get-profile. Usado pelo /verify-user.
services/logout.service.ts
Server Action de logout: auth.signOut(), apaga o cookie user_profile e redireciona para /.
lib/zod/login.validation.ts
Único schema Zod do módulo. Valida email (formato) e password (mínimo de seis caracteres).
types/user.type.ts
Tipos UserRole ('ADMIN' | 'SURVEYOR' | 'VIEWER') e UserProfile ({ name, email, role, company_id, survey_id }).

Edge Functions

Em supabase/functions/: get-profile (resolve papel e dados do perfil do próprio chamador a partir do JWT — desde 2026-06-10 ignora o userId do corpo), create-user-surveyor e create-user-viewer (convite e gravação na tabela-perfil), surveyors-crud e viewers-crud (listagem, atualização e exclusão sem envolver fluxo de convite). A edge magic-link-validate existe aqui também, mas pertence ao fluxo de respondente — ver Capítulo 07.

Atualizado 2026-06-10

A edge create-user-surveyor passou a viver no repositório (antes só existia deployada). Hoje roda em verify_jwt: true e é ADMIN-only, derivando a identidade do JWT via _shared/auth.ts — não confia mais no profile/userId do corpo. O frontend a chama em services/surveyors.service.ts; create-full-user (também no repo) é a antecessora.

Capítulo 02

Identidade & modelo de dados

Cada usuário autenticado tem duas linhas: uma em auth.users (Supabase Auth, guarda email, senha e tokens) e outra em uma das três tabelas-perfil do schema mydb, ligada pelo supabase_auth_id uuid. A linha do mydb carrega os dados de negócio (nome, telefone, vínculo com empresa ou pesquisa).

Não existe coluna role em lugar nenhum. O papel é derivado pela edge get-profile a partir de qual das três tabelas contém o supabase_auth_id do usuário logado. A ordem da busca é ADMIN → SURVEYOR → VIEWER; o primeiro match retorna.

Tabela-perfil Papel Colunas relevantes
mydb.users ADMIN id, name, email, password (legado), email_verified_at, supabase_auth_id.
mydb.surveyors SURVEYOR id, name, email, phone, company_id (NOT NULL, numeric), active (smallint), email_verified_at, password (legado), supabase_auth_id.
mydb.survey_viewers VIEWER id, name, email, phone, survey_id (NOT NULL, numeric), password (legado), supabase_auth_id.

O resultado da edge get-profile é serializado direto no cookie user_profile e é o que os Server Components em app/(main)/ leem para gating de UI. A forma é fixa:

types/user.type.ts typescript
export type UserRole = 'ADMIN' | 'SURVEYOR' | 'VIEWER';

export interface UserProfile {
  name: string;
  email: string;
  company_id: number | null;
  survey_id: number | null;
  role: UserRole;
}
Destaque

ADMIN tem sempre company_id: null e survey_id: null. SURVEYOR tem um company_id obrigatório e nenhum survey_id; VIEWER tem o oposto. Código que filtra "minha empresa" precisa testar company_id === null explicitamente para tratar ADMIN como "todas as empresas" — ver Capítulo 08.

Os rótulos que aparecem no header da plataforma são traduzidos em components/Header/Header.tsx: ADMIN vira USUÁRIO MESTRE, SURVEYOR vira ADMINISTRADOR DE PESQUISA, VIEWER vira VISUALIZADOR. A doc segue usando os identificadores em inglês porque são os valores no código e nas comparações de papel.

Capítulo 03

Fluxo de login

O login é submit-driven: a página é Client Component, o submit chama uma Server Action que faz o signInWithPassword, e o redirecionamento passa por uma rota intermediária (/verify-user) que monta o cookie de perfil. O usuário só chega em /surveys depois que esse cookie está no lugar.

  1. Submit no client. app/(auth)/login/page.tsx empacota o form em FormData e chama a Server Action login(). Em sucesso, router.replace('/verify-user').
  2. Server Action. app/(auth)/login/actions.ts valida com loginSchema (Zod) e chama supabase.auth.signInWithPassword usando o cliente de lib/supabase/server.ts (variante createServerClient com gerenciamento de cookies via next/headers). Os cookies sb-* com o JWT são gravados pelo SDK como efeito colateral.
  3. Tradução de erro. Mensagens do Supabase chegam em inglês; a action traduz os três casos comuns para português antes de retornar.
  4. Resolução do perfil. app/(auth)/verify-user/route.ts chama fetchUserProfileFromEdge(), que faz auth.getUser() (revalida com o servidor Supabase) e invoca a edge get-profile encaminhando o token da sessão no header Authorization. Desde 2026-06-10 a edge deriva o userId do próprio JWT (ignora qualquer userId do corpo), tenta mydb.users, mydb.surveyors e mydb.survey_viewers nessa ordem e devolve o UserProfile do primeiro match.
  5. Cookie user_profile. verify-user grava o perfil serializado como JSON, com flags httpOnly, secure, sameSite: 'lax' e maxAge: 60 * 60 (1 hora). Em caso de perfil ausente ou inconsistente (SURVEYOR sem company_id, VIEWER sem survey_id), redireciona para /login?error=unauthorized.
  6. Redirect final. verify-user redireciona para /surveys, que é a landing page autenticada para os três papéis.
app/(auth)/login/actions.ts typescript
const { error } = await supabase.auth.signInWithPassword(parsed.data);

if (error) {
  let translatedMessage = error.message;
  if (error.message === 'Invalid login credentials') {
    translatedMessage = 'E-mail ou senha incorretos.';
  } else if (error.message === 'Email not confirmed') {
    translatedMessage = 'E-mail não confirmado.';
  } else if (error.message === 'Too many requests') {
    translatedMessage = 'Muitas tentativas. Tente novamente mais tarde.';
  }
  return { success: false, formError: translatedMessage };
}

revalidatePath('/verify-user', 'layout');
return { success: true };
app/(auth)/verify-user/route.ts typescript
cookieStore.set({
  name: 'user_profile',
  value: JSON.stringify(profile satisfies UserProfile),
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/',
  maxAge: 60 * 60,
});

return NextResponse.redirect(new URL('/surveys', requestUrl));
Capítulo 04

Convite & confirmação de senha

Quem cria SURVEYOR e VIEWER é o ADMIN, pelo frontend em app/(main)/surveyors/ e app/(main)/viewers/. SURVEYOR e VIEWER não convidam ninguém. O convite passa por uma edge function que faz auth.admin.inviteUserByEmail e grava a linha correspondente em mydb.surveyors ou mydb.survey_viewers.

Criação de SURVEYOR e VIEWER

As duas edges seguem a mesma forma: create-user-surveyor e create-user-viewer. Ambas usam a SERVICE_ROLE_KEY, listam usuários do auth.users para detectar conflito por email, e ramificam em dois casos:

  • Usuário não existe. Chama auth.admin.inviteUserByEmail(email, { redirectTo: SITE_URL + '/confirmar-convite', data: {...} }). Os campos do payload (name, phone, company_id ou survey_id) viram metadata do usuário Supabase. Em seguida, a edge chama uma RPC (create_full_user_insert_surveyor ou create_full_user_insert_viewer) que insere a linha em mydb com o supabase_auth_id do novo usuário. Se a RPC falha, a edge faz rollback chamando auth.admin.deleteUser(newAuthUserId).
  • Usuário já existe mas não confirmou email. A edge chama auth.admin.generateLink({ type: 'invite' }) para reemitir o convite. Se o email já está confirmado, retorna HTTP 409 com a mensagem “Este usuário já está ativo e não pode ser convidado novamente.”

A coluna password nas tabelas-perfil é passada como 'remover-essa-coluna' nas RPCs — é placeholder legado, não é usado para autenticar. Detalhes em Capítulo 08.

Definição de senha (confirmar-convite)

O convite do Supabase chega como um email com link para SITE_URL/confirmar-convite#access_token=...&refresh_token=.... A página app/(auth)/confirmar-convite/page.tsx é Client Component e:

  1. Extrai access_token e refresh_token do hash fragment da URL (não da query string — assim os tokens não vão para o log do servidor).
  2. Chama supabase.auth.setSession({ access_token, refresh_token }) com o cliente browser.
  3. Limpa o hash com window.history.replaceState.
  4. Mostra o form de nova senha (mínimo seis caracteres, confirmação obrigatória) e dispara supabase.auth.updateUser({ password }).
  5. Redireciona para /login após três segundos.

Reset de senha

O fluxo de “esqueci minha senha” existe em paralelo ao convite, com uma diferença importante: usa o callback OAuth do Supabase. app/(auth)/esqueci-senha/actions.ts chama:

app/(auth)/esqueci-senha/actions.ts typescript
await supabase.auth.resetPasswordForEmail(parsed.data.email, {
  redirectTo: `${siteUrl}/auth/callback?next=/redefinir-senha`,
});

O email chega com um link para SITE_URL/auth/callback?code=…&next=/redefinir-senha. app/(auth)/auth/callback/route.ts troca o code por sessão (auth.exchangeCodeForSession) e redireciona para next — em produção, com proteção contra host forjado via x-forwarded-host. Se a troca falha, manda o usuário para /redefinir-senha?error=invalid.

A página app/(auth)/redefinir-senha/page.tsx tem o mesmo form de confirmar-convite, mas confirma a sessão via supabase.auth.getSession() em vez de setSession com tokens explícitos — é o único lugar do projeto que usa getSession (ver Capítulo 08).

Atenção

As duas edges de convite usam ${'$'}{Deno.env.get('SITE_URL')}/confirmar-convite como redirectTo. Se SITE_URL não estiver configurado no environment da edge, o Supabase cai no Site URL do Dashboard — em ambientes de dev, isso pode mandar o usuário para o link de produção sem aviso. A esqueci-senha usa NEXT_PUBLIC_SITE_URL do frontend Next.js (variável diferente).

Capítulo 05

Sessão & middleware

O Next.js 16 renomeou o entrypoint de middleware de middleware.ts para proxy.ts. O arquivo na raiz é mínimo — só repassa a request para updateSession:

proxy.ts typescript
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';

export async function proxy(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Três variantes do cliente

O pacote @supabase/ssr exige variantes diferentes para cada ambiente porque a forma de ler e escrever cookies muda:

Arquivo API @supabase/ssr Onde usar
lib/supabase/client.ts createBrowserClient Client Components ('use client'). Lê e escreve cookies pelo document.cookie. Único caminho que pode chamar setSession com tokens vindos do hash da URL (Capítulo 04).
lib/supabase/server.ts createServerClient Server Components, Route Handlers, Server Actions. Adapta o store de cookies do next/headers. A escrita em Server Components é silenciosamente ignorada — depende do middleware refrescar o JWT.
lib/supabase/middleware.ts createServerClient com callbacks de request.cookies/response.cookies Apenas o proxy.ts. Garante que cookies atualizados pelo SDK durante o refresh do JWT acompanhem a response do middleware.

O que o middleware faz

Em cada request, updateSession monta um NextResponse.next(), cria um cliente Supabase que propaga cookies do request para a response, chama auth.getUser() (que revalida o JWT com o servidor Supabase, não confia só no cookie), e decide se redireciona. Rotas públicas escapam do redirect:

lib/supabase/middleware.ts typescript
// IMPORTANT: DO NOT REMOVE auth.getUser()
const { data: { user } } = await supabase.auth.getUser();

if (
  !user &&
  !request.nextUrl.pathname.startsWith('/login') &&
  !request.nextUrl.pathname.startsWith('/confirmar-convite') &&
  !request.nextUrl.pathname.startsWith('/esqueci-senha') &&
  !request.nextUrl.pathname.startsWith('/redefinir-senha') &&
  !request.nextUrl.pathname.startsWith('/auth') &&
  !request.nextUrl.pathname.startsWith('/error') &&
  !request.nextUrl.pathname.startsWith('/responder-pesquisa')
) {
  const url = request.nextUrl.clone();
  url.pathname = '/login';
  return NextResponse.redirect(url);
}

O middleware não faz authorization por papel — só verifica autenticação. Bloqueio por papel acontece em cada Server Component (ex.: app/(main)/viewers/page.tsx faz if (profile.role !== 'ADMIN') redirect('/surveys')) e nas edge functions.

Como o perfil chega aos Server Components

Não é via auth.getUser() em cada página — é via leitura direta do cookie user_profile. O padrão está em components/Header/Header.tsx e em quase todas as páginas de app/(main)/*:

components/Header/Header.tsx tsx
const cookieStore = await cookies();
const cookie = cookieStore.get('user_profile');
const profile: UserProfile | null = cookie?.value
  ? (JSON.parse(cookie.value) as UserProfile)
  : null;

Isso evita uma chamada extra à edge get-profile por request, mas tem efeitos colaterais — o cookie tem TTL próprio e pode estar desatualizado (ver Capítulo 08).

Logout

services/logout.service.ts é uma Server Action curta: auth.signOut(), cookieStore.delete('user_profile') e redirect('/'). É disparado por <form action={logout}> no Header — não há JS adicional. O signOut revoga o refresh token no servidor Supabase e o middleware do próximo request encontra o usuário deslogado.

Capítulo 06

Matriz de permissões

Quatro papéis no total: três autenticados (ADMIN, SURVEYOR, VIEWER) e um modo público sem login (resposta via magic link). A tabela abaixo consolida o que cada um vê e faz. Cada célula foi conferida em código — referências entre parênteses indicam o arquivo onde o gate aparece.

Ação ADMIN SURVEYOR VIEWER Público
Faz login Sim Sim Sim Não (usa magic link)
/companies Sim, todas Sim, vê todas Bloqueado (redirect em companies/page.tsx)
/surveyors Sim, todas as empresas Sim Bloqueado (redirect em surveyors/page.tsx)
/viewers Sim Bloqueado (profile.role !== 'ADMIN' → redirect) Bloqueado
Cria pesquisa Sim, qualquer empresa Sim, só a própria empresa Não
Edita / encerra / exclui pesquisa Sim Sim, só da própria empresa Não (card só mostra Análise)
Envia magic links Sim Sim Não
Dashboard, grafo, Excel Sim, qualquer pesquisa Sim, só da própria empresa Sim, só a sua pesquisa
Convida SURVEYOR/VIEWER Sim Não Não
Responde pesquisa via token Sim, na rota /responder-pesquisa/[token]
Destaque

Operações que precisam de company_id (excluir pesquisa, criar respondente) usam o empresa_id do recurso-alvo quando o usuário é ADMIN, porque ADMIN tem profile.company_id = null. Padrão já documentado em Pesquisas — Capítulo 04; deve ser replicado em qualquer novo lugar que orquestre ações cross-empresa para ADMIN.

O enforcement de papel acontece em três camadas independentes: o Server Component esconde botões e bloqueia rotas pelo profile.role lido do cookie; o middleware bloqueia rotas autenticadas para quem não tem JWT válido; e cada edge function re-valida papel e escopo de empresa antes de qualquer escrita, usando o profile que vem no payload.

Capítulo 08

Pegadinhas & alertas de segurança

O middleware é proxy.ts, não middleware.ts

Next.js 16 renomeou o entrypoint padrão para proxy.ts. Quem procura por middleware.ts na raiz não acha nada. O arquivo lib/supabase/middleware.ts existe e segue se chamando assim por convenção do @supabase/ssr — não é o entrypoint do framework.

Papel é derivado, não armazenado

Nenhuma das três tabelas-perfil tem coluna role. O papel sai do match na ordem mydb.usersmydb.surveyorsmydb.survey_viewers. Não há unique constraint cruzada impedindo o mesmo supabase_auth_id de aparecer em mais de uma tabela. Se isso acontecer (por erro de operação), o usuário é tratado como o papel mais alto da ordem — pode silenciosamente virar ADMIN sem intenção.

getUser em todo lugar, exceto em redefinir-senha

Todo o resto do projeto usa supabase.auth.getUser(), que revalida o JWT com o servidor Supabase a cada chamada. A única exceção é app/(auth)/redefinir-senha/page.tsx, que usa supabase.auth.getSession() no client — lê só do cookie local, sem ida ao servidor. No contexto do reset (sessão acabou de ser estabelecida pelo callback) isso funciona, mas vale a inconsistência: toda nova página de auth deve usar getUser para manter o padrão.

Cookie user_profile tem TTL próprio de 1 hora

O JWT do Supabase é refrescado pelo middleware a cada request, mas o cookie user_profile não — quando expira, qualquer Server Component em app/(main)/* que faz JSON.parse(cookie.value) ou desestrutura profile.role sem checar null encontra erro. O middleware não detecta o problema porque só olha para o JWT do Supabase, não para o cookie de perfil. O caminho para resincronizar é re-passar por /verify-user, mas hoje isso só é disparado no fluxo de login.

Coluna password legada nas três tabelas-perfil

mydb.users.password, mydb.surveyors.password e mydb.survey_viewers.password existem como character varying NOT NULL e não são usados para autenticar. As edges de convite gravam o literal 'remover-essa-coluna' ali. A senha real vive em auth.users e é atualizada via supabase.auth.updateUser({ password }). As colunas são resíduo da migração de Laravel anterior ao Supabase Auth.

Edge functions são o único gate em escritas sensíveis

Atenção

As edges create-user-surveyor, create-user-viewer, surveyors-crud, viewers-crud, delete-survey e similares rodam com SERVICE_ROLE_KEY, o que bypassa RLS por construção. Quem garante que SURVEYOR não delete pesquisa de outra empresa, ou que VIEWER não escale, é a autorização da própria edge. Desde 2026-06-10 essa autorização deriva a identidade do JWT (via _shared/auth.ts: getAuthedUsergetUser(token) + resolveRole), com verify_jwt: true e regra deny-by-default — não mais do profile do corpo, que era spoofável. Escrever uma edge nova e esquecer essa validação (ou deixá-la em verify_jwt: false sem motivo) cria buraco de segurança imediato — não há rede no banco.

Cinco tabelas operacionais sem RLS

Atenção

As tabelas-perfil (mydb.users, mydb.surveyors, mydb.survey_viewers) e a tabela mydb.companies têm RLS habilitado — confirmado via Supabase MCP em 2026-05-18. As cinco que ficam expostas são as listadas no ROADMAP: mydb.survey_respondent_tokens, mydb.bulk_email_batches, mydb.bulk_whatsapp_batches, mydb.survey_messages e mydb.survey_message_respondents. Qualquer cliente com anon key consegue ler diretamente via PostgREST. A defesa hoje é que as edges relevantes rodam com SERVICE_ROLE e validam o contexto antes de ler/escrever; o vetor ainda aberto é leitura direta dos tokens de magic link pela anon key.

company_id: null é flag semântica para ADMIN

Código que filtra “minha empresa” tem que tratar ADMIN explicitamente — testar profile.company_id === null ou profile.role === 'ADMIN' antes de aplicar o filtro. Cair no else sem essa checagem trata ADMIN como SURVEYOR sem empresa e gera consultas vazias.

Divergência entre código do repo e código deployado

Resolvido em 2026-06-10: a edge create-user-surveyor passou a viver em supabase/functions/create-user-surveyor/ (assim como create-full-user/, sua antecessora) e foi endurecida (verify_jwt: true, identidade do JWT). Ainda assim, o MCP segue como fonte de verdade pro estado deployado: ~22 edges legadas continuam deployadas sem código no repo (Neurocalc experimental, fallbacks de grafo) — para lê-las em produção, usar mcp__supabase__get_edge_function com o slug. Ver o catálogo reconciliado em backend.html Cap. 08.

Variável isCeo em verify-user

app/(auth)/verify-user/route.ts tem const isCeo = role === 'SURVEYOR'. O nome é confuso: a flag identifica papel SURVEYOR, não CEO. É naming pendente de limpeza, não regra de negócio.