diff --git a/.changeset/onboard-rescan-and-success-links.md b/.changeset/onboard-rescan-and-success-links.md new file mode 100644 index 000000000..1e16fda2d --- /dev/null +++ b/.changeset/onboard-rescan-and-success-links.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Auth plugin onboard page: a rescan by someone who has left the community now says they're no longer a member instead of "You're in". The page checks a fresh organization list against the redeemed organization ID, clears a stale active organization, and shows a retryable error if the check fails. The redeem's refusal for a member who left now carries `data.action: "rejoin-organization"`. Child apps can configure the joined screen through the `onboard` auth variable: `links.next`/`links.home` (with an `{organizationId}` placeholder) and `links.explore` for the success and error panels (defaults stay `/build`, `/dashboard`, `/explore`; `next: false` hides the build link), plus `community.url`, an app endpoint that returns the joined community's page and channels for a "Visit {community}" button and channel links. diff --git a/plugins/auth/README.md b/plugins/auth/README.md index ce373b94a..c0c8d5361 100644 --- a/plugins/auth/README.md +++ b/plugins/auth/README.md @@ -55,6 +55,12 @@ It runs inside the **everything-plugin** framework (oRPC + Effect) and is design | `siwn.relayer.testnet.accountId` | `string` | — | Optional testnet relayer account ID | | `siwn.subAccount.mainnet.parentAccount` | `string` | — | Optional mainnet sub-account parent | | `siwn.subAccount.testnet.parentAccount` | `string` | — | Optional testnet sub-account parent | +| `onboard.links.next` | `{ href, label? } \| false` | `/build`, "Get build prompts" | First link on the onboard joined screen; `false` hides it | +| `onboard.links.home` | `{ href, label? }` | `/dashboard`, "Go to Home" | Last link on the onboard joined screen | +| `onboard.links.explore` | `{ href, label? }` | `/explore`, "Explore communities" | Link on the onboard error and status panels | +| `onboard.community.url` | `string` | — | App path returning `{ name?, href?, channels?: [{ label, url }] }` for the joined organization; renders "Visit {community}" and channel links | + +`onboard` hrefs must be app paths (`/…`) or `https://` URLs. `links.next`, `links.home` and `community.url` replace `{organizationId}` with the joined organization's ID. A failed or invalid `community.url` response shows nothing extra. ### Plugin Secrets diff --git a/plugins/auth/src/config-schemas.ts b/plugins/auth/src/config-schemas.ts index 8d2472512..af954c299 100644 --- a/plugins/auth/src/config-schemas.ts +++ b/plugins/auth/src/config-schemas.ts @@ -1,5 +1,6 @@ import { DEFAULT_DEVICE_LINK_CLIENT_ID } from "better-near-auth"; import { z } from "zod"; +import { onboardConfigSchema } from "./onboard-config"; export const API_KEY_CONFIG_IDS = ["user-keys", "org-keys"] as const; export const subAccountNetworkSchema = z.object({ @@ -142,6 +143,7 @@ export const authVariablesSchema = z.object({ from: z.string(), }) .optional(), + onboard: onboardConfigSchema.optional(), }); export const authSecretsSchema = z.object({ diff --git a/plugins/auth/src/onboard-config.ts b/plugins/auth/src/onboard-config.ts new file mode 100644 index 000000000..581519991 --- /dev/null +++ b/plugins/auth/src/onboard-config.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +export const MEMBERSHIP_ENDED_ACTION = "rejoin-organization"; + +export const ONBOARD_ORGANIZATION_ID_PLACEHOLDER = "{organizationId}"; + +const SAFE_HREF = /^(\/(?![/\\])|https:\/\/)/; + +const onboardHrefSchema = z + .string() + .trim() + .regex(SAFE_HREF, 'must be an app path ("/…") or an https:// URL'); + +export const onboardLinkSchema = z.object({ + href: onboardHrefSchema, + label: z.string().trim().min(1).optional(), +}); + +export const onboardConfigSchema = z.object({ + links: z + .object({ + next: z.union([z.literal(false), onboardLinkSchema]).optional(), + home: onboardLinkSchema.optional(), + explore: onboardLinkSchema + .refine( + (link) => !link.href.includes(ONBOARD_ORGANIZATION_ID_PLACEHOLDER), + "explore links have no organization to fill in", + ) + .optional(), + }) + .optional(), + community: z + .object({ + url: onboardHrefSchema.refine((url) => url.startsWith("/"), "must be an app path"), + }) + .optional(), +}); + +export const onboardCommunitySchema = z.object({ + name: z.string().trim().min(1).optional(), + href: onboardHrefSchema.optional(), + channels: z + .array( + z.object({ + label: z.string().trim().min(1), + url: z.string().regex(/^https?:\/\//), + }), + ) + .optional(), +}); + +export type OnboardLink = z.infer; +export type OnboardConfig = z.infer; +export type OnboardCommunity = z.infer; + +export function fillOrganizationId(template: string, organizationId: string): string { + return template.replaceAll( + ONBOARD_ORGANIZATION_ID_PLACEHOLDER, + encodeURIComponent(organizationId), + ); +} diff --git a/plugins/auth/src/services/domain-errors.ts b/plugins/auth/src/services/domain-errors.ts index 1478db5f2..1a4b2e1ef 100644 --- a/plugins/auth/src/services/domain-errors.ts +++ b/plugins/auth/src/services/domain-errors.ts @@ -1,5 +1,6 @@ import { ORPCError } from "@orpc/server"; import { Data, Effect, Schema } from "effect"; +import { MEMBERSHIP_ENDED_ACTION } from "../onboard-config"; export class AuthApiError extends Data.TaggedError("AuthApiError")<{ code: string; @@ -81,6 +82,10 @@ export class NotOrgMember extends Schema.TaggedError()("NotOrgMemb message: Schema.String, }) {} +export class MembershipEnded extends Schema.TaggedError()("MembershipEnded", { + message: Schema.String, +}) {} + export class NotOrganizer extends Schema.TaggedError()("NotOrganizer", { message: Schema.String, }) {} @@ -187,6 +192,7 @@ const FAILURE_TAGS = new Set([ "TeamNotFound", "TeamUpdateFailed", "NotOrgMember", + "MembershipEnded", "NotOrganizer", "PlatformAdminRequired", "EventEnded", @@ -230,6 +236,7 @@ export type AuthFailure = | TeamNotFound | TeamUpdateFailed | NotOrgMember + | MembershipEnded | NotOrganizer | PlatformAdminRequired | EventEnded @@ -306,6 +313,11 @@ export function toTransportError(error: AuthFailure): ORPCError return new ORPCError("INTERNAL_SERVER_ERROR", { message: error.message }); case "NotOrgMember": return new ORPCError("FORBIDDEN", { message: error.message, data: {} }); + case "MembershipEnded": + return new ORPCError("FORBIDDEN", { + message: error.message, + data: { action: MEMBERSHIP_ENDED_ACTION }, + }); case "NotOrganizer": return new ORPCError("FORBIDDEN", { message: error.message, diff --git a/plugins/auth/src/services/onboarding.ts b/plugins/auth/src/services/onboarding.ts index 57cde9c4b..040b61d54 100644 --- a/plugins/auth/src/services/onboarding.ts +++ b/plugins/auth/src/services/onboarding.ts @@ -22,7 +22,7 @@ import { CodeRevoked, EventEnded, EventTeamCreateFailed, - NotOrgMember, + MembershipEnded, OrgFull, OrgRequiresApproval, PlatformAdminRequired, @@ -486,7 +486,7 @@ export const buildOnboardingService = ({ }), ); if (!membership) { - return yield* new NotOrgMember({ + return yield* new MembershipEnded({ message: "You are no longer a member of this organization", }); } diff --git a/plugins/auth/tests/integration/onboarding.test.ts b/plugins/auth/tests/integration/onboarding.test.ts index 01c5c7995..2b9da6ac1 100644 --- a/plugins/auth/tests/integration/onboarding.test.ts +++ b/plugins/auth/tests/integration/onboarding.test.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import * as schema from "../../src/db/schema"; +import { MEMBERSHIP_ENDED_ACTION } from "../../src/onboard-config"; import { addTestMember, createTestHandlers, @@ -290,7 +291,11 @@ describe("onboarding handlers", () => { input: { code: code.code }, context: { reqHeaders: newcomer.reqHeaders }, }), - ).rejects.toThrow(/no longer a member/i); + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: expect.stringMatching(/no longer a member/i), + data: { action: MEMBERSHIP_ENDED_ACTION }, + }); const member = await services.services.db.query.member.findFirst({ where: and( diff --git a/plugins/auth/ui/src/i18n/catalogs.ts b/plugins/auth/ui/src/i18n/catalogs.ts index 7a8c9d837..0b6035991 100644 --- a/plugins/auth/ui/src/i18n/catalogs.ts +++ b/plugins/auth/ui/src/i18n/catalogs.ts @@ -118,6 +118,15 @@ export const englishLoginMessages = { "auth.device.approve.key": "Approve key", "auth.device.approve.approve": "Approve", "auth.device.approve.deny": "Deny", + "auth.onboard.verifying": "Checking your membership…", + "auth.onboard.left.title": "No longer a member", + "auth.onboard.left.description": + "You’re no longer a member of this community. Ask an organizer for a new code or an invitation.", + "auth.onboard.verifyFailed.title": "Couldn’t check your membership", + "auth.onboard.verifyFailed.description": + "We couldn’t confirm your membership. Check your connection and try again.", + "auth.onboard.visit": "Visit {community}", + "auth.onboard.channels": "{community} channels", } as const; export type LoginMessageId = keyof typeof englishLoginMessages; @@ -232,6 +241,15 @@ const spanishLoginMessages = { "auth.device.approve.key": "Aprobar clave", "auth.device.approve.approve": "Aprobar", "auth.device.approve.deny": "Rechazar", + "auth.onboard.verifying": "Comprobando tu membresía…", + "auth.onboard.left.title": "Ya no eres miembro", + "auth.onboard.left.description": + "Ya no eres miembro de esta comunidad. Pide a un organizador un código nuevo o una invitación.", + "auth.onboard.verifyFailed.title": "No pudimos comprobar tu membresía", + "auth.onboard.verifyFailed.description": + "No pudimos confirmar tu membresía. Revisa tu conexión e inténtalo de nuevo.", + "auth.onboard.visit": "Visitar {community}", + "auth.onboard.channels": "Canales de {community}", } satisfies Record; const frenchLoginMessages = { @@ -342,6 +360,15 @@ const frenchLoginMessages = { "auth.device.approve.key": "Approuver la clé", "auth.device.approve.approve": "Approuver", "auth.device.approve.deny": "Refuser", + "auth.onboard.verifying": "Vérification de votre adhésion…", + "auth.onboard.left.title": "Vous n’êtes plus membre", + "auth.onboard.left.description": + "Vous n’êtes plus membre de cette communauté. Demandez un nouveau code ou une invitation à un organisateur.", + "auth.onboard.verifyFailed.title": "Impossible de vérifier votre adhésion", + "auth.onboard.verifyFailed.description": + "Nous n’avons pas pu confirmer votre adhésion. Vérifiez votre connexion et réessayez.", + "auth.onboard.visit": "Visiter {community}", + "auth.onboard.channels": "Canaux de {community}", } satisfies Record; const chineseLoginMessages = { @@ -441,6 +468,13 @@ const chineseLoginMessages = { "auth.device.approve.key": "批准密钥", "auth.device.approve.approve": "批准", "auth.device.approve.deny": "拒绝", + "auth.onboard.verifying": "正在确认你的成员身份…", + "auth.onboard.left.title": "你已不再是成员", + "auth.onboard.left.description": "你已不再是这个社区的成员。请向组织者索取新的邀请码或邀请。", + "auth.onboard.verifyFailed.title": "无法确认你的成员身份", + "auth.onboard.verifyFailed.description": "我们无法确认你的成员身份。请检查网络连接后重试。", + "auth.onboard.visit": "访问 {community}", + "auth.onboard.channels": "{community} 的频道", } satisfies Record; const translatedLoginMessages: Record>> = { diff --git a/plugins/auth/ui/src/lib/onboard-config.ts b/plugins/auth/ui/src/lib/onboard-config.ts new file mode 100644 index 000000000..dcda5c7cc --- /dev/null +++ b/plugins/auth/ui/src/lib/onboard-config.ts @@ -0,0 +1,54 @@ +import type { RouterContext } from "@/context"; +import { + fillOrganizationId, + type OnboardCommunity, + type OnboardConfig, + type OnboardLink, + onboardCommunitySchema, + onboardConfigSchema, +} from "../../../src/onboard-config"; + +export type { OnboardCommunity, OnboardLink }; + +export const DEFAULT_ONBOARD_LINKS = { + next: { href: "/build", label: "Get build prompts" }, + home: { href: "/dashboard", label: "Go to Home" }, + explore: { href: "/explore", label: "Explore communities" }, +} as const; + +export function readOnboardConfig(runtimeConfig: RouterContext["runtimeConfig"]): OnboardConfig { + const parsed = onboardConfigSchema.safeParse(runtimeConfig?.auth?.variables?.onboard); + return parsed.success ? parsed.data : {}; +} + +export function resolveOnboardLinks(config: OnboardConfig, organizationId?: string) { + const fill = (link: OnboardLink, fallbackLabel: string) => ({ + href: organizationId ? fillOrganizationId(link.href, organizationId) : link.href, + label: link.label ?? fallbackLabel, + }); + const next = config.links?.next; + return { + next: + next === false + ? null + : fill(next ?? DEFAULT_ONBOARD_LINKS.next, DEFAULT_ONBOARD_LINKS.next.label), + home: fill(config.links?.home ?? DEFAULT_ONBOARD_LINKS.home, DEFAULT_ONBOARD_LINKS.home.label), + explore: fill( + config.links?.explore ?? DEFAULT_ONBOARD_LINKS.explore, + DEFAULT_ONBOARD_LINKS.explore.label, + ), + }; +} + +export async function fetchOnboardCommunity( + urlTemplate: string, + organizationId: string, +): Promise { + const response = await fetch(fillOrganizationId(urlTemplate, organizationId), { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + if (!response.ok) return null; + const parsed = onboardCommunitySchema.safeParse(await response.json()); + return parsed.success ? parsed.data : null; +} diff --git a/plugins/auth/ui/src/lib/onboard-membership.ts b/plugins/auth/ui/src/lib/onboard-membership.ts new file mode 100644 index 000000000..ac00a784b --- /dev/null +++ b/plugins/auth/ui/src/lib/onboard-membership.ts @@ -0,0 +1,35 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { type AuthClient, refreshSessionCache } from "everything-dev/ui/auth"; +import type { AuthApiClient } from "./api"; + +export const organizationsQueryKey = ["organizations"] as const; + +export async function verifyOrganizationMembership({ + apiClient, + auth, + queryClient, + organizationId, +}: { + apiClient: Pick; + auth: AuthClient; + queryClient: QueryClient; + organizationId?: string; +}): Promise { + const [organizations, session] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: organizationsQueryKey, + queryFn: () => apiClient.listOrganizations(), + staleTime: 0, + }), + refreshSessionCache(auth, queryClient), + ]); + const memberOf = new Set(organizations.map((organization) => organization.id)); + + const activeOrganizationId = session?.session.activeOrganizationId; + if (activeOrganizationId && !memberOf.has(activeOrganizationId)) { + await apiClient.setActiveOrganization({ organizationId: null }); + await refreshSessionCache(auth, queryClient); + } + + return !!organizationId && memberOf.has(organizationId); +} diff --git a/plugins/auth/ui/src/routes/_public/-onboard.test.tsx b/plugins/auth/ui/src/routes/_public/-onboard.test.tsx index a39cd4c03..5a742485c 100644 --- a/plugins/auth/ui/src/routes/_public/-onboard.test.tsx +++ b/plugins/auth/ui/src/routes/_public/-onboard.test.tsx @@ -12,12 +12,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route as OnboardRoute } from "./onboard"; const harness = vi.hoisted(() => ({ - session: null as { user: { id: string; name: string } } | null, + session: null as { + user: { id: string; name: string }; + session: { activeOrganizationId: string | null }; + } | null, getOnboardingCodeInfo: vi.fn(), redeemOnboardingCode: vi.fn(), + listOrganizations: vi.fn(), + setActiveOrganization: vi.fn(), clipboardWriteText: vi.fn(async () => undefined), + toastSuccess: vi.fn(), })); +vi.mock("sonner", () => ({ toast: { success: harness.toastSuccess, error: vi.fn() } })); + vi.mock("everything-dev/ui/auth", () => ({ sessionQueryOptions: () => ({ queryKey: ["session"], @@ -36,6 +44,7 @@ vi.mock("everything-dev/ui/auth", () => ({ vi.mock("better-near-auth/client", () => ({ isPasskeyWalletAvailable: () => true })); const CODE = "valid-code-12345"; +const ORG_ID = "org-chicago"; const info = { organizationName: "Chicago Builders", eventName: "Launch Night", @@ -45,7 +54,26 @@ const info = { usedUp: false, }; -function renderOnboard() { +const joined = { + success: true, + alreadyRedeemed: false, + organizationId: ORG_ID, + organizationName: "Chicago Builders", + eventName: "Launch Night", +}; + +function signedIn(name = "Grace", activeOrganizationId: string | null = null) { + harness.session = { user: { id: "user-1", name }, session: { activeOrganizationId } }; +} + +function membershipEnded() { + return Object.assign(new Error("You are no longer a member of this organization"), { + code: "FORBIDDEN", + data: { action: "rejoin-organization" }, + }); +} + +function renderOnboard(onboard?: Record) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const root = createRootRouteWithContext>()({ component: Outlet }); const onboardRoute = OnboardRoute.update({ @@ -63,9 +91,15 @@ function renderOnboard() { auth: { getOnboardingCodeInfo: harness.getOnboardingCodeInfo, redeemOnboardingCode: harness.redeemOnboardingCode, + listOrganizations: harness.listOrganizations, + setActiveOrganization: harness.setActiveOrganization, }, }, - runtimeConfig: { networkId: "mainnet", runtime: { gatewayId: "citynode.app" } }, + runtimeConfig: { + networkId: "mainnet", + runtime: { gatewayId: "citynode.app" }, + ...(onboard ? { auth: { variables: { onboard } } } : {}), + }, }, }); render( @@ -85,6 +119,7 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.clearAllMocks(); + vi.unstubAllGlobals(); harness.session = null; }); @@ -103,12 +138,9 @@ describe("onboard join flow", () => { }); it("asks an unnamed returning member for a name, then shows the done state", async () => { - harness.session = { user: { id: "user-1", name: "Passkey user" } }; + signedIn("Passkey user"); harness.getOnboardingCodeInfo.mockResolvedValue(info); - harness.redeemOnboardingCode.mockResolvedValue({ - organizationName: "Chicago Builders", - eventName: "Launch Night", - }); + harness.redeemOnboardingCode.mockResolvedValue(joined); renderOnboard(); @@ -127,12 +159,9 @@ describe("onboard join flow", () => { }); it("skips the name step for a returning member who already chose a name", async () => { - harness.session = { user: { id: "user-1", name: "Grace" } }; + signedIn(); harness.getOnboardingCodeInfo.mockResolvedValue(info); - harness.redeemOnboardingCode.mockResolvedValue({ - organizationName: "Chicago Builders", - eventName: "Launch Night", - }); + harness.redeemOnboardingCode.mockResolvedValue(joined); renderOnboard(); @@ -141,12 +170,9 @@ describe("onboard join flow", () => { }); it("points the done state at the build page", async () => { - harness.session = { user: { id: "user-1", name: "Grace" } }; + signedIn(); harness.getOnboardingCodeInfo.mockResolvedValue(info); - harness.redeemOnboardingCode.mockResolvedValue({ - organizationName: "Chicago Builders", - eventName: "Launch Night", - }); + harness.redeemOnboardingCode.mockResolvedValue(joined); renderOnboard(); @@ -156,12 +182,9 @@ describe("onboard join flow", () => { }); it("copies the desktop pairing link from the continue card", async () => { - harness.session = { user: { id: "user-1", name: "Grace" } }; + signedIn(); harness.getOnboardingCodeInfo.mockResolvedValue(info); - harness.redeemOnboardingCode.mockResolvedValue({ - organizationName: "Chicago Builders", - eventName: "Launch Night", - }); + harness.redeemOnboardingCode.mockResolvedValue(joined); renderOnboard(); @@ -173,4 +196,262 @@ describe("onboard join flow", () => { ), ); }); + it("keeps the happy path to the redeem alone", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.continue-on-computer")).toBeTruthy(); + expect(harness.listOrganizations).not.toHaveBeenCalled(); + expect(harness.toastSuccess).toHaveBeenCalledWith("You've joined Chicago Builders"); + }); +}); + +describe("onboard rescan", () => { + it("shows the joined state to a prior joiner who is still a member", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue({ ...joined, alreadyRedeemed: true }); + harness.listOrganizations.mockResolvedValue([{ id: ORG_ID, name: "Chicago Builders" }]); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.continue-on-computer")).toBeTruthy(); + expect(screen.getByTestId("onboard.success").textContent).toContain("Chicago Builders"); + expect(harness.setActiveOrganization).not.toHaveBeenCalled(); + }); + + it("tells a member who left that they are no longer a member, matching by ID not name", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue({ ...joined, alreadyRedeemed: true }); + harness.listOrganizations.mockResolvedValue([ + { id: "org-other-chicago", name: "Chicago Builders" }, + ]); + harness.setActiveOrganization.mockResolvedValue({ success: true }); + + renderOnboard(); + + const left = await screen.findByTestId("onboard.left"); + expect(left.textContent).toBe( + "You’re no longer a member of this community. Ask an organizer for a new code or an invitation.", + ); + expect(harness.setActiveOrganization).toHaveBeenCalledWith({ organizationId: null }); + expect(screen.queryByTestId("onboard.success")).toBeNull(); + expect(screen.queryByTestId("onboard.continue-on-computer")).toBeNull(); + expect(harness.toastSuccess).not.toHaveBeenCalled(); + }); + + it("treats the server's membership-ended refusal as left and clears a stale active organization", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockRejectedValue(membershipEnded()); + harness.listOrganizations.mockResolvedValue([{ id: "org-personal", name: "Grace" }]); + harness.setActiveOrganization.mockResolvedValue({ success: true }); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.left")).toBeTruthy(); + expect(harness.setActiveOrganization).toHaveBeenCalledWith({ organizationId: null }); + expect(screen.queryByTestId("onboard.error")).toBeNull(); + }); + + it("leaves a valid active organization alone", async () => { + signedIn("Grace", "org-personal"); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockRejectedValue(membershipEnded()); + harness.listOrganizations.mockResolvedValue([{ id: "org-personal", name: "Grace" }]); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.left")).toBeTruthy(); + expect(harness.setActiveOrganization).not.toHaveBeenCalled(); + }); + + it("shows a retryable error when the membership lookup fails, then recovers", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue({ ...joined, alreadyRedeemed: true }); + harness.listOrganizations + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce([{ id: ORG_ID, name: "Chicago Builders" }]); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.verify-failed")).toBeTruthy(); + expect(screen.queryByTestId("onboard.success")).toBeNull(); + expect(screen.queryByTestId("onboard.left")).toBeNull(); + + fireEvent.click(screen.getByTestId("onboard.verify-retry")); + + expect(await screen.findByTestId("onboard.continue-on-computer")).toBeTruthy(); + expect(harness.redeemOnboardingCode).toHaveBeenCalledTimes(1); + }); + + it("stays in the error state when the stale active organization cannot be cleared", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue({ ...joined, alreadyRedeemed: true }); + harness.listOrganizations.mockResolvedValue([]); + harness.setActiveOrganization.mockRejectedValue(new Error("session write failed")); + + renderOnboard(); + + expect(await screen.findByTestId("onboard.verify-failed")).toBeTruthy(); + expect(screen.queryByTestId("onboard.left")).toBeNull(); + }); +}); + +describe("onboard links", () => { + it("keeps /dashboard and /explore as the defaults", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockRejectedValue(new Error("Organization requires approval")); + + renderOnboard(); + + expect((await screen.findByTestId("onboard.explore-button")).getAttribute("href")).toBe( + "/explore", + ); + }); + + it("uses the configured links with the joined organization ID", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + + renderOnboard({ + links: { + next: { href: "/communities/{organizationId}", label: "Open your community" }, + home: { href: "/home" }, + }, + }); + + const next = await screen.findByTestId("onboard.build-button"); + expect(next.getAttribute("href")).toBe(`/communities/${ORG_ID}`); + expect(next.textContent).toBe("Open your community"); + const home = screen.getByTestId("onboard.home-button"); + expect(home.getAttribute("href")).toBe("/home"); + expect(home.textContent).toBe("Go to Home"); + }); + + it("can hide the build link", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + + renderOnboard({ links: { next: false } }); + + expect(await screen.findByTestId("onboard.home-button")).toBeTruthy(); + expect(screen.queryByTestId("onboard.build-button")).toBeNull(); + }); + + it("uses the configured explore link on the error panels", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue({ ...info, expired: true }); + + renderOnboard({ links: { explore: { href: "/communities", label: "Find a community" } } }); + + const explore = await screen.findByTestId("onboard.explore-button"); + expect(explore.getAttribute("href")).toBe("/communities"); + expect(explore.textContent).toBe("Find a community"); + }); + + it("ignores an unsafe configured link", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + + renderOnboard({ links: { next: { href: "javascript:alert(1)" } } }); + + expect((await screen.findByTestId("onboard.build-button")).getAttribute("href")).toBe("/build"); + }); +}); + +describe("onboard community", () => { + const community = { url: "/api/communities/by-organization/{organizationId}" }; + + function stubCommunity(response: () => Promise) { + const fetchMock = vi.fn(response); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("shows Visit and the community's channels for the joined organization", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + const fetchMock = stubCommunity(() => + Promise.resolve( + Response.json({ + name: "Chicago", + href: "/n/chicago", + channels: [ + { label: "Telegram", url: "https://t.me/chicago" }, + { label: "X", url: "https://x.com/chicago" }, + ], + }), + ), + ); + + renderOnboard({ community }); + + const visit = await screen.findByTestId("onboard.visit-button"); + expect(visit.textContent).toBe("Visit Chicago"); + expect(visit.getAttribute("href")).toBe("/n/chicago"); + expect(fetchMock).toHaveBeenCalledWith( + `/api/communities/by-organization/${ORG_ID}`, + expect.objectContaining({ credentials: "same-origin" }), + ); + const channels = screen.getAllByTestId("onboard.channel-link"); + expect(channels.map((channel) => channel.textContent)).toEqual(["Telegram", "X"]); + for (const channel of channels) { + expect(channel.getAttribute("target")).toBe("_blank"); + expect(channel.getAttribute("rel")).toBe("noopener noreferrer"); + } + }); + + it("shows Visit without a channel list when the community has none", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + stubCommunity(() => Promise.resolve(Response.json({ href: "/n/chicago", channels: [] }))); + + renderOnboard({ community }); + + expect((await screen.findByTestId("onboard.visit-button")).textContent).toBe( + "Visit Chicago Builders", + ); + expect(screen.queryByTestId("onboard.channels")).toBeNull(); + }); + + it("keeps the joined screen when the community lookup fails", async () => { + signedIn(); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue(joined); + const fetchMock = stubCommunity(() => Promise.reject(new Error("offline"))); + + renderOnboard({ community }); + + expect(await screen.findByTestId("onboard.continue-on-computer")).toBeTruthy(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(screen.queryByTestId("onboard.visit-button")).toBeNull(); + expect(screen.queryByTestId("onboard.channels")).toBeNull(); + }); + + it("never loads a community for a member who left", async () => { + signedIn("Grace", ORG_ID); + harness.getOnboardingCodeInfo.mockResolvedValue(info); + harness.redeemOnboardingCode.mockResolvedValue({ ...joined, alreadyRedeemed: true }); + harness.listOrganizations.mockResolvedValue([]); + harness.setActiveOrganization.mockResolvedValue({ success: true }); + const fetchMock = stubCommunity(() => Promise.resolve(Response.json({ href: "/n/chicago" }))); + + renderOnboard({ community }); + + expect(await screen.findByTestId("onboard.left")).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/auth/ui/src/routes/_public/onboard.tsx b/plugins/auth/ui/src/routes/_public/onboard.tsx index 28fb1d9f9..a6a0649c9 100644 --- a/plugins/auth/ui/src/routes/_public/onboard.tsx +++ b/plugins/auth/ui/src/routes/_public/onboard.tsx @@ -7,13 +7,13 @@ import { WarningCircleIcon, } from "@phosphor-icons/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { refreshSessionCache, sessionQueryOptions, useAuthClient } from "everything-dev/ui/auth"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { AuthPanel } from "@/components/auth-panel"; import { StepProgress } from "@/components/step-progress"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Item, ItemActions, @@ -23,7 +23,17 @@ import { ItemTitle, } from "@/components/ui/item"; import { Spinner } from "@/components/ui/spinner"; +import { LoginI18nProvider, useLoginTranslation } from "@/i18n/runtime"; import { getGatewayOrigin } from "@/lib/gateway-origin"; +import { + fetchOnboardCommunity, + type OnboardLink, + readOnboardConfig, + resolveOnboardLinks, +} from "@/lib/onboard-config"; +import { verifyOrganizationMembership } from "@/lib/onboard-membership"; +import { cn } from "@/lib/utils"; +import { MEMBERSHIP_ENDED_ACTION } from "../../../../src/onboard-config"; import { DisplayNameStep } from "./-display-name-step"; import { OnboardSignUp } from "./-onboard-sign-up"; import "../../styles.css"; @@ -45,10 +55,17 @@ export const Route = createFileRoute("/_public/onboard")({ validateSearch: (search: Record): SearchParams => ({ code: sanitizeCode(search.code), }), - component: OnboardPage, + component: OnboardRoute, }); -type Redeemed = { organizationName: string; eventName: string }; +type Redeemed = { organizationId: string; organizationName: string; eventName: string }; +type RedeemResult = Redeemed & { alreadyRedeemed: boolean }; +type RedeemFailure = { message?: string; code?: string; data?: { action?: string } }; +type Membership = "verifying" | "left" | "verifyFailed"; + +function isMembershipEnded(error: RedeemFailure | undefined): boolean { + return error?.code === "FORBIDDEN" && error.data?.action === MEMBERSHIP_ENDED_ACTION; +} const PASSKEY_DEFAULT_NAME = "Passkey user"; @@ -56,11 +73,22 @@ function hasChosenName(accountCreated: boolean, name: string | undefined | null) return !accountCreated && !!name && name !== PASSKEY_DEFAULT_NAME; } +function OnboardRoute() { + return ( + + + + ); +} + function OnboardPage() { + const t = useLoginTranslation(); const auth = useAuthClient(); const queryClient = useQueryClient(); + const router = useRouter(); const code = sanitizeCode(Route.useSearch().code); const { apiClient, runtimeConfig } = Route.useRouteContext(); + const onboardConfig = useMemo(() => readOnboardConfig(runtimeConfig), [runtimeConfig]); const { data: session } = useQuery(sessionQueryOptions(auth)); const { data: info } = useQuery({ queryKey: ["onboarding-info", code], @@ -75,6 +103,8 @@ function OnboardPage() { const [nameDone, setNameDone] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const [redeemError, setRedeemError] = useState(null); + const [membership, setMembership] = useState(null); + const rescanRef = useRef(null); const redeemingRef = useRef(false); const copyPairLink = async (url: string) => { @@ -88,17 +118,54 @@ function OnboardPage() { } }; + const join = (result: Redeemed, refreshSession: boolean) => { + setMembership(null); + setRedeemed({ + organizationId: result.organizationId, + organizationName: result.organizationName, + eventName: result.eventName, + }); + toast.success(`You've joined ${result.organizationName}`); + if (refreshSession) void refreshSessionCache(auth, queryClient); + }; + + const verifyRescan = (result: Redeemed | null) => { + rescanRef.current = result; + setMembership("verifying"); + void verifyOrganizationMembership({ + apiClient: apiClient.auth, + auth, + queryClient, + organizationId: result?.organizationId, + }) + .then((isMember) => { + if (result && isMember) { + join(result, false); + return; + } + setMembership("left"); + void router.invalidate(); + }) + .catch(() => setMembership("verifyFailed")); + }; + useEffect(() => { if (!session?.user || !code || redeemingRef.current || redeemed || redeemError) return; redeemingRef.current = true; void apiClient.auth .redeemOnboardingCode({ code }) - .then((result: Redeemed) => { - setRedeemed({ organizationName: result.organizationName, eventName: result.eventName }); - toast.success(`You've joined ${result.organizationName}`); - void refreshSessionCache(auth, queryClient); + .then((result: RedeemResult) => { + if (result.alreadyRedeemed) { + verifyRescan(result); + return; + } + join(result, true); }) - .catch((error: { message?: string }) => { + .catch((error: RedeemFailure | undefined) => { + if (isMembershipEnded(error)) { + verifyRescan(null); + return; + } setRedeemError(error?.message || "Could not join this organization"); }); }, [session?.user, code, redeemed, redeemError, auth, apiClient, queryClient]); @@ -132,22 +199,23 @@ function OnboardPage() { const gatewayOrigin = getGatewayOrigin(runtimeConfig); const gatewayHost = new URL(gatewayOrigin).host; const pairUrl = `${gatewayOrigin}/login?method=phone`; + const links = resolveOnboardLinks(onboardConfig, redeemed.organizationId); + const communityUrl = onboardConfig.community?.url; return ( } - title="Ready to start building?" + title={onboardConfig.links?.next === undefined ? "Ready to start building?" : "You're in"} titleTestId="onboard.heading" description={joinedLine} > - + {communityUrl ? ( + + ) : null} + {links.next ? : null} @@ -176,16 +244,55 @@ function OnboardPage() { + + + ); + } + + const explore = resolveOnboardLinks(onboardConfig).explore; + + if (membership === "verifying") { + return ( +
+

+ + {t("auth.onboard.verifying")} +

+
+ ); + } + + if (membership === "left") { + return ( + + ); + } + + if (membership === "verifyFailed") { + return ( + - + ); } @@ -195,6 +302,7 @@ function OnboardPage() { title="Invalid invitation" description="This link is missing its code. Ask the organizer for a new QR code." testId="onboard.invalid" + explore={explore} /> ); } @@ -219,6 +327,7 @@ function OnboardPage() { title="Invitation not found" description="This onboarding code is invalid. Ask the organizer for a new QR code." testId="onboard.not-found" + explore={explore} /> ); } @@ -235,12 +344,20 @@ function OnboardPage() { : "This onboarding code has reached its limit. Ask the organizer for a new one." } testId="onboard.unavailable" + explore={explore} /> ); } if (redeemError) { - return ; + return ( + + ); } if (session?.user) { @@ -285,14 +402,104 @@ function OnboardPage() { ); } +function LinkButton({ + link, + testId, + variant, +}: { + link: OnboardLink; + testId: string; + variant?: "outline"; +}) { + if (!link.href.startsWith("/")) { + return ( + + {link.label} + + ); + } + return ( + + ); +} + +function JoinedCommunity({ + urlTemplate, + organizationId, + organizationName, +}: { + urlTemplate: string; + organizationId: string; + organizationName: string; +}) { + const t = useLoginTranslation(); + const { data: community } = useQuery({ + queryKey: ["onboard-community", urlTemplate, organizationId], + queryFn: () => fetchOnboardCommunity(urlTemplate, organizationId), + retry: false, + staleTime: 5 * 60 * 1000, + }); + if (!community) return null; + const name = community.name ?? organizationName; + const channels = community.channels ?? []; + return ( + <> + {community.href ? ( + + ) : null} + {channels.length > 0 ? ( + + ) : null} + + ); +} + function StatusPanel({ title, description, testId, + explore, + children, }: { title: string; description: string; testId: string; + explore: OnboardLink; + children?: ReactNode; }) { return ( - + {children} + ); } diff --git a/tests/regression/browser/specs/onboard-rescan.spec.ts b/tests/regression/browser/specs/onboard-rescan.spec.ts new file mode 100644 index 000000000..3939b5ebd --- /dev/null +++ b/tests/regression/browser/specs/onboard-rescan.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from "@playwright/test"; +import { and, eq } from "drizzle-orm"; +import * as authSchema from "../../../../plugins/auth/src/db/schema.ts"; +import { createAuthTestInstance } from "../../lib/auth-test-instance.ts"; +import { computeRegressionEnv } from "../../lib/regression-env.mjs"; +import { collectErrors } from "../helpers/page-ready"; + +type Fixtures = Awaited>; + +const regressionEnv = computeRegressionEnv(); + +function cookieHeader(cookies: Array<{ name: string; value: string }>) { + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); +} + +async function createCode(fixtures: Fixtures, unique: string) { + const organizer = await fixtures.test.saveUser( + fixtures.test.createUser({ + email: `onboard-organizer-${unique}@regression.test`, + name: "Onboard Organizer", + emailVerified: true, + }), + ); + const orgName = `onboard-rescan-${unique}`; + const org = await fixtures.test.saveOrganization( + fixtures.test.createOrganization({ name: orgName, slug: orgName }), + ); + await fixtures.test.addMember({ userId: organizer.id, organizationId: org.id, role: "owner" }); + const cookies = await fixtures.test.getCookies({ userId: organizer.id, domain: "localhost" }); + + const response = await fetch(`${regressionEnv.baseUrl}/api/rpc/auth/createOnboardingCode`, { + method: "POST", + headers: { "content-type": "application/json", cookie: cookieHeader(cookies) }, + body: JSON.stringify({ + json: { eventId: `rescan-${unique}`, eventName: "Rescan Night", organizationId: org.id }, + }), + }); + if (!response.ok) { + throw new Error(`createOnboardingCode failed: ${response.status} ${await response.text()}`); + } + const { json } = (await response.json()) as { json: { code: string } }; + return { org, code: json.code }; +} + +test.describe("onboard rescan", () => { + let fixtures: Fixtures; + + test.beforeAll(async () => { + fixtures = await createAuthTestInstance({ + authDatabaseUrl: regressionEnv.dbUrls.AUTH_DATABASE_URL ?? "", + secret: regressionEnv.authSecret, + }); + }); + + test.afterAll(async () => { + await fixtures.close(); + }); + + test("a member who left is told so on rescan, not that they rejoined", async ({ page }) => { + const pageErrors = collectErrors(page); + const unique = `${process.pid}-${Date.now()}`; + const { org, code } = await createCode(fixtures, unique); + + const joiner = await fixtures.test.saveUser( + fixtures.test.createUser({ + email: `onboard-joiner-${unique}@regression.test`, + name: "Rescan Tester", + emailVerified: true, + }), + ); + await page + .context() + .addCookies(await fixtures.test.getCookies({ userId: joiner.id, domain: "localhost" })); + + await page.goto(`/onboard?code=${code}`); + await expect(page.getByTestId("onboard.continue-on-computer")).toBeVisible({ + timeout: 20000, + }); + await expect(page.getByTestId("onboard.success")).toContainText(org.name); + + await page.goto(`/onboard?code=${code}`); + await expect(page.getByTestId("onboard.continue-on-computer")).toBeVisible({ + timeout: 20000, + }); + + await fixtures.db + .delete(authSchema.member) + .where( + and(eq(authSchema.member.userId, joiner.id), eq(authSchema.member.organizationId, org.id)), + ); + await fixtures.db + .update(authSchema.session) + .set({ activeOrganizationId: org.id }) + .where(eq(authSchema.session.userId, joiner.id)); + + await page.goto(`/onboard?code=${code}`); + await expect(page.getByTestId("onboard.left")).toHaveText( + "You’re no longer a member of this community. Ask an organizer for a new code or an invitation.", + { timeout: 20000 }, + ); + await expect(page.getByTestId("onboard.success")).toHaveCount(0); + + await expect + .poll(async () => { + const sessions = await fixtures.db.query.session.findMany({ + where: eq(authSchema.session.userId, joiner.id), + }); + return sessions.map((session) => session.activeOrganizationId); + }) + .not.toContain(org.id); + + expect(pageErrors).toEqual([]); + }); +});