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.
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 chamasupabase.auth.signInWithPassword. - app/(auth)/verify-user/route.ts
- Route Handler GET intermediário: invoca a edge
get-profilepara descobrir o papel do usuário e grava o cookieuser_profile. Redireciona para/surveysem caso de sucesso, ou para/login?error=unauthorizedse 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.resetPasswordForEmailcomredirectToapontando para/auth/callback?next=/redefinir-senha. - app/(auth)/auth/callback/route.ts
- Callback do reset. Troca o
codeda query por sessão (auth.exchangeCodeForSession) e segue para onextrecebido (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 aplicaauth.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 comauth.getUser()e invoca a edgeget-profile. Usado pelo/verify-user.- services/logout.service.ts
- Server Action de logout:
auth.signOut(), apaga o cookieuser_profilee redireciona para/. - lib/zod/login.validation.ts
- Único schema Zod do módulo. Valida
email(formato) epassword(mínimo de seis caracteres). - types/user.type.ts
- Tipos
UserRole('ADMIN' | 'SURVEYOR' | 'VIEWER') eUserProfile({ 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.
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.
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:
export type UserRole = 'ADMIN' | 'SURVEYOR' | 'VIEWER';
export interface UserProfile {
name: string;
email: string;
company_id: number | null;
survey_id: number | null;
role: UserRole;
}
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.
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.
-
Submit no client.
app/(auth)/login/page.tsxempacota o form emFormDatae chama a Server Actionlogin(). Em sucesso,router.replace('/verify-user'). -
Server Action.
app/(auth)/login/actions.tsvalida comloginSchema(Zod) e chamasupabase.auth.signInWithPasswordusando o cliente delib/supabase/server.ts(variantecreateServerClientcom gerenciamento de cookies vianext/headers). Os cookiessb-*com o JWT são gravados pelo SDK como efeito colateral. - 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.
-
Resolução do perfil.
app/(auth)/verify-user/route.tschamafetchUserProfileFromEdge(), que fazauth.getUser()(revalida com o servidor Supabase) e invoca a edgeget-profileencaminhando o token da sessão no headerAuthorization. Desde 2026-06-10 a edge deriva ouserIddo próprio JWT (ignora qualqueruserIddo corpo), tentamydb.users,mydb.surveyorsemydb.survey_viewersnessa ordem e devolve oUserProfiledo primeiro match. -
Cookie
user_profile.verify-usergrava o perfil serializado como JSON, com flagshttpOnly,secure,sameSite: 'lax'emaxAge: 60 * 60(1 hora). Em caso de perfil ausente ou inconsistente (SURVEYORsemcompany_id,VIEWERsemsurvey_id), redireciona para/login?error=unauthorized. -
Redirect final.
verify-userredireciona para/surveys, que é a landing page autenticada para os três papéis.
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 };
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));
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_idousurvey_id) viram metadata do usuário Supabase. Em seguida, a edge chama uma RPC (create_full_user_insert_surveyoroucreate_full_user_insert_viewer) que insere a linha emmydbcom osupabase_auth_iddo novo usuário. Se a RPC falha, a edge faz rollback chamandoauth.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:
- Extrai
access_tokenerefresh_tokendo hash fragment da URL (não da query string — assim os tokens não vão para o log do servidor). - Chama
supabase.auth.setSession({ access_token, refresh_token })com o cliente browser. - Limpa o hash com
window.history.replaceState. - Mostra o form de nova senha (mínimo seis caracteres, confirmação obrigatória) e dispara
supabase.auth.updateUser({ password }). - Redireciona para
/loginapó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:
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).
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).
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:
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:
// 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)/*:
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.
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] |
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.
Magic link de respondente
Existe um caminho de acesso paralelo, sem login, para que pessoas
cadastradas como respondentes de uma pesquisa preencham o formulário.
Esse fluxo usa tokens efêmeros em
mydb.survey_respondent_tokens — não envolve
auth.users nem o cookie user_profile.
A rota /responder-pesquisa/[token] está na lista de
bypass do middleware (Capítulo 05), e a validação é feita pela edge
function magic-link-validate, que confere hash SHA-256
do token bruto, expires_at e revoked_at
contra a tabela. Detalhamento do formato do token, expiração,
revogação e do fluxo da página em
Resposta Pública e
Envio (Magic Links).
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
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
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.