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
42 changes: 26 additions & 16 deletions app/[locale]/invite/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { AcceptInviteCard } from "@/components/invite/AcceptInviteCard";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { inviteRedirectPath } from "@/lib/organization/invite-redirect";
import { company, companyInvite } from "@/schema";
import { inviteLinkState } from "@/lib/organization/invite-state";
import { asMembershipRole } from "@/lib/organization/membership";
import { companyInvite } from "@/schema";
import { invitedRoleOf } from "@/server/trpc/helpers/company-invite";

interface Props {
params: Promise<{ token: string }>;
}

// Open without a session, and the token in its address is the credential: never a search result.
export const metadata = { robots: { index: false, follow: false } };

export default async function InvitePage({ params }: Props) {
const { token } = await params;
const t = await getTranslations("team");
Expand All @@ -22,30 +28,34 @@ export default async function InvitePage({ params }: Props) {
},
});

if (!invite) {
return (
<CenteredCard title={t("invalidTitle")} description={t("invalidDescription")} />
);
}

if (invite.status !== "pending") {
return <CenteredCard title={t("usedTitle")} description={t("usedDescription")} />;
}

if (invite.expiresAt < new Date()) {
return (
<CenteredCard title={t("expiredTitle")} description={t("expiredDescription")} />
);
const state = inviteLinkState(invite, new Date());
if (!invite || state !== "open") {
const [title, description] =
state === "accepted"
? // No company name: a used link stays readable long after, by whoever holds it.
[t("acceptedTitle"), t("acceptedDescription")]
: state === "revoked"
? [t("revokedTitle"), t("revokedDescription")]
: state === "expired"
? [t("expiredTitle"), t("expiredDescription")]
: [t("invalidTitle"), t("invalidDescription")];
return <CenteredCard title={title} description={description} />;
}

const session = await getSession();
// The compliance role the person was invited for names the invite better than the access role,
// which is "member" for every invite sent today.
const complianceRole = invitedRoleOf(invite.assignmentContext);
const roleLabel = complianceRole
? t(`roles.${complianceRole}`)
: t(`memberRole.${asMembershipRole(invite.role) ?? "member"}`);

return (
<AcceptInviteCard
token={token}
inviteEmail={invite.email}
companyName={invite.company.name}
role={invite.role}
roleLabel={roleLabel}
redirectPath={inviteRedirectPath(invite.redirectPath)}
userEmail={session?.user.email ?? null}
isSignedIn={!!session}
Expand Down
4 changes: 3 additions & 1 deletion components/auth/SignInCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export function SignInCard({
const [mode, setMode] = useState<"login" | "register">(() =>
searchParams.get("mode") === "register" ? "register" : "login",
);
const [email, setEmail] = useState("");
// An invite link hands on the address it was sent to (AcceptInviteCard): an account under any
// other address could not accept it. Only a starting value; the field stays editable.
const [email, setEmail] = useState(() => searchParams.get("email")?.trim() ?? "");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState(() => {
Expand Down
51 changes: 31 additions & 20 deletions components/invite/AcceptInviteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ interface Props {
token: string;
inviteEmail: string;
companyName: string;
role: string;
/** The role the person is invited as, already in the reader's language. */
roleLabel: string;
/** Already checked to be a same-origin path (lib/organization/invite-redirect.ts). */
redirectPath: string;
userEmail: string | null;
Expand All @@ -32,7 +33,7 @@ export function AcceptInviteCard({
token,
inviteEmail,
companyName,
role,
roleLabel,
redirectPath,
userEmail,
isSignedIn,
Expand All @@ -59,28 +60,38 @@ export function AcceptInviteCard({
acceptMutation.mutate({ token });
};

// Case 1: Not signed in. Route to the standard signin page, which exposes
// both email/password and Google. The callbackUrl returns the user to
// /invite/[token] so the invite is auto-resolved after auth.
// Case 1: Not signed in. Most people opening an invite have no account yet, so creating one
// comes first, with the invited address filled in: an account under any other address cannot
// accept it. The callbackUrl returns the person to /invite/[token] after either way in.
if (!isSignedIn) {
const callbackUrl = `/invite/${token}`;
const query = { callbackUrl: `/invite/${token}`, email: inviteEmail };
return (
<CenteredCard
title={t("accept.joinTitle", { company: companyName })}
description={t("accept.signInDescription", { role })}
description={t("accept.signInDescription", { role: roleLabel })}
>
<Button asChild className="w-full" size="lg">
<Link
href={
{
pathname: "/auth/signin",
query: { callbackUrl },
} as never
}
>
{t("accept.signInToAccept")}
</Link>
</Button>
<p className="mb-4 text-center text-sm">
{t("accept.forAddress", { email: inviteEmail })}
</p>
<div className="space-y-2">
<Button asChild className="w-full" size="lg">
<Link
href={
{
pathname: "/auth/signin",
query: { ...query, mode: "register" },
} as never
}
>
{t("accept.createAccount")}
</Link>
</Button>
<Button asChild className="w-full" size="lg" variant="outline">
<Link href={{ pathname: "/auth/signin", query } as never}>
{t("accept.haveAccount")}
</Link>
</Button>
</div>
</CenteredCard>
);
}
Expand Down Expand Up @@ -110,7 +121,7 @@ export function AcceptInviteCard({
return (
<CenteredCard
title={t("accept.joinTitle", { company: companyName })}
description={t("accept.joinDescription", { role })}
description={t("accept.joinDescription", { role: roleLabel })}
>
<Button className="w-full" size="lg" disabled={accepting} onClick={handleAccept}>
<UserPlus className="mr-2 h-4 w-4" />
Expand Down
Loading
Loading