- DaisyUI themes openseo/openseo-dark -> crawlerx/crawlerx-dark on the MetatronCube palette (primary #3779b9, secondary #0f1d34, accent #4faede) - Load Inter as the app font; add --brand-gradient / .brand-gradient - Cube mark + "CrawlerX" wordmark in the sidebar and mobile top bar - Add public/crawlerx-mark.png; point transparent-logo.png at the cube mark - <title>, site.webmanifest, and all user-facing "OpenSEO" copy across src/client and src/routes -> "CrawlerX" Deferred: favicons (need resizing), agent-facing identity (MCP server name, SAM/onboarding prompts, fact sheet), web/ marketing site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
|
|
import { useState } from "react";
|
|
import { AuthPageCard, AuthPageShell } from "@/client/features/auth/AuthPage";
|
|
import { captureClientEvent } from "@/client/lib/posthog";
|
|
import { authClient, signOutAndRedirect, useSession } from "@/lib/auth-client";
|
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|
|
|
export const Route = createFileRoute("/accept-invitation/$id")({
|
|
beforeLoad: () => {
|
|
if (!isHostedClientAuthMode()) {
|
|
throw notFound();
|
|
}
|
|
},
|
|
component: AcceptInvitationPage,
|
|
});
|
|
|
|
function AcceptInvitationPage() {
|
|
const { id } = Route.useParams();
|
|
const { data: session, isPending: isSessionPending } = useSession();
|
|
|
|
return (
|
|
<AuthPageShell>
|
|
{isSessionPending ? null : session?.user ? (
|
|
<InvitationCard invitationId={id} userEmail={session.user.email} />
|
|
) : (
|
|
<SignedOutInvitationCard invitationId={id} />
|
|
)}
|
|
</AuthPageShell>
|
|
);
|
|
}
|
|
|
|
// getInvitation requires a session matching the invited email, so a
|
|
// logged-out visitor gets a generic shell — no invitation details are
|
|
// exposed pre-auth by design.
|
|
function SignedOutInvitationCard({ invitationId }: { invitationId: string }) {
|
|
const redirect = `/accept-invitation/${invitationId}`;
|
|
|
|
return (
|
|
<AuthPageCard title="You’re invited">
|
|
<p className="text-sm text-base-content/70">
|
|
You’ve been invited to join an organization on CrawlerX. Sign in
|
|
with the email address that received the invitation to accept it.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<Link
|
|
to="/sign-up"
|
|
search={{ redirect }}
|
|
className="btn btn-soft w-full"
|
|
>
|
|
Create account
|
|
</Link>
|
|
<Link
|
|
to="/sign-in"
|
|
search={{ redirect }}
|
|
className="btn btn-ghost w-full"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</div>
|
|
</AuthPageCard>
|
|
);
|
|
}
|
|
|
|
function InvitationCard({
|
|
invitationId,
|
|
userEmail,
|
|
}: {
|
|
invitationId: string;
|
|
userEmail: string;
|
|
}) {
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [declined, setDeclined] = useState(false);
|
|
|
|
const invitationQuery = useQuery({
|
|
queryKey: ["invitation", invitationId],
|
|
queryFn: async () => {
|
|
const result = await authClient.organization.getInvitation({
|
|
query: { id: invitationId },
|
|
});
|
|
if (result.error) {
|
|
throw new Error(result.error.message || "Invitation not found");
|
|
}
|
|
return result.data;
|
|
},
|
|
retry: false,
|
|
});
|
|
|
|
async function handleAccept() {
|
|
setActionError(null);
|
|
setIsSubmitting(true);
|
|
try {
|
|
const accepted = await authClient.organization.acceptInvitation({
|
|
invitationId,
|
|
});
|
|
if (accepted.error) {
|
|
setActionError(
|
|
accepted.error.message || "We couldn't accept the invitation.",
|
|
);
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
// Accepting updates the session row but not the session cookie cache;
|
|
// setActive refreshes the cookie so the app opens in the joined org
|
|
// immediately instead of after the cache expires.
|
|
await authClient.organization.setActive({
|
|
organizationId: accepted.data.invitation.organizationId,
|
|
});
|
|
captureClientEvent("team:invitation_accept");
|
|
// Full navigation: every cached query in this tab belongs to the old
|
|
// workspace.
|
|
window.location.assign("/");
|
|
} catch {
|
|
setActionError("We couldn't accept the invitation. Please try again.");
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function handleDecline() {
|
|
setActionError(null);
|
|
setIsSubmitting(true);
|
|
try {
|
|
const result = await authClient.organization.rejectInvitation({
|
|
invitationId,
|
|
});
|
|
if (result.error) {
|
|
setActionError(
|
|
result.error.message || "We couldn't decline the invitation.",
|
|
);
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
captureClientEvent("team:invitation_decline");
|
|
setDeclined(true);
|
|
} catch {
|
|
setActionError("We couldn't decline the invitation. Please try again.");
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (invitationQuery.isPending) {
|
|
return (
|
|
<AuthPageCard title="Checking invitation...">
|
|
<div className="flex justify-center py-4">
|
|
<span className="loading loading-spinner loading-md" />
|
|
</div>
|
|
</AuthPageCard>
|
|
);
|
|
}
|
|
|
|
if (invitationQuery.isError) {
|
|
return (
|
|
<AuthPageCard title="Invitation unavailable">
|
|
<p className="text-sm text-base-content/70">
|
|
This invitation may have expired, been canceled, or belong to a
|
|
different email address. You’re signed in as{" "}
|
|
<span className="font-medium" data-ph-mask>
|
|
{userEmail}
|
|
</span>
|
|
.
|
|
</p>
|
|
<p className="text-sm text-base-content/70">
|
|
If the invitation was sent to another address, sign out and sign back
|
|
in with that email. Otherwise ask your teammate to send a new invite.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-soft w-full"
|
|
onClick={() => {
|
|
// Signs out, then lands on sign-in with a redirect back to this
|
|
// invitation (staying signed in would bounce straight back here).
|
|
signOutAndRedirect();
|
|
}}
|
|
>
|
|
Use a different account
|
|
</button>
|
|
<Link to="/" className="btn btn-ghost w-full">
|
|
Go to dashboard
|
|
</Link>
|
|
</div>
|
|
</AuthPageCard>
|
|
);
|
|
}
|
|
|
|
if (declined) {
|
|
return (
|
|
<AuthPageCard title="Invitation declined">
|
|
<p className="text-sm text-base-content/70">
|
|
You declined the invitation to join{" "}
|
|
<span className="font-medium">
|
|
{invitationQuery.data.organizationName}
|
|
</span>
|
|
.
|
|
</p>
|
|
<Link to="/" className="btn btn-ghost w-full">
|
|
Go to dashboard
|
|
</Link>
|
|
</AuthPageCard>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthPageCard title="Join organization">
|
|
<p className="text-sm text-base-content/70">
|
|
<span className="font-medium" data-ph-mask>
|
|
{invitationQuery.data.inviterEmail}
|
|
</span>{" "}
|
|
invited you to join{" "}
|
|
<span className="font-medium">
|
|
{invitationQuery.data.organizationName}
|
|
</span>{" "}
|
|
on CrawlerX.
|
|
</p>
|
|
{actionError ? <p className="text-sm text-error">{actionError}</p> : null}
|
|
<div className="space-y-2">
|
|
<button
|
|
type="button"
|
|
className="btn btn-soft w-full"
|
|
disabled={isSubmitting}
|
|
onClick={() => void handleAccept()}
|
|
>
|
|
{isSubmitting ? "Joining..." : "Accept invitation"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost w-full"
|
|
disabled={isSubmitting}
|
|
onClick={() => void handleDecline()}
|
|
>
|
|
Decline
|
|
</button>
|
|
</div>
|
|
</AuthPageCard>
|
|
);
|
|
}
|