diff --git a/apps/cms/src/api/profile/content-types/profile/schema.json b/apps/cms/src/api/profile/content-types/profile/schema.json index 5c5f5eb..4c914b3 100644 --- a/apps/cms/src/api/profile/content-types/profile/schema.json +++ b/apps/cms/src/api/profile/content-types/profile/schema.json @@ -32,6 +32,12 @@ "github": { "type": "string" }, + "links": { + "type": "component", + "repeatable": true, + "component": "profile.link", + "max": 10 + }, "location": { "type": "string" }, diff --git a/apps/cms/src/components/profile/link.json b/apps/cms/src/components/profile/link.json new file mode 100644 index 0000000..063e9aa --- /dev/null +++ b/apps/cms/src/components/profile/link.json @@ -0,0 +1,30 @@ +{ + "collectionName": "components_profile_links", + "info": { + "displayName": "Link", + "icon": "link" + }, + "options": {}, + "attributes": { + "type": { + "type": "enumeration", + "required": true, + "enum": [ + "website", + "github", + "linkedin", + "x", + "instagram", + "youtube", + "linktree", + "other" + ] + }, + "value": { + "type": "string", + "required": true, + "maxLength": 2048 + } + }, + "config": {} +} diff --git a/apps/cms/src/extensions/better-auth/controllers/organization.ts b/apps/cms/src/extensions/better-auth/controllers/organization.ts index fd7b061..6496f3c 100644 --- a/apps/cms/src/extensions/better-auth/controllers/organization.ts +++ b/apps/cms/src/extensions/better-auth/controllers/organization.ts @@ -7,6 +7,7 @@ import { fromNodeHeaders } from "better-auth/node"; import { getPluginService, organizationProfileFields, + pickProfileLinks, validateProfileData, } from "../utils"; @@ -79,7 +80,10 @@ export default factories.createCoreController( const data = Object.fromEntries( organizationProfileFields .filter((field) => field in body) - .map((field) => [field, body[field]]), + .map((field) => [ + field, + field === "links" ? pickProfileLinks(body.links) : body[field], + ]), ); const errors = validateProfileData(data); diff --git a/apps/cms/src/extensions/better-auth/controllers/user.ts b/apps/cms/src/extensions/better-auth/controllers/user.ts index 14d7104..6f0c9ba 100644 --- a/apps/cms/src/extensions/better-auth/controllers/user.ts +++ b/apps/cms/src/extensions/better-auth/controllers/user.ts @@ -6,6 +6,7 @@ import { factories } from "@strapi/strapi"; import { fromNodeHeaders } from "better-auth/node"; import { getPluginService, + pickProfileLinks, userProfileFields, validateProfileData, } from "../utils"; @@ -61,7 +62,10 @@ export default factories.createCoreController( const data = Object.fromEntries( userProfileFields .filter((field) => field in body) - .map((field) => [field, body[field]]), + .map((field) => [ + field, + field === "links" ? pickProfileLinks(body.links) : body[field], + ]), ); const errors = validateProfileData(data); diff --git a/apps/cms/src/extensions/better-auth/services/organization.ts b/apps/cms/src/extensions/better-auth/services/organization.ts index 0f4174f..129b0df 100644 --- a/apps/cms/src/extensions/better-auth/services/organization.ts +++ b/apps/cms/src/extensions/better-auth/services/organization.ts @@ -163,11 +163,13 @@ export default factories.createCoreService( return strapi.documents("api::profile.profile").update({ documentId: organization.profile.documentId, data, + populate: ["links"], }); } const profile = await strapi.documents("api::profile.profile").create({ data, + populate: ["links"], }); await strapi.documents("plugin::better-auth.organization").update({ diff --git a/apps/cms/src/extensions/better-auth/services/user.ts b/apps/cms/src/extensions/better-auth/services/user.ts index f86a871..efbaee7 100644 --- a/apps/cms/src/extensions/better-auth/services/user.ts +++ b/apps/cms/src/extensions/better-auth/services/user.ts @@ -50,7 +50,7 @@ export default factories.createCoreService("plugin::better-auth.user", () => ({ const [user] = await strapi.documents("plugin::better-auth.user").findMany({ filters: { id: userId }, fields: ["documentId"], - populate: { profile: {} }, + populate: { profile: { populate: ["links"] } }, }); return user?.profile ?? null; @@ -80,11 +80,13 @@ export default factories.createCoreService("plugin::better-auth.user", () => ({ return strapi.documents("api::profile.profile").update({ documentId: user.profile.documentId, data, + populate: ["links"], }); } const profile = await strapi.documents("api::profile.profile").create({ data, + populate: ["links"], }); await strapi.documents("plugin::better-auth.user").update({ diff --git a/apps/cms/src/extensions/better-auth/utils/index.ts b/apps/cms/src/extensions/better-auth/utils/index.ts index b9b1299..6f040bd 100644 --- a/apps/cms/src/extensions/better-auth/utils/index.ts +++ b/apps/cms/src/extensions/better-auth/utils/index.ts @@ -16,6 +16,7 @@ export { type OwnerType, } from "./cascade-delete"; export { extractContentTypeName } from "./content-type-name"; +export { pickProfileLinks } from "./profile-links"; export { validateProfileData } from "./profile-validation"; export { sanitizeIfSvg } from "./sanitize-svg"; @@ -94,13 +95,14 @@ export async function isOrganizationMember( /** * `api::profile.profile` fields an org owner/admin may edit through the * organization "Profile" tab — scoped to exactly what the public - * organization page renders (bio/subtitle/website/github/location/readme). + * organization page renders (bio/subtitle/website/github/links/location/readme). */ export const organizationProfileFields = [ "bio", "subtitle", "website", "github", + "links", "location", "readme", ] as const; @@ -108,7 +110,7 @@ export const organizationProfileFields = [ /** * `api::profile.profile` fields a user may edit through their personal * "Profile" tab — scoped to exactly what the public user page renders - * (bio/subtitle/website/github/location/email/readme). Includes `email` + * (bio/subtitle/website/github/links/location/email/readme). Includes `email` * (a public contact address), unlike `organizationProfileFields` — an * organization has no equivalent distinct-from-account address. */ @@ -117,6 +119,7 @@ export const userProfileFields = [ "subtitle", "website", "github", + "links", "location", "email", "readme", diff --git a/apps/cms/src/extensions/better-auth/utils/profile-links.ts b/apps/cms/src/extensions/better-auth/utils/profile-links.ts new file mode 100644 index 0000000..9207d27 --- /dev/null +++ b/apps/cms/src/extensions/better-auth/utils/profile-links.ts @@ -0,0 +1,139 @@ +/** + * Rules for the repeatable `profile.link` component on + * `api::profile.profile` (see docs/adr/0003-profile-links.md). Social + * types store only the handle — the public URL is built from a fixed + * prefix at render time — while `website`/`other` store a full URL. + * + * The web app enforces the same rules inline before submitting; this is + * the server-side backstop for anyone calling the API directly. Keep the + * two in sync if either changes. + * + * The max of 10 links and the enum of types are also on the component + * schema itself (apps/cms/src/components/profile/link.json), so Strapi + * enforces them on every write path — they're re-checked here only to + * return a readable message. + */ + +export const PROFILE_LINK_TYPES = [ + "website", + "github", + "linkedin", + "x", + "instagram", + "youtube", + "linktree", + "other", +] as const; + +export type ProfileLinkType = (typeof PROFILE_LINK_TYPES)[number]; + +export const MAX_PROFILE_LINKS = 10; + +const HANDLE_RULES: Partial< + Record +> = { + github: { + label: "GitHub", + pattern: /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i, + example: "octocat", + }, + linkedin: { + label: "LinkedIn", + pattern: /^[a-z\d-]{3,100}$/i, + example: "jane-doe", + }, + x: { + label: "X (Twitter)", + pattern: /^[A-Za-z0-9_]{1,15}$/, + example: "janedoe", + }, + instagram: { + label: "Instagram", + pattern: /^[A-Za-z0-9._]{1,30}$/, + example: "janedoe", + }, + youtube: { + label: "YouTube", + pattern: /^[A-Za-z0-9._-]{3,30}$/, + example: "janedoe", + }, + linktree: { + label: "Linktree", + pattern: /^[A-Za-z0-9._]{2,30}$/, + example: "janedoe", + }, +}; + +function isWebAddress(value: string): boolean { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname.includes(".") + ); + } catch { + return false; + } +} + +function isProfileLinkType(type: unknown): type is ProfileLinkType { + return PROFILE_LINK_TYPES.includes(type as ProfileLinkType); +} + +/** + * Keeps only `type` and a trimmed `value` from each submitted link, so a + * client can't pass component `id`s (or anything else) through to the + * document service. `null` clears the list. Anything that isn't an array + * is returned untouched for `validateProfileLinks` to reject. + */ +export function pickProfileLinks(value: unknown): unknown { + if (value === null) return []; + if (!Array.isArray(value)) return value; + + return value.map((link) => { + if (typeof link !== "object" || link === null) return link; + const { type, value } = link as Record; + return { type, value: typeof value === "string" ? value.trim() : value }; + }); +} + +export function validateProfileLinks(links: unknown): string[] { + if (!Array.isArray(links)) return ["Links must be a list."]; + if (links.length > MAX_PROFILE_LINKS) { + return [`Add up to ${MAX_PROFILE_LINKS} links.`]; + } + + const errors: string[] = []; + + links.forEach((link, index) => { + const prefix = `Link ${index + 1}:`; + const { type, value } = (link ?? {}) as Record; + + if (!isProfileLinkType(type)) { + errors.push(`${prefix} Choose a link type.`); + return; + } + if (typeof value !== "string" || value === "") { + errors.push(`${prefix} Enter an address or username.`); + return; + } + + const rule = HANDLE_RULES[type]; + if (!rule) { + if (!isWebAddress(value)) { + errors.push( + `${prefix} Enter a full web address, like https://example.com.`, + ); + } + return; + } + + if (!rule.pattern.test(value)) { + errors.push( + `${prefix} Enter a ${rule.label} username, like ${rule.example}.`, + ); + } + }); + + return errors; +} diff --git a/apps/cms/src/extensions/better-auth/utils/profile-validation.ts b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts index ed5905a..118c557 100644 --- a/apps/cms/src/extensions/better-auth/utils/profile-validation.ts +++ b/apps/cms/src/extensions/better-auth/utils/profile-validation.ts @@ -10,9 +10,12 @@ * `api::profile.profile` schema itself * (apps/cms/src/api/profile/content-types/profile/schema.json), enforced * by Strapi on every write path. This file only covers format rules a - * schema attribute can't express. + * schema attribute can't express. Rules for `links` live in + * ./profile-links.ts. */ +import { validateProfileLinks } from "./profile-links"; + /** `https://github.com/` only — not a repo, gist, or org team link. */ const GITHUB_PROFILE_PATTERN = /^https:\/\/(www\.)?github\.com\/[a-zA-Z\d](?:[a-zA-Z\d]|-(?=[a-zA-Z\d])){0,38}\/?$/; @@ -62,5 +65,7 @@ export function validateProfileData(data: Record): string[] { if (error) errors.push(error); } + if ("links" in data) errors.push(...validateProfileLinks(data.links)); + return errors; } diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts index 5c0db1f..7512961 100644 --- a/apps/cms/types/generated/components.d.ts +++ b/apps/cms/types/generated/components.d.ts @@ -1,5 +1,33 @@ import type { Schema, Struct } from '@strapi/strapi'; +export interface ProfileLink extends Struct.ComponentSchema { + collectionName: 'components_profile_links'; + info: { + displayName: 'Link'; + icon: 'link'; + }; + attributes: { + type: Schema.Attribute.Enumeration< + [ + 'website', + 'github', + 'linkedin', + 'x', + 'instagram', + 'youtube', + 'linktree', + 'other', + ] + > & + Schema.Attribute.Required; + value: Schema.Attribute.String & + Schema.Attribute.Required & + Schema.Attribute.SetMinMaxLength<{ + maxLength: 2048; + }>; + }; +} + export interface SectionsCardGrid extends Struct.ComponentSchema { collectionName: 'components_sections_card_grids'; info: { @@ -223,6 +251,7 @@ export interface SharedVersionInfo extends Struct.ComponentSchema { declare module '@strapi/strapi' { export namespace Public { export interface ComponentSchemas { + 'profile.link': ProfileLink; 'sections.card-grid': SectionsCardGrid; 'sections.card-grid-item': SectionsCardGridItem; 'sections.cta': SectionsCta; diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index c75c246..e12fa00 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -1037,6 +1037,13 @@ export interface ApiProfileProfile extends Struct.CollectionTypeSchema { Schema.Attribute.Private; email: Schema.Attribute.String; github: Schema.Attribute.String; + links: Schema.Attribute.Component<'profile.link', true> & + Schema.Attribute.SetMinMax< + { + max: 10; + }, + number + >; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', diff --git a/docs/adr/0003-profile-links.md b/docs/adr/0003-profile-links.md new file mode 100644 index 0000000..8e6f55c --- /dev/null +++ b/docs/adr/0003-profile-links.md @@ -0,0 +1,42 @@ +# ADR 0003 — Profile links are a repeatable component of `{ type, value }` + +**Date:** 2026-09-25 +**Status:** Accepted + +## Context + +`api::profile.profile` (shared by user and organization profiles) has two fixed link fields, +`website` and `github`, each holding a full URL. Members want to list more places they can be +found (LinkedIn, X, YouTube, …), and adding one string field per network does not scale — every +new network would mean a schema change, a new form input and new rendering code. + +## Decision + +A repeatable component `profile.link` is added to `api::profile.profile` as `links` (max 10): + +- `type` — enumeration: `website`, `github`, `linkedin`, `x`, `instagram`, `youtube`, `linktree`, + `other`. +- `value` — string. For social types it is **only the handle** (e.g. `octocat`); the public URL is + built at render time from a fixed per-type prefix (e.g. `github.com/`). For `website` and + `other` it is a full `http(s)://` URL. + +Duplicate types are allowed (e.g. two websites). Order is the order the member added them in. + +Per-type format rules are enforced in the web app's profile form and, as a backstop, in the +better-auth profile endpoints (`utils/profile-links.ts`), following the existing split for +`validateProfileData`. The type enum and the max count also live on the component schema, so +Strapi enforces them on every write path. + +The legacy `website` and `github` fields stay in place until existing values have been migrated +into `links` by a one-off data migration (`src/migration/`, behind `ENABLE_MIGRATION`), after +which they are removed in a follow-up. + +## Consequences + +- Supporting a new network means adding an enum value and a prefix/pattern entry — no new field. +- Storing handles rather than URLs means a network changing its URL scheme (e.g. twitter.com → + x.com) is a one-line prefix change, with no data rewrite. +- Every read of a profile that needs links must populate `links` explicitly (component fields are + not populated by default). +- The type list and format rules are duplicated between the CMS and the web app. Accepted, as for + the other profile validation rules; both files point at each other.