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.users → mydb.surveyors → mydb.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: getAuthedUser → getUser(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.