wacrm/src/app/(auth)/login/page.tsx
Arnas Donauskas 47104042fd
feat(join): /join/[token] page + signup/login invite redirects
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>
2026-05-31 18:30:32 +03:00

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&apos;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>
);
}