mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
PR 7 of the multi-user accounts series. Closes the invite loop —
PR 6 lets admins generate links, this PR is what the recipient
sees when they click one.
What lands
- `src/app/join/layout.tsx` — minimal centered dark shell. Sits
outside both `(auth)` and `(dashboard)` route groups because
the page is hybrid: it renders for anonymous AND signed-in
visitors. Reusing either neighbouring layout would funnel one
audience through the wrong middleware redirect.
- `src/app/join/[token]/page.tsx` — client component. Calls
/api/invitations/[token]/peek + supabase.auth.getUser() in
parallel on mount and routes through four UI states:
peek | auth | render
--------------|--------------|----------------------------------
loading | — | spinner
ok:false (any)| — | friendly error + signup/signin CTAs
ok:true | signed out | "Sign up & join" + "Sign in" links
ok:true | signed in | "Accept invitation" button → redeem
Redeem is intentionally manual — auto-accepting on mount would
race with the signup → email-verification → return flow and
hide what's being accepted from the visitor.
AuthContext doesn't reach this route group (AuthProvider lives
inside (dashboard)), so we call Supabase directly here the same
way /login and /signup already do.
After a successful accept, the page does a full reload to
/dashboard (not router.push) so AuthProvider re-fetches the
profile with the new account_id and account_role.
- `src/app/(auth)/signup/page.tsx`:
- Reads `?invite=<token>` via useSearchParams.
- Passes emailRedirectTo: `${origin}/join/<token>` to
supabase.auth.signUp so the verification email returns the
user to the redeem step.
- Re-skins the card header for invite signups ("Create account
& join", different icon).
- Preserves the token through the "Sign in" link and the
"Back to sign in" button on the success screen.
- Default export now wraps the form in `<Suspense>` — Next 16
refuses to prerender pages calling useSearchParams without
it.
- `src/app/(auth)/login/page.tsx`:
- Reads `?invite=<token>`.
- On successful sign-in, routes to /join/<token> instead of
/dashboard.
- Re-skins the card header for invite logins.
- Preserves the token through the "Create account" link.
- Default export wrapped in `<Suspense>` for the same prerender
reason.
- `src/middleware.ts`:
- When an authenticated user hits /login?invite=<t> or
/signup?invite=<t>, redirect to /join/<t> instead of the
default /dashboard. Without this, a forwarded invite link
landing on an already-signed-in browser would silently drop
on /dashboard and the visitor would never see the accept
prompt.
- Both pre-existing redirect cases (no invite present, OR
/forgot-password with an invite — which doesn't make sense)
fall through to /dashboard as before. The search string is
cleared on both paths so we don't leak the invite token
into the dashboard URL bar.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds:
ƒ /join/[token] — dynamic (peek + auth fetch)
○ /login — static, hydrates the form
○ /signup — static, hydrates the form
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useState } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { MessageSquare, UsersRound } from "lucide-react";
|
|
|
|
// `useSearchParams` opts the component out of static prerendering
|
|
// unless it sits under a Suspense boundary. We split the form into
|
|
// a child component so the outer page can prerender the chrome
|
|
// (background, card frame) while the form hydrates with the query
|
|
// string on the client.
|
|
export default function LoginPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<LoginPageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function LoginPageInner() {
|
|
const searchParams = useSearchParams();
|
|
// Forwarded from `/join/<token>` when the visitor already has an
|
|
// account. After a successful sign-in we send them to the join
|
|
// page to accept rather than to /dashboard.
|
|
const inviteToken = searchParams.get("invite");
|
|
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const router = useRouter();
|
|
const supabase = createClient();
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
|
|
const { error } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password,
|
|
});
|
|
|
|
if (error) {
|
|
setError(error.message);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (inviteToken) {
|
|
router.push(`/join/${encodeURIComponent(inviteToken)}`);
|
|
} else {
|
|
router.push("/dashboard");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
|
<CardHeader className="items-center text-center">
|
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
|
{inviteToken ? (
|
|
<UsersRound className="h-6 w-6 text-primary" />
|
|
) : (
|
|
<MessageSquare className="h-6 w-6 text-primary" />
|
|
)}
|
|
</div>
|
|
<CardTitle className="text-xl text-white">
|
|
{inviteToken ? "Sign in to accept" : "Welcome back"}
|
|
</CardTitle>
|
|
<CardDescription className="text-slate-400">
|
|
{inviteToken
|
|
? "Sign in and we'll take you to the invitation."
|
|
: "Sign in to your account"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
|
{error && (
|
|
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="email" className="text-slate-300">
|
|
Email
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="password" className="text-slate-300">
|
|
Password
|
|
</Label>
|
|
<Link
|
|
href="/forgot-password"
|
|
className="text-sm text-primary hover:text-primary/80"
|
|
>
|
|
Forgot password?
|
|
</Link>
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{loading ? "Signing in..." : "Sign in"}
|
|
</Button>
|
|
</form>
|
|
|
|
<p className="mt-6 text-center text-sm text-slate-400">
|
|
Don't have an account?{" "}
|
|
<Link
|
|
href={
|
|
inviteToken
|
|
? `/signup?invite=${encodeURIComponent(inviteToken)}`
|
|
: "/signup"
|
|
}
|
|
className="text-primary hover:text-primary/80"
|
|
>
|
|
Create account
|
|
</Link>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|