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: 4 additions & 2 deletions apps/cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"lint": "biome check .",
"check-types": "tsc --noEmit",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
"upgrade:dry": "npx @strapi/upgrade latest --dry",
"test": "vitest run"
},
"dependencies": {
"@better-auth-ui/react": "^1.6.44",
Expand Down Expand Up @@ -58,7 +59,8 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.7"
},
"engines": {
"node": ">=20.0.0 <=24.x.x",
Expand Down
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
98 changes: 98 additions & 0 deletions apps/cms/src/extensions/better-auth/utils/profile-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
MAX_PROFILE_LINKS,
pickProfileLinks,
validateProfileLinks,
} from "./profile-links";

describe("pickProfileLinks", () => {
it("keeps only type and a trimmed value", () => {
expect(
pickProfileLinks([
{ id: 12, type: "github", value: " octocat ", extra: true },
]),
).toEqual([{ type: "github", value: "octocat" }]);
});

it("turns null into an empty list", () => {
expect(pickProfileLinks(null)).toEqual([]);
});

it("leaves non-array input for validation to reject", () => {
expect(pickProfileLinks("github.com/octocat")).toBe("github.com/octocat");
});
});

describe("validateProfileLinks", () => {
it("accepts an empty list", () => {
expect(validateProfileLinks([])).toEqual([]);
});

it("accepts valid handles and URLs, including duplicate types", () => {
expect(
validateProfileLinks([
{ type: "github", value: "octocat" },
{ type: "linkedin", value: "jane-doe" },
{ type: "x", value: "jane_doe" },
{ type: "instagram", value: "jane.doe" },
{ type: "youtube", value: "janedoe" },
{ type: "linktree", value: "janedoe" },
{ type: "website", value: "https://example.com" },
{ type: "website", value: "http://blog.example.com/path" },
{ type: "other", value: "https://www.npmjs.com/~octocat" },
]),
).toEqual([]);
});

it.each([
["github", "https://github.com/octocat", "GitHub"],
["github", "-octocat", "GitHub"],
["x", "way_too_long_for_x_handle", "X (Twitter)"],
["linkedin", "ab", "LinkedIn"],
["youtube", "@janedoe", "YouTube"],
])("rejects %s handle %j", (type, value, label) => {
const errors = validateProfileLinks([{ type, value }]);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/^Link 1: Enter a /);
expect(errors[0]).toContain(`${label} username`);
});

it.each([
"example.com",
"ftp://example.com",
"https://localhost",
])("rejects web address %j", (value) => {
expect(validateProfileLinks([{ type: "website", value }])).toEqual([
"Link 1: Enter a full web address, like https://example.com.",
]);
});

it("numbers errors by position", () => {
expect(
validateProfileLinks([
{ type: "github", value: "octocat" },
{ type: "facebook", value: "jane" },
{ type: "x", value: "" },
]),
).toEqual([
"Link 2: Choose a link type.",
"Link 3: Enter an address or username.",
]);
});

it(`rejects more than ${MAX_PROFILE_LINKS} links`, () => {
const links = Array.from({ length: MAX_PROFILE_LINKS + 1 }, () => ({
type: "github",
value: "octocat",
}));
expect(validateProfileLinks(links)).toEqual([
`Add up to ${MAX_PROFILE_LINKS} links.`,
]);
});

it("rejects anything that isn't a list", () => {
expect(validateProfileLinks({ type: "github" })).toEqual([
"Links must be a list.",
]);
});
});
Loading