Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions src/app/(app)/cadastros/invite-members-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button type="button" size="sm" onClick={() => openOrganizationProfile()}>
<UserPlus aria-hidden />
Convidar pessoas
</Button>
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) setMessage(null); }}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<UserPlus aria-hidden />
Convidar pessoas
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Convidar pessoa</DialogTitle>
<DialogDescription>Ela recebe um e-mail com o link. Entra com esse mesmo e-mail e aceita o convite.</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="invite-email">E-mail</Label>
<Input id="invite-email" type="email" required autoFocus autoComplete="off" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="invite-role">Papel</Label>
<Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Membro (registra horas nos seus projetos)</SelectItem>
<SelectItem value="admin">Administrador (acesso total)</SelectItem>
</SelectContent>
</Select>
</div>
{message && (
<p role={message.ok ? "status" : "alert"} className={message.ok ? "text-sm text-muted-foreground" : "text-sm text-destructive"}>
{message.text}
</p>
)}
<Button type="submit" disabled={busy || !email.trim()}>{busy ? "Enviando…" : "Enviar convite"}</Button>
</form>
</DialogContent>
</Dialog>
);
}
13 changes: 0 additions & 13 deletions src/app/(auth)/sign-in/[[...sign-in]]/page.tsx

This file was deleted.

38 changes: 38 additions & 0 deletions src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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 (
<Suspense fallback={<div className="h-72 w-[25rem] max-w-full animate-pulse rounded-lg bg-muted" aria-hidden />}>
<SignInContent searchParams={searchParams} />
</Suspense>
);
}

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;

// 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 <UnavailableNotice />;
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 <SignInForm next={next} providers={providers} socialFailed={error === "social"} />;
}
140 changes: 140 additions & 0 deletions src/app/(auth)/sign-in/sign-in-form.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Card className="w-[25rem] max-w-full">
<CardHeader>
<CardTitle>Entrar no Compasso</CardTitle>
<CardDescription>
{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()}.`}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{step === "email" ? (
<form onSubmit={sendCode} className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" required autoFocus autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<Button type="submit" disabled={pending}>
{pending ? "Enviando…" : "Receber código"}
</Button>
</form>
) : (
<form onSubmit={verify} className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="code">Código</Label>
<Input
id="code"
required
autoFocus
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
className="text-center font-mono text-lg tracking-[0.4em]"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
/>
</div>
<Button type="submit" disabled={pending || code.length !== 6}>
{pending ? "Entrando…" : "Entrar"}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setStep("email"); setCode(""); setError(null); }}>
Usar outro e-mail
</Button>
</form>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{step === "email" && (providers.google || providers.github) && (
<div className="grid gap-2 border-t pt-4">
{providers.google && (
<Button type="button" variant="outline" onClick={() => social("google")}>
Continuar com Google
</Button>
)}
{providers.github && (
<Button type="button" variant="outline" onClick={() => social("github")}>
Continuar com GitHub
</Button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">Primeiro acesso? Use o mesmo e-mail: a conta é criada automaticamente.</p>
</CardContent>
</Card>
);
}

/** 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 (
<Card className="w-[25rem] max-w-full">
<CardHeader>
<CardTitle>Acesso indisponível</CardTitle>
<CardDescription>Esta conta ou este workspace não está mais disponível. Saia e entre com outra conta, ou fale com quem administra o workspace.</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={async () => {
await authClient.signOut();
router.replace("/sign-in");
router.refresh();
}}
>
Sair
</Button>
</CardContent>
</Card>
);
}
12 changes: 0 additions & 12 deletions src/app/(auth)/sign-up/[[...sign-up]]/page.tsx

This file was deleted.

6 changes: 6 additions & 0 deletions src/app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
@@ -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");
}
41 changes: 41 additions & 0 deletions src/app/accept-invitation/[id]/accept-invitation.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Card className="w-[25rem] max-w-full">
<CardHeader>
<CardTitle>Você foi convidado</CardTitle>
<CardDescription>Entrar no workspace como {email}.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<Button onClick={accept} disabled={busy}>{busy ? "Entrando…" : "Aceitar convite"}</Button>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<Button variant="ghost" size="sm" onClick={() => router.push("/onboarding")}>Ver meus workspaces</Button>
</CardContent>
</Card>
);
}
29 changes: 29 additions & 0 deletions src/app/accept-invitation/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 (
<main className="flex flex-1 flex-col items-center justify-center gap-6 p-4">
<Logo />
<Suspense fallback={<div className="h-40 w-80 animate-pulse rounded-lg bg-muted" aria-hidden />}>
<Content params={params} />
</Suspense>
</main>
);
}

async function Content({ params }: { params: PageProps<"/accept-invitation/[id]">["params"] }) {
const { id } = await params;
// 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 <AcceptInvitation invitationId={id} email={session.user.email} />;
}
12 changes: 2 additions & 10 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,15 +51,8 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
>
<body className="min-h-full flex flex-col">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<ClerkProvider
signInUrl="/sign-in"
signUpUrl="/sign-up"
signInFallbackRedirectUrl="/"
signUpFallbackRedirectUrl="/"
>
<OfflineNotice />
{children}
</ClerkProvider>
<OfflineNotice />
{children}
<ServiceWorkerRegister />
</ThemeProvider>
</body>
Expand Down
Loading
Loading