diff --git a/.changeset/profile-sections.md b/.changeset/profile-sections.md new file mode 100644 index 000000000..2a9ca3151 --- /dev/null +++ b/.changeset/profile-sections.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Auth plugin Settings → Profile: child apps can add their own sections through the `profile` auth variable. Each entry in `profile.sections` has an `id`, `title`, optional `description` and a `url`, an app path that returns `{ items, empty?, incomplete?, link? }`. The page renders each section below the profile fields in config order. A section that fails to load, or returns an invalid response, shows an error with Retry, and the rest of the page keeps working. diff --git a/plugins/auth/README.md b/plugins/auth/README.md index c0c8d5361..85071fb7c 100644 --- a/plugins/auth/README.md +++ b/plugins/auth/README.md @@ -59,9 +59,12 @@ It runs inside the **everything-plugin** framework (oRPC + Effect) and is design | `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 | +| `profile.sections` | `{ id, title, description?, url }[]` | — | Up to 5 child-app sections on Settings → Profile, below the profile fields, in this order | `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. +`profile.sections[].id` is kebab-case and unique; `url` is an app path the auth page fetches with the user's session. It returns `{ items: [{ title, description?, meta?, href? }], empty?, incomplete?, link?: { href, label } }` (at most 100 items; hrefs are app paths or `https://` URLs). `empty` replaces "Nothing here yet.", `incomplete` shows a note under the list, and `link` adds a link next to the section title. A failed or invalid response shows an error with Retry for that section only. An invalid `profile` config shows no sections. + ### Plugin Secrets | Secret | Type | Default | Description | diff --git a/plugins/auth/src/config-schemas.ts b/plugins/auth/src/config-schemas.ts index af954c299..1fa06750d 100644 --- a/plugins/auth/src/config-schemas.ts +++ b/plugins/auth/src/config-schemas.ts @@ -1,6 +1,7 @@ import { DEFAULT_DEVICE_LINK_CLIENT_ID } from "better-near-auth"; import { z } from "zod"; import { onboardConfigSchema } from "./onboard-config"; +import { profileConfigSchema } from "./profile-config"; export const API_KEY_CONFIG_IDS = ["user-keys", "org-keys"] as const; export const subAccountNetworkSchema = z.object({ @@ -144,6 +145,7 @@ export const authVariablesSchema = z.object({ }) .optional(), onboard: onboardConfigSchema.optional(), + profile: profileConfigSchema.optional(), }); export const authSecretsSchema = z.object({ diff --git a/plugins/auth/src/onboard-config.ts b/plugins/auth/src/onboard-config.ts index 581519991..3997937cd 100644 --- a/plugins/auth/src/onboard-config.ts +++ b/plugins/auth/src/onboard-config.ts @@ -6,13 +6,13 @@ export const ONBOARD_ORGANIZATION_ID_PLACEHOLDER = "{organizationId}"; const SAFE_HREF = /^(\/(?![/\\])|https:\/\/)/; -const onboardHrefSchema = z +export const appHrefSchema = z .string() .trim() .regex(SAFE_HREF, 'must be an app path ("/…") or an https:// URL'); export const onboardLinkSchema = z.object({ - href: onboardHrefSchema, + href: appHrefSchema, label: z.string().trim().min(1).optional(), }); @@ -31,14 +31,14 @@ export const onboardConfigSchema = z.object({ .optional(), community: z .object({ - url: onboardHrefSchema.refine((url) => url.startsWith("/"), "must be an app path"), + url: appHrefSchema.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(), + href: appHrefSchema.optional(), channels: z .array( z.object({ diff --git a/plugins/auth/src/profile-config.ts b/plugins/auth/src/profile-config.ts new file mode 100644 index 000000000..14fce7bd9 --- /dev/null +++ b/plugins/auth/src/profile-config.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { appHrefSchema } from "./onboard-config"; + +export const MAX_PROFILE_SECTIONS = 5; +export const MAX_PROFILE_SECTION_ITEMS = 100; + +const appPathSchema = appHrefSchema.refine((url) => url.startsWith("/"), "must be an app path"); + +export const profileSectionConfigSchema = z.object({ + id: z + .string() + .trim() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "must be kebab-case"), + title: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + url: appPathSchema, +}); + +export const profileConfigSchema = z.object({ + sections: z + .array(profileSectionConfigSchema) + .max(MAX_PROFILE_SECTIONS) + .refine( + (sections) => new Set(sections.map((section) => section.id)).size === sections.length, + "section ids must be unique", + ) + .optional(), +}); + +export const profileSectionContentSchema = z.object({ + items: z + .array( + z.object({ + title: z.string().trim().min(1), + description: z.string().trim().min(1).optional(), + meta: z.string().trim().min(1).optional(), + href: appHrefSchema.optional(), + }), + ) + .max(MAX_PROFILE_SECTION_ITEMS), + empty: z.string().trim().min(1).optional(), + incomplete: z.string().trim().min(1).optional(), + link: z.object({ href: appHrefSchema, label: z.string().trim().min(1) }).optional(), +}); + +export type ProfileConfig = z.infer; +export type ProfileSectionConfig = z.infer; +export type ProfileSectionContent = z.infer; diff --git a/plugins/auth/ui/src/lib/profile-sections.test.ts b/plugins/auth/ui/src/lib/profile-sections.test.ts new file mode 100644 index 000000000..b283f4c7b --- /dev/null +++ b/plugins/auth/ui/src/lib/profile-sections.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { readProfileSections } from "./profile-sections"; + +const withProfile = (profile: unknown) => + ({ auth: { variables: { profile } } }) as unknown as Parameters[0]; + +describe("readProfileSections", () => { + it("returns the configured sections in order", () => { + const sections = [ + { id: "attendance", title: "Events you attended", url: "/api/me/attendance" }, + { id: "badges", title: "Badges", description: "Earned at events", url: "/api/me/badges" }, + ]; + expect(readProfileSections(withProfile({ sections }))).toEqual(sections); + }); + + it("returns no sections when none are configured", () => { + expect(readProfileSections(undefined)).toEqual([]); + expect(readProfileSections(withProfile(undefined))).toEqual([]); + expect(readProfileSections(withProfile({}))).toEqual([]); + }); + + it.each([ + ["an external url", [{ id: "a", title: "A", url: "https://example.com/a" }]], + ["a protocol-relative url", [{ id: "a", title: "A", url: "//example.com/a" }]], + ["a non-kebab id", [{ id: "Attendance", title: "A", url: "/a" }]], + [ + "duplicate ids", + [ + { id: "a", title: "A", url: "/a" }, + { id: "a", title: "B", url: "/b" }, + ], + ], + [ + "more than five sections", + Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, title: "S", url: "/s" })), + ], + ])("ignores a config with %s", (_label, sections) => { + expect(readProfileSections(withProfile({ sections }))).toEqual([]); + }); +}); diff --git a/plugins/auth/ui/src/lib/profile-sections.ts b/plugins/auth/ui/src/lib/profile-sections.ts new file mode 100644 index 000000000..bbf91419a --- /dev/null +++ b/plugins/auth/ui/src/lib/profile-sections.ts @@ -0,0 +1,27 @@ +import type { RouterContext } from "@/context"; +import { + type ProfileSectionConfig, + type ProfileSectionContent, + profileConfigSchema, + profileSectionContentSchema, +} from "../../../src/profile-config"; + +export type { ProfileSectionConfig, ProfileSectionContent }; + +export function readProfileSections( + runtimeConfig: RouterContext["runtimeConfig"], +): ProfileSectionConfig[] { + const parsed = profileConfigSchema.safeParse(runtimeConfig?.auth?.variables?.profile); + return parsed.success ? (parsed.data.sections ?? []) : []; +} + +export async function fetchProfileSection(url: string): Promise { + const response = await fetch(url, { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + if (!response.ok) throw new Error(`Profile section request failed (${response.status})`); + const parsed = profileSectionContentSchema.safeParse(await response.json()); + if (!parsed.success) throw new Error("Profile section response is invalid"); + return parsed.data; +} diff --git a/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-sections.tsx b/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-sections.tsx new file mode 100644 index 000000000..24f5e80dd --- /dev/null +++ b/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-sections.tsx @@ -0,0 +1,147 @@ +import { CaretRightIcon, WarningIcon } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { ReactElement } from "react"; +import { SectionHeader } from "@/components/layout/section-header"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + fetchProfileSection, + type ProfileSectionConfig, + type ProfileSectionContent, +} from "@/lib/profile-sections"; + +/** + * Child-app sections declared in the `profile` auth variable. Each one reads + * its own app endpoint, so a failing section never takes the page down. + */ +export function ProfileSections({ + sections, + userId, +}: { + sections: ProfileSectionConfig[]; + userId: string; +}) { + return sections.map((section) => ( + + )); +} + +function ProfileSection({ section, userId }: { section: ProfileSectionConfig; userId: string }) { + const { data, isPending, isError, isFetching, refetch } = useQuery({ + queryKey: ["profile-section", section.id, section.url, userId], + queryFn: () => fetchProfileSection(section.url), + retry: false, + staleTime: 60 * 1000, + }); + + return ( +
+ {data.link.label} : undefined} + /> + {isPending ? ( +
+ + +
+ ) : isError ? ( + + + + + + Couldn't load {section.title} + The rest of your profile still works. + + + + + + ) : ( + + )} +
+ ); +} + +function SectionContent({ content }: { content: ProfileSectionContent }) { + return ( + <> + {content.items.length === 0 ? ( +

+ {content.empty ?? "Nothing here yet."} +

+ ) : ( +
    + {content.items.map((item, index) => { + const body = ( + <> + + {item.title} + {item.description && {item.description}} + + {item.meta && ( + {item.meta} + )} + {item.href && } + + ); + return ( + // Items carry no id; the list is replaced as a whole on refetch. +
  • + + {body} + +
  • + ); + })} +
+ )} + {content.incomplete && ( +

+ {content.incomplete} +

+ )} + + ); +} + +function linkElement(href: string): ReactElement { + if (href.startsWith("/")) return ; + // biome-ignore lint/a11y/useAnchorContent: the rendering component supplies the content + return ; +} + +function LinkTo({ href, children }: { href: string; children: string }) { + return ( + + ); +} diff --git a/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-settings.tsx b/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-settings.tsx index b9d23813b..7f93b82fa 100644 --- a/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-settings.tsx +++ b/plugins/auth/ui/src/routes/_authenticated/settings/-components/profile-settings.tsx @@ -22,7 +22,9 @@ import { import type { LoginLocale } from "@/i18n/catalogs"; import { LoginLanguageSelector } from "@/i18n/language-selector"; import { LoginI18nProvider } from "@/i18n/runtime"; +import type { ProfileSectionConfig } from "@/lib/profile-sections"; import { isSyntheticEmail } from "@/lib/synthetic-email"; +import { ProfileSections } from "./profile-sections"; type ProfileUser = { id: string; @@ -32,7 +34,7 @@ type ProfileUser = { locale?: string | null; }; -export function ProfileSettings() { +export function ProfileSettings({ sections = [] }: { sections?: ProfileSectionConfig[] }) { const auth = useAuthClient(); const { data: session } = useQuery(sessionQueryOptions(auth)); const user = session?.user; @@ -98,6 +100,7 @@ export function ProfileSettings() { )} + setAddEmailOpen(true)} /> diff --git a/plugins/auth/ui/src/routes/_authenticated/settings/-profile.test.tsx b/plugins/auth/ui/src/routes/_authenticated/settings/-profile.test.tsx index e4593ece4..fb1ca454e 100644 --- a/plugins/auth/ui/src/routes/_authenticated/settings/-profile.test.tsx +++ b/plugins/auth/ui/src/routes/_authenticated/settings/-profile.test.tsx @@ -108,3 +108,134 @@ describe("profile settings", () => { await waitFor(() => expect(harness.success).toHaveBeenCalledWith("Language updated")); }); }); + +describe("profile sections from the child app", () => { + const fetchMock = vi.fn(); + + function renderWithSections(sections: Parameters[0]["sections"]) { + harness.session = { user: { id: "user-1", name: "Tester", email: "person@example.com" } }; + harness.getSession.mockImplementation(async () => ({ data: harness.session, error: null })); + vi.stubGlobal("fetch", fetchMock); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + } + + function respond(body: unknown, status = 200) { + return Promise.resolve(new Response(JSON.stringify(body), { status })); + } + + afterEach(() => { + fetchMock.mockReset(); + vi.unstubAllGlobals(); + }); + + it("renders a section's items, link and incomplete note below the profile fields", async () => { + fetchMock.mockImplementation(() => + respond({ + items: [ + { + title: "Builder night", + description: "Lisbon", + meta: "Oct 12", + href: "/n/lisbon", + }, + { title: "Demo day" }, + ], + incomplete: "Some events may be missing.", + link: { href: "/dashboard", label: "See all" }, + }), + ); + + renderWithSections([ + { + id: "attendance", + title: "Events you attended", + description: "Across your communities", + url: "/api/attendance", + }, + ]); + + const section = await screen.findByTestId("settings.profile-section.attendance"); + expect(await screen.findByText("Builder night")).toBeTruthy(); + expect(screen.getByText("Lisbon")).toBeTruthy(); + expect(screen.getByText("Oct 12")).toBeTruthy(); + expect(screen.getByText("Demo day")).toBeTruthy(); + expect(screen.getByText("Builder night").closest("a")?.getAttribute("to")).toBe("/n/lisbon"); + expect(screen.getByText("Demo day").closest("a")).toBeNull(); + expect(screen.getByTestId("settings.profile-section-incomplete").textContent).toBe( + "Some events may be missing.", + ); + expect(screen.getByText("See all").closest("a")?.getAttribute("to")).toBe("/dashboard"); + expect(screen.getByText("Events you attended")).toBeTruthy(); + expect( + screen.getByTestId("settings.profile-heading").compareDocumentPosition(section) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/attendance", + expect.objectContaining({ credentials: "same-origin" }), + ); + }); + + it("keeps the page working when one section fails, and retries it", async () => { + let attendanceCalls = 0; + fetchMock.mockImplementation((url: string) => { + if (url === "/api/attendance") { + attendanceCalls += 1; + return attendanceCalls === 1 ? respond({}, 500) : respond({ items: [{ title: "Back" }] }); + } + return respond({ items: [{ title: "Badge" }] }); + }); + + renderWithSections([ + { id: "attendance", title: "Events", url: "/api/attendance" }, + { id: "badges", title: "Badges", url: "/api/badges" }, + ]); + + expect(await screen.findByTestId("settings.profile-section-error")).toBeTruthy(); + expect(screen.getByText("Couldn't load Events")).toBeTruthy(); + expect(await screen.findByText("Badge")).toBeTruthy(); + expect(screen.getByTestId("settings.display-name-input")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("settings.profile-section-retry")); + + expect(await screen.findByText("Back")).toBeTruthy(); + expect(screen.queryByTestId("settings.profile-section-error")).toBeNull(); + }); + + it("treats a response that doesn't match the contract as an error", async () => { + fetchMock.mockImplementation(() => + respond({ items: [{ title: "Bad", href: "javascript:alert(1)" }] }), + ); + + renderWithSections([{ id: "attendance", title: "Events", url: "/api/attendance" }]); + + expect(await screen.findByTestId("settings.profile-section-error")).toBeTruthy(); + expect(screen.queryByText("Bad")).toBeNull(); + }); + + it("shows the section's empty text, or a default", async () => { + fetchMock.mockImplementation((url: string) => + respond(url === "/api/a" ? { items: [], empty: "No check-ins yet." } : { items: [] }), + ); + + renderWithSections([ + { id: "a", title: "A", url: "/api/a" }, + { id: "b", title: "B", url: "/api/b" }, + ]); + + expect(await screen.findByText("No check-ins yet.")).toBeTruthy(); + expect(await screen.findByText("Nothing here yet.")).toBeTruthy(); + }); + + it("requests nothing when no sections are configured", async () => { + renderWithSections([]); + + expect(await screen.findByTestId("settings.profile-heading")).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/auth/ui/src/routes/_authenticated/settings/profile.tsx b/plugins/auth/ui/src/routes/_authenticated/settings/profile.tsx index bdc389db8..c36d4d7e5 100644 --- a/plugins/auth/ui/src/routes/_authenticated/settings/profile.tsx +++ b/plugins/auth/ui/src/routes/_authenticated/settings/profile.tsx @@ -1,6 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { readProfileSections } from "@/lib/profile-sections"; import { ProfileSettings } from "./-components/profile-settings"; export const Route = createFileRoute("/_authenticated/settings/profile")({ - component: ProfileSettings, + component: ProfileRoute, }); + +function ProfileRoute() { + const { runtimeConfig } = Route.useRouteContext(); + const sections = useMemo(() => readProfileSections(runtimeConfig), [runtimeConfig]); + return ; +}