Autentica homologação com Supabase real e resolve workspace no servidor
Duas lacunas impediam qualquer uso de homologação. Primeira: sem SSO do Elo Pro e sem o login local, não havia como entrar. Segunda, mais grave: nada no repositório gravava o cookie `elo_pro_workspace_id`, que era lido como origem do workspace ativo — mesmo com sessão válida a resposta era sempre "sem workspace". - `identity-provider.ts` define o contrato de identidade. Trocar o Supabase de homologação pelo SSO do Elo Pro passa a ser acrescentar uma implementação; nenhum outro arquivo conhece o mecanismo. - `/api/auth/staging/session` autentica com `signInWithPassword` no projeto Supabase de homologação. Há senha, emitida e validada pelo Supabase, e o Adaptador continua sem tabela de usuário, senha ou sessão própria. - O login local sem senha existe apenas em `ADAPTER_ENV=development`, e não por configuração: fora de development o provedor nem entra na lista. - `workspace.ts` resolve o workspace por associação real lida sob RLS. O cookie passa a ser preferência, não prova: aceitá-lo como autorização transformava um valor editável pelo cliente em credencial. - `/api/auth/workspace` grava a preferência só depois de confirmar a associação, e `/selecionar-workspace` existe para quem participa de mais de um workspace. - Rate limiting por usuário e bucket nas rotas do módulo, com Retry-After. Upload e processamento custam muito mais que leitura. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b972589949
commit
f5560cc4ac
15 files changed
+586
-55
No files matched your search
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
|
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Fconfiguracoes");
|
||||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||||
if (!canManageSpreadsheetModels(auth.ctx)) {
|
if (!canManageSpreadsheetModels(auth.ctx)) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function OperationPage() {
|
export default async function OperationPage() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
|
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador%2Foperacao");
|
||||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||||
return (
|
return (
|
||||||
<AppShell ctx={auth.ctx}>
|
<AppShell ctx={auth.ctx}>
|
||||||
|
|||||||
@@ -10,10 +10,17 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function AdapterHome() {
|
export default async function AdapterHome() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
|
if (auth.kind === "workspace_choice") redirect("/selecionar-workspace?next=%2Fadaptador");
|
||||||
if (auth.kind !== "ok") {
|
if (auth.kind !== "ok") {
|
||||||
return (
|
return (
|
||||||
<main className="p-8">
|
<main className="p-8">
|
||||||
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
|
<ErrorState
|
||||||
|
message={
|
||||||
|
auth.kind === "no_workspace"
|
||||||
|
? "Sua identidade não está associada a nenhum workspace."
|
||||||
|
: "Você não é membro deste workspace."
|
||||||
|
}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { supabaseServerClient, DEV_COOKIE, WORKSPACE_COOKIE } from "@/server/auth/identity-provider";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const jar = await cookies();
|
const jar = await cookies();
|
||||||
jar.delete("adapter_dev_session");
|
jar.delete(DEV_COOKIE);
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
jar.delete(WORKSPACE_COOKIE);
|
||||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
const supabase = supabaseServerClient(jar);
|
||||||
if (url && key) {
|
if (supabase) await supabase.auth.signOut();
|
||||||
const supabase = createServerClient(url, key, {
|
|
||||||
cookies: {
|
|
||||||
getAll: () => jar.getAll(),
|
|
||||||
setAll: (items) => {
|
|
||||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
}
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { stagingAuthEnabled } from "@/config/env";
|
||||||
|
import { supabaseServerClient } from "@/server/auth/identity-provider";
|
||||||
|
import { hitRateLimit, requestSubject } from "@/server/rate-limit";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login de homologação com Supabase Auth real do projeto de homologação.
|
||||||
|
* Não é o dev auth: há senha, a sessão é emitida e validada pelo Supabase e o
|
||||||
|
* Adaptador continua sem tabela de usuário ou de senha própria. Indisponível
|
||||||
|
* em development e em produção.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!stagingAuthEnabled()) {
|
||||||
|
return NextResponse.json({ error: { code: "NOT_FOUND", message: "Indisponível." } }, { status: 404 });
|
||||||
|
}
|
||||||
|
const limit = await hitRateLimit("login", requestSubject(request));
|
||||||
|
if (!limit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "RATE_LIMITED", message: "Muitas tentativas. Aguarde e tente de novo." } },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(limit.retryAfterSeconds) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { email?: string; password?: string };
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as { email?: string; password?: string };
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
|
||||||
|
}
|
||||||
|
const email = body.email?.trim();
|
||||||
|
const password = body.password;
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "UNAUTHENTICATED", message: "Informe e-mail e senha de homologação." } },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jar = await cookies();
|
||||||
|
const supabase = supabaseServerClient(jar);
|
||||||
|
if (!supabase) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "MISCONFIGURED", message: "Supabase de homologação não configurado." } },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
if (error || !data.user) {
|
||||||
|
// Nunca devolve o motivo exato: não confirma existência de conta.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "UNAUTHENTICATED", message: "Credenciais não reconhecidas." } },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isProductionEnv } from "@/config/env";
|
||||||
|
import { getDb } from "@/infra/db/client";
|
||||||
|
import { currentMemberships, WORKSPACE_COOKIE } from "@/server/auth/context";
|
||||||
|
import { assertMembership } from "@/server/auth/workspace";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Associações do usuário autenticado. */
|
||||||
|
export async function GET() {
|
||||||
|
const current = await currentMemberships();
|
||||||
|
if (!current) {
|
||||||
|
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ memberships: current.memberships });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grava a preferência de workspace ativo. O cookie resultante é só preferência:
|
||||||
|
* toda requisição revalida a associação no servidor. Gravar aqui sem checar
|
||||||
|
* membership transformaria o cookie em credencial.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const current = await currentMemberships();
|
||||||
|
if (!current) {
|
||||||
|
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Sessão necessária." } }, { status: 401 });
|
||||||
|
}
|
||||||
|
let body: { workspaceId?: string };
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as { workspaceId?: string };
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { code: "INVALID_BODY", message: "Corpo inválido." } }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!body.workspaceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "WORKSPACE_REQUIRED", message: "Informe o workspace." } },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const db = await getDb();
|
||||||
|
try {
|
||||||
|
await assertMembership(db, current.userId, body.workspaceId);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "WORKSPACE_FORBIDDEN", message: "Você não é membro deste workspace." } },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const jar = await cookies();
|
||||||
|
jar.set(WORKSPACE_COOKIE, body.workspaceId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: isProductionEnv() || process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 12,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, workspaceId: body.workspaceId });
|
||||||
|
}
|
||||||
+23
-4
@@ -1,9 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { adapterEnv } from "@/config/env";
|
||||||
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
||||||
import { resolveAuth } from "@/server/auth/context";
|
import { resolveAuth } from "@/server/auth/context";
|
||||||
import { devAuthEnabled } from "@/server/auth/dev-session";
|
import { authMode } from "@/server/auth/identity-provider";
|
||||||
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||||
import { DevLogin } from "@/components/dev-login";
|
import { DevLogin } from "@/components/dev-login";
|
||||||
|
import { StagingLogin } from "@/components/staging-login";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,10 +14,14 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
|||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
const nextPath = safeReturnPath(params.next);
|
const nextPath = safeReturnPath(params.next);
|
||||||
if (auth.kind === "ok") redirect(nextPath);
|
if (auth.kind === "ok") redirect(nextPath);
|
||||||
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
|
if (auth.kind === "workspace_choice") redirect(`/selecionar-workspace?next=${encodeURIComponent(nextPath)}`);
|
||||||
|
|
||||||
|
const mode = authMode();
|
||||||
|
if (mode === "elo-pro-sso" && process.env.ELO_PRO_LOGIN_URL) {
|
||||||
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
|
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
|
||||||
}
|
}
|
||||||
const expired = auth.kind === "session_expired" || params.reason === "expired";
|
const expired = auth.kind === "session_expired" || params.reason === "expired";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl p-8">
|
<main className="mx-auto max-w-xl p-8">
|
||||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||||
@@ -23,8 +29,21 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis
|
|||||||
<p className="mt-3 text-sm text-ink/80">
|
<p className="mt-3 text-sm text-ink/80">
|
||||||
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
||||||
</p>
|
</p>
|
||||||
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
|
{adapterEnv() === "staging" ? (
|
||||||
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
|
<p className="mt-3 inline-flex rounded-full bg-warning/15 px-3 py-1 text-xs font-semibold">
|
||||||
|
Ambiente de homologação
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente.</p> : null}
|
||||||
|
|
||||||
|
{mode === "dev" ? <DevLogin identities={DEV_IDENTITIES} /> : null}
|
||||||
|
{mode === "staging-supabase" ? <StagingLogin next={nextPath} /> : null}
|
||||||
|
{mode === "unconfigured" ? (
|
||||||
|
<p className="mt-6 text-sm">
|
||||||
|
Autenticação não configurada. Em produção defina <code>ELO_PRO_LOGIN_URL</code>; em homologação defina{" "}
|
||||||
|
<code>ADAPTER_STAGING_AUTH=1</code> com o projeto Supabase de homologação.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { currentMemberships } from "@/server/auth/context";
|
||||||
|
import { safeReturnPath } from "@/server/auth/return-to";
|
||||||
|
import { WorkspacePicker } from "@/components/workspace-picker";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SelecionarWorkspacePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ next?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const nextPath = safeReturnPath(params.next);
|
||||||
|
const current = await currentMemberships();
|
||||||
|
if (!current) redirect(`/login?next=${encodeURIComponent(nextPath)}`);
|
||||||
|
if (current.memberships.length === 0) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-xl p-8">
|
||||||
|
<h1 className="text-2xl font-semibold">Nenhum workspace</h1>
|
||||||
|
<p className="mt-3 text-sm text-ink/80">
|
||||||
|
Sua identidade não está associada a nenhum workspace. Peça a um administrador do Elo Pro para incluir seu
|
||||||
|
acesso.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-xl p-8">
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||||
|
<h1 className="mt-2 text-2xl font-semibold">Workspace ativo</h1>
|
||||||
|
<WorkspacePicker memberships={current.memberships} next={nextPath} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, TextInput } from "./ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login de homologação. A credencial vai direto ao Supabase Auth do projeto de
|
||||||
|
* homologação: o Adaptador não guarda senha nem cria cadastro.
|
||||||
|
*/
|
||||||
|
export function StagingLogin({ next }: { next: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-6 space-y-3"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/staging/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as { error?: { message?: string } } | null;
|
||||||
|
setMessage(body?.error?.message ?? "Não foi possível entrar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block text-sm font-medium" htmlFor="staging-email">
|
||||||
|
E-mail de homologação
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="staging-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="block text-sm font-medium" htmlFor="staging-password">
|
||||||
|
Senha
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="staging-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={pending} className="w-full">
|
||||||
|
Entrar na homologação
|
||||||
|
</Button>
|
||||||
|
{message ? (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-ink/70">
|
||||||
|
Contas de homologação são criadas no projeto Supabase de homologação pela operação. Em produção esta tela não
|
||||||
|
existe: a identidade é a do Elo Pro.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Card } from "./ui";
|
||||||
|
|
||||||
|
export type WorkspaceOption = {
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
role: string;
|
||||||
|
moduleEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escolha do workspace ativo. A seleção grava apenas preferência: o servidor
|
||||||
|
* revalida a associação em toda requisição.
|
||||||
|
*/
|
||||||
|
export function WorkspacePicker({ memberships, next }: { memberships: WorkspaceOption[]; next: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mt-6">
|
||||||
|
<h2 className="text-lg font-semibold">Selecione o workspace</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink/80">
|
||||||
|
Você participa de mais de um workspace. O Adaptador trabalha em um por vez.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{memberships.map((membership) => (
|
||||||
|
<Button
|
||||||
|
key={membership.workspaceId}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full justify-between"
|
||||||
|
loading={pending === membership.workspaceId}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(membership.workspaceId);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/workspace", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ workspaceId: membership.workspaceId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setMessage("Não foi possível selecionar este workspace.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{membership.workspaceName}</span>
|
||||||
|
<span className="text-xs font-normal text-ink/70">
|
||||||
|
{membership.role}
|
||||||
|
{membership.moduleEnabled ? "" : " · módulo desligado"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{message ? (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-danger">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-2
@@ -1,9 +1,14 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
/** Caminhos que exigem alguma sessão. A autorização real é no servidor. */
|
||||||
|
const GUARDED = ["/adaptador", "/api/spreadsheet-adapter", "/selecionar-workspace"];
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const guarded = pathname.startsWith("/adaptador") || pathname.startsWith("/api/spreadsheet-adapter");
|
const guarded = GUARDED.some((prefix) => pathname.startsWith(prefix));
|
||||||
if (!guarded) return NextResponse.next();
|
if (!guarded) return NextResponse.next();
|
||||||
|
// Presença de cookie é só um atalho para evitar render desnecessário.
|
||||||
|
// Nenhuma decisão de autorização acontece aqui.
|
||||||
const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
|
const hasDev = Boolean(request.cookies.get("adapter_dev_session")?.value);
|
||||||
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
const hasSupabase = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||||
if (hasDev || hasSupabase) return NextResponse.next();
|
if (hasDev || hasSupabase) return NextResponse.next();
|
||||||
@@ -12,10 +17,11 @@ export function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const login = request.nextUrl.clone();
|
const login = request.nextUrl.clone();
|
||||||
login.pathname = "/login";
|
login.pathname = "/login";
|
||||||
|
login.search = "";
|
||||||
login.searchParams.set("next", pathname);
|
login.searchParams.set("next", pathname);
|
||||||
return NextResponse.redirect(login);
|
return NextResponse.redirect(login);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*"],
|
matcher: ["/adaptador/:path*", "/api/spreadsheet-adapter/:path*", "/selecionar-workspace"],
|
||||||
};
|
};
|
||||||
+36
-32
@@ -1,49 +1,45 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
|
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
|
||||||
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
|
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||||
import { getDb } from "@/infra/db/client";
|
import { getDb } from "@/infra/db/client";
|
||||||
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
import { activeProviders, WORKSPACE_COOKIE, type Identity } from "./identity-provider";
|
||||||
|
import { listMemberships, resolveWorkspace, type Membership } from "./workspace";
|
||||||
|
|
||||||
export type AuthState =
|
export type AuthState =
|
||||||
| { kind: "anonymous" }
|
| { kind: "anonymous" }
|
||||||
| { kind: "session_expired" }
|
| { kind: "session_expired" }
|
||||||
| { kind: "no_workspace" }
|
| { kind: "no_workspace" }
|
||||||
| { kind: "not_member" }
|
| { kind: "not_member" }
|
||||||
|
| { kind: "workspace_choice"; memberships: Membership[] }
|
||||||
| { kind: "ok"; ctx: AuthenticatedContext };
|
| { kind: "ok"; ctx: AuthenticatedContext };
|
||||||
|
|
||||||
export async function resolveAuth(): Promise<AuthState> {
|
export { WORKSPACE_COOKIE };
|
||||||
|
|
||||||
|
async function resolveIdentity(): Promise<{ identity: Identity | null; hadHint: boolean }> {
|
||||||
const jar = await cookies();
|
const jar = await cookies();
|
||||||
let userId: string | null = null;
|
const providers = activeProviders();
|
||||||
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
|
let hadHint = false;
|
||||||
const devToken = jar.get("adapter_dev_session")?.value;
|
for (const provider of providers) {
|
||||||
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
if (provider.hadSessionHint(jar)) hadHint = true;
|
||||||
if (devToken && devAuthEnabled()) {
|
const identity = await provider.resolve(jar);
|
||||||
const dev = verifyDevSession(devToken);
|
if (identity) return { identity, hadHint };
|
||||||
if (dev) {
|
|
||||||
userId = dev.userId;
|
|
||||||
workspaceId = dev.workspaceId;
|
|
||||||
}
|
}
|
||||||
}
|
return { identity: null, hadHint };
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
}
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
if (!userId && supabaseUrl && supabaseKey) {
|
export async function resolveAuth(): Promise<AuthState> {
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseKey, {
|
const { identity, hadHint } = await resolveIdentity();
|
||||||
cookies: {
|
if (!identity) return { kind: hadHint ? "session_expired" : "anonymous" };
|
||||||
getAll: () => jar.getAll(),
|
|
||||||
setAll: (items) => {
|
|
||||||
for (const item of items) jar.set(item.name, item.value, item.options);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { data } = await supabase.auth.getUser();
|
|
||||||
userId = data.user?.id ?? null;
|
|
||||||
}
|
|
||||||
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
|
|
||||||
if (!workspaceId) return { kind: "no_workspace" };
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.withUser(userId, (query) =>
|
const memberships = await listMemberships(db, identity.userId);
|
||||||
|
const resolution = resolveWorkspace(memberships, identity.workspaceHint);
|
||||||
|
if (resolution.kind === "none") return { kind: "no_workspace" };
|
||||||
|
if (resolution.kind === "choice") return { kind: "workspace_choice", memberships: resolution.memberships };
|
||||||
|
|
||||||
|
const workspaceId = resolution.workspaceId;
|
||||||
|
const rows = await db.withUser(identity.userId, (query) =>
|
||||||
query.query<{
|
query.query<{
|
||||||
display_name: string;
|
display_name: string;
|
||||||
is_superadmin: boolean;
|
is_superadmin: boolean;
|
||||||
@@ -61,13 +57,13 @@ export async function resolveAuth(): Promise<AuthState> {
|
|||||||
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||||
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
|
||||||
where p.id = $1`,
|
where p.id = $1`,
|
||||||
[userId, workspaceId],
|
[identity.userId, workspaceId],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) return { kind: "not_member" };
|
if (!row) return { kind: "not_member" };
|
||||||
const ctx = {
|
const ctx = {
|
||||||
userId,
|
userId: identity.userId,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
isSuperadmin: row.is_superadmin,
|
isSuperadmin: row.is_superadmin,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -78,3 +74,11 @@ export async function resolveAuth(): Promise<AuthState> {
|
|||||||
};
|
};
|
||||||
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
|
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lista as associações do usuário autenticado, para a tela de escolha. */
|
||||||
|
export async function currentMemberships(): Promise<{ userId: string; memberships: Membership[] } | null> {
|
||||||
|
const { identity } = await resolveIdentity();
|
||||||
|
if (!identity) return null;
|
||||||
|
const db = await getDb();
|
||||||
|
return { userId: identity.userId, memberships: await listMemberships(db, identity.userId) };
|
||||||
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { adapterEnv } from "@/config/env";
|
||||||
|
|
||||||
export type DevSession = { userId: string; workspaceId: string; exp: number };
|
export type DevSession = { userId: string; workspaceId: string; exp: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessão local sem senha. Existe apenas em `ADAPTER_ENV=development`.
|
||||||
|
* Homologação pública usa Supabase Auth real — ver `stagingAuthEnabled`.
|
||||||
|
*
|
||||||
|
* A autoridade é `ADAPTER_ENV`, não `NODE_ENV`: a imagem roda sempre com
|
||||||
|
* `NODE_ENV=production` (build otimizado), inclusive no compose local. E o
|
||||||
|
* padrão protege o caso perigoso: sem `ADAPTER_ENV` explícito, um processo com
|
||||||
|
* `NODE_ENV=production` resolve para `production` e esta função devolve false.
|
||||||
|
*/
|
||||||
export function devAuthEnabled(): boolean {
|
export function devAuthEnabled(): boolean {
|
||||||
return process.env.NODE_ENV !== "production" && process.env.ADAPTER_DEV_AUTH === "1" && Boolean(process.env.ADAPTER_DEV_AUTH_SECRET);
|
return (
|
||||||
|
adapterEnv() === "development" &&
|
||||||
|
process.env.ADAPTER_DEV_AUTH === "1" &&
|
||||||
|
Boolean(process.env.ADAPTER_DEV_AUTH_SECRET)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function secret(): string {
|
function secret(): string {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { cookies } from "next/headers";
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { adapterEnv, stagingAuthEnabled } from "@/config/env";
|
||||||
|
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
||||||
|
|
||||||
|
export type CookieJar = Awaited<ReturnType<typeof cookies>>;
|
||||||
|
|
||||||
|
/** Identidade resolvida. O Adaptador nunca cria usuário, senha ou cadastro. */
|
||||||
|
export type Identity = {
|
||||||
|
userId: string;
|
||||||
|
/** Workspace sugerido pelo provedor. É dica, nunca autorização. */
|
||||||
|
workspaceHint: string | null;
|
||||||
|
source: "supabase" | "dev";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface de autenticação. Hoje há duas implementações sobre o mesmo
|
||||||
|
* Supabase Auth — o projeto próprio de homologação e o projeto do Elo Pro —
|
||||||
|
* e uma de desenvolvimento local. Quando o SSO do Elo Pro existir, basta uma
|
||||||
|
* nova implementação deste contrato: nada fora daqui conhece o mecanismo.
|
||||||
|
*/
|
||||||
|
export interface IdentityProvider {
|
||||||
|
readonly name: string;
|
||||||
|
/** Identidade da requisição, ou null quando não há sessão válida. */
|
||||||
|
resolve(jar: CookieJar): Promise<Identity | null>;
|
||||||
|
/** Indica se havia resquício de sessão, para distinguir expirada de anônima. */
|
||||||
|
hadSessionHint(jar: CookieJar): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPABASE_COOKIE_HINT = (name: string) => name.startsWith("sb-") && name.includes("auth");
|
||||||
|
|
||||||
|
export function supabaseServerClient(jar: CookieJar) {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
if (!url || !key) return null;
|
||||||
|
return createServerClient(url, key, {
|
||||||
|
cookies: {
|
||||||
|
getAll: () => jar.getAll(),
|
||||||
|
setAll: (items) => {
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
jar.set(item.name, item.value, item.options);
|
||||||
|
} catch {
|
||||||
|
// Server Components não deixam gravar cookie. O refresh acontece na próxima rota.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supabase Auth. Serve o projeto próprio de homologação e o do Elo Pro. */
|
||||||
|
export const supabaseIdentityProvider: IdentityProvider = {
|
||||||
|
name: "supabase",
|
||||||
|
async resolve(jar) {
|
||||||
|
const supabase = supabaseServerClient(jar);
|
||||||
|
if (!supabase) return null;
|
||||||
|
const { data } = await supabase.auth.getUser();
|
||||||
|
const userId = data.user?.id ?? null;
|
||||||
|
if (!userId) return null;
|
||||||
|
return { userId, workspaceHint: jar.get(WORKSPACE_COOKIE)?.value ?? null, source: "supabase" };
|
||||||
|
},
|
||||||
|
hadSessionHint(jar) {
|
||||||
|
return jar.getAll().some((cookie) => SUPABASE_COOKIE_HINT(cookie.name));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sessão local assinada, sem senha. Existe apenas em development. */
|
||||||
|
export const devIdentityProvider: IdentityProvider = {
|
||||||
|
name: "dev",
|
||||||
|
async resolve(jar) {
|
||||||
|
if (!devAuthEnabled()) return null;
|
||||||
|
const token = jar.get(DEV_COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
const session = verifyDevSession(token);
|
||||||
|
if (!session) return null;
|
||||||
|
return { userId: session.userId, workspaceHint: session.workspaceId, source: "dev" };
|
||||||
|
},
|
||||||
|
hadSessionHint(jar) {
|
||||||
|
return devAuthEnabled() && Boolean(jar.get(DEV_COOKIE)?.value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEV_COOKIE = "adapter_dev_session";
|
||||||
|
export const WORKSPACE_COOKIE = "elo_pro_workspace_id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provedores na ordem de precedência do ambiente. Em staging e produção o
|
||||||
|
* provedor de desenvolvimento não entra na lista — não é questão de
|
||||||
|
* configuração, é ausência no caminho de código.
|
||||||
|
*/
|
||||||
|
export function activeProviders(): IdentityProvider[] {
|
||||||
|
if (adapterEnv() === "development") return [devIdentityProvider, supabaseIdentityProvider];
|
||||||
|
return [supabaseIdentityProvider];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Descreve como o ambiente atual autentica. Usado pela tela de login. */
|
||||||
|
export function authMode(): "dev" | "staging-supabase" | "elo-pro-sso" | "unconfigured" {
|
||||||
|
if (devAuthEnabled()) return "dev";
|
||||||
|
if (stagingAuthEnabled()) return "staging-supabase";
|
||||||
|
if (process.env.ELO_PRO_LOGIN_URL) return "elo-pro-sso";
|
||||||
|
return "unconfigured";
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Db } from "@/infra/db/types";
|
||||||
|
import type { Role } from "@/domain/spreadsheet-adapter/types";
|
||||||
|
|
||||||
|
export type Membership = {
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
role: Role;
|
||||||
|
moduleEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceResolution =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "resolved"; workspaceId: string }
|
||||||
|
| { kind: "choice"; memberships: Membership[] };
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isWorkspaceId(value: string | null | undefined): value is string {
|
||||||
|
return typeof value === "string" && UUID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associações reais do usuário, lidas com o papel `app_user` sob RLS.
|
||||||
|
* Esta é a única fonte de autorização de workspace.
|
||||||
|
*/
|
||||||
|
export async function listMemberships(db: Db, userId: string): Promise<Membership[]> {
|
||||||
|
const rows = await db.withUser(userId, (query) =>
|
||||||
|
query.query<{
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_name: string;
|
||||||
|
role: Role;
|
||||||
|
module_enabled: boolean;
|
||||||
|
}>(
|
||||||
|
`select w.id as workspace_id, w.name as workspace_name, m.role,
|
||||||
|
coalesce(f.enabled, false) as module_enabled
|
||||||
|
from public.workspace_members m
|
||||||
|
join public.workspaces w on w.id = m.workspace_id
|
||||||
|
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
|
||||||
|
where m.user_id = $1
|
||||||
|
order by w.name asc`,
|
||||||
|
[userId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
workspaceName: row.workspace_name,
|
||||||
|
role: row.role,
|
||||||
|
moduleEnabled: row.module_enabled,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve o workspace no servidor. O cookie da plataforma é apenas uma dica de
|
||||||
|
* preferência: se não corresponder a uma associação real ele é ignorado, nunca
|
||||||
|
* aceito como prova. Com uma única associação a escolha é automática; com mais
|
||||||
|
* de uma e sem dica válida, quem escolhe é a pessoa.
|
||||||
|
*/
|
||||||
|
export function resolveWorkspace(memberships: Membership[], hint: string | null | undefined): WorkspaceResolution {
|
||||||
|
if (memberships.length === 0) return { kind: "none" };
|
||||||
|
if (isWorkspaceId(hint)) {
|
||||||
|
const match = memberships.find((item) => item.workspaceId.toLowerCase() === hint.toLowerCase());
|
||||||
|
if (match) return { kind: "resolved", workspaceId: match.workspaceId };
|
||||||
|
}
|
||||||
|
if (memberships.length === 1) return { kind: "resolved", workspaceId: memberships[0].workspaceId };
|
||||||
|
return { kind: "choice", memberships };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirma que o usuário é membro antes de gravar a preferência no cookie. */
|
||||||
|
export async function assertMembership(db: Db, userId: string, workspaceId: string): Promise<Membership> {
|
||||||
|
if (!isWorkspaceId(workspaceId)) throw new Error("workspace inválido");
|
||||||
|
const memberships = await listMemberships(db, userId);
|
||||||
|
const match = memberships.find((item) => item.workspaceId.toLowerCase() === workspaceId.toLowerCase());
|
||||||
|
if (!match) throw new Error("associação não encontrada");
|
||||||
|
return match;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user