diff --git a/ee/apps/den-controller/src/auth.ts b/ee/apps/den-controller/src/auth.ts index d0b6bdfa..4b94ffcd 100644 --- a/ee/apps/den-controller/src/auth.ts +++ b/ee/apps/den-controller/src/auth.ts @@ -8,7 +8,7 @@ import { createDenTypeId, normalizeDenTypeId } from "./db/typeid.js" import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js" import { env } from "./env.js" import { syncDenSignupContact } from "./loops.js" -import { ensureUserOrgAccess, seedDefaultOrganizationRoles } from "./orgs.js" +import { seedDefaultOrganizationRoles } from "./orgs.js" import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js" const socialProviders = { @@ -43,7 +43,7 @@ function getInvitationOrigin() { } function buildInvitationLink(invitationId: string) { - return new URL(`/?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() + return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() } export const auth = betterAuth({ @@ -123,18 +123,10 @@ export const auth = betterAuth({ sendOnSignUp: true, sendOnSignIn: true, afterEmailVerification: async (user) => { - const userId = normalizeDenTypeId("user", user.id) - await Promise.all([ - ensureUserOrgAccess({ - userId, - email: user.email, - name: user.name, - }), - syncDenSignupContact({ - email: user.email, - name: user.name, - }), - ]) + await syncDenSignupContact({ + email: user.email, + name: user.name, + }) }, }, emailAndPassword: { diff --git a/ee/apps/den-controller/src/http/orgs.ts b/ee/apps/den-controller/src/http/orgs.ts index e0feb992..a87093a4 100644 --- a/ee/apps/den-controller/src/http/orgs.ts +++ b/ee/apps/den-controller/src/http/orgs.ts @@ -16,6 +16,7 @@ import { env } from "../env.js" import { acceptInvitationForUser, createOrganizationForUser, + getInvitationPreview, getOrganizationContextForUser, listAssignableRoles, removeOrganizationMember, @@ -35,6 +36,10 @@ const inviteMemberSchema = z.object({ role: z.string().trim().min(1).max(64), }) +const acceptInvitationSchema = z.object({ + id: z.string().trim().min(1), +}) + const updateMemberRoleSchema = z.object({ role: z.string().trim().min(1).max(64), }) @@ -95,7 +100,7 @@ function getInvitationOrigin() { } function buildInvitationLink(invitationId: string) { - return new URL(`/?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() + return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() } function parseTemplateJson(value: string) { @@ -222,17 +227,34 @@ orgsRouter.post("/", asyncRoute(async (req, res) => { res.status(201).json({ organization: context?.organization ?? null }) })) -orgsRouter.get("/invitations/accept", asyncRoute(async (req, res) => { +orgsRouter.get("/invitations/preview", asyncRoute(async (req, res) => { + const invitationIdRaw = typeof req.query.id === "string" ? req.query.id.trim() : "" + const invitation = invitationIdRaw ? await getInvitationPreview(invitationIdRaw) : null + + if (!invitation) { + res.status(404).json({ error: "invitation_not_found" }) + return + } + + res.json(invitation) +})) + +orgsRouter.post("/invitations/accept", asyncRoute(async (req, res) => { const session = await requireSession(req, res) if (!session) { return } - const invitationIdRaw = typeof req.query.id === "string" ? req.query.id.trim() : "" + const parsed = acceptInvitationSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + const accepted = await acceptInvitationForUser({ userId: session.user.id, email: session.user.email ?? `${session.user.id}@placeholder.local`, - invitationId: invitationIdRaw || null, + invitationId: parsed.data.id, }) if (!accepted) { diff --git a/ee/apps/den-controller/src/http/workers.ts b/ee/apps/den-controller/src/http/workers.ts index 0b745c51..1c4f4100 100644 --- a/ee/apps/den-controller/src/http/workers.ts +++ b/ee/apps/den-controller/src/http/workers.ts @@ -8,7 +8,7 @@ import { AuditEventTable, AuthUserTable, DaytonaSandboxTable, OrgMembershipTable import { env } from "../env.js" import { asyncRoute, isTransientDbConnectionError } from "./errors.js" import { getRequestSession } from "./session.js" -import { resolveUserOrganizationsForSession } from "../orgs.js" +import { ensureUserOrgAccess, listUserOrgs, setSessionActiveOrganization } from "../orgs.js" import { deprovisionWorker, provisionWorker } from "../workers/provisioner.js" import { customDomainForWorker } from "../workers/vanity-domain.js" import { createDenTypeId, normalizeDenTypeId } from "../db/typeid.js" @@ -290,15 +290,34 @@ async function resolveActiveOrgId(session: Awaited org.id)) + + let activeOrgId: OrgId | null = null + if (session.session?.activeOrganizationId) { + try { + const normalized = normalizeDenTypeId("organization", session.session.activeOrganizationId) + if (availableOrgIds.has(normalized)) { + activeOrgId = normalized + } + } catch { + activeOrgId = null + } + } + + activeOrgId ??= orgs[0]?.id ?? null + if (sessionId && activeOrgId && activeOrgId !== session.session?.activeOrganizationId) { + await setSessionActiveOrganization(sessionId, activeOrgId) + } + + return activeOrgId } async function countUserCloudWorkers(userId: UserId) { diff --git a/ee/apps/den-controller/src/orgs.ts b/ee/apps/den-controller/src/orgs.ts index db2f14b3..da3fb578 100644 --- a/ee/apps/den-controller/src/orgs.ts +++ b/ee/apps/den-controller/src/orgs.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, gt } from "./db/drizzle.js" +import { and, asc, eq } from "./db/drizzle.js" import { db } from "./db/index.js" import { AuthSessionTable, @@ -19,6 +19,24 @@ type OrgId = typeof OrganizationTable.$inferSelect.id type MemberRow = typeof MemberTable.$inferSelect type InvitationRow = typeof InvitationTable.$inferSelect +export type InvitationStatus = "pending" | "accepted" | "canceled" | "expired" + +export type InvitationPreview = { + invitation: { + id: string + email: string + role: string + status: InvitationStatus + expiresAt: Date + createdAt: Date + } + organization: { + id: OrgId + name: string + slug: string + } +} + export type UserOrgSummary = { id: OrgId name: string @@ -142,18 +160,29 @@ async function listMembershipRows(userId: UserId) { .orderBy(asc(MemberTable.createdAt)) } -async function listPendingInvitations(email: string) { - return db +function getInvitationStatus(invitation: Pick): InvitationStatus { + if (invitation.status !== "pending") { + return invitation.status as Exclude + } + + return invitation.expiresAt > new Date() ? "pending" : "expired" +} + +async function getInvitationById(invitationIdRaw: string) { + let invitationId + try { + invitationId = normalizeDenTypeId("invitation", invitationIdRaw) + } catch { + return null + } + + const rows = await db .select() .from(InvitationTable) - .where( - and( - eq(InvitationTable.email, email.trim().toLowerCase()), - eq(InvitationTable.status, "pending"), - gt(InvitationTable.expiresAt, new Date()), - ), - ) - .orderBy(asc(InvitationTable.createdAt)) + .where(eq(InvitationTable.id, invitationId)) + .limit(1) + + return rows[0] ?? null } async function ensureDefaultDynamicRoles(orgId: OrgId) { @@ -292,17 +321,26 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { export async function acceptInvitationForUser(input: { userId: UserId email: string - invitationId?: string | null + invitationId: string | null }) { - const invitations = await listPendingInvitations(input.email) - const invitation = input.invitationId - ? invitations.find((candidate) => candidate.id === input.invitationId) ?? null - : (invitations[0] ?? null) + if (!input.invitationId) { + return null + } + + const invitation = await getInvitationById(input.invitationId) if (!invitation) { return null } + if (invitation.email.trim().toLowerCase() !== input.email.trim().toLowerCase()) { + return null + } + + if (getInvitationStatus(invitation) !== "pending") { + return null + } + const member = await acceptInvitation(invitation, input.userId) return { invitation, @@ -310,6 +348,49 @@ export async function acceptInvitationForUser(input: { } } +export async function getInvitationPreview(invitationIdRaw: string): Promise { + let invitationId + try { + invitationId = normalizeDenTypeId("invitation", invitationIdRaw) + } catch { + return null + } + + const rows = await db + .select({ + invitation: { + id: InvitationTable.id, + email: InvitationTable.email, + role: InvitationTable.role, + status: InvitationTable.status, + expiresAt: InvitationTable.expiresAt, + createdAt: InvitationTable.createdAt, + }, + organization: { + id: OrganizationTable.id, + name: OrganizationTable.name, + slug: OrganizationTable.slug, + }, + }) + .from(InvitationTable) + .innerJoin(OrganizationTable, eq(InvitationTable.organizationId, OrganizationTable.id)) + .where(eq(InvitationTable.id, invitationId)) + .limit(1) + + const row = rows[0] + if (!row) { + return null + } + + return { + invitation: { + ...row.invitation, + status: getInvitationStatus(row.invitation), + }, + organization: row.organization, + } +} + async function createOrganizationRecord(input: { userId: UserId name: string @@ -340,8 +421,6 @@ async function createOrganizationRecord(input: { export async function ensureUserOrgAccess(input: { userId: UserId - email: string - name?: string | null }) { const memberships = await listMembershipRows(input.userId) if (memberships.length > 0) { @@ -350,16 +429,7 @@ export async function ensureUserOrgAccess(input: { return memberships[0].organizationId } - const pendingInvitation = (await listPendingInvitations(input.email))[0] - if (pendingInvitation) { - const acceptedMember = await acceptInvitation(pendingInvitation, input.userId) - return acceptedMember.organizationId - } - - return createOrganizationRecord({ - userId: input.userId, - name: buildPersonalOrgName(input.email), - }) + return null } export async function createOrganizationForUser(input: { @@ -425,11 +495,18 @@ export async function resolveUserOrganizationsForSession(input: { }) { await ensureUserOrgAccess({ userId: input.userId, - email: input.email, - name: input.name, }) - const orgs = await listUserOrgs(input.userId) + let orgs = await listUserOrgs(input.userId) + if (orgs.length === 0) { + await createOrganizationRecord({ + userId: input.userId, + name: buildPersonalOrgName(input.email), + }) + + orgs = await listUserOrgs(input.userId) + } + const availableOrgIds = new Set(orgs.map((org) => org.id)) let activeOrgId: OrgId | null = null diff --git a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx index b322a3bf..60de4d31 100644 --- a/ee/apps/den-web/app/(den)/_components/auth-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/auth-screen.tsx @@ -293,7 +293,7 @@ export function AuthScreen() { const next = verificationRequired ? await submitVerificationCode(event) : await submitAuth(event); - if (next === "dashboard") { + if (next === "dashboard" || next === "join-org") { const target = await resolveUserLandingRoute(); if (target && !isSamePathname(pathname, target)) { router.replace(target); diff --git a/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx new file mode 100644 index 00000000..34e70e32 --- /dev/null +++ b/ee/apps/den-web/app/(den)/_components/join-org-screen.tsx @@ -0,0 +1,279 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { getErrorMessage, requestJson } from "../_lib/den-flow"; +import { + PENDING_ORG_INVITATION_STORAGE_KEY, + formatRoleLabel, + getJoinOrgRoute, + getOrgDashboardRoute, + parseInvitationPreviewPayload, + type DenInvitationPreview, +} from "../_lib/den-org"; +import { useDenFlow } from "../_providers/den-flow-provider"; + +function LoadingCard({ title, body }: { title: string; body: string }) { + return ( +
+

OpenWork Cloud

+
+

{title}

+

{body}

+
+
+
+
+
+ ); +} + +function statusMessage(preview: DenInvitationPreview | null) { + switch (preview?.invitation.status) { + case "accepted": + return "This invitation has already been accepted."; + case "canceled": + return "This invitation has been canceled."; + case "expired": + return "This invitation has expired."; + default: + return "This invitation is no longer available."; + } +} + +export function JoinOrgScreen({ invitationId }: { invitationId: string }) { + const router = useRouter(); + const { user, sessionHydrated, signOut } = useDenFlow(); + const [preview, setPreview] = useState(null); + const [previewBusy, setPreviewBusy] = useState(true); + const [previewError, setPreviewError] = useState(null); + const [joinBusy, setJoinBusy] = useState(false); + const [joinError, setJoinError] = useState(null); + + const signUpHref = useMemo(() => { + if (!invitationId) { + return "/?mode=sign-up"; + } + + return `/?mode=sign-up&invite=${encodeURIComponent(invitationId)}`; + }, [invitationId]); + + const signInHref = useMemo(() => { + if (!invitationId) { + return "/?mode=sign-in"; + } + + return `/?mode=sign-in&invite=${encodeURIComponent(invitationId)}`; + }, [invitationId]); + + const invitedEmailMatches = preview && user + ? preview.invitation.email.trim().toLowerCase() === user.email.trim().toLowerCase() + : false; + + useEffect(() => { + let cancelled = false; + + async function loadPreview() { + if (!invitationId) { + setPreview(null); + setPreviewError("Missing invitation link."); + setPreviewBusy(false); + return; + } + + setPreviewBusy(true); + setPreviewError(null); + + try { + const { response, payload } = await requestJson( + `/v1/orgs/invitations/preview?id=${encodeURIComponent(invitationId)}`, + { method: "GET" }, + 12000, + ); + + if (cancelled) { + return; + } + + if (!response.ok) { + if (typeof window !== "undefined" && response.status === 404) { + window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); + } + + setPreview(null); + setPreviewError(getErrorMessage(payload, response.status === 404 ? "This invitation is no longer available." : `Could not load the invitation (${response.status}).`)); + return; + } + + const nextPreview = parseInvitationPreviewPayload(payload); + if (!nextPreview) { + setPreview(null); + setPreviewError("The invitation details were incomplete."); + return; + } + + setPreview(nextPreview); + } catch (error) { + if (!cancelled) { + setPreview(null); + setPreviewError(error instanceof Error ? error.message : "Could not load the invitation."); + } + } finally { + if (!cancelled) { + setPreviewBusy(false); + } + } + } + + void loadPreview(); + + return () => { + cancelled = true; + }; + }, [invitationId]); + + async function handleAcceptInvitation() { + if (!invitationId) { + setJoinError("Missing invitation link."); + return; + } + + setJoinBusy(true); + setJoinError(null); + + try { + const { response, payload } = await requestJson( + "/v1/orgs/invitations/accept", + { + method: "POST", + body: JSON.stringify({ id: invitationId }), + }, + 12000, + ); + + if (!response.ok) { + setJoinError(getErrorMessage(payload, response.status === 404 ? "This invitation could not be accepted." : `Could not join the organization (${response.status}).`)); + return; + } + + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); + } + + const acceptedPayload = typeof payload === "object" && payload ? payload as { organizationSlug?: unknown } : null; + const organizationSlug = typeof acceptedPayload?.organizationSlug === "string" ? acceptedPayload.organizationSlug.trim() : ""; + router.replace(organizationSlug ? getOrgDashboardRoute(organizationSlug) : "/dashboard"); + } catch (error) { + setJoinError(error instanceof Error ? error.message : "Could not join the organization."); + } finally { + setJoinBusy(false); + } + } + + async function handleSwitchAccount() { + await signOut(); + router.replace(getJoinOrgRoute(invitationId)); + } + + if (!sessionHydrated || previewBusy) { + return ; + } + + if (!preview) { + return ( +
+
+

OpenWork Cloud

+

Invitation unavailable.

+

{previewError ?? "This invitation could not be loaded."}

+
+
+ + Back to OpenWork Cloud + +
+
+ ); + } + + const showAcceptAction = preview.invitation.status === "pending" && Boolean(user) && invitedEmailMatches; + + return ( +
+
+

OpenWork Cloud

+
+

You've been invited to

+

{preview.organization.name}

+
+

Role: {formatRoleLabel(preview.invitation.role)}

+
+ + {user ? ( +
+ Signed in as {user.email} +
+ ) : null} + + {preview.invitation.status !== "pending" ? ( +
+

{statusMessage(preview)}

+
+ + {user && invitedEmailMatches ? "Open organization" : "Back to OpenWork Cloud"} + +
+
+ ) : !user ? ( +
+

Create an account or sign in first, then come back here to confirm the invitation.

+
+ + Create account to continue + + + Sign in instead + +
+
+ ) : !invitedEmailMatches ? ( +
+

+ This invite was sent to {preview.invitation.email}. Sign in with that email to join the organization. +

+
+ +
+
+ ) : ( +
+

Click to join

+
+ +
+
+ )} + + {joinError ?

{joinError}

: null} + {previewError ?

{previewError}

: null} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index f68289ae..7601b627 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -34,6 +34,22 @@ export type DenOrgInvitation = { createdAt: string | null; }; +export type DenInvitationPreview = { + invitation: { + id: string; + email: string; + role: string; + status: string; + expiresAt: string | null; + createdAt: string | null; + }; + organization: { + id: string; + name: string; + slug: string; + }; +}; + export type DenOrgRole = { id: string; role: string; @@ -140,6 +156,10 @@ export function getOrgDashboardRoute(orgSlug: string): string { return `/o/${encodeURIComponent(orgSlug)}/dashboard`; } +export function getJoinOrgRoute(invitationId: string): string { + return `/join-org?invite=${encodeURIComponent(invitationId)}`; +} + export function getManageMembersRoute(orgSlug: string): string { return `${getOrgDashboardRoute(orgSlug)}/manage-members`; } @@ -338,3 +358,39 @@ export function parseOrgContextPayload(payload: unknown): DenOrgContext | null { roles, }; } + +export function parseInvitationPreviewPayload(payload: unknown): DenInvitationPreview | null { + if (!isRecord(payload) || !isRecord(payload.invitation) || !isRecord(payload.organization)) { + return null; + } + + const invitation = payload.invitation; + const organization = payload.organization; + const invitationId = asString(invitation.id); + const invitationEmail = asString(invitation.email); + const invitationRole = asString(invitation.role); + const invitationStatus = asString(invitation.status); + const organizationId = asString(organization.id); + const organizationName = asString(organization.name); + const organizationSlug = asString(organization.slug); + + if (!invitationId || !invitationEmail || !invitationRole || !invitationStatus || !organizationId || !organizationName || !organizationSlug) { + return null; + } + + return { + invitation: { + id: invitationId, + email: invitationEmail, + role: invitationRole, + status: invitationStatus, + expiresAt: asIsoString(invitation.expiresAt), + createdAt: asIsoString(invitation.createdAt), + }, + organization: { + id: organizationId, + name: organizationName, + slug: organizationSlug, + }, + }; +} diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index bef78be6..6f444030 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -55,11 +55,13 @@ import { } from "../_lib/den-flow"; import { PENDING_ORG_INVITATION_STORAGE_KEY, + getJoinOrgRoute, getOrgDashboardRoute, parseOrgListPayload, } from "../_lib/den-org"; type LaunchWorkerResult = "success" | "checkout" | "error"; +type AuthNavigationResult = "dashboard" | "checkout" | "join-org" | null; type DenFlowContextValue = { authMode: AuthMode; @@ -81,8 +83,8 @@ type DenFlowContextValue = { desktopRedirectUrl: string | null; desktopRedirectBusy: boolean; showAuthFeedback: boolean; - submitAuth: (event: FormEvent) => Promise<"dashboard" | "checkout" | null>; - submitVerificationCode: (event: FormEvent) => Promise<"dashboard" | "checkout" | null>; + submitAuth: (event: FormEvent) => Promise; + submitVerificationCode: (event: FormEvent) => Promise; resendVerificationCode: () => Promise; cancelVerification: () => void; beginSocialAuth: (provider: SocialAuthProvider) => Promise; @@ -165,6 +167,15 @@ function readLocalStorage(key: string): T | null { } } +function getPendingOrgInvitationId() { + if (typeof window === "undefined") { + return null; + } + + const invitationId = window.sessionStorage.getItem(PENDING_ORG_INVITATION_STORAGE_KEY)?.trim() ?? ""; + return invitationId || null; +} + export function DenFlowProvider({ children }: { children: ReactNode }) { const [authMode, setAuthModeState] = useState("sign-up"); const [email, setEmail] = useState(""); @@ -357,7 +368,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { nextMode: AuthMode, trimmedEmail: string, payloadOverride?: unknown, - ): Promise<"dashboard" | "checkout" | null> { + ): Promise { let payload = payloadOverride; if (payload === undefined || (!getToken(payload) && nextMode === "sign-up" && Boolean(password))) { @@ -426,6 +437,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return null; } + if (authenticatedUser && getPendingOrgInvitationId()) { + return "join-org"; + } + if (authenticatedUser && nextMode === "sign-up") { return await beginSignupOnboarding(authenticatedUser, "email"); } @@ -976,46 +991,9 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return parseOrgListPayload(payload); } - async function acceptPendingInvitationIfNeeded() { - if (typeof window === "undefined") { - return null; - } - - const invitationId = window.sessionStorage.getItem(PENDING_ORG_INVITATION_STORAGE_KEY)?.trim() ?? ""; - if (!invitationId) { - return null; - } - - const headers = new Headers(); - if (authToken) { - headers.set("Authorization", `Bearer ${authToken}`); - } - - const { response, payload } = await requestJson( - `/v1/orgs/invitations/accept?id=${encodeURIComponent(invitationId)}`, - { method: "GET", headers }, - 12000, - ); - - if (!response.ok) { - if (response.status === 404) { - window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); - } - return null; - } - - window.sessionStorage.removeItem(PENDING_ORG_INVITATION_STORAGE_KEY); - if (typeof payload === "object" && payload && "organizationSlug" in payload && typeof payload.organizationSlug === "string") { - return payload.organizationSlug; - } - - return null; - } - async function resolveDashboardRoute() { - const acceptedOrgSlug = await acceptPendingInvitationIfNeeded(); const orgDirectory = await loadOrgDirectory(); - const activeOrgSlug = acceptedOrgSlug ?? orgDirectory.activeOrgSlug ?? orgDirectory.orgs[0]?.slug ?? null; + const activeOrgSlug = orgDirectory.activeOrgSlug ?? orgDirectory.orgs[0]?.slug ?? null; return activeOrgSlug ? getOrgDashboardRoute(activeOrgSlug) : null; } @@ -1089,6 +1067,11 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { return null; } + const pendingInvitationId = getPendingOrgInvitationId(); + if (pendingInvitationId) { + return getJoinOrgRoute(pendingInvitationId); + } + const dashboardRoute = await resolveDashboardRoute(); if (!onboardingPending) { @@ -1874,6 +1857,11 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { method: pendingSocialSignup, email_domain: getEmailDomain(user.email) }); + + if (getPendingOrgInvitationId()) { + return; + } + void beginSignupOnboarding(user, pendingSocialSignup); }, [user?.id]); diff --git a/ee/apps/den-web/app/(den)/join-org/page.tsx b/ee/apps/den-web/app/(den)/join-org/page.tsx new file mode 100644 index 00000000..4b4ef751 --- /dev/null +++ b/ee/apps/den-web/app/(den)/join-org/page.tsx @@ -0,0 +1,17 @@ +import { JoinOrgScreen } from "../_components/join-org-screen"; + +export default async function JoinOrgPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const inviteParam = params.invite; + const invitationId = typeof inviteParam === "string" + ? inviteParam.trim() + : Array.isArray(inviteParam) + ? (inviteParam[0]?.trim() ?? "") + : ""; + + return ; +} diff --git a/ee/apps/den-web/next-env.d.ts b/ee/apps/den-web/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/ee/apps/den-web/next-env.d.ts +++ b/ee/apps/den-web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/app/pr/screenshots/den-invite-confirmation/after-join-dashboard.png b/packages/app/pr/screenshots/den-invite-confirmation/after-join-dashboard.png new file mode 100644 index 00000000..d0f22b32 Binary files /dev/null and b/packages/app/pr/screenshots/den-invite-confirmation/after-join-dashboard.png differ diff --git a/packages/app/pr/screenshots/den-invite-confirmation/before-signup.png b/packages/app/pr/screenshots/den-invite-confirmation/before-signup.png new file mode 100644 index 00000000..31ff41be Binary files /dev/null and b/packages/app/pr/screenshots/den-invite-confirmation/before-signup.png differ diff --git a/packages/app/pr/screenshots/den-invite-confirmation/join-confirmation.png b/packages/app/pr/screenshots/den-invite-confirmation/join-confirmation.png new file mode 100644 index 00000000..54eb2b79 Binary files /dev/null and b/packages/app/pr/screenshots/den-invite-confirmation/join-confirmation.png differ