mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
This commit is contained in:
parent
eeb0143e81
commit
47104042fd
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -14,9 +14,28 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { MessageSquare } from "lucide-react";
|
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() {
|
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 [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -40,7 +59,11 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inviteToken) {
|
||||||
|
router.push(`/join/${encodeURIComponent(inviteToken)}`);
|
||||||
|
} else {
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -48,11 +71,19 @@ export default function LoginPage() {
|
|||||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
<CardHeader className="items-center text-center">
|
<CardHeader className="items-center text-center">
|
||||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
<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" />
|
<MessageSquare className="h-6 w-6 text-primary" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl text-white">Welcome back</CardTitle>
|
<CardTitle className="text-xl text-white">
|
||||||
|
{inviteToken ? "Sign in to accept" : "Welcome back"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
Sign in to your account
|
{inviteToken
|
||||||
|
? "Sign in and we'll take you to the invitation."
|
||||||
|
: "Sign in to your account"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -113,7 +144,11 @@ export default function LoginPage() {
|
|||||||
<p className="mt-6 text-center text-sm text-slate-400">
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/signup"
|
href={
|
||||||
|
inviteToken
|
||||||
|
? `/signup?invite=${encodeURIComponent(inviteToken)}`
|
||||||
|
: "/signup"
|
||||||
|
}
|
||||||
className="text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Create account
|
Create account
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -13,9 +14,27 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { MessageSquare, CheckCircle } from "lucide-react";
|
import { MessageSquare, CheckCircle, UsersRound } from "lucide-react";
|
||||||
|
|
||||||
|
// `useSearchParams` opts the component out of static prerendering
|
||||||
|
// unless wrapped in Suspense — same pattern as /login.
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SignupPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignupPageInner() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// When the user lands here from `/join/<token>` we carry the
|
||||||
|
// invite token in the query so it survives the signup → email
|
||||||
|
// verification → redirect round-trip. `emailRedirectTo` below
|
||||||
|
// points back at /join/<token> so the user lands on the redeem
|
||||||
|
// step after verifying instead of being dropped on /dashboard.
|
||||||
|
const inviteToken = searchParams.get("invite");
|
||||||
|
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -41,6 +60,14 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
// If we have an invite token, point Supabase's verification
|
||||||
|
// email back at the join page so the user can accept after
|
||||||
|
// verifying. Without a token, Supabase uses its default
|
||||||
|
// redirect (the app root).
|
||||||
|
const emailRedirectTo = inviteToken
|
||||||
|
? `${window.location.origin}/join/${encodeURIComponent(inviteToken)}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@ -48,6 +75,7 @@ export default function SignupPage() {
|
|||||||
data: {
|
data: {
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
},
|
},
|
||||||
|
...(emailRedirectTo ? { emailRedirectTo } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +107,13 @@ export default function SignupPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Link href="/login">
|
<Link
|
||||||
|
href={
|
||||||
|
inviteToken
|
||||||
|
? `/login?invite=${encodeURIComponent(inviteToken)}`
|
||||||
|
: "/login"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
@ -98,11 +132,19 @@ export default function SignupPage() {
|
|||||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
<CardHeader className="items-center text-center">
|
<CardHeader className="items-center text-center">
|
||||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
<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" />
|
<MessageSquare className="h-6 w-6 text-primary" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl text-white">Create account</CardTitle>
|
<CardTitle className="text-xl text-white">
|
||||||
|
{inviteToken ? "Create account & join" : "Create account"}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
Get started with CRM Template for WhatsApp
|
{inviteToken
|
||||||
|
? "Verify your email, then accept the invitation to join your team."
|
||||||
|
: "Get started with CRM Template for WhatsApp"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -185,7 +227,11 @@ export default function SignupPage() {
|
|||||||
<p className="mt-6 text-center text-sm text-slate-400">
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href={
|
||||||
|
inviteToken
|
||||||
|
? `/login?invite=${encodeURIComponent(inviteToken)}`
|
||||||
|
: "/login"
|
||||||
|
}
|
||||||
className="text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
|
|||||||
274
src/app/join/[token]/page.tsx
Normal file
274
src/app/join/[token]/page.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// /join/[token] — invitation redemption landing page.
|
||||||
|
//
|
||||||
|
// Four UI states driven by:
|
||||||
|
// - the peek result (server-validated invite payload), and
|
||||||
|
// - whether the visitor is currently authenticated.
|
||||||
|
//
|
||||||
|
// ┌──────────────────────┬───────────────┬─────────────────────────┐
|
||||||
|
// │ peek │ auth │ render │
|
||||||
|
// ├──────────────────────┼───────────────┼─────────────────────────┤
|
||||||
|
// │ loading │ — │ spinner │
|
||||||
|
// │ ok:false (any reason)│ — │ friendly error + signup │
|
||||||
|
// │ ok:true │ signed out │ "Sign up" + "Sign in" │
|
||||||
|
// │ ok:true │ signed in │ "Accept" button → redeem │
|
||||||
|
// └──────────────────────┴───────────────┴─────────────────────────┘
|
||||||
|
//
|
||||||
|
// We deliberately do NOT redeem automatically on page load — the
|
||||||
|
// invitee should confirm what account/role they're accepting.
|
||||||
|
// Auto-redeem would also race with the signup flow returning to
|
||||||
|
// this page after email verification.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { CheckCircle, Loader2, MailX, ShieldCheck, UsersRound } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
|
||||||
|
interface PeekOk {
|
||||||
|
ok: true;
|
||||||
|
account_name: string;
|
||||||
|
role: 'admin' | 'agent' | 'viewer';
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
interface PeekFail {
|
||||||
|
ok: false;
|
||||||
|
reason: 'not_found' | 'used' | 'expired' | 'server_error';
|
||||||
|
}
|
||||||
|
type PeekResult = PeekOk | PeekFail;
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<PeekOk['role'], string> = {
|
||||||
|
admin: 'Admin',
|
||||||
|
agent: 'Agent',
|
||||||
|
viewer: 'Viewer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAIL_COPY: Record<PeekFail['reason'], { title: string; body: string }> = {
|
||||||
|
not_found: {
|
||||||
|
title: 'Invite not found',
|
||||||
|
body: 'This link doesn’t match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.',
|
||||||
|
},
|
||||||
|
used: {
|
||||||
|
title: 'Invite already used',
|
||||||
|
body: 'This invitation has already been accepted. If that wasn’t you, ask the account admin to send a fresh link.',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
title: 'Invite expired',
|
||||||
|
body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.',
|
||||||
|
},
|
||||||
|
server_error: {
|
||||||
|
title: 'Something went wrong',
|
||||||
|
body: 'We couldn’t verify this invitation right now. Try refreshing the page in a moment.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JoinPage() {
|
||||||
|
const params = useParams<{ token: string }>();
|
||||||
|
const token = params?.token;
|
||||||
|
|
||||||
|
const [peek, setPeek] = useState<PeekResult | null>(null);
|
||||||
|
// Local auth probe — the AuthProvider lives inside the (dashboard)
|
||||||
|
// route group, so it doesn't reach this page. We hit Supabase
|
||||||
|
// directly the same way `/login` and `/signup` do.
|
||||||
|
const [authedUserId, setAuthedUserId] = useState<string | null | undefined>(
|
||||||
|
undefined, // undefined = unknown / still loading; null = signed out
|
||||||
|
);
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
|
||||||
|
// Fetch peek + auth state on mount. The peek endpoint is
|
||||||
|
// rate-limited per-IP (30/min) so double-mounting in React 19
|
||||||
|
// strict mode dev is harmless.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [peekRes, authRes] = await Promise.all([
|
||||||
|
fetch(`/api/invitations/${encodeURIComponent(token)}/peek`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
}),
|
||||||
|
createClient().auth.getUser(),
|
||||||
|
]);
|
||||||
|
const peekBody = (await peekRes.json()) as PeekResult;
|
||||||
|
if (cancelled) return;
|
||||||
|
setPeek(peekBody);
|
||||||
|
setAuthedUserId(authRes.data.user?.id ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[join] peek error:', err);
|
||||||
|
if (cancelled) return;
|
||||||
|
setPeek({ ok: false, reason: 'server_error' });
|
||||||
|
setAuthedUserId(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleAccept = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setAccepting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/invitations/${encodeURIComponent(token)}/redeem`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = (await res.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
toast.error(payload.error || 'Failed to accept invitation');
|
||||||
|
setAccepting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Welcome to the team');
|
||||||
|
// Full reload (not router.push) so AuthProvider re-fetches
|
||||||
|
// the profile with the new account_id and account_role.
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[join] redeem error:', err);
|
||||||
|
toast.error('Could not reach the server');
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// ----- Loading state (peek pending OR auth not yet resolved) -----
|
||||||
|
if (peek === null || authedUserId === undefined) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-slate-400">Verifying invitation…</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Peek failed -----
|
||||||
|
if (!peek.ok) {
|
||||||
|
const copy = FAIL_COPY[peek.reason];
|
||||||
|
return (
|
||||||
|
<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-red-500/10">
|
||||||
|
<MailX className="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">{copy.title}</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
{copy.body}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
<Link href="/signup">
|
||||||
|
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
Create a new account instead
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Peek OK -----
|
||||||
|
const inviteHeader = (
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||||
|
<UsersRound className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">
|
||||||
|
You're invited to{' '}
|
||||||
|
<span className="text-primary">{peek.account_name}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
You'll join as{' '}
|
||||||
|
<span className="inline-flex items-center gap-1 text-white">
|
||||||
|
<ShieldCheck className="size-3.5 text-primary" />
|
||||||
|
{ROLE_LABEL[peek.role]}
|
||||||
|
</span>
|
||||||
|
. Link valid until{' '}
|
||||||
|
{new Date(peek.expires_at).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----- Authed: show Accept button -----
|
||||||
|
if (authedUserId) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
{inviteHeader}
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleAccept}
|
||||||
|
disabled={accepting}
|
||||||
|
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{accepting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Accepting…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="size-4" />
|
||||||
|
Accept invitation
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
Accepting moves your login into{' '}
|
||||||
|
<span className="text-slate-400">{peek.account_name}</span>. Your
|
||||||
|
empty personal account from signup will be cleaned up.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Not authed: prompt to sign up or sign in -----
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
{inviteHeader}
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
|
||||||
|
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
Create account & join
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
>
|
||||||
|
I already have an account
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/join/layout.tsx
Normal file
26
src/app/join/layout.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// ============================================================
|
||||||
|
// /join/[token] layout — minimal full-bleed dark shell.
|
||||||
|
//
|
||||||
|
// The route group sits outside both `(auth)` and `(dashboard)`
|
||||||
|
// because it's hybrid: the page must render for anonymous
|
||||||
|
// visitors (to show "Sign up to join Acme") *and* for signed-in
|
||||||
|
// users (to show "Accept invite"). Reusing `(auth)`'s layout
|
||||||
|
// would funnel signed-in users through the middleware's auth-
|
||||||
|
// page redirect; reusing `(dashboard)` would funnel anonymous
|
||||||
|
// visitors through its login redirect. A dedicated layout
|
||||||
|
// avoids both.
|
||||||
|
//
|
||||||
|
// Styling matches the login / signup pages — centered card on a
|
||||||
|
// slate-950 background — so the join experience feels like a
|
||||||
|
// natural step in the auth funnel rather than a foreign page.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export default function JoinLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,14 +25,30 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
// Auth pages - redirect to dashboard if already logged in
|
// Auth pages - redirect to dashboard if already logged in.
|
||||||
|
// Exception: when an invite token is in the query string we
|
||||||
|
// send the already-signed-in user to /join/<token> instead so
|
||||||
|
// they can accept the invitation in one click. Without this,
|
||||||
|
// a forwarded invite link to someone who's already signed in
|
||||||
|
// would silently drop them on /dashboard.
|
||||||
if (user && (
|
if (user && (
|
||||||
request.nextUrl.pathname === '/login' ||
|
request.nextUrl.pathname === '/login' ||
|
||||||
request.nextUrl.pathname === '/signup' ||
|
request.nextUrl.pathname === '/signup' ||
|
||||||
request.nextUrl.pathname === '/forgot-password'
|
request.nextUrl.pathname === '/forgot-password'
|
||||||
)) {
|
)) {
|
||||||
const url = request.nextUrl.clone()
|
const url = request.nextUrl.clone()
|
||||||
|
const inviteToken = request.nextUrl.searchParams.get('invite')
|
||||||
|
if (
|
||||||
|
inviteToken &&
|
||||||
|
(request.nextUrl.pathname === '/login' ||
|
||||||
|
request.nextUrl.pathname === '/signup')
|
||||||
|
) {
|
||||||
|
url.pathname = `/join/${encodeURIComponent(inviteToken)}`
|
||||||
|
url.search = ''
|
||||||
|
} else {
|
||||||
url.pathname = '/dashboard'
|
url.pathname = '/dashboard'
|
||||||
|
url.search = ''
|
||||||
|
}
|
||||||
return NextResponse.redirect(url)
|
return NextResponse.redirect(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user