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
6 changes: 6 additions & 0 deletions apps/cms/src/api/profile/content-types/profile/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@
"github": {
"type": "string"
},
"links": {
"type": "component",
"repeatable": true,
"component": "profile.link",
"max": 10
},
"location": {
"type": "string"
},
Expand Down
30 changes: 30 additions & 0 deletions apps/cms/src/components/profile/link.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fromNodeHeaders } from "better-auth/node";
import {
getPluginService,
organizationProfileFields,
pickProfileLinks,
validateProfileData,
} from "../utils";

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion apps/cms/src/extensions/better-auth/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { factories } from "@strapi/strapi";
import { fromNodeHeaders } from "better-auth/node";
import {
getPluginService,
pickProfileLinks,
userProfileFields,
validateProfileData,
} from "../utils";
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/cms/src/extensions/better-auth/services/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion apps/cms/src/extensions/better-auth/services/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions apps/cms/src/extensions/better-auth/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -94,21 +95,22 @@ 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;

/**
* `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.
*/
Expand All @@ -117,6 +119,7 @@ export const userProfileFields = [
"subtitle",
"website",
"github",
"links",
"location",
"email",
"readme",
Expand Down
139 changes: 139 additions & 0 deletions apps/cms/src/extensions/better-auth/utils/profile-links.ts
Original file line number Diff line number Diff line change
@@ -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<ProfileLinkType, { label: string; pattern: RegExp; example: string }>
> = {
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<string, unknown>;
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<string, unknown>;

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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/<username>` 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}\/?$/;
Expand Down Expand Up @@ -62,5 +65,7 @@ export function validateProfileData(data: Record<string, unknown>): string[] {
if (error) errors.push(error);
}

if ("links" in data) errors.push(...validateProfileLinks(data.links));

return errors;
}
29 changes: 29 additions & 0 deletions apps/cms/types/generated/components.d.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions apps/cms/types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading