Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/profile-sections.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions plugins/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions plugins/auth/src/config-schemas.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -144,6 +145,7 @@ export const authVariablesSchema = z.object({
})
.optional(),
onboard: onboardConfigSchema.optional(),
profile: profileConfigSchema.optional(),
});

export const authSecretsSchema = z.object({
Expand Down
8 changes: 4 additions & 4 deletions plugins/auth/src/onboard-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand All @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions plugins/auth/src/profile-config.ts
Original file line number Diff line number Diff line change
@@ -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<typeof profileConfigSchema>;
export type ProfileSectionConfig = z.infer<typeof profileSectionConfigSchema>;
export type ProfileSectionContent = z.infer<typeof profileSectionContentSchema>;
40 changes: 40 additions & 0 deletions plugins/auth/ui/src/lib/profile-sections.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof readProfileSections>[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([]);
});
});
27 changes: 27 additions & 0 deletions plugins/auth/ui/src/lib/profile-sections.ts
Original file line number Diff line number Diff line change
@@ -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<ProfileSectionContent> {
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;
}
Original file line number Diff line number Diff line change
@@ -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) => (
<ProfileSection key={section.id} section={section} userId={userId} />
));
}

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 (
<section className="flex flex-col gap-4" data-testid={`settings.profile-section.${section.id}`}>
<SectionHeader
title={section.title}
description={section.description}
action={data?.link ? <LinkTo href={data.link.href}>{data.link.label}</LinkTo> : undefined}
/>
{isPending ? (
<div className="flex flex-col gap-2" data-testid="settings.profile-section-loading">
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
) : isError ? (
<Item variant="muted" role="alert" data-testid="settings.profile-section-error">
<ItemMedia variant="icon">
<WarningIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Couldn't load {section.title}</ItemTitle>
<ItemDescription>The rest of your profile still works.</ItemDescription>
</ItemContent>
<ItemActions className="w-full sm:w-auto">
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={isFetching}
onClick={() => void refetch()}
data-testid="settings.profile-section-retry"
>
{isFetching ? "Retrying…" : "Retry"}
</Button>
</ItemActions>
</Item>
) : (
<SectionContent content={data} />
)}
</section>
);
}

function SectionContent({ content }: { content: ProfileSectionContent }) {
return (
<>
{content.items.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="settings.profile-section-empty">
{content.empty ?? "Nothing here yet."}
</p>
) : (
<ul className="flex flex-col gap-2">
{content.items.map((item, index) => {
const body = (
<>
<ItemContent className="min-w-0">
<ItemTitle className="wrap-anywhere">{item.title}</ItemTitle>
{item.description && <ItemDescription>{item.description}</ItemDescription>}
</ItemContent>
{item.meta && (
<span className="text-sm text-muted-foreground tabular-nums">{item.meta}</span>
)}
{item.href && <CaretRightIcon className="text-muted-foreground" />}
</>
);
return (
// Items carry no id; the list is replaced as a whole on refetch.
<li key={index} data-testid="settings.profile-section-item">
<Item
variant="outline"
size="sm"
render={item.href ? linkElement(item.href) : undefined}
>
{body}
</Item>
</li>
);
})}
</ul>
)}
{content.incomplete && (
<p
className="text-sm text-muted-foreground"
data-testid="settings.profile-section-incomplete"
>
{content.incomplete}
</p>
)}
</>
);
}

function linkElement(href: string): ReactElement {
if (href.startsWith("/")) return <Link to={href} />;
// biome-ignore lint/a11y/useAnchorContent: the rendering component supplies the content
return <a href={href} rel="noopener noreferrer" />;
}

function LinkTo({ href, children }: { href: string; children: string }) {
return (
<Button variant="link" size="sm" nativeButton={false} render={linkElement(href)}>
{children}
</Button>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -98,6 +100,7 @@ export function ProfileSettings() {
)}
<DisplayNameForm key={user.name ?? ""} user={user} />
</section>
<ProfileSections sections={sections} userId={user.id} />
<LanguageSettings user={user} />
<AccountDetails user={user} onAddEmail={() => setAddEmailOpen(true)} />
<AddEmailDialog open={addEmailOpen} onOpenChange={setAddEmailOpen} />
Expand Down
Loading
Loading