Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ DATABASE_URL='mongodb://'
PAYLOAD_SECRET= # openssl rand -hex 32

NEXT_PUBLIC_SERVER_URL=http://localhost:3000
DRAFT_SECRET_TOKEN= # openssl rand -hex 32

SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com
SMTP_USER=
Expand Down
3 changes: 2 additions & 1 deletion src/access/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ import { USER_ROLES } from '@/constants/userRoles'
type isAuthenticated = (args: AccessArgs<User>) => boolean

export const admin: isAuthenticated = ({ req: { user } }) => {
return Boolean(user && user.role.includes(USER_ROLES.ADMIN))
if (!user || !('role' in user) || !user.role) return false
return Boolean(user.role.includes(USER_ROLES.ADMIN))
}
10 changes: 6 additions & 4 deletions src/access/adminOrSelf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { Access } from 'payload'

export const adminOrSelf: Access = ({ req: { user } }) => {
if (!user) return false
return (
user.role.includes(USER_ROLES.ADMIN) ||
user.role.includes(USER_ROLES.SUPER_ADMIN) || { id: { equals: user.id } }
)
if ('role' in user && user.role) {
if (user.role.includes(USER_ROLES.ADMIN) || user.role.includes(USER_ROLES.SUPER_ADMIN)) {
return true
}
}
return { id: { equals: user.id } }
}
30 changes: 29 additions & 1 deletion src/access/anyone.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ASPactores Do you think we should add some sort of authorizer here to check the tokens?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Provide a sample scenario or model where this access pattern will be used for.
  • Provide tests for this collection access function. Just provide screenshots that it works for unauthenticated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a needed change for the other tickets, then we can move this file change to a separate PR, or move the change for the endpoints/durianpy-website-types in another separate PR

Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
import type { Access } from 'payload'

export const anyone: Access = () => true
export const anyone: Access = ({ req: { user, headers, query, routeParams, pathname } }) => {
if (user) {
return true
}

const authorization = headers.get('authorization')
if (
authorization &&
process.env.DRAFT_SECRET_TOKEN &&
authorization === `Bearer ${process.env.DRAFT_SECRET_TOKEN}`
) {
return true
}

const isGlobalRequest =
typeof routeParams?.global === 'string' ||
(typeof pathname === 'string' && pathname.includes('/api/globals/'))

// Global reads should return boolean access, not a where clause.
if (isGlobalRequest) {
const draftParam = query?.draft
const isDraftRequested = draftParam === true || draftParam === 'true'
return !isDraftRequested
}

return {
or: [{ _status: { equals: 'published' } }, { _status: { exists: false } }],
}
}
3 changes: 2 additions & 1 deletion src/access/checkResourceAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export function checkResourceAccess(

if (slugType === 'group') {
const groupItems = getSidebarGroupItems(assignedSlug as SidebarGroupSlug)
isApplicable = groupItems.includes(resourceSlug as CollectionSlug)
isApplicable =
groupItems.includes(resourceSlug as CollectionSlug) || assignedSlug === resourceSlug
} else if (slugType === 'collection') {
isApplicable = assignedSlug === resourceSlug
}
Expand Down
3 changes: 2 additions & 1 deletion src/access/superAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import type { AccessArgs } from 'payload'
import type { User } from '@/payload-types'

export const superAdmin = ({ req: { user } }: AccessArgs<User>) => {
return Boolean(user && user.role.includes('super-admin'))
if (!user || !('role' in user) || !user.role) return false
return Boolean(user.role.includes('super-admin'))
}
21 changes: 3 additions & 18 deletions src/collections/ServiceAccounts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { anyAdmin } from '@/access/anyAdmin'
import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles'
import { USER_ROLES } from '@/constants/userRoles'
import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections'
import { adminOrSelf } from '@/access/adminOrSelf'
import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup'
Expand All @@ -21,10 +21,10 @@ export const ServiceAccounts: CollectionConfig = {
update: adminOrSelf,
},
admin: {
defaultColumns: ['name', 'email', 'createdAt'],
defaultColumns: ['name', 'createdAt'],
useAsTitle: 'name',
hidden({ user }) {
if (!user) return true
if (!user || !user.role) return true
return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN)
},
group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN),
Expand All @@ -40,21 +40,6 @@ export const ServiceAccounts: CollectionConfig = {
required: true,
label: 'Service Account Name',
},
{
name: 'role',
type: 'select',
required: true,
saveToJWT: true,
hasMany: true,
access: {
create: ({ req }) => anyAdmin({ req }),
update: ({ req }) => anyAdmin({ req }),
},
options: Object.values(USER_ROLES).map((value) => ({
value,
label: USER_ROLE_LABELS[value],
})),
},
{
name: 'permissions',
type: 'array',
Expand Down
5 changes: 3 additions & 2 deletions src/collections/Users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export const Users: CollectionConfig = {
},
access: {
admin: ({ req: { user } }) => {
if (!user || !('role' in user) || !user.role) return false
const allowedRoles = Object.values(USER_ROLES)
return Boolean(user && user.role.some((role) => allowedRoles.includes(role)))
return Boolean(user.role.some((r) => allowedRoles.includes(r)))
},
create: anyAdmin,
delete: anyAdmin,
Expand All @@ -48,7 +49,7 @@ export const Users: CollectionConfig = {
defaultColumns: ['email'],
useAsTitle: 'email',
hidden({ user }) {
if (!user) return true
if (!user || !('role' in user) || !user.role) return true
return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN)
},
group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN),
Expand Down
167 changes: 167 additions & 0 deletions src/endpoints/durianpy-website-types/index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's revisit this for endpoint once all models for the durianpy website CMS integration is done, so we can check if all types are generated properly

Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import fs from 'fs'
import path from 'path'
import { checkResourceAccess } from '@/access/checkResourceAccess'
import { SIDEBAR_GROUPS, getSidebarGroupItems } from '@/constants/sidebarGroup'

import { APIError, type Endpoint } from 'payload'

const GROUP_SLUG = SIDEBAR_GROUPS.DURIANPY_WEBSITE

function getCommentStartBefore(content: string, index: number): number {
const commentEnd = content.lastIndexOf('*/', index)
if (commentEnd === -1) return index

const commentStart = content.lastIndexOf('/**', commentEnd)
if (commentStart === -1) return index

const between = content.slice(commentEnd + 2, index)
return /^\s*$/.test(between) ? commentStart : index
}

function extractInterfaceBlock(content: string, interfaceName: string): string | null {
const declaration = `export interface ${interfaceName}`
const declarationIndex = content.indexOf(declaration)

if (declarationIndex === -1) return null

const startIndex = getCommentStartBefore(content, declarationIndex)
const braceStart = content.indexOf('{', declarationIndex)

if (braceStart === -1) return null

let braceDepth = 0
for (let i = braceStart; i < content.length; i += 1) {
const char = content[i]

if (char === '{') braceDepth += 1
if (char === '}') braceDepth -= 1

if (braceDepth === 0) {
return content.slice(startIndex, i + 1)
}
}

return null
}

function getTypeMapFromConfig(typesFileContent: string): Map<string, string> {
const map = new Map<string, string>()

const extractEntriesFromBlock = (blockMatch: RegExpMatchArray | null) => {
if (!blockMatch) return
const blockContent = blockMatch[1]
const entryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm
let entryMatch: RegExpExecArray | null
while ((entryMatch = entryRegex.exec(blockContent)) !== null) {
const slug = entryMatch[1] ?? entryMatch[2]
const typeName = entryMatch[3]
if (slug && typeName) {
map.set(slug, typeName)
}
}
}

extractEntriesFromBlock(typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/))
extractEntriesFromBlock(typesFileContent.match(/globals:\s*\{([\s\S]*?)\n\s*\};/))

return map
}

export const durianpyWebsiteTypesEndpoint: Endpoint = {
method: 'get',
path: '/durianpy-website-types',
handler: async (req) => {
if (!req.user && req.payload) {
const { user } = await req.payload.auth({ headers: req.headers })
req.user = user
}

if (!req.user) {
throw new APIError('Unauthorized', 401)
}

const hasAccess = checkResourceAccess({ req }, GROUP_SLUG, 'read')
if (!hasAccess) {
throw new APIError('Forbidden', 403)
}

try {
const fullTypes = fs.readFileSync(
path.resolve(process.cwd(), 'src/payload-types.ts'),
'utf-8',
)

const groupResourceSlugs = [...getSidebarGroupItems(GROUP_SLUG)].sort((a, b) =>
a.localeCompare(b),
)

const resourceTypeMap = getTypeMapFromConfig(fullTypes)

const missingTypeMappings: string[] = []
const selectedTypeNames = groupResourceSlugs
.map((slug) => {
const typeName = resourceTypeMap.get(slug)
if (!typeName) missingTypeMappings.push(slug)
return typeName
})
.filter((name): name is string => Boolean(name))

if (missingTypeMappings.length > 0) {
return new Response(
JSON.stringify({
error: 'Missing resource to type mappings in payload-types.ts',
group: GROUP_SLUG,
missingResourceSlugs: missingTypeMappings,
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}

const missingInterfaceBlocks: string[] = []
const extractedInterfaces = selectedTypeNames
.map((typeName) => {
const block = extractInterfaceBlock(fullTypes, typeName)
if (!block) missingInterfaceBlocks.push(typeName)
return block
})
.filter((block): block is string => Boolean(block))

if (missingInterfaceBlocks.length > 0) {
return new Response(
JSON.stringify({
error: 'Could not extract one or more interfaces from payload-types.ts',
group: GROUP_SLUG,
missingInterfaces: missingInterfaceBlocks,
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}

const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Resources: ${groupResourceSlugs.join(', ')}`
const payload = `${typeSyncHeader}\n\n${extractedInterfaces.join('\n\n')}`

return new Response(payload, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
})
} catch (error) {
if (error instanceof APIError) {
throw error
}
throw new APIError(
error instanceof Error
? error.message
: 'Failed to generate durianpy-website type sync payload',
500,
)
}
},
}
2 changes: 0 additions & 2 deletions src/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,6 @@ export interface Sample {
export interface ServiceAccount {
id: string;
name: string;
role: ('super-admin' | 'admin' | 'writer' | 'reader')[];
permissions?:
| {
resource:
Expand Down Expand Up @@ -1035,7 +1034,6 @@ export interface SampleSelect<T extends boolean = true> {
*/
export interface ServiceAccountsSelect<T extends boolean = true> {
name?: T;
role?: T;
permissions?:
| T
| {
Expand Down
2 changes: 2 additions & 0 deletions src/payload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Sponsors } from './collections/durianpy-website/Sponsors'
import { Carousel } from './globals/durianpy-website/Carousel'
import { SIGs } from './collections/durianpy-website/SIGs'
import { CodeOfConduct } from './globals/durianpy-website/CodeOfConduct'
import { durianpyWebsiteTypesEndpoint } from './endpoints/durianpy-website-types'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
Expand Down Expand Up @@ -103,6 +104,7 @@ export default buildConfig({
}),
}),
collections: [Media, Categories, Users, Sample, ServiceAccounts, Events, Sponsors, SIGs],
endpoints: [durianpyWebsiteTypesEndpoint],
globals: [HomepageConfig, CTASection, StatisticsConfig, Carousel, CodeOfConduct],
cors: [getServerSideOrigin()].filter(Boolean).map((url) => {
try {
Expand Down
17 changes: 14 additions & 3 deletions src/seed/admin/collections/ServiceAccounts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Payload, PayloadRequest } from 'payload'
import { COLLECTIONS } from '@/constants/collections'
import { USER_ROLES } from '@/constants/userRoles'
import { PERMISSIONS } from '@/constants/permissions'
import { SIDEBAR_GROUPS } from '@/constants/sidebarGroup'

export async function seedServiceAccounts({
payload,
Expand All @@ -12,12 +13,22 @@ export async function seedServiceAccounts({
const serviceAccounts = [
{
name: 'GitHub Actions Bot',
role: [USER_ROLES.SUPER_ADMIN],
permissions: [
{
resource: SIDEBAR_GROUPS.ADMIN,
accessLevel: PERMISSIONS.FULL_ACCESS,
},
],
enableAPIKey: true,
},
{
name: 'Integration Service',
role: [USER_ROLES.ADMIN],
permissions: [
{
resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE,
accessLevel: PERMISSIONS.READ,
},
],
enableAPIKey: true,
},
]
Expand Down
Loading
Loading