From 5309f75a7a5de82723f1265f04c35d52de1e272a Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 12:40:40 +0000 Subject: [PATCH 1/2] feat(auth): login com Better Auth no lugar do Clerk (ADR-033, PR 2/4) Swap direto: requireWorkspaceContext e isAdminSession passam a usar o Better Auth; sai o proxy e o ClerkProvider. Login por codigo de e-mail (+ Google/GitHub quando configurados), onboarding (nome, convites pendentes, criar/escolher workspace), pagina de aceite de convite, menu da conta no header e dialogo basico de convite. Fast path de 1 query no contexto. BETTER_AUTH_URL derivado da URL da branch nos Previews da Vercel. Clerk (pacote, webhook, mappers) so sai no PR 4. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- .../(app)/cadastros/invite-members-button.tsx | 71 ++++++++- .../(auth)/sign-in/[[...sign-in]]/page.tsx | 13 -- src/app/(auth)/sign-in/page.tsx | 36 +++++ src/app/(auth)/sign-in/sign-in-form.tsx | 140 ++++++++++++++++++ .../(auth)/sign-up/[[...sign-up]]/page.tsx | 12 -- src/app/(auth)/sign-up/page.tsx | 6 + .../[id]/accept-invitation.tsx | 41 +++++ src/app/accept-invitation/[id]/page.tsx | 27 ++++ src/app/layout.tsx | 12 +- src/app/onboarding/invitations.tsx | 61 ++++++++ src/app/onboarding/onboarding-panel.tsx | 126 ++++++++++++++++ src/app/onboarding/page.tsx | 26 ++-- src/components/account-menu.tsx | 75 ++++++++++ src/components/app-header.tsx | 14 +- src/lib/safe-next.test.ts | 17 +++ src/lib/safe-next.ts | 12 ++ src/lib/workspace-slug.test.ts | 14 ++ src/lib/workspace-slug.ts | 6 + src/proxy.ts | 17 --- src/server/auth/env.test.ts | 6 + src/server/auth/env.ts | 6 + src/server/auth/workspace-context.test.ts | 13 ++ src/server/auth/workspace-context.ts | 51 ++++++- src/server/session-role.ts | 13 +- src/server/workspace-context.ts | 107 +------------ 25 files changed, 724 insertions(+), 198 deletions(-) delete mode 100644 src/app/(auth)/sign-in/[[...sign-in]]/page.tsx create mode 100644 src/app/(auth)/sign-in/page.tsx create mode 100644 src/app/(auth)/sign-in/sign-in-form.tsx delete mode 100644 src/app/(auth)/sign-up/[[...sign-up]]/page.tsx create mode 100644 src/app/(auth)/sign-up/page.tsx create mode 100644 src/app/accept-invitation/[id]/accept-invitation.tsx create mode 100644 src/app/accept-invitation/[id]/page.tsx create mode 100644 src/app/onboarding/invitations.tsx create mode 100644 src/app/onboarding/onboarding-panel.tsx create mode 100644 src/components/account-menu.tsx create mode 100644 src/lib/safe-next.test.ts create mode 100644 src/lib/safe-next.ts create mode 100644 src/lib/workspace-slug.test.ts create mode 100644 src/lib/workspace-slug.ts delete mode 100644 src/proxy.ts diff --git a/src/app/(app)/cadastros/invite-members-button.tsx b/src/app/(app)/cadastros/invite-members-button.tsx index 31e7b6f..0c7f307 100644 --- a/src/app/(app)/cadastros/invite-members-button.tsx +++ b/src/app/(app)/cadastros/invite-members-button.tsx @@ -1,19 +1,74 @@ "use client"; -import { useClerk } from "@clerk/nextjs"; import { UserPlus } from "lucide-react"; +import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { authClient } from "@/lib/auth-client"; /** - * Invites are handled by Clerk's organization panel (ADR-033: no custom UI on Clerk's API, to keep the exit cheap). - * The panel lists members, sends e-mail invitations with a role (admin or member), shows the pending ones and revokes them. + * Sends an e-mail invitation through Better Auth (the organization plugin; the invite expires in 48 h and only + * the invited e-mail can accept it). Listing/revoking pending invites and changing roles arrive with PR 3 (ADR-033). */ export function InviteMembersButton() { - const { openOrganizationProfile } = useClerk(); + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setMessage(null); + const { error } = await authClient.organization.inviteMember({ email: email.trim().toLowerCase(), role, resend: true }); + setBusy(false); + if (error) return setMessage({ ok: false, text: "Não foi possível enviar o convite. Confira o e-mail e se você é administrador." }); + setMessage({ ok: true, text: `Convite enviado para ${email.trim()}. Vale por 48 horas.` }); + setEmail(""); + } + return ( - + { setOpen(next); if (!next) setMessage(null); }}> + + + + + + Convidar pessoa + Ela recebe um e-mail com o link. Entra com esse mesmo e-mail e aceita o convite. + +
+
+ + setEmail(e.target.value)} /> +
+
+ + +
+ {message && ( +

+ {message.text} +

+ )} + +
+
+
); } diff --git a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx deleted file mode 100644 index 4bfa10d..0000000 --- a/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { SignIn } from "@clerk/nextjs"; -import { Suspense } from "react"; - -export const metadata = { title: "Entrar" }; - -// Clerk's UI reads the pathname on the client; Next 16 (cacheComponents) needs it behind Suspense. -export default function SignInPage() { - return ( - }> - - - ); -} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..20d5465 --- /dev/null +++ b/src/app/(auth)/sign-in/page.tsx @@ -0,0 +1,36 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { safeNext } from "@/lib/safe-next"; +import { getAuth } from "@/server/auth/auth"; +import { getAuthEnv } from "@/server/auth/env"; +import { SignInForm, UnavailableNotice } from "./sign-in-form"; + +export const metadata = { title: "Entrar" }; + +// Reading cookies/searchParams is dynamic: Next 16 (cacheComponents) needs it behind Suspense. +export default function SignInPage({ searchParams }: PageProps<"/sign-in">) { + return ( + }> + + + ); +} + +async function SignInContent({ searchParams }: { searchParams: PageProps<"/sign-in">["searchParams"] }) { + const params = await searchParams; + const next = safeNext(typeof params.next === "string" ? params.next : undefined); + const error = typeof params.error === "string" ? params.error : undefined; + + const session = await getAuth().api.getSession({ headers: await headers() }); + // `unavailable` means the signed-in person was refused by the app: show why instead of redirecting back (loop). + if (error === "unavailable") return ; + if (session) redirect(next); + + const env = getAuthEnv(); + const providers = { + google: Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET), + github: Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET), + }; + return ; +} diff --git a/src/app/(auth)/sign-in/sign-in-form.tsx b/src/app/(auth)/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..c7c8366 --- /dev/null +++ b/src/app/(auth)/sign-in/sign-in-form.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; + +type Providers = { google: boolean; github: boolean }; + +export function SignInForm({ next, providers, socialFailed }: { next: string; providers: Providers; socialFailed?: boolean }) { + const router = useRouter(); + const [step, setStep] = useState<"email" | "code">("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(socialFailed ? "Não foi possível entrar com essa conta. Tente o código por e-mail." : null); + const [pending, setPending] = useState(false); + + async function sendCode(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(null); + const { error: err } = await authClient.emailOtp.sendVerificationOtp({ email: email.trim(), type: "sign-in" }); + setPending(false); + if (err) return setError(err.status === 429 ? "Muitas tentativas. Aguarde um minuto e tente de novo." : "Não foi possível enviar o código. Confira o e-mail."); + setStep("code"); + } + + async function verify(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(null); + const { error: err } = await authClient.signIn.emailOtp({ email: email.trim(), otp: code.trim() }); + if (err) { + setPending(false); + return setError("Código inválido ou expirado."); + } + router.replace(next); + router.refresh(); + } + + async function social(provider: "google" | "github") { + setError(null); + await authClient.signIn.social({ provider, callbackURL: next, errorCallbackURL: "/sign-in?error=social" }); + } + + return ( + + + Entrar no Compasso + + {step === "email" ? "Enviamos um código de 6 dígitos para o seu e-mail. Sem senha." : `Digite o código enviado para ${email.trim()}.`} + + + + {step === "email" ? ( +
+
+ + setEmail(e.target.value)} /> +
+ +
+ ) : ( +
+
+ + setCode(e.target.value.replace(/\D/g, ""))} + /> +
+ + +
+ )} + {error && ( +

+ {error} +

+ )} + {step === "email" && (providers.google || providers.github) && ( +
+ {providers.google && ( + + )} + {providers.github && ( + + )} +
+ )} +

Primeiro acesso? Use o mesmo e-mail: a conta é criada automaticamente.

+
+
+ ); +} + +/** The signed-in person was refused (deleted account or archived workspace). Offer to sign out instead of looping. */ +export function UnavailableNotice() { + const router = useRouter(); + return ( + + + Acesso indisponível + Esta conta ou este workspace não está mais disponível. Saia e entre com outra conta, ou fale com quem administra o workspace. + + + + + + ); +} diff --git a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx deleted file mode 100644 index 5646c2b..0000000 --- a/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SignUp } from "@clerk/nextjs"; -import { Suspense } from "react"; - -export const metadata = { title: "Criar conta" }; - -export default function SignUpPage() { - return ( - }> - - - ); -} diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..2c779d9 --- /dev/null +++ b/src/app/(auth)/sign-up/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +// There is no separate sign-up: a first sign-in with a new e-mail creates the account (ADR-033). Old links keep working. +export default function SignUpPage() { + redirect("/sign-in"); +} diff --git a/src/app/accept-invitation/[id]/accept-invitation.tsx b/src/app/accept-invitation/[id]/accept-invitation.tsx new file mode 100644 index 0000000..f272a89 --- /dev/null +++ b/src/app/accept-invitation/[id]/accept-invitation.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; + +/** Explicit click (no side effect on GET). The server only accepts it when the invitation is for the signed-in e-mail. */ +export function AcceptInvitation({ invitationId, email }: { invitationId: string; email: string }) { + const router = useRouter(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function accept() { + setBusy(true); + setError(null); + const { data, error: err } = await authClient.organization.acceptInvitation({ invitationId }); + if (err || !data) { + setBusy(false); + return setError(`Não foi possível aceitar. O convite precisa ser para ${email}, e pode ter expirado ou sido cancelado.`); + } + await authClient.organization.setActive({ organizationId: data.invitation.organizationId }); + router.replace("/"); + router.refresh(); + } + + return ( + + + Você foi convidado + Entrar no workspace como {email}. + + + + {error &&

{error}

} + +
+
+ ); +} diff --git a/src/app/accept-invitation/[id]/page.tsx b/src/app/accept-invitation/[id]/page.tsx new file mode 100644 index 0000000..5645dd6 --- /dev/null +++ b/src/app/accept-invitation/[id]/page.tsx @@ -0,0 +1,27 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { Logo } from "@/components/logo"; +import { getAuth } from "@/server/auth/auth"; +import { AcceptInvitation } from "./accept-invitation"; + +export const metadata = { title: "Convite" }; + +export default function AcceptInvitationPage({ params }: PageProps<"/accept-invitation/[id]">) { + return ( +
+ + }> + + +
+ ); +} + +async function Content({ params }: { params: PageProps<"/accept-invitation/[id]">["params"] }) { + const { id } = await params; + const session = await getAuth().api.getSession({ headers: await headers() }); + // Signed-out people sign in (or sign up: same flow) with the invited e-mail and come back here. + if (!session) redirect(`/sign-in?next=${encodeURIComponent(`/accept-invitation/${id}`)}`); + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d75d888..ce1d4bf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,3 @@ -import { ClerkProvider } from "@clerk/nextjs"; import type { Metadata, Viewport } from "next"; import localFont from "next/font/local"; import { OfflineNotice } from "@/components/offline-notice"; @@ -52,15 +51,8 @@ export default function RootLayout({ children }: LayoutProps<"/">) { > - - - {children} - + + {children} diff --git a/src/app/onboarding/invitations.tsx b/src/app/onboarding/invitations.tsx new file mode 100644 index 0000000..943a9a7 --- /dev/null +++ b/src/app/onboarding/invitations.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; + +type Invitation = { id: string; organizationName?: string; role?: string | null }; + +/** Pending invitations addressed to the signed-in e-mail (the server only lists those). */ +export function Invitations({ onAccepted }: { onAccepted: (organizationId: string) => void }) { + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + authClient.organization.listUserInvitations().then(({ data }) => { + if (!cancelled && data) setItems(data.filter((i) => i.status === "pending") as Invitation[]); + }); + return () => { + cancelled = true; + }; + }, []); + + if (items.length === 0 && !error) return null; + + async function accept(id: string) { + setError(null); + const { data, error: err } = await authClient.organization.acceptInvitation({ invitationId: id }); + if (err || !data) return setError("Não foi possível aceitar o convite. Ele pode ter expirado."); + onAccepted(data.invitation.organizationId); + } + + async function reject(id: string) { + setError(null); + const { error: err } = await authClient.organization.rejectInvitation({ invitationId: id }); + if (err) return setError("Não foi possível recusar o convite."); + setItems((prev) => prev.filter((i) => i.id !== id)); + } + + return ( + + + Convites para você + Aceite para entrar no workspace. + + + {items.map((i) => ( +
+ {i.organizationName ?? "Workspace"} + + + + +
+ ))} + {error &&

{error}

} +
+
+ ); +} diff --git a/src/app/onboarding/onboarding-panel.tsx b/src/app/onboarding/onboarding-panel.tsx new file mode 100644 index 0000000..293f59e --- /dev/null +++ b/src/app/onboarding/onboarding-panel.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; +import { workspaceSlug } from "@/lib/workspace-slug"; +import { Invitations } from "./invitations"; + +/** First access: name (e-mail sign-up leaves it empty) → pending invitations → own workspaces → create one. */ +export function OnboardingPanel({ email, initialName }: { email: string; initialName: string }) { + const router = useRouter(); + const [name, setName] = useState(initialName); + const [hasName, setHasName] = useState(Boolean(initialName)); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [workspaceName, setWorkspaceName] = useState(""); + + const orgs = authClient.useListOrganizations(); + + async function run(action: () => Promise<{ error?: { message?: string } | null }>, fallback: string, done?: () => void) { + setBusy(true); + setError(null); + const { error: err } = await action(); + setBusy(false); + if (err) return setError(fallback); + done?.(); + } + + async function saveName(event: FormEvent) { + event.preventDefault(); + await run(() => authClient.updateUser({ name: name.trim() }), "Não foi possível salvar o nome.", () => { + setHasName(true); + router.refresh(); + }); + } + + async function enter(organizationId: string) { + await run(() => authClient.organization.setActive({ organizationId }), "Não foi possível abrir o workspace.", () => { + router.replace("/"); + router.refresh(); + }); + } + + async function create(event: FormEvent) { + event.preventDefault(); + const trimmed = workspaceName.trim(); + await run( + async () => { + const created = await authClient.organization.create({ name: trimmed, slug: workspaceSlug(trimmed) }); + if (created.error) return created; + return authClient.organization.setActive({ organizationId: created.data.id }); + }, + "Não foi possível criar o workspace.", + () => { + router.replace("/"); + router.refresh(); + }, + ); + } + + if (!hasName) { + return ( + + + Como devemos te chamar? + Seu nome aparece nos registros e relatórios. Conta: {email} + + +
+
+ + setName(e.target.value)} /> +
+ {error &&

{error}

} + +
+
+
+ ); + } + + return ( +
+
+

Escolha ou crie um workspace

+

+ O workspace reúne as horas, os projetos e os membros do seu time. Use um por empregador ou grupo. +

+
+ enter(organizationId)} /> + {orgs.data && orgs.data.length > 0 && ( + + + Seus workspaces + + + {orgs.data.map((org) => ( + + ))} + + + )} + + + Criar um workspace + + +
+
+ + setWorkspaceName(e.target.value)} /> +
+ +
+
+
+ {error &&

{error}

} +
+ ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 3fa0961..d4f5858 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -1,7 +1,9 @@ -import { OrganizationList } from "@clerk/nextjs"; -import { auth } from "@clerk/nextjs/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { Logo } from "@/components/logo"; +import { getAuth } from "@/server/auth/auth"; +import { OnboardingPanel } from "./onboarding-panel"; export const metadata = { title: "Escolher workspace" }; @@ -10,24 +12,14 @@ export default function OnboardingPage() {
}> - +
); } -async function Picker() { - const { userId, redirectToSignIn } = await auth(); - if (!userId) return redirectToSignIn(); - return ( - <> -
-

Escolha ou crie um workspace

-

- O workspace reúne as horas, os projetos e os membros do seu time. Use um por empregador ou grupo. -

-
- - - ); +async function Panel() { + const session = await getAuth().api.getSession({ headers: await headers() }); + if (!session) redirect("/sign-in"); + return ; } diff --git a/src/components/account-menu.tsx b/src/components/account-menu.tsx new file mode 100644 index 0000000..59a8454 --- /dev/null +++ b/src/components/account-menu.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Building2, LogOut, Plus } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { authClient } from "@/lib/auth-client"; + +function initials(name: string | undefined, email: string | undefined) { + const source = (name?.trim() || email || "?").split(/[\s@.]+/).filter(Boolean); + return ((source[0]?.[0] ?? "?") + (source.length > 1 ? (source[1]?.[0] ?? "") : "")).toUpperCase(); +} + +/** Workspace name + account button; one dialog holds the account, the workspace switcher and sign-out (no extra menu dependency). */ +export function AccountMenu() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const session = authClient.useSession(); + const orgs = authClient.useListOrganizations(); + const active = authClient.useActiveOrganization(); + + async function switchTo(organizationId: string) { + await authClient.organization.setActive({ organizationId }); + setOpen(false); + router.replace("/"); + router.refresh(); + } + + async function signOut() { + await authClient.signOut(); + router.replace("/sign-in"); + router.refresh(); + } + + const user = session.data?.user; + return ( + + + + + + + {user?.name || user?.email} + {user?.email} + +
+

Workspaces

+ {(orgs.data ?? []).map((org) => ( + + ))} + +
+ +
+
+ ); +} diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index 4a2d32d..af4626f 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -1,6 +1,6 @@ -import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; import Link from "next/link"; import { Suspense } from "react"; +import { AccountMenu } from "@/components/account-menu"; import { AppMobileNav } from "@/components/app-sidebar"; import { Logo } from "@/components/logo"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -17,18 +17,8 @@ export function AppHeader() {
- {/* Clerk's widgets read the pathname on the client: keep them behind Suspense (Next 16 cacheComponents). */} - }> - - - }> - - +
diff --git a/src/lib/safe-next.test.ts b/src/lib/safe-next.test.ts new file mode 100644 index 0000000..0565d29 --- /dev/null +++ b/src/lib/safe-next.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { safeNext } from "./safe-next"; + +describe("safeNext", () => { + it("keeps same-origin paths and queries", () => { + expect(safeNext("/relatorios?mes=2026-10")).toBe("/relatorios?mes=2026-10"); + expect(safeNext("/accept-invitation/abc")).toBe("/accept-invitation/abc"); + }); + it("falls back for anything that could leave the site", () => { + for (const bad of ["//evil.com", "/\\evil.com", "https://evil.com", "javascript:alert(1)", "evil.com", "", null, undefined]) { + expect(safeNext(bad as string | null | undefined)).toBe("/"); + } + }); + it("uses the given fallback", () => { + expect(safeNext("https://x.com", "/painel")).toBe("/painel"); + }); +}); diff --git a/src/lib/safe-next.ts b/src/lib/safe-next.ts new file mode 100644 index 0000000..7bd5c34 --- /dev/null +++ b/src/lib/safe-next.ts @@ -0,0 +1,12 @@ +const BASE = "http://local.invalid"; + +/** Post-login redirect target: only same-origin paths ("/x?y"), never "//host", "/\\host" or absolute URLs (open redirect). */ +export function safeNext(value: string | null | undefined, fallback = "/"): string { + if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return fallback; + try { + const url = new URL(value, BASE); + return url.origin === BASE ? url.pathname + url.search : fallback; + } catch { + return fallback; + } +} diff --git a/src/lib/workspace-slug.test.ts b/src/lib/workspace-slug.test.ts new file mode 100644 index 0000000..f4e46c5 --- /dev/null +++ b/src/lib/workspace-slug.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { workspaceSlug } from "./workspace-slug"; + +describe("workspaceSlug", () => { + it("slugifies the name and appends the suffix", () => { + expect(workspaceSlug("Minha Consultoria Ltda.", "abc123")).toBe("minha-consultoria-ltda-abc123"); + }); + it("is unique per call by default", () => { + expect(workspaceSlug("Acme")).not.toBe(workspaceSlug("Acme")); + }); + it("copes with names without letters", () => { + expect(workspaceSlug("###", "x1")).toBe("workspace-x1"); + }); +}); diff --git a/src/lib/workspace-slug.ts b/src/lib/workspace-slug.ts new file mode 100644 index 0000000..469ff1a --- /dev/null +++ b/src/lib/workspace-slug.ts @@ -0,0 +1,6 @@ +import { slugify } from "./slug"; + +/** Better Auth requires a unique slug per organization; a short random suffix avoids asking the person for one. */ +export function workspaceSlug(name: string, suffix: string = crypto.randomUUID().slice(0, 6)): string { + return `${slugify(name, 40)}-${suffix}`; +} diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index 7ade20e..0000000 --- a/src/proxy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { clerkMiddleware } from "@clerk/nextjs/server"; - -// The proxy only makes the Clerk session available to `auth()`. It does NOT -// decide who may see what: path matching can diverge from how Next routes a -// request, so every protected page, route handler and Server Action checks -// access itself via `requireWorkspaceContext()` (src/server/workspace-context.ts). -// Point Clerk redirects (e.g. auth().redirectToSignIn()) at our own pages instead of -// the hosted Account Portal, which is what Clerk uses when no sign-in URL is configured. -export default clerkMiddleware({ signInUrl: "/sign-in", signUpUrl: "/sign-up" }); - -export const config = { - matcher: [ - // Skip Next internals and static files (icons, manifest, fonts, ...). - "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", - "/(api|trpc)(.*)", - ], -}; diff --git a/src/server/auth/env.test.ts b/src/server/auth/env.test.ts index d3ba6aa..025a51b 100644 --- a/src/server/auth/env.test.ts +++ b/src/server/auth/env.test.ts @@ -12,6 +12,12 @@ describe("getAuthEnv", () => { expect(() => getAuthEnv({ NODE_ENV: "production", BETTER_AUTH_SECRET: secret })).toThrow(); expect(getAuthEnv({ NODE_ENV: "production", BETTER_AUTH_SECRET: secret, BETTER_AUTH_URL: "https://app.example.com" }).BETTER_AUTH_URL).toBe("https://app.example.com"); }); + it("derives the URL of a Vercel preview from the branch URL, but never overrides an explicit one", () => { + const preview = { NODE_ENV: "production", BETTER_AUTH_SECRET: secret, VERCEL_ENV: "preview", VERCEL_BRANCH_URL: "compasso-git-x.vercel.app" }; + expect(getAuthEnv(preview).BETTER_AUTH_URL).toBe("https://compasso-git-x.vercel.app"); + expect(getAuthEnv({ ...preview, BETTER_AUTH_URL: "https://custom.example.com" }).BETTER_AUTH_URL).toBe("https://custom.example.com"); + expect(() => getAuthEnv({ ...preview, VERCEL_ENV: "production" })).toThrow(/required in production/); + }); it("rejects a short secret", () => { expect(() => getAuthEnv({ BETTER_AUTH_SECRET: "short" })).toThrow(); }); diff --git a/src/server/auth/env.ts b/src/server/auth/env.ts index d8eb3db..783021a 100644 --- a/src/server/auth/env.ts +++ b/src/server/auth/env.ts @@ -6,6 +6,8 @@ const schema = z.object({ BETTER_AUTH_URL: z.url().optional(), RESEND_API_KEY: z.string().min(1).optional(), MAIL_FROM: z.string().min(3).optional(), + VERCEL_ENV: z.string().optional(), + VERCEL_BRANCH_URL: z.string().optional(), GOOGLE_CLIENT_ID: z.string().min(1).optional(), GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), GITHUB_CLIENT_ID: z.string().min(1).optional(), @@ -24,6 +26,10 @@ export function getAuthEnv(source: Record = process. throw new Error(`Invalid auth environment variables: ${JSON.stringify(z.flattenError(parsed.error).fieldErrors)}`); } const env = parsed.data; + // Preview deployments have one URL per branch: derive it so no variable has to be kept in sync by hand. + if (!env.BETTER_AUTH_URL && env.VERCEL_ENV === "preview" && env.VERCEL_BRANCH_URL) { + env.BETTER_AUTH_URL = `https://${env.VERCEL_BRANCH_URL}`; + } if (env.NODE_ENV === "production" && (!env.BETTER_AUTH_SECRET || !env.BETTER_AUTH_URL)) { throw new Error("BETTER_AUTH_SECRET (>= 32 chars) and BETTER_AUTH_URL are required in production"); } diff --git a/src/server/auth/workspace-context.test.ts b/src/server/auth/workspace-context.test.ts index b884030..0a46d35 100644 --- a/src/server/auth/workspace-context.test.ts +++ b/src/server/auth/workspace-context.test.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; import type { Db } from "@/db"; +import { organization as authOrganization } from "@/db/auth-schema"; import { users, workspaceMembers, workspaces } from "@/db/schema"; import { createTestDb, type TestDb } from "@/test/db"; import { createAuth } from "./auth"; @@ -63,6 +64,18 @@ describe("resolveWorkspaceContext", () => { expect(await db.select().from(users).where(eq(users.email, "ana@example.com"))).toHaveLength(1); }); + it("syncs a changed name and a renamed workspace even when the rows already exist (fast path falls back)", async () => { + const u = await signIn("renamer@example.com"); + const org = await auth.api.createOrganization({ headers: u.headers, body: { name: "Old Name", slug: "old-name" } }); + const first = await resolveWorkspaceContext(asDb(), sessionOf({ ...u.user, name: "" }, org.id)); + expect(first.kind).toBe("ok"); + const second = await resolveWorkspaceContext(asDb(), sessionOf({ ...u.user, name: "Renata" }, org.id)); + expect(second.kind === "ok" && second.context.userName).toBe("Renata"); + await db.update(authOrganization).set({ name: "New Name" }).where(eq(authOrganization.id, org.id)); + const third = await resolveWorkspaceContext(asDb(), sessionOf({ ...u.user, name: "Renata" }, org.id)); + expect(third.kind === "ok" && third.context.workspaceName).toBe("New Name"); + }); + it("returns no-org without an active organization and no-membership for a stranger", async () => { const u = await signIn("lone@example.com"); expect((await resolveWorkspaceContext(asDb(), sessionOf(u.user, null))).kind).toBe("no-org"); diff --git a/src/server/auth/workspace-context.ts b/src/server/auth/workspace-context.ts index 2a79509..e0ef73c 100644 --- a/src/server/auth/workspace-context.ts +++ b/src/server/auth/workspace-context.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { getDb, type Db } from "@/db"; @@ -48,6 +48,47 @@ export async function resolveWorkspaceContext(db: Db, session: AuthSessionLike): .limit(1); if (!row) return { kind: "no-membership" }; + const role = roleFromAuth(row.role); + + // Fast path (one query): our rows already mirror the session, so there is nothing to write. + const [known] = await db + .select({ + userId: users.id, + userName: users.name, + avatarUrl: users.avatarUrl, + timezone: users.timezone, + workspaceId: workspaces.id, + workspaceName: workspaces.name, + role: workspaceMembers.role, + }) + .from(users) + .innerJoin(workspaceMembers, eq(workspaceMembers.userId, users.id)) + .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)) + .where( + and( + eq(users.authId, session.user.id), + eq(workspaces.authOrgId, orgId), + // logically removed members / archived workspaces / anonymized users never take the fast path + isNull(workspaceMembers.removedAt), + isNull(workspaces.archivedAt), + isNull(users.deletedAt), + ), + ) + .limit(1); + const sessionName = session.user.name?.trim() || null; + if ( + known && + known.role === role && + known.workspaceName === row.orgName && + (!sessionName || sessionName === known.userName) && + (session.user.image ?? null) === known.avatarUrl + ) { + return { + kind: "ok", + context: { userId: known.userId, workspaceId: known.workspaceId, role, timezone: known.timezone, userName: known.userName, workspaceName: known.workspaceName }, + }; + } + const user = await ensureUser(db, session.user); if (!user) return { kind: "deleted" }; @@ -59,7 +100,6 @@ export async function resolveWorkspaceContext(db: Db, session: AuthSessionLike): .returning(); if (workspace.archivedAt) return { kind: "archived" }; - const role = roleFromAuth(row.role); await db .insert(workspaceMembers) .values({ workspaceId: workspace.id, userId: user.id, role }) @@ -111,10 +151,15 @@ async function ensureUser(db: Db, authUser: AuthSessionLike["user"]) { return created; } -/** Page/action entry point. Not used by the app until the login swap (PR 2). */ +/** + * Page/action entry point (re-exported as `requireWorkspaceContext`). Redirects to sign-in when signed out and to + * /onboarding when the person still has to give a name or pick a workspace. + */ export async function requireAuthWorkspaceContext(): Promise { const session = await getAuth().api.getSession({ headers: await headers() }); if (!session) redirect("/sign-in"); + // E-mail sign-up leaves the name empty: onboarding asks for it before anything else. + if (!session.user.name?.trim()) redirect("/onboarding"); const result = await resolveWorkspaceContext(getDb(), session); switch (result.kind) { case "ok": diff --git a/src/server/session-role.ts b/src/server/session-role.ts index 8e421dc..ce38be2 100644 --- a/src/server/session-role.ts +++ b/src/server/session-role.ts @@ -1,8 +1,13 @@ -import { auth } from "@clerk/nextjs/server"; -import { roleFromClerk } from "@/lib/roles"; +import { headers } from "next/headers"; +import { getAuth } from "@/server/auth/auth"; +import { roleFromAuth } from "@/server/auth/workspace-context"; /** Whether the signed-in user is an admin of the active organization. Cosmetic only (menu items): authorization stays in the tenant layer. */ export async function isAdminSession(): Promise { - const { orgRole } = await auth(); - return roleFromClerk(orgRole) === "admin"; + try { + const { role } = await getAuth().api.getActiveMemberRole({ headers: await headers() }); + return roleFromAuth(role) === "admin"; + } catch { + return false; // signed out or no active organization + } } diff --git a/src/server/workspace-context.ts b/src/server/workspace-context.ts index f94cc52..84a5812 100644 --- a/src/server/workspace-context.ts +++ b/src/server/workspace-context.ts @@ -1,18 +1,5 @@ -import { auth, clerkClient, currentUser } from "@clerk/nextjs/server"; -import { and, eq, isNull } from "drizzle-orm"; -import { redirect } from "next/navigation"; -import { getDb } from "@/db"; -import { users, workspaceMembers, workspaces } from "@/db/schema"; -import { roleFromClerk, type WorkspaceRole } from "@/lib/roles"; -import { userFromClerk, workspaceFromClerk } from "@/lib/clerk-mappers"; -import { isStale } from "@/lib/stale"; -import { upsertMembership, upsertUser, upsertWorkspace } from "@/server/clerk-sync"; - -/** - * Safety net next to the Clerk webhook: if a webhook is missed, profile and - * organization changes made in Clerk are still pulled in at most this often. - */ -const RESYNC_AFTER_MS = 10 * 60_000; +import type { WorkspaceRole } from "@/lib/roles"; +import { requireAuthWorkspaceContext } from "@/server/auth/workspace-context"; /** Who is acting, in which workspace, and with which role. Every query must be scoped by `workspaceId`. */ export type WorkspaceContext = { @@ -25,91 +12,7 @@ export type WorkspaceContext = { }; /** - * Resolves the signed-in user and active Clerk organization into our own - * user/workspace/membership rows, creating them on first access (lazy - * provisioning). Idempotent: concurrent first requests converge on the same - * rows. The Clerk webhook keeps these rows in sync using the same upserts (src/server/clerk-sync.ts). - * - * Redirects to sign-in when signed out and to /onboarding when the user has - * no active organization. + * Resolves the signed-in person and their active organization (Better Auth, ADR-033) into our own + * user/workspace/membership rows, creating them on first access. See src/server/auth/workspace-context.ts. */ -export async function requireWorkspaceContext(): Promise { - const { userId: clerkUserId, orgId: clerkOrgId, orgRole, redirectToSignIn } = await auth(); - if (!clerkUserId) return redirectToSignIn(); - if (!clerkOrgId) redirect("/onboarding"); - - const role = roleFromClerk(orgRole); - const existing = await findContext(clerkUserId, clerkOrgId); - if (existing && existing.role === role && !isStale(existing.syncedAt, new Date(), RESYNC_AFTER_MS)) { - return existing.context; - } - return provision(clerkUserId, clerkOrgId, role); -} - -async function findContext( - clerkUserId: string, - clerkOrgId: string, -): Promise<{ context: WorkspaceContext; role: WorkspaceRole; syncedAt: Date } | null> { - const [row] = await getDb() - .select({ - userId: users.id, - userName: users.name, - timezone: users.timezone, - workspaceId: workspaces.id, - workspaceName: workspaces.name, - role: workspaceMembers.role, - syncedAt: users.updatedAt, - }) - .from(users) - .innerJoin(workspaceMembers, eq(workspaceMembers.userId, users.id)) - .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)) - .where( - and( - eq(users.clerkId, clerkUserId), - eq(workspaces.clerkOrgId, clerkOrgId), - // logically removed members / archived workspaces / anonymized users have no access - isNull(workspaceMembers.removedAt), - isNull(workspaces.archivedAt), - isNull(users.deletedAt), - ), - ) - .limit(1); - if (!row) return null; - const { role, syncedAt, ...context } = row; - return { context: { ...context, role }, role, syncedAt }; -} - -async function provision( - clerkUserId: string, - clerkOrgId: string, - role: WorkspaceRole, -): Promise { - const clerkUser = await currentUser(); - if (!clerkUser || clerkUser.id !== clerkUserId) throw new Error("Clerk user not found"); - const userData = userFromClerk({ - id: clerkUser.id, - first_name: clerkUser.firstName, - last_name: clerkUser.lastName, - image_url: clerkUser.imageUrl, - primary_email_address_id: clerkUser.primaryEmailAddressId, - email_addresses: clerkUser.emailAddresses.map((e) => ({ id: e.id, email_address: e.emailAddress })), - }); - if (!userData) throw new Error("Clerk user has no email address"); - const org = await (await clerkClient()).organizations.getOrganization({ organizationId: clerkOrgId }); - - // neon-http has no interactive transactions: three idempotent upserts, in dependency order. - const user = await upsertUser(userData); - if (!user) throw new Error("User was deleted"); - // Clerk just confirmed the organization exists, so this is the one place that may un-archive it. - const workspace = await upsertWorkspace(workspaceFromClerk(org), { unarchive: true }); - await upsertMembership(workspace.id, user.id, role); - - return { - userId: user.id, - workspaceId: workspace.id, - role, - timezone: user.timezone, - userName: user.name, - workspaceName: workspace.name, - }; -} +export const requireWorkspaceContext: () => Promise = requireAuthWorkspaceContext; From 2160e9f6ea34b3387326583df520ef59a94cf9ef Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 13:01:19 +0000 Subject: [PATCH 2/2] fix(auth): ler os headers antes de tocar em env/DB (build sem DATABASE_URL) No prerender, getAuth() rodava antes de headers() e exigia DATABASE_URL; agora headers() vem primeiro e torna a pagina dinamica. Em session-role o headers() saiu do try para nao engolir o bail-out do prerender. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- src/app/(auth)/sign-in/page.tsx | 4 +++- src/app/accept-invitation/[id]/page.tsx | 4 +++- src/app/onboarding/page.tsx | 4 +++- src/server/auth/workspace-context.ts | 4 +++- src/server/session-role.ts | 3 ++- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 20d5465..b2c85bf 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -22,7 +22,9 @@ async function SignInContent({ searchParams }: { searchParams: PageProps<"/sign- const next = safeNext(typeof params.next === "string" ? params.next : undefined); const error = typeof params.error === "string" ? params.error : undefined; - const session = await getAuth().api.getSession({ headers: await headers() }); + // Read the request first: during prerender this is what makes the page dynamic, before any DB/env access. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); // `unavailable` means the signed-in person was refused by the app: show why instead of redirecting back (loop). if (error === "unavailable") return ; if (session) redirect(next); diff --git a/src/app/accept-invitation/[id]/page.tsx b/src/app/accept-invitation/[id]/page.tsx index 5645dd6..639e768 100644 --- a/src/app/accept-invitation/[id]/page.tsx +++ b/src/app/accept-invitation/[id]/page.tsx @@ -20,7 +20,9 @@ export default function AcceptInvitationPage({ params }: PageProps<"/accept-invi async function Content({ params }: { params: PageProps<"/accept-invitation/[id]">["params"] }) { const { id } = await params; - const session = await getAuth().api.getSession({ headers: await headers() }); + // Read the request first: during prerender this is what makes the page dynamic, before any DB/env access. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); // Signed-out people sign in (or sign up: same flow) with the invited e-mail and come back here. if (!session) redirect(`/sign-in?next=${encodeURIComponent(`/accept-invitation/${id}`)}`); return ; diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index d4f5858..88454ac 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -19,7 +19,9 @@ export default function OnboardingPage() { } async function Panel() { - const session = await getAuth().api.getSession({ headers: await headers() }); + // Read the request first: during prerender this is what makes the page dynamic, before any DB/env access. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); if (!session) redirect("/sign-in"); return ; } diff --git a/src/server/auth/workspace-context.ts b/src/server/auth/workspace-context.ts index e0ef73c..ac410f9 100644 --- a/src/server/auth/workspace-context.ts +++ b/src/server/auth/workspace-context.ts @@ -156,7 +156,9 @@ async function ensureUser(db: Db, authUser: AuthSessionLike["user"]) { * /onboarding when the person still has to give a name or pick a workspace. */ export async function requireAuthWorkspaceContext(): Promise { - const session = await getAuth().api.getSession({ headers: await headers() }); + // Read the request first: during prerender this is what makes the page dynamic, before any DB/env access. + const requestHeaders = await headers(); + const session = await getAuth().api.getSession({ headers: requestHeaders }); if (!session) redirect("/sign-in"); // E-mail sign-up leaves the name empty: onboarding asks for it before anything else. if (!session.user.name?.trim()) redirect("/onboarding"); diff --git a/src/server/session-role.ts b/src/server/session-role.ts index ce38be2..a4e29fd 100644 --- a/src/server/session-role.ts +++ b/src/server/session-role.ts @@ -4,8 +4,9 @@ import { roleFromAuth } from "@/server/auth/workspace-context"; /** Whether the signed-in user is an admin of the active organization. Cosmetic only (menu items): authorization stays in the tenant layer. */ export async function isAdminSession(): Promise { + const requestHeaders = await headers(); // outside the try: a prerender bail-out must not be swallowed try { - const { role } = await getAuth().api.getActiveMemberRole({ headers: await headers() }); + const { role } = await getAuth().api.getActiveMemberRole({ headers: requestHeaders }); return roleFromAuth(role) === "admin"; } catch { return false; // signed out or no active organization