From e98fabfd0e6e2f759593c151c0ffe766c7283304 Mon Sep 17 00:00:00 2001 From: DrMint <29893320+DrMint@users.noreply.github.com> Date: Wed, 26 Jul 2023 01:14:10 +0200 Subject: [PATCH] Access control --- package-lock.json | 28 +++ package.json | 1 + src/accesses/collections/mustBeAdminOrSelf.ts | 11 + .../collections/mustHaveAtLeastOneRole.ts | 8 + src/accesses/mustBeAdmin.ts | 9 + .../ContentFolders/ContentFolders.ts | 1 + .../ContentThumbnails/ContentThumbnails.ts | 1 + src/collections/Contents/Contents.ts | 9 + src/collections/Currencies/Currencies.ts | 39 ++++ src/collections/Files/Files.ts | 1 + src/collections/Keys/Keys.ts | 23 +++ src/collections/{ => Languages}/Languages.ts | 7 +- .../LibraryItemThumbnails.ts | 1 + src/collections/LibraryItems/LibraryItems.ts | 189 ++++++++++++++++-- .../PostThumbnails/PostThumbnails.ts | 1 + src/collections/Posts/Posts.ts | 11 +- .../RecorderThumbnails/RecorderThumbnails.ts | 1 + src/collections/Recorders/Recorders.ts | 54 ++++- .../Recorders/hooks/beforeDuplicate.ts | 9 - .../beforeLoginMustHaveAtLeastOneRole.ts | 7 + src/collections/Users.ts | 59 ------ src/components/QuickFilters.tsx | 47 +++++ src/constants.ts | 8 +- .../optionalGroupField/optionalGroupField.ts | 14 ++ .../translatedFields/translatedFields.ts | 2 +- src/hooks/beforeDuplicateAddCopyTo.ts | 5 + src/hooks/beforeDuplicatePiping.ts | 5 + src/hooks/beforeDuplicateUnpublish.ts | 7 + src/payload.config.ts | 8 +- src/styles.scss | 15 ++ src/types/collections.ts | 181 ++++++++--------- src/utils/versionedCollectionConfig.ts | 15 +- 32 files changed, 571 insertions(+), 206 deletions(-) create mode 100644 src/accesses/collections/mustBeAdminOrSelf.ts create mode 100644 src/accesses/collections/mustHaveAtLeastOneRole.ts create mode 100644 src/accesses/mustBeAdmin.ts create mode 100644 src/collections/Currencies/Currencies.ts rename src/collections/{ => Languages}/Languages.ts (76%) delete mode 100644 src/collections/Recorders/hooks/beforeDuplicate.ts create mode 100644 src/collections/Recorders/hooks/beforeLoginMustHaveAtLeastOneRole.ts delete mode 100644 src/collections/Users.ts create mode 100644 src/components/QuickFilters.tsx create mode 100644 src/fields/optionalGroupField/optionalGroupField.ts create mode 100644 src/hooks/beforeDuplicateAddCopyTo.ts create mode 100644 src/hooks/beforeDuplicatePiping.ts create mode 100644 src/hooks/beforeDuplicateUnpublish.ts diff --git a/package-lock.json b/package-lock.json index 10e6af8..f7c4e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "devDependencies": { "@types/dotenv": "^8.2.0", "@types/express": "^4.17.9", + "@types/react-router-dom": "^5.3.3", "copyfiles": "^2.4.1", "nodemon": "^2.0.6", "prettier": "^3.0.0", @@ -2998,6 +2999,12 @@ "@types/node": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -3098,6 +3105,27 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.6", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", diff --git a/package.json b/package.json index 9594f4e..d98770b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@types/dotenv": "^8.2.0", "@types/express": "^4.17.9", + "@types/react-router-dom": "^5.3.3", "copyfiles": "^2.4.1", "nodemon": "^2.0.6", "prettier": "^3.0.0", diff --git a/src/accesses/collections/mustBeAdminOrSelf.ts b/src/accesses/collections/mustBeAdminOrSelf.ts new file mode 100644 index 0000000..8fb7f38 --- /dev/null +++ b/src/accesses/collections/mustBeAdminOrSelf.ts @@ -0,0 +1,11 @@ +import { Access } from "payload/config"; +import { Recorder } from "../../types/collections"; +import { RecordersRoles } from "../../constants"; +import { isUndefined } from "../../utils/asserts"; + +export const mustBeAdminOrSelf: Access = ({ req }) => { + const user = req.user as Recorder | undefined; + if (isUndefined(user)) return false; + if (user.role.includes(RecordersRoles.Admin)) return true; + return { id: { equals: user.id } }; +}; diff --git a/src/accesses/collections/mustHaveAtLeastOneRole.ts b/src/accesses/collections/mustHaveAtLeastOneRole.ts new file mode 100644 index 0000000..6e07c86 --- /dev/null +++ b/src/accesses/collections/mustHaveAtLeastOneRole.ts @@ -0,0 +1,8 @@ +import { Recorder } from "../../types/collections"; +import { isUndefined } from "../../utils/asserts"; + +export const mustHaveAtLeastOneRole = ({ req }): boolean => { + const user = req.user as Recorder | undefined; + if (isUndefined(user)) return false; + return user.role.length > 0; +}; diff --git a/src/accesses/mustBeAdmin.ts b/src/accesses/mustBeAdmin.ts new file mode 100644 index 0000000..476682f --- /dev/null +++ b/src/accesses/mustBeAdmin.ts @@ -0,0 +1,9 @@ +import { Recorder } from "../types/collections"; +import { RecordersRoles } from "../constants"; +import { isUndefined } from "../utils/asserts"; + +export const mustBeAdmin = ({ req }): boolean => { + const user = req.user as Recorder | undefined; + if (isUndefined(user)) return false; + return user.role.includes(RecordersRoles.Admin); +}; diff --git a/src/collections/ContentFolders/ContentFolders.ts b/src/collections/ContentFolders/ContentFolders.ts index ff3acb7..9cbc83e 100644 --- a/src/collections/ContentFolders/ContentFolders.ts +++ b/src/collections/ContentFolders/ContentFolders.ts @@ -22,6 +22,7 @@ export const ContentFolders = buildCollectionConfig( admin: { useAsTitle: fields.slug, defaultColumns: [fields.slug, fields.translations], + disableDuplicate: true, group: CollectionGroup.Collections, }, timestamps: false, diff --git a/src/collections/ContentThumbnails/ContentThumbnails.ts b/src/collections/ContentThumbnails/ContentThumbnails.ts index 749eb21..bb9e95c 100644 --- a/src/collections/ContentThumbnails/ContentThumbnails.ts +++ b/src/collections/ContentThumbnails/ContentThumbnails.ts @@ -16,6 +16,7 @@ export const ContentThumbnails = buildCollectionConfig( defaultSort: fields.filename, admin: { useAsTitle: fields.filename, + disableDuplicate: true, group: CollectionGroup.Media, }, upload: { diff --git a/src/collections/Contents/Contents.ts b/src/collections/Contents/Contents.ts index d8e848b..647b325 100644 --- a/src/collections/Contents/Contents.ts +++ b/src/collections/Contents/Contents.ts @@ -9,6 +9,9 @@ import { fileField } from "../../fields/fileField/fileField"; import { contentBlocks } from "./Blocks/blocks"; import { ContentThumbnails } from "../ContentThumbnails/ContentThumbnails"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; +import { beforeDuplicatePiping } from "../../hooks/beforeDuplicatePiping"; +import { beforeDuplicateUnpublish } from "../../hooks/beforeDuplicateUnpublish"; +import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; const fields = { slug: "slug", @@ -53,6 +56,12 @@ export const Contents = buildVersionedCollectionConfig( fields.status, ], group: CollectionGroup.Collections, + hooks: { + beforeDuplicate: beforeDuplicatePiping([ + beforeDuplicateUnpublish, + beforeDuplicateAddCopyTo(fields.slug), + ]), + }, preview: (doc) => `https://accords-library.com/contents/${doc.slug}`, }, fields: [ diff --git a/src/collections/Currencies/Currencies.ts b/src/collections/Currencies/Currencies.ts new file mode 100644 index 0000000..78dbed3 --- /dev/null +++ b/src/collections/Currencies/Currencies.ts @@ -0,0 +1,39 @@ +import { mustBeAdmin } from "../../accesses/mustBeAdmin"; +import { CollectionGroup } from "../../constants"; +import { buildCollectionConfig } from "../../utils/collectionConfig"; + +const fields = { + id: "id", +} as const satisfies Record; + +export const Currencies = buildCollectionConfig( + { + singular: "Currency", + plural: "Currencies", + }, + () => ({ + defaultSort: fields.id, + admin: { + useAsTitle: fields.id, + defaultColumns: [fields.id], + disableDuplicate: true, + group: CollectionGroup.Meta, + }, + access: { create: mustBeAdmin, update: mustBeAdmin }, + timestamps: false, + fields: [ + { + name: fields.id, + type: "text", + unique: true, + required: true, + validate: (value) => { + if (/^[A-Z]{3}$/g.test(value)) { + return true; + } + return "The code must be a valid ISO 4217 currency code (e.g: EUR, CAD...)"; + }, + }, + ], + }) +); diff --git a/src/collections/Files/Files.ts b/src/collections/Files/Files.ts index 370b2f2..d3749b6 100644 --- a/src/collections/Files/Files.ts +++ b/src/collections/Files/Files.ts @@ -15,6 +15,7 @@ export const Files = buildCollectionConfig( defaultSort: fields.filename, admin: { useAsTitle: fields.filename, + disableDuplicate: true, group: CollectionGroup.Media, }, fields: [ diff --git a/src/collections/Keys/Keys.ts b/src/collections/Keys/Keys.ts index 473f5f6..79b559d 100644 --- a/src/collections/Keys/Keys.ts +++ b/src/collections/Keys/Keys.ts @@ -5,6 +5,9 @@ import { localizedFields } from "../../fields/translatedFields/translatedFields" import { Key } from "../../types/collections"; import { isDefined } from "../../utils/asserts"; import { buildCollectionConfig } from "../../utils/collectionConfig"; +import { mustBeAdmin } from "../../accesses/mustBeAdmin"; +import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; +import { QuickFilters } from "../../components/QuickFilters"; const fields = { slug: "slug", @@ -27,6 +30,26 @@ export const Keys: CollectionConfig = buildCollectionConfig( useAsTitle: fields.slug, defaultColumns: [fields.slug, fields.type, fields.translations], group: CollectionGroup.Meta, + components: { + BeforeListTable: [ + () => + QuickFilters({ + route: "/admin/collections/keys", + filters: [ + { label: "Wordings", filter: "where[type][equals]=Wordings" }, + { label: "∅ English", filter: "where[translations.language][not_equals]=en" }, + { label: "∅ French", filter: "where[translations.language][not_equals]=fr" }, + ], + }), + ], + }, + hooks: { + beforeDuplicate: beforeDuplicateAddCopyTo(fields.slug), + }, + }, + access: { + create: mustBeAdmin, + delete: mustBeAdmin, }, timestamps: false, versions: false, diff --git a/src/collections/Languages.ts b/src/collections/Languages/Languages.ts similarity index 76% rename from src/collections/Languages.ts rename to src/collections/Languages/Languages.ts index 0238303..fc35515 100644 --- a/src/collections/Languages.ts +++ b/src/collections/Languages/Languages.ts @@ -1,5 +1,6 @@ -import { CollectionGroup } from "../constants"; -import { buildCollectionConfig } from "../utils/collectionConfig"; +import { mustBeAdmin } from "../../accesses/mustBeAdmin"; +import { CollectionGroup } from "../../constants"; +import { buildCollectionConfig } from "../../utils/collectionConfig"; const fields = { id: "id", @@ -16,8 +17,10 @@ export const Languages = buildCollectionConfig( admin: { useAsTitle: fields.name, defaultColumns: [fields.name, fields.id], + disableDuplicate: true, group: CollectionGroup.Meta, }, + access: { create: mustBeAdmin, update: mustBeAdmin }, timestamps: false, fields: [ { diff --git a/src/collections/LibraryItemThumbnails/LibraryItemThumbnails.ts b/src/collections/LibraryItemThumbnails/LibraryItemThumbnails.ts index 801283f..0706faf 100644 --- a/src/collections/LibraryItemThumbnails/LibraryItemThumbnails.ts +++ b/src/collections/LibraryItemThumbnails/LibraryItemThumbnails.ts @@ -16,6 +16,7 @@ export const LibraryItemThumbnails = buildCollectionConfig( defaultSort: fields.filename, admin: { useAsTitle: fields.filename, + disableDuplicate: true, group: CollectionGroup.Media, }, upload: { diff --git a/src/collections/LibraryItems/LibraryItems.ts b/src/collections/LibraryItems/LibraryItems.ts index f774693..9cac990 100644 --- a/src/collections/LibraryItems/LibraryItems.ts +++ b/src/collections/LibraryItems/LibraryItems.ts @@ -11,8 +11,13 @@ import { isDefined, isUndefined } from "../../utils/asserts"; import { LibraryItemThumbnails } from "../LibraryItemThumbnails/LibraryItemThumbnails"; import { LibraryItem } from "../../types/collections"; import { Keys } from "../Keys/Keys"; -import { Languages } from "../Languages"; +import { Languages } from "../Languages/Languages"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; +import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; +import { beforeDuplicateUnpublish } from "../../hooks/beforeDuplicateUnpublish"; +import { beforeDuplicatePiping } from "../../hooks/beforeDuplicatePiping"; +import { Currencies } from "../Currencies/Currencies"; +import { optionalGroupField } from "../../fields/optionalGroupField/optionalGroupField"; const fields = { status: "status", @@ -29,6 +34,9 @@ const fields = { width: "width", height: "height", thickness: "thickness", + price: "price", + priceAmount: "amount", + priceCurrency: "currency", releaseDate: "releaseDate", itemType: "itemType", textual: "textual", @@ -39,19 +47,24 @@ const fields = { textualLanguages: "languages", audio: "audio", audioSubtype: "audioSubtype", + scans: "scans", + scansCover: "cover", + scansCoverFront: "front", + scansCoverSpine: "spine", + scansCoverBack: "back", + scansDustjacket: "dustjacket", + scansDustjacketFront: "front", + scansDustjacketSpine: "spine", + scansDustjacketBack: "back", + scansObibelt: "obibelt", + scansObibeltFront: "front", + scansObibeltSpine: "spine", + scansObibeltBack: "back", + scansPages: "pages", + scansPagesPage: "page", + scansPagesImage: "image", } as const satisfies Record; -const validateSizeValue = (value?: number) => { - if (isDefined(value) && value <= 0) return "This value must be greater than 0"; - return true; -}; - -const validateRequiredSizeValue = (value?: number) => { - if (isUndefined(value)) return "This field is required."; - if (value <= 0) return "This value must be greater than 0."; - return true; -}; - export const LibraryItems = buildVersionedCollectionConfig( { singular: "Library Item", @@ -63,9 +76,15 @@ export const LibraryItems = buildVersionedCollectionConfig( useAsTitle: fields.slug, description: "A comprehensive list of all Yokoverse’s side materials (books, novellas, artbooks, \ -stage plays, manga, drama CDs, and comics).", + stage plays, manga, drama CDs, and comics).", defaultColumns: [fields.slug, fields.thumbnail, fields.status], group: CollectionGroup.Collections, + hooks: { + beforeDuplicate: beforeDuplicatePiping([ + beforeDuplicateUnpublish, + beforeDuplicateAddCopyTo(fields.slug), + ]), + }, preview: (doc) => `https://accords-library.com/library/${doc.slug}`, }, fields: [ @@ -135,9 +154,115 @@ stage plays, manga, drama CDs, and comics).", }, ], }, - { - name: "size", - type: "group", + optionalGroupField({ + name: fields.scans, + fields: [ + optionalGroupField({ + name: fields.scansCover, + fields: [ + { + type: "row", + fields: [ + imageField({ + name: fields.scansCoverFront, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansCoverSpine, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansCoverBack, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + ], + }, + ], + }), + optionalGroupField({ + name: fields.scansDustjacket, + label: "Dust Jacket", + labels: { singular: "Dust Jacket", plural: "Dust Jackets" }, + fields: [ + { + type: "row", + fields: [ + imageField({ + name: fields.scansDustjacketFront, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansDustjacketSpine, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansDustjacketBack, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + ], + }, + ], + }), + optionalGroupField({ + name: fields.scansObibelt, + label: "Obi Belt", + labels: { singular: "Obi Belt", plural: "Obi Belts" }, + fields: [ + { + type: "row", + fields: [ + imageField({ + name: fields.scansObibeltFront, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansObibeltSpine, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + imageField({ + name: fields.scansObibeltBack, + relationTo: LibraryItemThumbnails.slug, + admin: { width: "33%" }, + }), + ], + }, + ], + }), + { + name: fields.scansPages, + type: "array", + fields: [ + { + type: "row", + fields: [ + { + name: fields.scansPagesPage, + type: "number", + required: true, + admin: { width: "33%" }, + }, + imageField({ + name: fields.scansPagesImage, + relationTo: LibraryItemThumbnails.slug, + required: true, + admin: { width: "66%" }, + }), + ], + }, + ], + }, + ], + }), + optionalGroupField({ + name: fields.size, admin: { condition: (data) => !data.digital }, fields: [ { @@ -146,25 +271,49 @@ stage plays, manga, drama CDs, and comics).", { name: fields.width, type: "number", - validate: validateRequiredSizeValue, + required: true, admin: { step: 1, width: "33%", description: "in mm." }, }, { name: fields.height, type: "number", - validate: validateRequiredSizeValue, + required: true, admin: { step: 1, width: "33%", description: "in mm." }, }, { name: fields.thickness, type: "number", - validate: validateSizeValue, admin: { step: 1, width: "33%", description: "in mm." }, }, ], }, ], - }, + }), + optionalGroupField({ + name: fields.price, + admin: { className: "group-array" }, + fields: [ + { + type: "row", + fields: [ + { + name: fields.priceAmount, + type: "number", + required: true, + min: 0, + admin: { width: "50%" }, + }, + { + name: fields.priceCurrency, + type: "relationship", + relationTo: Currencies.slug, + required: true, + admin: { allowCreate: false, width: "50%" }, + }, + ], + }, + ], + }), { name: fields.itemType, type: "radio", diff --git a/src/collections/PostThumbnails/PostThumbnails.ts b/src/collections/PostThumbnails/PostThumbnails.ts index 7972957..25e1ba9 100644 --- a/src/collections/PostThumbnails/PostThumbnails.ts +++ b/src/collections/PostThumbnails/PostThumbnails.ts @@ -16,6 +16,7 @@ export const PostThumbnails = buildCollectionConfig( defaultSort: fields.filename, admin: { useAsTitle: fields.filename, + disableDuplicate: true, group: CollectionGroup.Media, }, upload: { diff --git a/src/collections/Posts/Posts.ts b/src/collections/Posts/Posts.ts index b691750..26c9039 100644 --- a/src/collections/Posts/Posts.ts +++ b/src/collections/Posts/Posts.ts @@ -8,6 +8,9 @@ import { removeTranslatorsForTranscripts } from "./hooks/beforeValidate"; import { Keys } from "../Keys/Keys"; import { PostThumbnails } from "../PostThumbnails/PostThumbnails"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; +import { beforeDuplicatePiping } from "../../hooks/beforeDuplicatePiping"; +import { beforeDuplicateUnpublish } from "../../hooks/beforeDuplicateUnpublish"; +import { beforeDuplicateAddCopyTo } from "../../hooks/beforeDuplicateAddCopyTo"; const fields = { slug: "slug", @@ -36,9 +39,15 @@ export const Posts = buildVersionedCollectionConfig( useAsTitle: fields.slug, description: "News articles written by our Recorders! Here you will find announcements about \ -new merch/items releases, guides, theories, unboxings, showcases...", + new merch/items releases, guides, theories, unboxings, showcases...", defaultColumns: [fields.slug, fields.thumbnail, fields.categories], group: CollectionGroup.Collections, + hooks: { + beforeDuplicate: beforeDuplicatePiping([ + beforeDuplicateUnpublish, + beforeDuplicateAddCopyTo(fields.slug), + ]), + }, preview: (doc) => `https://accords-library.com/news/${doc.slug}`, }, hooks: { diff --git a/src/collections/RecorderThumbnails/RecorderThumbnails.ts b/src/collections/RecorderThumbnails/RecorderThumbnails.ts index 33bfae3..5b28ad1 100644 --- a/src/collections/RecorderThumbnails/RecorderThumbnails.ts +++ b/src/collections/RecorderThumbnails/RecorderThumbnails.ts @@ -16,6 +16,7 @@ export const RecorderThumbnails = buildCollectionConfig( defaultSort: fields.filename, admin: { useAsTitle: fields.filename, + disableDuplicate: true, group: CollectionGroup.Media, }, upload: { diff --git a/src/collections/Recorders/Recorders.ts b/src/collections/Recorders/Recorders.ts index 7c2936a..66a52b8 100644 --- a/src/collections/Recorders/Recorders.ts +++ b/src/collections/Recorders/Recorders.ts @@ -1,10 +1,13 @@ import { localizedFields } from "../../fields/translatedFields/translatedFields"; -import { Languages } from "../Languages"; -import { beforeDuplicate } from "./hooks/beforeDuplicate"; -import { CollectionGroup } from "../../constants"; +import { Languages } from "../Languages/Languages"; +import { CollectionGroup, RecordersRoles } from "../../constants"; import { RecorderThumbnails } from "../RecorderThumbnails/RecorderThumbnails"; import { imageField } from "../../fields/imageField/imageField"; import { buildCollectionConfig } from "../../utils/collectionConfig"; +import { mustBeAdmin } from "../../accesses/mustBeAdmin"; +import { mustBeAdminOrSelf } from "../../accesses/collections/mustBeAdminOrSelf"; +import { beforeLoginMustHaveAtLeastOneRole } from "./hooks/beforeLoginMustHaveAtLeastOneRole"; +import { QuickFilters } from "../../components/QuickFilters"; const fields = { username: "username", @@ -13,6 +16,7 @@ const fields = { biographies: "biographies", biography: "biography", avatar: "avatar", + role: "role", } as const satisfies Record; export const Recorders = buildCollectionConfig( @@ -24,17 +28,43 @@ export const Recorders = buildCollectionConfig( defaultSort: fields.username, admin: { useAsTitle: fields.username, - hooks: { beforeDuplicate }, description: - "Recorders are contributors of the Accord's Library project. Create a Recorder here to be able to credit them in other collections", + "Recorders are contributors of the Accord's Library project. Ask an admin to create a \ + Recorder here to be able to credit them in other collections.", defaultColumns: [ fields.username, fields.avatar, fields.anonymize, fields.biographies, fields.languages, + fields.role, ], + disableDuplicate: true, group: CollectionGroup.Meta, + components: { + BeforeListTable: [ + () => + QuickFilters({ + route: "/admin/collections/recorders", + filters: [ + { label: "Admins", filter: "where[role][equals]=Admin" }, + { label: "Recorders", filter: "where[role][equals]=Recorder" }, + { label: "∅ Role", filter: "where[role][not_in]=Admin,Recorder" }, + { label: "Anonymized", filter: "where[anonymize][equals]=true" }, + ], + }), + ], + }, + }, + auth: true, + access: { + unlock: mustBeAdmin, + update: mustBeAdminOrSelf, + delete: mustBeAdmin, + create: mustBeAdmin, + }, + hooks: { + beforeLogin: [beforeLoginMustHaveAtLeastOneRole], }, timestamps: false, fields: [ @@ -75,6 +105,20 @@ export const Recorders = buildCollectionConfig( }, fields: [{ name: fields.biography, type: "textarea" }], }), + { + name: fields.role, + type: "select", + access: { + update: mustBeAdmin, + create: mustBeAdmin, + }, + hasMany: true, + options: Object.entries(RecordersRoles).map(([value, label]) => ({ + label, + value, + })), + admin: { position: "sidebar" }, + }, { name: fields.anonymize, type: "checkbox", diff --git a/src/collections/Recorders/hooks/beforeDuplicate.ts b/src/collections/Recorders/hooks/beforeDuplicate.ts deleted file mode 100644 index ac73166..0000000 --- a/src/collections/Recorders/hooks/beforeDuplicate.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BeforeDuplicate } from "payload/types"; -import { Recorder } from "../../../types/collections"; - -export const beforeDuplicate: BeforeDuplicate = ({ data }) => { - return { - ...data, - id: `${data.id}-copy`, - }; -}; diff --git a/src/collections/Recorders/hooks/beforeLoginMustHaveAtLeastOneRole.ts b/src/collections/Recorders/hooks/beforeLoginMustHaveAtLeastOneRole.ts new file mode 100644 index 0000000..82c84e3 --- /dev/null +++ b/src/collections/Recorders/hooks/beforeLoginMustHaveAtLeastOneRole.ts @@ -0,0 +1,7 @@ +import { BeforeLoginHook } from "payload/dist/collections/config/types"; + +export const beforeLoginMustHaveAtLeastOneRole: BeforeLoginHook = ({ user }) => { + if (user.role.length === 0) { + throw new Error("User is not authorized to log-in."); + } +}; diff --git a/src/collections/Users.ts b/src/collections/Users.ts deleted file mode 100644 index aac5a47..0000000 --- a/src/collections/Users.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { CollectionGroup, UserRoles } from "../constants"; -import { Recorders } from "./Recorders/Recorders"; -import { buildCollectionConfig } from "../utils/collectionConfig"; - -const fields = { - recorder: "recorder", - name: "name", - email: "email", - role: "role", -} as const satisfies Record; - -export const Users = buildCollectionConfig( - { - singular: "User", - plural: "Users", - }, - () => ({ - auth: true, - defaultSort: fields.recorder, - admin: { - useAsTitle: fields.name, - defaultColumns: [fields.recorder, fields.name, fields.email, fields.role], - group: CollectionGroup.Administration, - }, - timestamps: false, - fields: [ - { - type: "row", - fields: [ - { - name: fields.recorder, - type: "relationship", - relationTo: Recorders.slug, - admin: { width: "33%" }, - }, - { - name: fields.name, - type: "text", - required: true, - unique: true, - admin: { width: "33%" }, - }, - { - name: fields.role, - required: true, - defaultValue: [UserRoles.Recorder], - type: "select", - hasMany: true, - options: Object.entries(UserRoles).map(([value, label]) => ({ - label, - value, - })), - admin: { width: "33%" }, - }, - ], - }, - ], - }) -); diff --git a/src/components/QuickFilters.tsx b/src/components/QuickFilters.tsx new file mode 100644 index 0000000..869c627 --- /dev/null +++ b/src/components/QuickFilters.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { styled } from "styled-components"; +import { Link } from "react-router-dom"; + +type Props = { + route: string; + filters: { label: string; filter: string }[]; +}; + +export const QuickFilters = ({ route, filters }: Props) => { + return ( + +
Quick Filters:
+ + + {filters.map(({ label, filter }, index) => ( + + ))} + +
+ ); +}; + +type FilterProps = { + label: string; + to: string; +}; + +const FilterCell = ({ label, to }: FilterProps) => ( + + {label} + +); + +const Container = styled.div` + display: flex; + place-items: center; + gap: 1rem; + margin-top: -1rem; + margin-bottom: 2rem; +`; + +const FilterContainer = styled.div` + display: flex; + place-items: center; + gap: 0.5rem; +`; diff --git a/src/constants.ts b/src/constants.ts index 2aa33f6..984eac3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,7 +2,6 @@ export enum CollectionGroup { Collections = "Collections", Media = "Media", Meta = "Meta", - Administration = "Administration", } export enum KeysTypes { @@ -43,7 +42,12 @@ export enum LibraryItemsTextualPageOrders { RightToLeft = "Right to left", } -export enum UserRoles { +export enum RecordersRoles { Admin = "Admin", Recorder = "Recorder", } + +export enum CollectionStatus { + Draft = "draft", + Published = "published", +} diff --git a/src/fields/optionalGroupField/optionalGroupField.ts b/src/fields/optionalGroupField/optionalGroupField.ts new file mode 100644 index 0000000..9572342 --- /dev/null +++ b/src/fields/optionalGroupField/optionalGroupField.ts @@ -0,0 +1,14 @@ +import { ArrayField } from "payload/types"; + +type Props = Omit; + +export const optionalGroupField = ({ + admin: { className = "", ...otherAdmin } = {}, + ...otherProps +}: Props): ArrayField => ({ + ...otherProps, + type: "array", + minRows: 0, + maxRows: 1, + admin: { ...otherAdmin, className: `${className} group-array` }, +}); diff --git a/src/fields/translatedFields/translatedFields.ts b/src/fields/translatedFields/translatedFields.ts index 3ce1073..995727d 100644 --- a/src/fields/translatedFields/translatedFields.ts +++ b/src/fields/translatedFields/translatedFields.ts @@ -1,7 +1,7 @@ import { ArrayField, Field } from "payload/types"; import { hasDuplicates } from "../../utils/validation"; import { isDefined, isUndefined } from "../../utils/asserts"; -import { Languages } from "../../collections/Languages"; +import { Languages } from "../../collections/Languages/Languages"; import { RowLabel } from "./RowLabel"; import { Cell } from "./Cell"; diff --git a/src/hooks/beforeDuplicateAddCopyTo.ts b/src/hooks/beforeDuplicateAddCopyTo.ts new file mode 100644 index 0000000..c8b3bad --- /dev/null +++ b/src/hooks/beforeDuplicateAddCopyTo.ts @@ -0,0 +1,5 @@ +import { BeforeDuplicate } from "payload/types"; + +export const beforeDuplicateAddCopyTo = + (fieldName: string): BeforeDuplicate => + ({ data }) => ({ ...data, [fieldName]: `${data[fieldName]}-copy` }); diff --git a/src/hooks/beforeDuplicatePiping.ts b/src/hooks/beforeDuplicatePiping.ts new file mode 100644 index 0000000..f59f888 --- /dev/null +++ b/src/hooks/beforeDuplicatePiping.ts @@ -0,0 +1,5 @@ +import { BeforeDuplicate } from "payload/types"; + +export const beforeDuplicatePiping = (hooks: BeforeDuplicate[]): BeforeDuplicate => { + return ({ data: initialData }) => hooks.reduce((data, hook) => hook({ data }), initialData); +}; diff --git a/src/hooks/beforeDuplicateUnpublish.ts b/src/hooks/beforeDuplicateUnpublish.ts new file mode 100644 index 0000000..42573a3 --- /dev/null +++ b/src/hooks/beforeDuplicateUnpublish.ts @@ -0,0 +1,7 @@ +import { BeforeDuplicate } from "payload/types"; +import { CollectionStatus } from "../constants"; + +export const beforeDuplicateUnpublish: BeforeDuplicate = ({ data }) => ({ + ...data, + _status: CollectionStatus.Draft, +}); diff --git a/src/payload.config.ts b/src/payload.config.ts index 0bb4463..e7af68a 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -1,7 +1,6 @@ import { buildConfig } from "payload/config"; import path from "path"; -import { Users } from "./collections/Users"; -import { Languages } from "./collections/Languages"; +import { Languages } from "./collections/Languages/Languages"; import { Recorders } from "./collections/Recorders/Recorders"; import { Posts } from "./collections/Posts/Posts"; import { Keys } from "./collections/Keys/Keys"; @@ -15,11 +14,12 @@ import { ContentThumbnails } from "./collections/ContentThumbnails/ContentThumbn import { ContentFolders } from "./collections/ContentFolders/ContentFolders"; import { Logo } from "./components/Logo"; import { Icon } from "./components/Icon"; +import { Currencies } from "./collections/Currencies/Currencies"; export default buildConfig({ serverURL: "https://dashboard.accords-library.com", admin: { - user: Users.slug, + user: Recorders.slug, components: { graphics: { Logo, Icon } }, meta: { favicon: "/public/favicon.ico", @@ -39,9 +39,9 @@ export default buildConfig({ PostThumbnails, Files, Languages, + Currencies, Recorders, Keys, - Users, ], globals: [], telemetry: false, diff --git a/src/styles.scss b/src/styles.scss index d1114bc..880a966 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -45,3 +45,18 @@ html[data-theme="light"] { --color-base-950: #11100b; --color-base-1000: #000000; } + +.field-type.array-field.group-array { + .array-field__header-actions, + .collapsible__drag, + .collapsible__toggle, + .collapsible__header-wrap, + .collapsible__indicator, + .array-actions__add, + .array-actions__duplicate { + display: none; + } + .collapsible__actions-wrap { + z-index: 1; + } +} diff --git a/src/types/collections.ts b/src/types/collections.ts index df04ee8..3ee3a6e 100644 --- a/src/types/collections.ts +++ b/src/types/collections.ts @@ -25,19 +25,18 @@ export type ContentFoldersTranslation = { export interface Config { collections: { - "library-items": LibraryItem; + 'library-items': LibraryItem; contents: Content; - "content-folders": ContentFolder; + 'content-folders': ContentFolder; posts: Post; - "content-thumbnails": ContentThumbnail; - "library-item-thumbnails": LibraryItemThumbnail; - "recorder-thumbnails": RecorderThumbnail; - "post-thumbnails": PostThumbnail; + 'content-thumbnails': ContentThumbnail; + 'library-item-thumbnails': LibraryItemThumbnail; + 'recorder-thumbnails': RecorderThumbnail; + 'post-thumbnails': PostThumbnail; files: File; languages: Language; recorders: Recorder; keys: Key; - users: User; }; globals: {}; } @@ -53,50 +52,55 @@ export interface LibraryItem { digital: boolean; downloadable: boolean; size?: { - width?: number; - height?: number; + width: number; + height: number; thickness?: number; - }; - itemType?: "Textual" | "Audio" | "Video" | "Game" | "Other"; + id?: string; + }[]; + price?: { + priceAmount: number; + id?: string; + }[]; + itemType?: 'Textual' | 'Audio' | 'Video' | 'Game' | 'Other'; textual?: { subtype?: | { value: string; - relationTo: "keys"; + relationTo: 'keys'; }[] | { value: Key; - relationTo: "keys"; + relationTo: 'keys'; }[]; languages?: | { value: string; - relationTo: "languages"; + relationTo: 'languages'; }[] | { value: Language; - relationTo: "languages"; + relationTo: 'languages'; }[]; pageCount?: number; - bindingType?: "Paperback" | "Hardcover"; - pageOrder?: "LeftToRight" | "RightToLeft"; + bindingType?: 'Paperback' | 'Hardcover'; + pageOrder?: 'LeftToRight' | 'RightToLeft'; }; audio?: { audioSubtype?: | { value: string; - relationTo: "keys"; + relationTo: 'keys'; }[] | { value: Key; - relationTo: "keys"; + relationTo: 'keys'; }[]; }; releaseDate?: string; - lastModifiedBy: string | User; + lastModifiedBy: string | Recorder; updatedAt: string; createdAt: string; - _status?: "draft" | "published"; + _status?: 'draft' | 'published'; } export interface LibraryItemThumbnail { id: string; @@ -139,27 +143,30 @@ export interface Key { id: string; slug: string; type: - | "Contents" - | "LibraryAudio" - | "LibraryVideo" - | "LibraryTextual" - | "LibraryGroup" - | "Library" - | "Weapons" - | "GamePlatforms" - | "Categories" - | "Wordings"; + | 'Contents' + | 'LibraryAudio' + | 'LibraryVideo' + | 'LibraryTextual' + | 'LibraryGroup' + | 'Library' + | 'Weapons' + | 'GamePlatforms' + | 'Categories' + | 'Wordings'; translations?: CategoryTranslations; } export interface Language { id: string; name: string; } -export interface User { +export interface Recorder { id: string; - recorder: string | Recorder; - name: string; - role: ("Admin" | "Recorder")[]; + username: string; + avatar?: string | RecorderThumbnail; + languages?: string[] | Language[]; + biographies?: RecorderBiographies; + role?: ('Admin' | 'Recorder')[]; + anonymize: boolean; email: string; resetPasswordToken?: string; resetPasswordExpiration?: string; @@ -169,14 +176,6 @@ export interface User { lockUntil?: string; password?: string; } -export interface Recorder { - id: string; - username: string; - avatar?: string | RecorderThumbnail; - languages?: string[] | Language[]; - biographies?: RecorderBiographies; - anonymize: boolean; -} export interface RecorderThumbnail { id: string; updatedAt: string; @@ -213,15 +212,15 @@ export interface Content { categories?: | { value: string; - relationTo: "keys"; + relationTo: 'keys'; }[] | { value: Key; - relationTo: "keys"; + relationTo: 'keys'; }[]; type?: { value: string | Key; - relationTo: "keys"; + relationTo: 'keys'; }; translations: { language: string | Language; @@ -240,10 +239,10 @@ export interface Content { audio?: string | File; id?: string; }[]; - lastModifiedBy: string | User; + lastModifiedBy: string | Recorder; updatedAt: string; createdAt: string; - _status?: "draft" | "published"; + _status?: 'draft' | 'published'; } export interface ContentThumbnail { id: string; @@ -280,25 +279,19 @@ export interface TextBlock { }[]; id?: string; blockName?: string; - blockType: "textBlock"; + blockType: 'textBlock'; } export interface Section { content?: (Section_Section | Section_Tabs | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section { - content?: ( - | Section_Section_Section - | Section_Section_Tabs - | TranscriptBlock - | QuoteBlock - | TextBlock - )[]; + content?: (Section_Section_Section | Section_Section_Tabs | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section_Section { content?: ( @@ -310,25 +303,25 @@ export interface Section_Section_Section { )[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section_Section_Section { content?: (Section_Section_Section_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section_Section_Section_Section { content?: (TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface TranscriptBlock { lines: (LineBlock | CueBlock)[]; id?: string; blockName?: string; - blockType: "transcriptBlock"; + blockType: 'transcriptBlock'; } export interface LineBlock { content: { @@ -336,13 +329,13 @@ export interface LineBlock { }[]; id?: string; blockName?: string; - blockType: "lineBlock"; + blockType: 'lineBlock'; } export interface CueBlock { content: string; id?: string; blockName?: string; - blockType: "cueBlock"; + blockType: 'cueBlock'; } export interface QuoteBlock { from: string; @@ -351,120 +344,120 @@ export interface QuoteBlock { }[]; id?: string; blockName?: string; - blockType: "quoteBlock"; + blockType: 'quoteBlock'; } export interface Section_Section_Section_Tabs { tabs?: Section_Section_Section_Tabs_Tab[]; id?: string; blockName?: string; - blockType: "tabs"; + blockType: 'tabs'; } export interface Section_Section_Section_Tabs_Tab { content?: (Section_Section_Section_Tabs_Tab_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "tab"; + blockType: 'tab'; } export interface Section_Section_Section_Tabs_Tab_Section { content?: (TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section_Tabs { tabs?: Section_Section_Tabs_Tab[]; id?: string; blockName?: string; - blockType: "tabs"; + blockType: 'tabs'; } export interface Section_Section_Tabs_Tab { content?: (Section_Section_Tabs_Tab_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "tab"; + blockType: 'tab'; } export interface Section_Section_Tabs_Tab_Section { content?: (Section_Section_Tabs_Tab_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Section_Tabs_Tab_Section_Section { content?: (TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Tabs { tabs?: Section_Tabs_Tab[]; id?: string; blockName?: string; - blockType: "tabs"; + blockType: 'tabs'; } export interface Section_Tabs_Tab { content?: (Section_Tabs_Tab_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "tab"; + blockType: 'tab'; } export interface Section_Tabs_Tab_Section { content?: (Section_Tabs_Tab_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Tabs_Tab_Section_Section { content?: (Section_Tabs_Tab_Section_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Section_Tabs_Tab_Section_Section_Section { content?: (TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Tabs { tabs?: Tabs_Tab[]; id?: string; blockName?: string; - blockType: "tabs"; + blockType: 'tabs'; } export interface Tabs_Tab { content?: (Tabs_Tab_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "tab"; + blockType: 'tab'; } export interface Tabs_Tab_Section { content?: (Tabs_Tab_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Tabs_Tab_Section_Section { content?: (Tabs_Tab_Section_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Tabs_Tab_Section_Section_Section { content?: (Tabs_Tab_Section_Section_Section_Section | TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface Tabs_Tab_Section_Section_Section_Section { content?: (TranscriptBlock | QuoteBlock | TextBlock)[]; id?: string; blockName?: string; - blockType: "section"; + blockType: 'section'; } export interface File { id: string; filename: string; - type: "LibraryScans" | "LibrarySoundtracks" | "ContentVideo" | "ContentAudio"; + type: 'LibraryScans' | 'LibrarySoundtracks' | 'ContentVideo' | 'ContentAudio'; updatedAt: string; createdAt: string; } @@ -475,20 +468,20 @@ export interface ContentFolder { subfolders?: | { value: string; - relationTo: "content-folders"; + relationTo: 'content-folders'; }[] | { value: ContentFolder; - relationTo: "content-folders"; + relationTo: 'content-folders'; }[]; contents?: | { value: string; - relationTo: "contents"; + relationTo: 'contents'; }[] | { value: Content; - relationTo: "contents"; + relationTo: 'contents'; }[]; } export interface Post { @@ -498,20 +491,20 @@ export interface Post { authors: | { value: string; - relationTo: "recorders"; + relationTo: 'recorders'; }[] | { value: Recorder; - relationTo: "recorders"; + relationTo: 'recorders'; }[]; categories?: | { value: string; - relationTo: "keys"; + relationTo: 'keys'; }[] | { value: Key; - relationTo: "keys"; + relationTo: 'keys'; }[]; translations: { language: string | Language; @@ -527,10 +520,10 @@ export interface Post { }[]; publishedDate: string; hidden?: boolean; - lastModifiedBy: string | User; + lastModifiedBy: string | Recorder; updatedAt: string; createdAt: string; - _status?: "draft" | "published"; + _status?: 'draft' | 'published'; } export interface PostThumbnail { id: string; diff --git a/src/utils/versionedCollectionConfig.ts b/src/utils/versionedCollectionConfig.ts index b125a15..93e20af 100644 --- a/src/utils/versionedCollectionConfig.ts +++ b/src/utils/versionedCollectionConfig.ts @@ -1,29 +1,26 @@ import { CollectionBeforeChangeHook, CollectionConfig, RelationshipField } from "payload/types"; -import { Users } from "../collections/Users"; import { BuildCollectionConfig, GenerationFunctionProps, buildCollectionConfig, } from "./collectionConfig"; +import { Recorders } from "../collections/Recorders/Recorders"; const fields = { lastModifiedBy: "lastModifiedBy" }; const beforeChangeLastModifiedBy: CollectionBeforeChangeHook = async ({ data: { updatedBy, ...data }, req, -}) => { - console.log(data, req.user); - return { - ...data, - [fields.lastModifiedBy]: req.user.id, - }; -}; +}) => ({ + ...data, + [fields.lastModifiedBy]: req.user.id, +}); const lastModifiedByField = (): RelationshipField => ({ name: fields.lastModifiedBy, type: "relationship", required: true, - relationTo: Users.slug, + relationTo: Recorders.slug, admin: { readOnly: true, position: "sidebar" }, });