diff --git a/app/[locale]/invite/[token]/page.tsx b/app/[locale]/invite/[token]/page.tsx
index 0faf76d08..1036efd0b 100644
--- a/app/[locale]/invite/[token]/page.tsx
+++ b/app/[locale]/invite/[token]/page.tsx
@@ -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");
@@ -22,30 +28,34 @@ export default async function InvitePage({ params }: Props) {
},
});
- if (!invite) {
- return (
-
- );
- }
-
- if (invite.status !== "pending") {
- return ;
- }
-
- if (invite.expiresAt < new Date()) {
- return (
-
- );
+ 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 ;
}
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 (
(() =>
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(() => {
diff --git a/components/invite/AcceptInviteCard.tsx b/components/invite/AcceptInviteCard.tsx
index b64193a2f..95fa6c140 100644
--- a/components/invite/AcceptInviteCard.tsx
+++ b/components/invite/AcceptInviteCard.tsx
@@ -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;
@@ -32,7 +33,7 @@ export function AcceptInviteCard({
token,
inviteEmail,
companyName,
- role,
+ roleLabel,
redirectPath,
userEmail,
isSignedIn,
@@ -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 (
-
+