@extantape/payload-plugin-roles 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.js +30 -0
- package/dist/roles.d.ts +72 -0
- package/dist/roles.js +88 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EXTANT APE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# payload-plugin-roles
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Per-site role ladders for multi-tenant [Payload](https://payloadcms.com) sites.
|
|
4
|
+
|
|
5
|
+
Most sites need a handful of roles per site, where a higher role can do everything a lower one can.
|
|
6
|
+
Permission matrices are overkill for that. This plugin gives you:
|
|
7
|
+
|
|
8
|
+
- **A ladder** of per-site roles, lowest first, e.g. **Member · Author · Curator · Administrator**.
|
|
9
|
+
`atLeast('author')` means Author, Curator or Administrator.
|
|
10
|
+
- **Platform roles** (e.g. **Maintainer**) for people who work across every site.
|
|
11
|
+
- **Add-on roles** outside the ladder (e.g. "Lab partner") that grant only what you check for them.
|
|
12
|
+
- **Per-site role names**: call Curator "Editor" on one site without changing permissions.
|
|
13
|
+
- **Access helpers** that return Payload `Where` constraints (`tenant in [...]`), built for
|
|
14
|
+
[`@payloadcms/plugin-multi-tenant`](https://payloadcms.com/docs/plugins/multi-tenant)'s `tenants[].roles` layout.
|
|
15
|
+
|
|
16
|
+
A role on one site never grants anything on another.
|
|
17
|
+
|
|
18
|
+
## On its own, and with others
|
|
19
|
+
|
|
20
|
+
**On its own:** `defineRoles` gives you the helpers with no plugin installed at all. `rolesPlugin` only adds per-site role names to your tenants collection and keeps the role system on `config.custom.roles`. Field names default to the multi-tenant plugin's layout, and `fields` changes them.
|
|
21
|
+
|
|
22
|
+
**Works with:**
|
|
23
|
+
- **Payload's multi-tenant plugin** (`@payloadcms/plugin-multi-tenant`): its `tenants[].roles` layout is the default, and `roles.options()` fills its role select.
|
|
24
|
+
|
|
25
|
+
No other Captive Origins plugin reads `config.custom.roles` yet.
|
|
26
|
+
|
|
27
|
+
**Family:** It's part of the **Identity** family (a working draft): plugins designed to sit together for accounts and access.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @extantape/payload-plugin-roles
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { defineRoles, rolesPlugin } from '@extantape/payload-plugin-roles'
|
|
37
|
+
|
|
38
|
+
export const roles = defineRoles({
|
|
39
|
+
ladder: [
|
|
40
|
+
{ value: 'member', label: 'Member' },
|
|
41
|
+
{ value: 'author', label: 'Author' },
|
|
42
|
+
{ value: 'curator', label: 'Curator' },
|
|
43
|
+
{ value: 'admin', label: 'Administrator' },
|
|
44
|
+
],
|
|
45
|
+
addOns: [{ value: 'lab', label: 'Lab partner' }],
|
|
46
|
+
platform: [{ value: 'maintainer', label: 'Maintainer' }],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export default buildConfig({
|
|
50
|
+
plugins: [
|
|
51
|
+
multiTenantPlugin({
|
|
52
|
+
tenantsArrayField: { rowFields: [{ name: 'roles', type: 'select', hasMany: true, options: roles.options() }] },
|
|
53
|
+
userHasAccessToAllTenants: (user) => roles.isPlatform(user, 'maintainer'),
|
|
54
|
+
}),
|
|
55
|
+
rolesPlugin(roles), // adds "Role names on this site" to the tenants collection
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Helpers
|
|
61
|
+
|
|
62
|
+
| Helper | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `roles.atLeast('curator')` | `['curator', 'admin']` |
|
|
65
|
+
| `roles.sites(user, 'author')` | Site IDs where the user is Author or higher |
|
|
66
|
+
| `roles.has(user, site, 'curator', { platformRole: 'maintainer' })` | Curator or higher there (maintainers pass) |
|
|
67
|
+
| `roles.highest(user, site)` | The user's top ladder role on a site |
|
|
68
|
+
| `roles.isPlatform(user, 'maintainer')` | Platform role check |
|
|
69
|
+
| `roles.access.atLeast('curator', { platformRole: 'maintainer' })` | A Payload `Access`: `true` for maintainers, else `{ tenant: { in: [...] } }` |
|
|
70
|
+
| `roles.label(role, site.roleLabels)` / `roles.options(aliases)` | Names and select options, with per-site names |
|
|
71
|
+
|
|
72
|
+
Field names default to the multi-tenant plugin's (`tenants`, `tenant`, `roles`) and `platformRoles`;
|
|
73
|
+
change them with `fields`.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Config, FieldAccess, Plugin } from 'payload';
|
|
2
|
+
import type { RoleSystem } from './roles.js';
|
|
3
|
+
export type RolesPluginOptions = {
|
|
4
|
+
/** The tenants (sites) collection that gets per-site role names. Default `tenants`. Set false to skip. */
|
|
5
|
+
tenants?: string | false;
|
|
6
|
+
/** Who may rename roles on a site (field access). Default: anyone who may update the site. */
|
|
7
|
+
canRename?: FieldAccess;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Adds per-site role names ("aliases", e.g. call Curator "Editor" on one site)
|
|
11
|
+
* to the tenants collection and keeps the role system on `config.custom.roles`.
|
|
12
|
+
* The helpers themselves come from `defineRoles()` and work without the plugin.
|
|
13
|
+
*/
|
|
14
|
+
export declare const rolesPlugin: (system: RoleSystem, options?: RolesPluginOptions) => ((config: Config) => Config) & Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds per-site role names ("aliases", e.g. call Curator "Editor" on one site)
|
|
3
|
+
* to the tenants collection and keeps the role system on `config.custom.roles`.
|
|
4
|
+
* The helpers themselves come from `defineRoles()` and work without the plugin.
|
|
5
|
+
*/
|
|
6
|
+
export const rolesPlugin = (system, options = {}) => (config) => {
|
|
7
|
+
const tenants = options.tenants === false ? null : (options.tenants ?? 'tenants');
|
|
8
|
+
const aliases = {
|
|
9
|
+
name: 'roleLabels',
|
|
10
|
+
label: 'Role names on this site',
|
|
11
|
+
type: 'array',
|
|
12
|
+
admin: { description: 'Optional: show a role under another name here (e.g. Curator as “Editor”). Permissions don’t change.', initCollapsed: true },
|
|
13
|
+
access: options.canRename ? { create: options.canRename, update: options.canRename } : undefined,
|
|
14
|
+
fields: [
|
|
15
|
+
{
|
|
16
|
+
type: 'row',
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: 'role', type: 'select', required: true, options: [...system.ladder, ...system.addOns].map((r) => ({ label: r.label, value: r.value })), admin: { width: '50%' } },
|
|
19
|
+
{ name: 'label', type: 'text', required: true, admin: { width: '50%' } },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
const next = {
|
|
25
|
+
...config,
|
|
26
|
+
collections: (config.collections ?? []).map((c) => (tenants && c.slug === tenants ? { ...c, fields: [...c.fields, aliases] } : c)),
|
|
27
|
+
custom: { ...config.custom, roles: system },
|
|
28
|
+
};
|
|
29
|
+
return next;
|
|
30
|
+
};
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Access } from 'payload';
|
|
2
|
+
/** One role: the stored value and its default label. */
|
|
3
|
+
export type RoleDef = {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
};
|
|
8
|
+
export type RolesConfig = {
|
|
9
|
+
/** Per-site roles, lowest first. Each includes every role below it (Curator can do what Author can). */
|
|
10
|
+
ladder: RoleDef[];
|
|
11
|
+
/** Per-site roles outside the ladder (e.g. "Lab partner"): they grant only what you check for them. */
|
|
12
|
+
addOns?: RoleDef[];
|
|
13
|
+
/** Platform-wide roles (e.g. "Maintainer"), for every site. */
|
|
14
|
+
platform?: RoleDef[];
|
|
15
|
+
/** Where roles live on the user. Defaults match @payloadcms/plugin-multi-tenant. */
|
|
16
|
+
fields?: {
|
|
17
|
+
tenants?: string;
|
|
18
|
+
tenant?: string;
|
|
19
|
+
roles?: string;
|
|
20
|
+
platform?: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** Any user object (e.g. Payload's generated `User`); read defensively. */
|
|
24
|
+
type AnyUser = unknown;
|
|
25
|
+
/** Per-site display names, e.g. `[{ role: 'curator', label: 'Editor' }]` (stored on the site by the plugin). */
|
|
26
|
+
export type RoleAliases = {
|
|
27
|
+
role: string;
|
|
28
|
+
label: string;
|
|
29
|
+
}[] | null | undefined;
|
|
30
|
+
export type RoleSystem = ReturnType<typeof defineRoles>;
|
|
31
|
+
/**
|
|
32
|
+
* A role system: a ranked ladder of per-site roles, add-on roles and platform
|
|
33
|
+
* roles, with helpers that answer "may this user do X on this site?" and
|
|
34
|
+
* Payload access functions built from them.
|
|
35
|
+
*/
|
|
36
|
+
export declare function defineRoles(config: RolesConfig): {
|
|
37
|
+
ladder: RoleDef[];
|
|
38
|
+
addOns: RoleDef[];
|
|
39
|
+
platform: RoleDef[];
|
|
40
|
+
fields: {
|
|
41
|
+
tenants: string;
|
|
42
|
+
tenant: string;
|
|
43
|
+
roles: string;
|
|
44
|
+
platform: string;
|
|
45
|
+
};
|
|
46
|
+
/** Every per-site role value (ladder and add-ons). */
|
|
47
|
+
values: string[];
|
|
48
|
+
rank: (role: string) => number;
|
|
49
|
+
atLeast: (role: string) => string[];
|
|
50
|
+
isPlatform: (user: AnyUser, role?: string) => boolean;
|
|
51
|
+
sites: (user: AnyUser, role: string | readonly string[]) => string[];
|
|
52
|
+
has: (user: AnyUser, site: unknown, role: string, { platformRole }?: {
|
|
53
|
+
platformRole?: string;
|
|
54
|
+
}) => boolean;
|
|
55
|
+
highest: (user: AnyUser, site: unknown) => string | null;
|
|
56
|
+
label: (role: string, aliases?: RoleAliases) => string;
|
|
57
|
+
options: (aliases?: RoleAliases) => {
|
|
58
|
+
label: string;
|
|
59
|
+
value: string;
|
|
60
|
+
}[];
|
|
61
|
+
platformOptions: () => {
|
|
62
|
+
label: string;
|
|
63
|
+
value: string;
|
|
64
|
+
}[];
|
|
65
|
+
access: {
|
|
66
|
+
atLeast: (role: string, { platformRole, field }?: {
|
|
67
|
+
platformRole?: string;
|
|
68
|
+
field?: string;
|
|
69
|
+
}) => Access;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export {};
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const idOf = (v) => (v && typeof v === 'object' ? v.id != null ? String(v.id) : null : v != null && v !== '' ? String(v) : null);
|
|
2
|
+
/**
|
|
3
|
+
* A role system: a ranked ladder of per-site roles, add-on roles and platform
|
|
4
|
+
* roles, with helpers that answer "may this user do X on this site?" and
|
|
5
|
+
* Payload access functions built from them.
|
|
6
|
+
*/
|
|
7
|
+
export function defineRoles(config) {
|
|
8
|
+
const ladder = config.ladder;
|
|
9
|
+
const addOns = config.addOns ?? [];
|
|
10
|
+
const platform = config.platform ?? [];
|
|
11
|
+
if (!ladder.length)
|
|
12
|
+
throw new Error('payload-plugin-roles: the ladder needs at least one role');
|
|
13
|
+
const values = [...ladder, ...addOns].map((r) => r.value);
|
|
14
|
+
if (new Set(values).size !== values.length)
|
|
15
|
+
throw new Error('payload-plugin-roles: role values must be unique');
|
|
16
|
+
const f = { tenants: 'tenants', tenant: 'tenant', roles: 'roles', platform: 'platformRoles', ...config.fields };
|
|
17
|
+
const rank = (role) => ladder.findIndex((r) => r.value === role);
|
|
18
|
+
/** Ladder roles at or above `role`; an add-on role is only itself. */
|
|
19
|
+
const atLeast = (role) => {
|
|
20
|
+
const i = rank(role);
|
|
21
|
+
if (i >= 0)
|
|
22
|
+
return ladder.slice(i).map((r) => r.value);
|
|
23
|
+
if (addOns.some((r) => r.value === role))
|
|
24
|
+
return [role];
|
|
25
|
+
throw new Error(`payload-plugin-roles: unknown role "${role}"`);
|
|
26
|
+
};
|
|
27
|
+
const field = (user, name) => (user && typeof user === 'object' ? user[name] : undefined);
|
|
28
|
+
const rows = (user) => { const v = field(user, f.tenants); return Array.isArray(v) ? v : []; };
|
|
29
|
+
const platformRoles = (user) => { const v = field(user, f.platform); return Array.isArray(v) ? v : []; };
|
|
30
|
+
/** A row's roles. Anything but an array (e.g. a string) reads as no roles: unclear data grants nothing. */
|
|
31
|
+
const rowRoles = (row) => {
|
|
32
|
+
const v = row && typeof row === 'object' ? row[f.roles] : undefined;
|
|
33
|
+
return Array.isArray(v) ? v.filter((r) => typeof r === 'string') : [];
|
|
34
|
+
};
|
|
35
|
+
/** Has this platform role (any platform role when omitted). */
|
|
36
|
+
const isPlatform = (user, role) => (role ? platformRoles(user).includes(role) : platformRoles(user).length > 0);
|
|
37
|
+
/** Sites where the user holds `role` or higher (or exactly one of a list of roles). */
|
|
38
|
+
const sites = (user, role) => {
|
|
39
|
+
const wanted = typeof role === 'string' ? atLeast(role) : role;
|
|
40
|
+
return rows(user)
|
|
41
|
+
.filter((row) => rowRoles(row).some((r) => wanted.includes(r)))
|
|
42
|
+
.map((row) => idOf(row[f.tenant]))
|
|
43
|
+
.filter((id) => Boolean(id));
|
|
44
|
+
};
|
|
45
|
+
/** Whether the user holds `role` or higher on this site. `platformRole` holders pass everywhere. */
|
|
46
|
+
const has = (user, site, role, { platformRole } = {}) => (platformRole ? isPlatform(user, platformRole) : false) || (Boolean(idOf(site)) && sites(user, role).includes(idOf(site)));
|
|
47
|
+
/** The user's highest ladder role on a site (null if none). No site, or a row without one, never matches. */
|
|
48
|
+
const highest = (user, site) => {
|
|
49
|
+
const id = idOf(site);
|
|
50
|
+
if (!id)
|
|
51
|
+
return null;
|
|
52
|
+
const row = rows(user).find((r) => r && typeof r === 'object' && idOf(r[f.tenant]) === id);
|
|
53
|
+
const held = rowRoles(row).map(rank).filter((i) => i >= 0);
|
|
54
|
+
return held.length ? ladder[Math.max(...held)].value : null;
|
|
55
|
+
};
|
|
56
|
+
const label = (role, aliases) => aliases?.find((a) => a.role === role)?.label || [...ladder, ...addOns, ...platform].find((r) => r.value === role)?.label || role;
|
|
57
|
+
/** Select options for per-site roles (ladder highest first, then add-ons), with optional aliases. */
|
|
58
|
+
const options = (aliases) => [...[...ladder].reverse(), ...addOns].map((r) => ({ label: label(r.value, aliases), value: r.value }));
|
|
59
|
+
const platformOptions = () => platform.map((r) => ({ label: r.label, value: r.value }));
|
|
60
|
+
/**
|
|
61
|
+
* Access: platform role holders see everything; others only records of sites
|
|
62
|
+
* where they hold `role` or higher (a `tenant in [...]` constraint).
|
|
63
|
+
*/
|
|
64
|
+
const accessAtLeast = (role, { platformRole, field = f.tenant } = {}) => ({ req }) => {
|
|
65
|
+
if (platformRole && isPlatform(req.user, platformRole))
|
|
66
|
+
return true;
|
|
67
|
+
const ids = sites(req.user, role);
|
|
68
|
+
return ids.length ? { [field]: { in: ids } } : false;
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
ladder,
|
|
72
|
+
addOns,
|
|
73
|
+
platform,
|
|
74
|
+
fields: f,
|
|
75
|
+
/** Every per-site role value (ladder and add-ons). */
|
|
76
|
+
values,
|
|
77
|
+
rank,
|
|
78
|
+
atLeast,
|
|
79
|
+
isPlatform,
|
|
80
|
+
sites,
|
|
81
|
+
has,
|
|
82
|
+
highest,
|
|
83
|
+
label,
|
|
84
|
+
options,
|
|
85
|
+
platformOptions,
|
|
86
|
+
access: { atLeast: accessAtLeast },
|
|
87
|
+
};
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@extantape/payload-plugin-roles",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Roles for Payload: per-site role ladders (Member, Author, Curator, Administrator) for multi-tenant sites, plus add-on and platform roles, per-site role names and access helpers.",
|
|
5
|
+
"homepage": "https://github.com/ExtantApe/payload-plugin-roles#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/ExtantApe/payload-plugin-roles/issues"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"payload",
|
|
11
|
+
"payload-plugin",
|
|
12
|
+
"payloadcms",
|
|
13
|
+
"roles",
|
|
14
|
+
"rbac",
|
|
15
|
+
"multi-tenant",
|
|
16
|
+
"access-control",
|
|
17
|
+
"permissions"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/ExtantApe/payload-plugin-roles.git"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.9"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"payload": "^3.80.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^24.0.0",
|
|
53
|
+
"payload": "3.92.1",
|
|
54
|
+
"typescript": "^5.9.0",
|
|
55
|
+
"vitest": "^5.1.0"
|
|
56
|
+
}
|
|
57
|
+
}
|