init commit for iPAL-kit plugin

This commit is contained in:
2026-07-18 20:30:23 +02:00
parent 10638127a9
commit 5733a9c8bf
119 changed files with 6892 additions and 411 deletions
+42
View File
@@ -0,0 +1,42 @@
import type { Access, FieldAccess } from 'payload'
import type { Role } from './types.js'
import { hasMinimumRole, isAdmin } from './predicates.js'
/**
* Collection-level access (returns boolean | Where).
* Use in collection `access.read/create/update/delete`.
*/
export const adminOnly: Access = ({ req: { user } }) => isAdmin(user)
export const adminOrEditor: Access = ({ req: { user } }) => hasMinimumRole(user, 'editor')
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
/** Requires at least the given role. */
export const requireRole =
(minimum: Role): Access =>
({ req: { user } }) =>
hasMinimumRole(user, minimum)
/** Admins see all; others are constrained to their own document. */
export const adminOrSelf: Access = ({ req: { user } }) => {
if (isAdmin(user)) {return true}
if (!user) {return false}
return { id: { equals: user.id } }
}
/**
* Field-level access (returns boolean only — no Where support).
* Use in field `access.read/update`.
*/
export const adminOnlyField: FieldAccess = ({ req: { user } }) => isAdmin(user)
export const adminOrEditorField: FieldAccess = ({ req: { user } }) => hasMinimumRole(user, 'editor')
/** Requires at least the given role, for field-level access. */
export const requireRoleField =
(minimum: Role): FieldAccess =>
({ req: { user } }) =>
hasMinimumRole(user, minimum)
+15
View File
@@ -0,0 +1,15 @@
export {
adminOnly,
adminOnlyField,
adminOrEditor,
adminOrEditorField,
adminOrSelf,
authenticated,
requireRole,
requireRoleField,
} from './access.js'
export { injectRoles } from './injectRoles.js'
export { hasMinimumRole, isAdmin, isEditor } from './predicates.js'
export { buildRolesField } from './rolesField.js'
export type { AccessOption, Role } from './types.js'
export { ROLE_HIERARCHY } from './types.js'
+29
View File
@@ -0,0 +1,29 @@
import type { Config } from 'payload'
import type { AccessOption } from './types.js'
import { buildRolesField } from './rolesField.js'
/**
* Injects the fixed `roles` field into the client's auth collection.
*
* The plugin owns the role definition; the client owns the collection. This
* finds the collection by slug and appends the field. We control the whole
* stack, so no conflict handling is needed — the field is simply added.
*/
export function injectRoles(config: Config, access: AccessOption): Config {
const rolesField = buildRolesField(access.defaultRole)
return {
...config,
collections: (config.collections ?? []).map((collection) => {
if (collection.slug !== access.authCollection) {
return collection
}
return {
...collection,
fields: [...collection.fields, rolesField],
}
}),
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Role } from './types.js'
import { ROLE_HIERARCHY } from './types.js'
/**
* The plugin can't know the client's generated User type, and Payload types
* `req.user` loosely (UntypedUser | null). Predicates therefore accept an
* unknown-ish user and read `roles` defensively — no assumptions about shape
* beyond an optional roles array.
*/
type MaybeUser = { roles?: null | Role[] } | null | Record<string, unknown> | undefined
/** Safely extracts the roles array from a loosely-typed user. */
function getRoles(user: MaybeUser): Role[] {
if (!user || typeof user !== 'object') {return []}
const roles = (user as { roles?: unknown }).roles
if (!Array.isArray(roles)) {return []}
return roles.filter((role): role is Role => ROLE_HIERARCHY.includes(role as Role))
}
/** Highest-privilege role index the user holds, or -1 if none. */
function highestRoleIndex(user: MaybeUser): number {
const roles = getRoles(user)
if (!roles.length) {return -1}
return Math.max(...roles.map((role) => ROLE_HIERARCHY.indexOf(role)))
}
/**
* True if the user holds at least the given role in the hierarchy.
* admin satisfies 'editor' and 'user'; editor satisfies 'user'.
*/
export function hasMinimumRole(user: MaybeUser, minimum: Role): boolean {
return highestRoleIndex(user) >= ROLE_HIERARCHY.indexOf(minimum)
}
/** True if the user is an admin. */
export function isAdmin(user: MaybeUser): boolean {
return hasMinimumRole(user, 'admin')
}
/** True if the user is an editor or higher (editor, admin). */
export function isEditor(user: MaybeUser): boolean {
return hasMinimumRole(user, 'editor')
}
+34
View File
@@ -0,0 +1,34 @@
import type { Field } from 'payload'
import type { Role } from './types.js'
import { isAdmin } from './predicates.js'
import { ROLE_HIERARCHY } from './types.js'
/**
* Builds the fixed `roles` field the plugin injects into the auth collection.
*
* Saved to the JWT so role checks avoid a database lookup. Only admins can
* change roles, preventing privilege escalation by lower-privilege users.
*/
export function buildRolesField(defaultRole: Role = 'user'): Field {
return {
name: 'roles',
type: 'select',
access: {
// Only admins may assign or change roles
update: ({ req: { user } }) => isAdmin(user),
},
admin: {
description: 'Role hierarchy: admin > editor > user.',
},
defaultValue: [defaultRole],
hasMany: true,
options: ROLE_HIERARCHY.map((role) => ({
label: role.charAt(0).toUpperCase() + role.slice(1),
value: role,
})),
required: true,
saveToJWT: true,
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Role hierarchy, lowest to highest privilege.
* A higher role satisfies any requirement met by a lower one.
*/
export const ROLE_HIERARCHY = ['user', 'editor', 'admin'] as const
export type Role = (typeof ROLE_HIERARCHY)[number]
/**
* Access-control options.
*
* The plugin injects a fixed `roles` field into the client's auth collection
* — the collection itself belongs to the client (create-payload-app), the
* role definition belongs to the plugin.
*/
export type AccessOption = {
/** Slug of the client's auth collection, e.g. 'users'. */
authCollection: string
/** Role assigned to new users. Defaults to 'user'. */
defaultRole?: Role
}