Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implementation of Central CMS #887

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
| [**Code for Africa**](./apps/codeforafrica/) | Africa's largest network of civic tech and open data labs |
| [**PesaYetu**](./apps/pesayetu/) | Data to hold your government accountable |
| [**RoboShield**](./apps/roboshield/) | Guard your website against AI Bots |
| [**Central CMS**](./apps/centralcms/) | Manage content of your web Apps |

## Blogs

Expand Down
4 changes: 4 additions & 0 deletions apps/centralcms/.env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
MONGO_URL=
PAYLOAD_SECRET=YOUR_SECRET_HERE
NEXT_PUBLIC_IMAGE_DOMAINS=
NEXT_PUBLIC_IMAGE_UNOPTIMIZED=
36 changes: 36 additions & 0 deletions apps/centralcms/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# dependencies
node_modules
.pnp
.pnp.js
.pnpm-debug.log

# typescript
dist/

# testing
coverage

# next.js
.next/
out/

# payload
build/

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Vercel
.vercel
.now

# turbo
.turbo
test-results/
playwright-report/
12 changes: 12 additions & 0 deletions apps/centralcms/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ["next/core-web-vitals", "plugin:prettier/recommended"],
settings: {
"import/resolver": {
webpack: {
config: "./eslint.webpack.config.js",
},
},
},
};
43 changes: 43 additions & 0 deletions apps/centralcms/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

m453h marked this conversation as resolved.
Show resolved Hide resolved
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

/.idea/*
!/.idea/runConfigurations

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

.env

/media
120 changes: 120 additions & 0 deletions apps/centralcms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Central CMS

This repo has contains source code for [Payload 3.0](https://github.com/payloadcms/payload) CMS for multiple full stack Applications. It borrows ideas from this [example](https://payloadcms.com/blog/how-to-build-a-multi-tenant-app-with-payload) the main difference is that this example is suited for a scenario where you are managing content of multiple instances of the same application while in our case we are managing content of multiple instances of different applications.

## Running the project in Development mode

To spin up the project locally, follow these steps:

1. First clone the repo
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
m453h marked this conversation as resolved.
Show resolved Hide resolved
1. Next `pnpm install && pnpm run dev` (or `docker-compose up`, see [Docker](#docker))
1. Now `open http://localhost:3000/admin` to access the admin panel
1. Create your first super-admin user using the form on the page

That's it!

Next step see [Configuring Domains for your applications](#configuring-domains-for-your-applications)

### Docker

Alternatively, you can use [Docker](https://www.docker.com) to spin up this project locally. To do so, follow these steps:

1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
1. Next run `docker-compose up`
1. Follow [steps 4 and 5 from above](#development) to login and create your first super-admin user

That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.

Next step see [Configuring Domains for your applications](#configuring-domains-for-your-applications)

## Production

To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:

1. First invoke the `payload build` script by running `pnpm build` or `pnpm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
1. Then run `pnpm start` to run Node in production and serve Payload from the `./build` directory.

Next step see [Configuring Domains for your applications](#configuring-domains-for-your-applications)

## Configuring Domains for your applications

After creating your Super-admin account you will need to create tenants (application instances) which will be tied to non super-admin user accounts. To do so, follow these steps:

1. Select `Tenants` from the side bar menu or dashboard.
1. Click `Create New`
1. Provide the Name, and Domain for your application.

Take Note:

- For local development, you may need to edit your `/etc/hosts` file to ensure that you have multiple domains see this [article](https://linuxize.com/post/how-to-edit-your-hosts-file/).
- The domain configured for a tenant is used for authentication and authorization services for this application.

- Therefore in this setup, non-super admin users will only be able to log in via a URL with a domain that has been configured for a tenant tied to their account i.e. If a user account is tied with Tenant A then the user account will only be able to log in via the allowed domains for Tenant A.
- A user account may be tied to more than one tenant, this allows a user to be authenticated using the same credentials for multiple applications.

- A user account may have different roles per each tenant tied to their account.

- A tenant may have more than one allowed domain, users will only be able to log in if they make login requests via a URL belonging to domains assigned to a tenant tied to their account.

- Upon authentication via a given URL, the user will only be able to see content and configurations belonging to a specific tenant belonging to the domain they have been authenticated from

- User accounts can be authenticated by using API keys, this is expected to be used for the Full-stack applications accessing content from the CMS, see [Authentication Config](https://payloadcms.com/docs/authentication/config).

## Development Approach

This section provides an overview of the approach and thought process behind the development of this application. It outlines the strategies, methodologies, and considerations that guided the overall development process. By understanding this approach, you can gain insight into the decisions made and the reasoning behind the design and implementation of the Multi-tenant application.

### Project folder structure

This application follow a standard convention in which each of the different configurations for a specific application e.g. collections, blocks, and fields will be enclosed in a folder structure following this convention:
`src/payload/<CONFIGURATION_TYPE>/<APP_NAME>/`

Two examples have been provided in this repository which are RoboShield and CodeforAfrica web applications. For example to store blocks under each application we can use the following paths:

- `src/payload/blocks/roboshield`

- `src/payload/blocks/codeforafrica`

Moreover, to store collections we can use:

- `src/payload/collections/roboshield`

- `src/payload/collections/codeforafrica`

This convention is expected to significantly reduce the time to migrate the multiple existing Payload instances to a single CMS instance and ensure that the code responsible for managing the content of a specific application is isolated, consequently improving maintainability. It should be noted that some of the configurations are shared across different applications and can be placed in a shared global folder to improve reusability, see the `src/payload/fields`

### User Authentication and Authorization

Custom logic has been implemented for authorization and authentication to meet this use case.

For authentication two custom hooks have been tied to the User collection namely:

- `checkDomain`: This hook is invoked `beforeLogin` It ensures that users with valid credentials (correct username and password) are also permitted to log in based on the domain from which they are accessing the application. This extra layer of security helps control access and enforces domain-specific login permissions.

- `recordLastLoggedInTenant`: This hook is invoked `afterLogin`. It records the id of the tenant a user has been authenticated with. This is further used in providing access control to ensure that a user only sees collections, blocks and other configurations belonging to the domain they have been authenticated with. Take note: Super-Admins will only be authenticated via

For authorization a custom function that is used in collection level `Access` function(s) named `canAccessFromDomain` has been implemented. The collection level Access has been tied to each collection which verifies if a user can view a given collection after being authenticated from a specific domain. The implementation of the function can be seen on `src/payload/access/<APP_NAME>`

An example of this implementation can be seen below:

```
import type { Access } from "payload";
import canAccessFromDomain from "@/payload/access/canAccessFromDomain"

export const canRead: Access = ({ req: { user } }) => {
return canAccessFromDomain(user, "CodeforAfrica");
};

```

The `canRead` Access function is tied to each collection under a specific folder e.g. for the example to control access of Authors collection we can use the following code snippet:

```
import { canRead } from "@/payload/access/codeforafrica";

const Authors: CollectionConfig = {
slug: "author",
access: {
read: canRead,
```
25 changes: 25 additions & 0 deletions apps/centralcms/eslint.webpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const path = require("path");

module.exports = {
module: {
rules: [
{
test: /\.svg$/i,
type: "asset",
resourceQuery: /url/, // *.svg?url
},
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url
use: ["@svgr/webpack"],
},
],
},
resolve: {
alias: {
"@/centralcms": path.resolve(__dirname, "src/"),
},
extensions: [".ts", ".tsx"],
},
};
24 changes: 24 additions & 0 deletions apps/centralcms/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { withPayload } from "@payloadcms/next/withPayload";

const PROJECT_ROOT = process.env.PROJECT_ROOT?.trim();
const outputFileTracingRoot = PROJECT_ROOT
? path.resolve(__dirname, PROJECT_ROOT)
: undefined;

/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@commons-ui/core", "@commons-ui/next"],
reactStrictMode: true,
output: "standalone",
outputFileTracingRoot,
images: {
domains: process.env.NEXT_PUBLIC_IMAGE_DOMAINS?.split(",")
?.map((d) => d.trim())
?.filter((d) => d),
unoptimized:
process.env.NEXT_PUBLIC_IMAGE_UNOPTIMIZED?.trim()?.toLowerCase() ===
"true",
},
};

export default withPayload(nextConfig);
56 changes: 56 additions & 0 deletions apps/centralcms/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "centralcms",
"version": "0.0.1",
"author": "Code for Africa <[email protected]>",
"description": "Headless CMS for CfA full stack applications",
"keywords": [
"cms",
"next",
"next.js",
"react",
"payload"
],
"license": "MIT",
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:types": "payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@commons-ui/core": "workspace:*",
"@commons-ui/next": "workspace:*",
"@mui/utils": "catalog:",
"@payloadcms/db-mongodb": "catalog:payload-3",
"@payloadcms/db-postgres": "catalog:payload-3",
"@payloadcms/next": "catalog:payload-3",
"@payloadcms/plugin-cloud": "catalog:payload-3",
"@payloadcms/plugin-seo": "catalog:payload-3",
"@payloadcms/richtext-lexical": "catalog:payload-3",
"@payloadcms/ui": "catalog:payload-3",
"cross-env": "catalog:payload-3",
"graphql": "catalog:payload-3",
"next": "catalog:payload-3",
"payload": "catalog:payload-3",
"react": "catalog:payload-3",
"react-dom": "catalog:payload-3",
"sharp": "catalog:"
},
"devDependencies": {
"@commons-ui/testing-library": "workspace:*",
"@svgr/webpack": "catalog:",
"@types/node": "catalog:payload-3",
"@types/react": "catalog:payload-3",
"@types/react-dom": "catalog:payload-3",
"eslint": "catalog:payload-3",
"eslint-config-next": "catalog:payload-3",
"typescript": "catalog:"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from "next";

import config from "@payload-config";
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
import { importMap } from "../importMap";

type Args = {
params: {
segments: string[];
};
searchParams: {
[key: string]: string | string[];
};
};

export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });

const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams });

export default NotFound;
26 changes: 26 additions & 0 deletions apps/centralcms/src/app/(payload)/admin/[[...segments]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from "next";
import { importMap } from "../importMap.js";
import config from "@payload-config";
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";

type Args = {
params: {
segments: string[];
};
searchParams: {
[key: string]: string | string[];
};
};

export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });

const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams });

export default Page;
Loading
Loading