diff --git a/apps/dashboard/src/app/(dashboard)/invite/client.tsx b/apps/dashboard/src/app/(dashboard)/invite/client.tsx index f8597840..afe6711a 100644 --- a/apps/dashboard/src/app/(dashboard)/invite/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/invite/client.tsx @@ -7,6 +7,12 @@ import { useQueryStates } from "nuqs"; import { useTransition } from "react"; import { toast } from "sonner"; +import { Link } from "@/components/common/link"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, @@ -14,6 +20,16 @@ import { SectionHeader, SectionTitle, } from "@/components/content/section"; +import { + FormCard, + FormCardContent, + FormCardDescription, + FormCardFooter, + FormCardFooterInfo, + FormCardHeader, + FormCardTitle, +} from "@/components/forms/form-card"; +import { formatDate } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; import { switchWorkspace } from "@/lib/workspace-cookie"; @@ -42,11 +58,20 @@ export function Client() {
- Error - - {error.message} + Invitation + + This invitation can't be opened. + + Invitation unavailable + + {error.message} + + +
); @@ -55,48 +80,90 @@ export function Client() { if (!invitation) return null; if (invitation.acceptedAt) return null; + const { workspace } = invitation; + return (
Invitation - You've been invited to join the workspace{" "} - {invitation.workspace.name ? ( - {invitation.workspace.name} - ) : ( - {invitation.workspace.slug} - )} - . + Accepting switches you into the workspace. You can switch back + anytime from the sidebar. - + }} + > + {isPending ? "Accepting..." : "Accept Invitation"} + + +
); diff --git a/apps/dashboard/src/app/login/_components/actions.ts b/apps/dashboard/src/app/login/_components/actions.ts index face7a3f..fb8e6cb1 100644 --- a/apps/dashboard/src/app/login/_components/actions.ts +++ b/apps/dashboard/src/app/login/_components/actions.ts @@ -7,9 +7,20 @@ import { signIn } from "@/lib/auth"; import { ssoLookupRateLimit } from "@/lib/rate-limit/sso-lookup"; import { SSO_ORG_COOKIE } from "@/lib/sso-cookie"; +// Same-origin paths only; the Auth.js `redirect` callback is the second line +// of defense, not the first. +function sanitizeRedirectTo(raw: FormDataEntryValue | null) { + const value = String(raw ?? ""); + return value.startsWith("/") && !value.startsWith("//") ? value : undefined; +} + export async function signInWithResendAction(formData: FormData) { try { - await signIn("resend", formData); + // next-auth lifts `redirectTo` into the magic link's `callbackUrl` itself. + await signIn("resend", { + email: String(formData.get("email") ?? ""), + redirectTo: sanitizeRedirectTo(formData.get("redirectTo")), + }); } catch (e) { console.error(e); } @@ -28,11 +39,8 @@ export async function startSsoSignIn( formData: FormData, ): Promise { const email = String(formData.get("email") ?? ""); - const redirectToRaw = String(formData.get("redirectTo") ?? ""); const redirectTo = - redirectToRaw.startsWith("/") && !redirectToRaw.startsWith("//") - ? redirectToRaw - : "/overview"; + sanitizeRedirectTo(formData.get("redirectTo")) ?? "/overview"; if (!email.includes("@")) return { error: GENERIC_ERROR }; diff --git a/apps/dashboard/src/app/login/_components/magic-link-form.tsx b/apps/dashboard/src/app/login/_components/magic-link-form.tsx index 866d552e..879196d5 100644 --- a/apps/dashboard/src/app/login/_components/magic-link-form.tsx +++ b/apps/dashboard/src/app/login/_components/magic-link-form.tsx @@ -8,10 +8,14 @@ import { toast } from "sonner"; import { signInWithResendAction } from "./actions"; import { LoginButton } from "./login-button"; +interface MagicLinkFormProps { + redirectTo?: string; +} + /** * @deprecated - only to be used in development mode */ -export default function MagicLinkForm() { +export function MagicLinkForm({ redirectTo }: MagicLinkFormProps) { const { pending } = useFormStatus(); return ( @@ -27,6 +31,9 @@ export default function MagicLinkForm() { }} className="grid gap-2" > + {redirectTo ? ( + + ) : null}
diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index 51d6b14f..9b1eaa2c 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -8,7 +8,7 @@ import type { SearchParams } from "nuqs/server"; import { signIn } from "@/lib/auth"; import { LoginButton } from "./_components/login-button"; -import MagicLinkForm from "./_components/magic-link-form"; +import { MagicLinkForm } from "./_components/magic-link-form"; import { SsoForm } from "./_components/sso-form"; import { searchParamsCache } from "./search-params"; @@ -53,7 +53,7 @@ export default async function Page(props: { {process.env.NODE_ENV === "development" || process.env.SELF_HOST === "true" ? (
- +
) : null} diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts index c25b1e3e..a601db9c 100644 --- a/packages/api/src/router/email/index.ts +++ b/packages/api/src/router/email/index.ts @@ -134,11 +134,16 @@ export const emailRouter = createTRPCRouter({ if (!_invitation) return; + const baseUrl = opts.ctx.req?.nextUrl?.origin + ? `${opts.ctx.req.nextUrl.origin}/invite` + : undefined; + await emailClient.sendTeamInvitation({ to: _invitation.email, token: _invitation.token, invitedBy: `${opts.ctx.user.email}`, workspaceName: opts.ctx.workspace.name || "openstatus", + baseUrl, }); } }), diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 32e753eb..b582f434 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -268,7 +268,9 @@ export class EmailClient { public async sendTeamInvitation(req: TeamInvitationProps & { to: string }) { if (env.NODE_ENV === "development") { + const inviteUrl = `${req.baseUrl ?? "http://localhost:3000/invite"}?token=${req.token}`; console.log(`Sending team invitation email to ${req.to}`); + console.log(`>>> Team Invitation Link: ${inviteUrl}`); return; }