Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../access/adminOnly'
|
||||
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateFooter } from './hooks/revalidateFooter'
|
||||
|
||||
@@ -43,6 +44,6 @@ export const Footer: GlobalConfig = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateFooter],
|
||||
afterChange: [revalidateFooter, auditGlobalChange('footer')],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../access/adminOnly'
|
||||
import { auditGlobalChange } from '../collections/Audit/hooks/auditHooks'
|
||||
import { link } from '@/fields/link'
|
||||
import { revalidateHeader } from './hooks/revalidateHeader'
|
||||
|
||||
@@ -29,6 +30,6 @@ export const Header: GlobalConfig = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidateHeader],
|
||||
afterChange: [revalidateHeader, auditGlobalChange('header')],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
const props = register(name, { required: required })
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxUi
|
||||
defaultChecked={defaultValue}
|
||||
id={name}
|
||||
{...props}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue(props.name, checked)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client'
|
||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import RichText from '@/components/RichText'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { fields } from './fields'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: DefaultTypedEditorState
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
{
|
||||
id?: string
|
||||
} & FormBlockType
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: formFromProps.fields,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
register,
|
||||
} = formMethods
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
|
||||
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: FormFieldBlock[]) => {
|
||||
let loadingTimerID: ReturnType<typeof setTimeout>
|
||||
const submitForm = async () => {
|
||||
setError(undefined)
|
||||
|
||||
const dataToSend = Object.entries(data).map(([name, value]) => ({
|
||||
field: name,
|
||||
value,
|
||||
}))
|
||||
|
||||
// delay loading indicator by 1s
|
||||
loadingTimerID = setTimeout(() => {
|
||||
setIsLoading(true)
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const req = await fetch(`${getClientSideURL()}/api/form-submissions`, {
|
||||
body: JSON.stringify({
|
||||
form: formID,
|
||||
submissionData: dataToSend,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const res = (await req.json()) as any
|
||||
|
||||
clearTimeout(loadingTimerID)
|
||||
|
||||
if (req.status >= 400) {
|
||||
setIsLoading(false)
|
||||
|
||||
setError({
|
||||
message: res.errors?.[0]?.message || 'Internal Server Error',
|
||||
status: res.status,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setHasSubmitted(true)
|
||||
|
||||
if (confirmationType === 'redirect' && redirect) {
|
||||
const { url } = redirect
|
||||
|
||||
const redirectUrl = url
|
||||
|
||||
if (redirectUrl) router.push(redirectUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
setIsLoading(false)
|
||||
setError({
|
||||
message: 'Something went wrong.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void submitForm()
|
||||
},
|
||||
[router, formID, redirect, confirmationType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="container lg:max-w-[48rem]">
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
||||
)}
|
||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
||||
<FormProvider {...formMethods}>
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText data={confirmationMessage as DefaultTypedEditorState} />
|
||||
)}
|
||||
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
|
||||
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
|
||||
{!hasSubmitted && (
|
||||
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 last:mb-0">
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields?.map((field, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
||||
if (Field) {
|
||||
return (
|
||||
<div className="mb-6 last:mb-0" key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button form={formID} type="submit" variant="default">
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label className="" htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countryOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
export const countryOptions = [
|
||||
{
|
||||
label: 'Afghanistan',
|
||||
value: 'AF',
|
||||
},
|
||||
{
|
||||
label: 'Åland Islands',
|
||||
value: 'AX',
|
||||
},
|
||||
{
|
||||
label: 'Albania',
|
||||
value: 'AL',
|
||||
},
|
||||
{
|
||||
label: 'Algeria',
|
||||
value: 'DZ',
|
||||
},
|
||||
{
|
||||
label: 'American Samoa',
|
||||
value: 'AS',
|
||||
},
|
||||
{
|
||||
label: 'Andorra',
|
||||
value: 'AD',
|
||||
},
|
||||
{
|
||||
label: 'Angola',
|
||||
value: 'AO',
|
||||
},
|
||||
{
|
||||
label: 'Anguilla',
|
||||
value: 'AI',
|
||||
},
|
||||
{
|
||||
label: 'Antarctica',
|
||||
value: 'AQ',
|
||||
},
|
||||
{
|
||||
label: 'Antigua and Barbuda',
|
||||
value: 'AG',
|
||||
},
|
||||
{
|
||||
label: 'Argentina',
|
||||
value: 'AR',
|
||||
},
|
||||
{
|
||||
label: 'Armenia',
|
||||
value: 'AM',
|
||||
},
|
||||
{
|
||||
label: 'Aruba',
|
||||
value: 'AW',
|
||||
},
|
||||
{
|
||||
label: 'Australia',
|
||||
value: 'AU',
|
||||
},
|
||||
{
|
||||
label: 'Austria',
|
||||
value: 'AT',
|
||||
},
|
||||
{
|
||||
label: 'Azerbaijan',
|
||||
value: 'AZ',
|
||||
},
|
||||
{
|
||||
label: 'Bahamas',
|
||||
value: 'BS',
|
||||
},
|
||||
{
|
||||
label: 'Bahrain',
|
||||
value: 'BH',
|
||||
},
|
||||
{
|
||||
label: 'Bangladesh',
|
||||
value: 'BD',
|
||||
},
|
||||
{
|
||||
label: 'Barbados',
|
||||
value: 'BB',
|
||||
},
|
||||
{
|
||||
label: 'Belarus',
|
||||
value: 'BY',
|
||||
},
|
||||
{
|
||||
label: 'Belgium',
|
||||
value: 'BE',
|
||||
},
|
||||
{
|
||||
label: 'Belize',
|
||||
value: 'BZ',
|
||||
},
|
||||
{
|
||||
label: 'Benin',
|
||||
value: 'BJ',
|
||||
},
|
||||
{
|
||||
label: 'Bermuda',
|
||||
value: 'BM',
|
||||
},
|
||||
{
|
||||
label: 'Bhutan',
|
||||
value: 'BT',
|
||||
},
|
||||
{
|
||||
label: 'Bolivia',
|
||||
value: 'BO',
|
||||
},
|
||||
{
|
||||
label: 'Bosnia and Herzegovina',
|
||||
value: 'BA',
|
||||
},
|
||||
{
|
||||
label: 'Botswana',
|
||||
value: 'BW',
|
||||
},
|
||||
{
|
||||
label: 'Bouvet Island',
|
||||
value: 'BV',
|
||||
},
|
||||
{
|
||||
label: 'Brazil',
|
||||
value: 'BR',
|
||||
},
|
||||
{
|
||||
label: 'British Indian Ocean Territory',
|
||||
value: 'IO',
|
||||
},
|
||||
{
|
||||
label: 'Brunei Darussalam',
|
||||
value: 'BN',
|
||||
},
|
||||
{
|
||||
label: 'Bulgaria',
|
||||
value: 'BG',
|
||||
},
|
||||
{
|
||||
label: 'Burkina Faso',
|
||||
value: 'BF',
|
||||
},
|
||||
{
|
||||
label: 'Burundi',
|
||||
value: 'BI',
|
||||
},
|
||||
{
|
||||
label: 'Cambodia',
|
||||
value: 'KH',
|
||||
},
|
||||
{
|
||||
label: 'Cameroon',
|
||||
value: 'CM',
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
value: 'CA',
|
||||
},
|
||||
{
|
||||
label: 'Cape Verde',
|
||||
value: 'CV',
|
||||
},
|
||||
{
|
||||
label: 'Cayman Islands',
|
||||
value: 'KY',
|
||||
},
|
||||
{
|
||||
label: 'Central African Republic',
|
||||
value: 'CF',
|
||||
},
|
||||
{
|
||||
label: 'Chad',
|
||||
value: 'TD',
|
||||
},
|
||||
{
|
||||
label: 'Chile',
|
||||
value: 'CL',
|
||||
},
|
||||
{
|
||||
label: 'China',
|
||||
value: 'CN',
|
||||
},
|
||||
{
|
||||
label: 'Christmas Island',
|
||||
value: 'CX',
|
||||
},
|
||||
{
|
||||
label: 'Cocos (Keeling) Islands',
|
||||
value: 'CC',
|
||||
},
|
||||
{
|
||||
label: 'Colombia',
|
||||
value: 'CO',
|
||||
},
|
||||
{
|
||||
label: 'Comoros',
|
||||
value: 'KM',
|
||||
},
|
||||
{
|
||||
label: 'Congo',
|
||||
value: 'CG',
|
||||
},
|
||||
{
|
||||
label: 'Congo, The Democratic Republic of the',
|
||||
value: 'CD',
|
||||
},
|
||||
{
|
||||
label: 'Cook Islands',
|
||||
value: 'CK',
|
||||
},
|
||||
{
|
||||
label: 'Costa Rica',
|
||||
value: 'CR',
|
||||
},
|
||||
{
|
||||
label: "Cote D'Ivoire",
|
||||
value: 'CI',
|
||||
},
|
||||
{
|
||||
label: 'Croatia',
|
||||
value: 'HR',
|
||||
},
|
||||
{
|
||||
label: 'Cuba',
|
||||
value: 'CU',
|
||||
},
|
||||
{
|
||||
label: 'Cyprus',
|
||||
value: 'CY',
|
||||
},
|
||||
{
|
||||
label: 'Czech Republic',
|
||||
value: 'CZ',
|
||||
},
|
||||
{
|
||||
label: 'Denmark',
|
||||
value: 'DK',
|
||||
},
|
||||
{
|
||||
label: 'Djibouti',
|
||||
value: 'DJ',
|
||||
},
|
||||
{
|
||||
label: 'Dominica',
|
||||
value: 'DM',
|
||||
},
|
||||
{
|
||||
label: 'Dominican Republic',
|
||||
value: 'DO',
|
||||
},
|
||||
{
|
||||
label: 'Ecuador',
|
||||
value: 'EC',
|
||||
},
|
||||
{
|
||||
label: 'Egypt',
|
||||
value: 'EG',
|
||||
},
|
||||
{
|
||||
label: 'El Salvador',
|
||||
value: 'SV',
|
||||
},
|
||||
{
|
||||
label: 'Equatorial Guinea',
|
||||
value: 'GQ',
|
||||
},
|
||||
{
|
||||
label: 'Eritrea',
|
||||
value: 'ER',
|
||||
},
|
||||
{
|
||||
label: 'Estonia',
|
||||
value: 'EE',
|
||||
},
|
||||
{
|
||||
label: 'Ethiopia',
|
||||
value: 'ET',
|
||||
},
|
||||
{
|
||||
label: 'Falkland Islands (Malvinas)',
|
||||
value: 'FK',
|
||||
},
|
||||
{
|
||||
label: 'Faroe Islands',
|
||||
value: 'FO',
|
||||
},
|
||||
{
|
||||
label: 'Fiji',
|
||||
value: 'FJ',
|
||||
},
|
||||
{
|
||||
label: 'Finland',
|
||||
value: 'FI',
|
||||
},
|
||||
{
|
||||
label: 'France',
|
||||
value: 'FR',
|
||||
},
|
||||
{
|
||||
label: 'French Guiana',
|
||||
value: 'GF',
|
||||
},
|
||||
{
|
||||
label: 'French Polynesia',
|
||||
value: 'PF',
|
||||
},
|
||||
{
|
||||
label: 'French Southern Territories',
|
||||
value: 'TF',
|
||||
},
|
||||
{
|
||||
label: 'Gabon',
|
||||
value: 'GA',
|
||||
},
|
||||
{
|
||||
label: 'Gambia',
|
||||
value: 'GM',
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'GE',
|
||||
},
|
||||
{
|
||||
label: 'Germany',
|
||||
value: 'DE',
|
||||
},
|
||||
{
|
||||
label: 'Ghana',
|
||||
value: 'GH',
|
||||
},
|
||||
{
|
||||
label: 'Gibraltar',
|
||||
value: 'GI',
|
||||
},
|
||||
{
|
||||
label: 'Greece',
|
||||
value: 'GR',
|
||||
},
|
||||
{
|
||||
label: 'Greenland',
|
||||
value: 'GL',
|
||||
},
|
||||
{
|
||||
label: 'Grenada',
|
||||
value: 'GD',
|
||||
},
|
||||
{
|
||||
label: 'Guadeloupe',
|
||||
value: 'GP',
|
||||
},
|
||||
{
|
||||
label: 'Guam',
|
||||
value: 'GU',
|
||||
},
|
||||
{
|
||||
label: 'Guatemala',
|
||||
value: 'GT',
|
||||
},
|
||||
{
|
||||
label: 'Guernsey',
|
||||
value: 'GG',
|
||||
},
|
||||
{
|
||||
label: 'Guinea',
|
||||
value: 'GN',
|
||||
},
|
||||
{
|
||||
label: 'Guinea-Bissau',
|
||||
value: 'GW',
|
||||
},
|
||||
{
|
||||
label: 'Guyana',
|
||||
value: 'GY',
|
||||
},
|
||||
{
|
||||
label: 'Haiti',
|
||||
value: 'HT',
|
||||
},
|
||||
{
|
||||
label: 'Heard Island and Mcdonald Islands',
|
||||
value: 'HM',
|
||||
},
|
||||
{
|
||||
label: 'Holy See (Vatican City State)',
|
||||
value: 'VA',
|
||||
},
|
||||
{
|
||||
label: 'Honduras',
|
||||
value: 'HN',
|
||||
},
|
||||
{
|
||||
label: 'Hong Kong',
|
||||
value: 'HK',
|
||||
},
|
||||
{
|
||||
label: 'Hungary',
|
||||
value: 'HU',
|
||||
},
|
||||
{
|
||||
label: 'Iceland',
|
||||
value: 'IS',
|
||||
},
|
||||
{
|
||||
label: 'India',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'Indonesia',
|
||||
value: 'ID',
|
||||
},
|
||||
{
|
||||
label: 'Iran, Islamic Republic Of',
|
||||
value: 'IR',
|
||||
},
|
||||
{
|
||||
label: 'Iraq',
|
||||
value: 'IQ',
|
||||
},
|
||||
{
|
||||
label: 'Ireland',
|
||||
value: 'IE',
|
||||
},
|
||||
{
|
||||
label: 'Isle of Man',
|
||||
value: 'IM',
|
||||
},
|
||||
{
|
||||
label: 'Israel',
|
||||
value: 'IL',
|
||||
},
|
||||
{
|
||||
label: 'Italy',
|
||||
value: 'IT',
|
||||
},
|
||||
{
|
||||
label: 'Jamaica',
|
||||
value: 'JM',
|
||||
},
|
||||
{
|
||||
label: 'Japan',
|
||||
value: 'JP',
|
||||
},
|
||||
{
|
||||
label: 'Jersey',
|
||||
value: 'JE',
|
||||
},
|
||||
{
|
||||
label: 'Jordan',
|
||||
value: 'JO',
|
||||
},
|
||||
{
|
||||
label: 'Kazakhstan',
|
||||
value: 'KZ',
|
||||
},
|
||||
{
|
||||
label: 'Kenya',
|
||||
value: 'KE',
|
||||
},
|
||||
{
|
||||
label: 'Kiribati',
|
||||
value: 'KI',
|
||||
},
|
||||
{
|
||||
label: "Democratic People's Republic of Korea",
|
||||
value: 'KP',
|
||||
},
|
||||
{
|
||||
label: 'Korea, Republic of',
|
||||
value: 'KR',
|
||||
},
|
||||
{
|
||||
label: 'Kosovo',
|
||||
value: 'XK',
|
||||
},
|
||||
{
|
||||
label: 'Kuwait',
|
||||
value: 'KW',
|
||||
},
|
||||
{
|
||||
label: 'Kyrgyzstan',
|
||||
value: 'KG',
|
||||
},
|
||||
{
|
||||
label: "Lao People's Democratic Republic",
|
||||
value: 'LA',
|
||||
},
|
||||
{
|
||||
label: 'Latvia',
|
||||
value: 'LV',
|
||||
},
|
||||
{
|
||||
label: 'Lebanon',
|
||||
value: 'LB',
|
||||
},
|
||||
{
|
||||
label: 'Lesotho',
|
||||
value: 'LS',
|
||||
},
|
||||
{
|
||||
label: 'Liberia',
|
||||
value: 'LR',
|
||||
},
|
||||
{
|
||||
label: 'Libyan Arab Jamahiriya',
|
||||
value: 'LY',
|
||||
},
|
||||
{
|
||||
label: 'Liechtenstein',
|
||||
value: 'LI',
|
||||
},
|
||||
{
|
||||
label: 'Lithuania',
|
||||
value: 'LT',
|
||||
},
|
||||
{
|
||||
label: 'Luxembourg',
|
||||
value: 'LU',
|
||||
},
|
||||
{
|
||||
label: 'Macao',
|
||||
value: 'MO',
|
||||
},
|
||||
{
|
||||
label: 'Macedonia, The Former Yugoslav Republic of',
|
||||
value: 'MK',
|
||||
},
|
||||
{
|
||||
label: 'Madagascar',
|
||||
value: 'MG',
|
||||
},
|
||||
{
|
||||
label: 'Malawi',
|
||||
value: 'MW',
|
||||
},
|
||||
{
|
||||
label: 'Malaysia',
|
||||
value: 'MY',
|
||||
},
|
||||
{
|
||||
label: 'Maldives',
|
||||
value: 'MV',
|
||||
},
|
||||
{
|
||||
label: 'Mali',
|
||||
value: 'ML',
|
||||
},
|
||||
{
|
||||
label: 'Malta',
|
||||
value: 'MT',
|
||||
},
|
||||
{
|
||||
label: 'Marshall Islands',
|
||||
value: 'MH',
|
||||
},
|
||||
{
|
||||
label: 'Martinique',
|
||||
value: 'MQ',
|
||||
},
|
||||
{
|
||||
label: 'Mauritania',
|
||||
value: 'MR',
|
||||
},
|
||||
{
|
||||
label: 'Mauritius',
|
||||
value: 'MU',
|
||||
},
|
||||
{
|
||||
label: 'Mayotte',
|
||||
value: 'YT',
|
||||
},
|
||||
{
|
||||
label: 'Mexico',
|
||||
value: 'MX',
|
||||
},
|
||||
{
|
||||
label: 'Micronesia, Federated States of',
|
||||
value: 'FM',
|
||||
},
|
||||
{
|
||||
label: 'Moldova, Republic of',
|
||||
value: 'MD',
|
||||
},
|
||||
{
|
||||
label: 'Monaco',
|
||||
value: 'MC',
|
||||
},
|
||||
{
|
||||
label: 'Mongolia',
|
||||
value: 'MN',
|
||||
},
|
||||
{
|
||||
label: 'Montenegro',
|
||||
value: 'ME',
|
||||
},
|
||||
{
|
||||
label: 'Montserrat',
|
||||
value: 'MS',
|
||||
},
|
||||
{
|
||||
label: 'Morocco',
|
||||
value: 'MA',
|
||||
},
|
||||
{
|
||||
label: 'Mozambique',
|
||||
value: 'MZ',
|
||||
},
|
||||
{
|
||||
label: 'Myanmar',
|
||||
value: 'MM',
|
||||
},
|
||||
{
|
||||
label: 'Namibia',
|
||||
value: 'NA',
|
||||
},
|
||||
{
|
||||
label: 'Nauru',
|
||||
value: 'NR',
|
||||
},
|
||||
{
|
||||
label: 'Nepal',
|
||||
value: 'NP',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands',
|
||||
value: 'NL',
|
||||
},
|
||||
{
|
||||
label: 'Netherlands Antilles',
|
||||
value: 'AN',
|
||||
},
|
||||
{
|
||||
label: 'New Caledonia',
|
||||
value: 'NC',
|
||||
},
|
||||
{
|
||||
label: 'New Zealand',
|
||||
value: 'NZ',
|
||||
},
|
||||
{
|
||||
label: 'Nicaragua',
|
||||
value: 'NI',
|
||||
},
|
||||
{
|
||||
label: 'Niger',
|
||||
value: 'NE',
|
||||
},
|
||||
{
|
||||
label: 'Nigeria',
|
||||
value: 'NG',
|
||||
},
|
||||
{
|
||||
label: 'Niue',
|
||||
value: 'NU',
|
||||
},
|
||||
{
|
||||
label: 'Norfolk Island',
|
||||
value: 'NF',
|
||||
},
|
||||
{
|
||||
label: 'Northern Mariana Islands',
|
||||
value: 'MP',
|
||||
},
|
||||
{
|
||||
label: 'Norway',
|
||||
value: 'NO',
|
||||
},
|
||||
{
|
||||
label: 'Oman',
|
||||
value: 'OM',
|
||||
},
|
||||
{
|
||||
label: 'Pakistan',
|
||||
value: 'PK',
|
||||
},
|
||||
{
|
||||
label: 'Palau',
|
||||
value: 'PW',
|
||||
},
|
||||
{
|
||||
label: 'Palestinian Territory, Occupied',
|
||||
value: 'PS',
|
||||
},
|
||||
{
|
||||
label: 'Panama',
|
||||
value: 'PA',
|
||||
},
|
||||
{
|
||||
label: 'Papua New Guinea',
|
||||
value: 'PG',
|
||||
},
|
||||
{
|
||||
label: 'Paraguay',
|
||||
value: 'PY',
|
||||
},
|
||||
{
|
||||
label: 'Peru',
|
||||
value: 'PE',
|
||||
},
|
||||
{
|
||||
label: 'Philippines',
|
||||
value: 'PH',
|
||||
},
|
||||
{
|
||||
label: 'Pitcairn',
|
||||
value: 'PN',
|
||||
},
|
||||
{
|
||||
label: 'Poland',
|
||||
value: 'PL',
|
||||
},
|
||||
{
|
||||
label: 'Portugal',
|
||||
value: 'PT',
|
||||
},
|
||||
{
|
||||
label: 'Puerto Rico',
|
||||
value: 'PR',
|
||||
},
|
||||
{
|
||||
label: 'Qatar',
|
||||
value: 'QA',
|
||||
},
|
||||
{
|
||||
label: 'Reunion',
|
||||
value: 'RE',
|
||||
},
|
||||
{
|
||||
label: 'Romania',
|
||||
value: 'RO',
|
||||
},
|
||||
{
|
||||
label: 'Russian Federation',
|
||||
value: 'RU',
|
||||
},
|
||||
{
|
||||
label: 'Rwanda',
|
||||
value: 'RW',
|
||||
},
|
||||
{
|
||||
label: 'Saint Helena',
|
||||
value: 'SH',
|
||||
},
|
||||
{
|
||||
label: 'Saint Kitts and Nevis',
|
||||
value: 'KN',
|
||||
},
|
||||
{
|
||||
label: 'Saint Lucia',
|
||||
value: 'LC',
|
||||
},
|
||||
{
|
||||
label: 'Saint Pierre and Miquelon',
|
||||
value: 'PM',
|
||||
},
|
||||
{
|
||||
label: 'Saint Vincent and the Grenadines',
|
||||
value: 'VC',
|
||||
},
|
||||
{
|
||||
label: 'Samoa',
|
||||
value: 'WS',
|
||||
},
|
||||
{
|
||||
label: 'San Marino',
|
||||
value: 'SM',
|
||||
},
|
||||
{
|
||||
label: 'Sao Tome and Principe',
|
||||
value: 'ST',
|
||||
},
|
||||
{
|
||||
label: 'Saudi Arabia',
|
||||
value: 'SA',
|
||||
},
|
||||
{
|
||||
label: 'Senegal',
|
||||
value: 'SN',
|
||||
},
|
||||
{
|
||||
label: 'Serbia',
|
||||
value: 'RS',
|
||||
},
|
||||
{
|
||||
label: 'Seychelles',
|
||||
value: 'SC',
|
||||
},
|
||||
{
|
||||
label: 'Sierra Leone',
|
||||
value: 'SL',
|
||||
},
|
||||
{
|
||||
label: 'Singapore',
|
||||
value: 'SG',
|
||||
},
|
||||
{
|
||||
label: 'Slovakia',
|
||||
value: 'SK',
|
||||
},
|
||||
{
|
||||
label: 'Slovenia',
|
||||
value: 'SI',
|
||||
},
|
||||
{
|
||||
label: 'Solomon Islands',
|
||||
value: 'SB',
|
||||
},
|
||||
{
|
||||
label: 'Somalia',
|
||||
value: 'SO',
|
||||
},
|
||||
{
|
||||
label: 'South Africa',
|
||||
value: 'ZA',
|
||||
},
|
||||
{
|
||||
label: 'South Georgia and the South Sandwich Islands',
|
||||
value: 'GS',
|
||||
},
|
||||
{
|
||||
label: 'Spain',
|
||||
value: 'ES',
|
||||
},
|
||||
{
|
||||
label: 'Sri Lanka',
|
||||
value: 'LK',
|
||||
},
|
||||
{
|
||||
label: 'Sudan',
|
||||
value: 'SD',
|
||||
},
|
||||
{
|
||||
label: 'Suriname',
|
||||
value: 'SR',
|
||||
},
|
||||
{
|
||||
label: 'Svalbard and Jan Mayen',
|
||||
value: 'SJ',
|
||||
},
|
||||
{
|
||||
label: 'Swaziland',
|
||||
value: 'SZ',
|
||||
},
|
||||
{
|
||||
label: 'Sweden',
|
||||
value: 'SE',
|
||||
},
|
||||
{
|
||||
label: 'Switzerland',
|
||||
value: 'CH',
|
||||
},
|
||||
{
|
||||
label: 'Syrian Arab Republic',
|
||||
value: 'SY',
|
||||
},
|
||||
{
|
||||
label: 'Taiwan',
|
||||
value: 'TW',
|
||||
},
|
||||
{
|
||||
label: 'Tajikistan',
|
||||
value: 'TJ',
|
||||
},
|
||||
{
|
||||
label: 'Tanzania, United Republic of',
|
||||
value: 'TZ',
|
||||
},
|
||||
{
|
||||
label: 'Thailand',
|
||||
value: 'TH',
|
||||
},
|
||||
{
|
||||
label: 'Timor-Leste',
|
||||
value: 'TL',
|
||||
},
|
||||
{
|
||||
label: 'Togo',
|
||||
value: 'TG',
|
||||
},
|
||||
{
|
||||
label: 'Tokelau',
|
||||
value: 'TK',
|
||||
},
|
||||
{
|
||||
label: 'Tonga',
|
||||
value: 'TO',
|
||||
},
|
||||
{
|
||||
label: 'Trinidad and Tobago',
|
||||
value: 'TT',
|
||||
},
|
||||
{
|
||||
label: 'Tunisia',
|
||||
value: 'TN',
|
||||
},
|
||||
{
|
||||
label: 'Turkey',
|
||||
value: 'TR',
|
||||
},
|
||||
{
|
||||
label: 'Turkmenistan',
|
||||
value: 'TM',
|
||||
},
|
||||
{
|
||||
label: 'Turks and Caicos Islands',
|
||||
value: 'TC',
|
||||
},
|
||||
{
|
||||
label: 'Tuvalu',
|
||||
value: 'TV',
|
||||
},
|
||||
{
|
||||
label: 'Uganda',
|
||||
value: 'UG',
|
||||
},
|
||||
{
|
||||
label: 'Ukraine',
|
||||
value: 'UA',
|
||||
},
|
||||
{
|
||||
label: 'United Arab Emirates',
|
||||
value: 'AE',
|
||||
},
|
||||
{
|
||||
label: 'United Kingdom',
|
||||
value: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'United States',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
label: 'United States Minor Outlying Islands',
|
||||
value: 'UM',
|
||||
},
|
||||
{
|
||||
label: 'Uruguay',
|
||||
value: 'UY',
|
||||
},
|
||||
{
|
||||
label: 'Uzbekistan',
|
||||
value: 'UZ',
|
||||
},
|
||||
{
|
||||
label: 'Vanuatu',
|
||||
value: 'VU',
|
||||
},
|
||||
{
|
||||
label: 'Venezuela',
|
||||
value: 'VE',
|
||||
},
|
||||
{
|
||||
label: 'Viet Nam',
|
||||
value: 'VN',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, British',
|
||||
value: 'VG',
|
||||
},
|
||||
{
|
||||
label: 'Virgin Islands, U.S.',
|
||||
value: 'VI',
|
||||
},
|
||||
{
|
||||
label: 'Wallis and Futuna',
|
||||
value: 'WF',
|
||||
},
|
||||
{
|
||||
label: 'Western Sahara',
|
||||
value: 'EH',
|
||||
},
|
||||
{
|
||||
label: 'Yemen',
|
||||
value: 'YE',
|
||||
},
|
||||
{
|
||||
label: 'Zambia',
|
||||
value: 'ZM',
|
||||
},
|
||||
{
|
||||
label: 'Zimbabwe',
|
||||
value: 'ZW',
|
||||
},
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
export const Error = ({ name }: { name: string }) => {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
return (
|
||||
<div className="mt-2 text-red-500 text-sm">
|
||||
{(errors[name]?.message as string) || 'This field is required'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import RichText from '@/components/RichText'
|
||||
import React from 'react'
|
||||
|
||||
import { Width } from '../Width'
|
||||
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
|
||||
return (
|
||||
<Width className="my-12" width="100">
|
||||
{message && <RichText data={message} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required })}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select as SelectComponent,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = options.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectComponent>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { StateField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { stateOptions } from './options'
|
||||
|
||||
export const State: React.FC<
|
||||
StateField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = stateOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stateOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export const stateOptions = [
|
||||
{ label: 'Alabama', value: 'AL' },
|
||||
{ label: 'Alaska', value: 'AK' },
|
||||
{ label: 'Arizona', value: 'AZ' },
|
||||
{ label: 'Arkansas', value: 'AR' },
|
||||
{ label: 'California', value: 'CA' },
|
||||
{ label: 'Colorado', value: 'CO' },
|
||||
{ label: 'Connecticut', value: 'CT' },
|
||||
{ label: 'Delaware', value: 'DE' },
|
||||
{ label: 'Florida', value: 'FL' },
|
||||
{ label: 'Georgia', value: 'GA' },
|
||||
{ label: 'Hawaii', value: 'HI' },
|
||||
{ label: 'Idaho', value: 'ID' },
|
||||
{ label: 'Illinois', value: 'IL' },
|
||||
{ label: 'Indiana', value: 'IN' },
|
||||
{ label: 'Iowa', value: 'IA' },
|
||||
{ label: 'Kansas', value: 'KS' },
|
||||
{ label: 'Kentucky', value: 'KY' },
|
||||
{ label: 'Louisiana', value: 'LA' },
|
||||
{ label: 'Maine', value: 'ME' },
|
||||
{ label: 'Maryland', value: 'MD' },
|
||||
{ label: 'Massachusetts', value: 'MA' },
|
||||
{ label: 'Michigan', value: 'MI' },
|
||||
{ label: 'Minnesota', value: 'MN' },
|
||||
{ label: 'Mississippi', value: 'MS' },
|
||||
{ label: 'Missouri', value: 'MO' },
|
||||
{ label: 'Montana', value: 'MT' },
|
||||
{ label: 'Nebraska', value: 'NE' },
|
||||
{ label: 'Nevada', value: 'NV' },
|
||||
{ label: 'New Hampshire', value: 'NH' },
|
||||
{ label: 'New Jersey', value: 'NJ' },
|
||||
{ label: 'New Mexico', value: 'NM' },
|
||||
{ label: 'New York', value: 'NY' },
|
||||
{ label: 'North Carolina', value: 'NC' },
|
||||
{ label: 'North Dakota', value: 'ND' },
|
||||
{ label: 'Ohio', value: 'OH' },
|
||||
{ label: 'Oklahoma', value: 'OK' },
|
||||
{ label: 'Oregon', value: 'OR' },
|
||||
{ label: 'Pennsylvania', value: 'PA' },
|
||||
{ label: 'Rhode Island', value: 'RI' },
|
||||
{ label: 'South Carolina', value: 'SC' },
|
||||
{ label: 'South Dakota', value: 'SD' },
|
||||
{ label: 'Tennessee', value: 'TN' },
|
||||
{ label: 'Texas', value: 'TX' },
|
||||
{ label: 'Utah', value: 'UT' },
|
||||
{ label: 'Vermont', value: 'VT' },
|
||||
{ label: 'Virginia', value: 'VA' },
|
||||
{ label: 'Washington', value: 'WA' },
|
||||
{ label: 'West Virginia', value: 'WV' },
|
||||
{ label: 'Wisconsin', value: 'WI' },
|
||||
{ label: 'Wyoming', value: 'WY' },
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Text: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Textarea: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
rows?: number
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<TextAreaComponent
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
rows={rows}
|
||||
{...register(name, { required: required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const Width: React.FC<{
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
}> = ({ children, className, width }) => {
|
||||
return (
|
||||
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const FormBlock: Block = {
|
||||
slug: 'formBlock',
|
||||
interfaceName: 'FormBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'enableIntro',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
admin: {
|
||||
condition: (_, { enableIntro }) => Boolean(enableIntro),
|
||||
},
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
],
|
||||
graphQL: {
|
||||
singularName: 'FormBlock',
|
||||
},
|
||||
labels: {
|
||||
plural: 'Form Blocks',
|
||||
singular: 'Form Block',
|
||||
},
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Checkbox } from './Checkbox'
|
||||
import { Country } from './Country'
|
||||
import { Email } from './Email'
|
||||
import { Message } from './Message'
|
||||
import { Number } from './Number'
|
||||
import { Select } from './Select'
|
||||
import { State } from './State'
|
||||
import { Text } from './Text'
|
||||
import { Textarea } from './Textarea'
|
||||
|
||||
export const fields = {
|
||||
checkbox: Checkbox,
|
||||
country: Country,
|
||||
email: Email,
|
||||
message: Message,
|
||||
number: Number,
|
||||
select: Select,
|
||||
state: State,
|
||||
text: Text,
|
||||
textarea: Textarea,
|
||||
}
|
||||
@@ -5,14 +5,12 @@ import type { Page } from '@/payload-types'
|
||||
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||
import { ContentBlock } from '@/blocks/Content/Component'
|
||||
import { FormBlock } from '@/blocks/Form/Component'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||
|
||||
const blockComponents = {
|
||||
archive: ArchiveBlock,
|
||||
content: ContentBlock,
|
||||
cta: CallToActionBlock,
|
||||
formBlock: FormBlock,
|
||||
mediaBlock: MediaBlock,
|
||||
}
|
||||
|
||||
|
||||
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AfterChangeHook } from 'payload'
|
||||
|
||||
import { logDocumentChange } from '@/utilities/auditLogger'
|
||||
|
||||
/**
|
||||
* 創建稽核日誌 Hook
|
||||
* 用於追蹤文件的創建、更新、刪除操作
|
||||
*/
|
||||
export const auditChange =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
// 跳過 audit 集合本身以避免無限循環
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
operation as 'create' | 'update' | 'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除稽核日誌 Hook
|
||||
* 用於追蹤文件刪除操作(需要在刪除前記錄標題)
|
||||
*/
|
||||
export const auditDelete =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Global 稽核日誌 Hook
|
||||
* 用於追蹤 Global 配置的變更
|
||||
*/
|
||||
export const auditGlobalChange =
|
||||
(global: string): AfterChangeHook =>
|
||||
async ({ req, previousDoc, doc }) => {
|
||||
if (!req.user) return doc
|
||||
|
||||
const { auditLogger } = await import('@/utilities/auditLogger')
|
||||
|
||||
await auditLogger(req, {
|
||||
action: 'update',
|
||||
collection: `global_${global}`,
|
||||
userId: req.user.id,
|
||||
userName: req.user.name as string,
|
||||
userEmail: req.user.email as string,
|
||||
userRole: String(req.user.role ?? ''),
|
||||
changes: {
|
||||
previous: previousDoc,
|
||||
current: doc,
|
||||
},
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
108
apps/backend/src/collections/Audit/index.ts
Normal file
108
apps/backend/src/collections/Audit/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
|
||||
export const Audit: CollectionConfig = {
|
||||
slug: 'audit',
|
||||
access: {
|
||||
create: () => false, // 僅透過程式自動創建
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
update: () => false, // 不允許修改日誌
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['timestamp', 'action', 'collection', 'userId'],
|
||||
useAsTitle: 'action',
|
||||
description: '系統稽核日誌 - 自動記錄所有重要操作',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '登入', value: 'login' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
{ label: '創建', value: 'create' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '刪除', value: 'delete' },
|
||||
{ label: '發布', value: 'publish' },
|
||||
{ label: '取消發布', value: 'unpublish' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '受影響的集合名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentTitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件標題',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '執行操作的使用者 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userEmail',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者信箱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userRole',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '管理員', value: 'admin' },
|
||||
{ label: '編輯者', value: 'editor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'IP 位址',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '瀏覽器 User Agent',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: '變更內容詳細資訊',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { anyone } from '../access/anyone'
|
||||
import { authenticated } from '../access/authenticated'
|
||||
import { adminOrEditor } from '../access/adminOrEditor'
|
||||
import { auditChange } from './Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
@@ -16,6 +17,10 @@ export const Categories: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [auditChange('categories')],
|
||||
afterDelete: [auditChange('categories')],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Archive } from '../../blocks/ArchiveBlock/config'
|
||||
import { CallToAction } from '../../blocks/CallToAction/config'
|
||||
import { Content } from '../../blocks/Content/config'
|
||||
@@ -123,9 +124,9 @@ export const Pages: CollectionConfig<'pages'> = {
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePage],
|
||||
afterChange: [revalidatePage, auditChange('pages')],
|
||||
beforeChange: [populatePublishedAt],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('pages')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { anyone } from '../../access/anyone'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Portfolio: CollectionConfig = {
|
||||
slug: 'portfolio',
|
||||
access: {
|
||||
create: adminOrEditor,
|
||||
create: authenticated,
|
||||
read: anyone,
|
||||
update: adminOrEditor,
|
||||
delete: adminOrEditor,
|
||||
update: authenticated,
|
||||
delete: authenticated,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
@@ -66,6 +67,10 @@ export const Portfolio: CollectionConfig = {
|
||||
},
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [auditChange('portfolio')],
|
||||
afterDelete: [auditChange('portfolio')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Banner } from '../../blocks/Banner/config'
|
||||
import { Code } from '../../blocks/Code/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
@@ -263,9 +264,9 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePost],
|
||||
afterChange: [revalidatePost, auditChange('posts')],
|
||||
afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('posts')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { AfterLoginHook, AfterLogoutHook } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { logLogin, logLogout } from '../../utilities/auditLogger'
|
||||
|
||||
const afterLogin: AfterLoginHook = async ({ req, user }) => {
|
||||
if (user?.id) {
|
||||
await logLogin(req, user.id)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
const afterLogout: AfterLogoutHook = async ({ req }) => {
|
||||
if (req.user?.id) {
|
||||
await logLogout(req, req.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
@@ -8,7 +24,7 @@ export const Users: CollectionConfig = {
|
||||
admin: adminOnly,
|
||||
create: adminOnly,
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
read: authenticated,
|
||||
update: adminOnly,
|
||||
},
|
||||
admin: {
|
||||
@@ -16,6 +32,10 @@ export const Users: CollectionConfig = {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
auth: true,
|
||||
hooks: {
|
||||
afterLogin: [afterLogin],
|
||||
afterLogout: [afterLogout],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { RequiredDataFromCollectionSlug } from 'payload'
|
||||
|
||||
export const contactForm: RequiredDataFromCollectionSlug<'forms'> = {
|
||||
confirmationMessage: {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'The contact form has been submitted successfully.',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
tag: 'h2',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
confirmationType: 'message',
|
||||
createdAt: '2023-01-12T21:47:41.374Z',
|
||||
emails: [
|
||||
{
|
||||
emailFrom: '"Payload" \u003Cdemo@payloadcms.com\u003E',
|
||||
emailTo: '{{email}}',
|
||||
message: {
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'Your contact form submission was successfully received.',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
subject: "You've received a new message.",
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'full-name',
|
||||
blockName: 'full-name',
|
||||
blockType: 'text',
|
||||
label: 'Full Name',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
blockName: 'email',
|
||||
blockType: 'email',
|
||||
label: 'Email',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
blockName: 'phone',
|
||||
blockType: 'number',
|
||||
label: 'Phone',
|
||||
required: false,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
name: 'message',
|
||||
blockName: 'message',
|
||||
blockType: 'textarea',
|
||||
label: 'Message',
|
||||
required: true,
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
redirect: undefined,
|
||||
submitButtonLabel: 'Submit',
|
||||
title: 'Contact Form',
|
||||
updatedAt: '2023-01-12T21:47:41.374Z',
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CollectionSlug, GlobalSlug, Payload, PayloadRequest, File } from 'payload'
|
||||
|
||||
import { contactForm as contactFormData } from './contact-form'
|
||||
import { contact as contactPageData } from './contact-page'
|
||||
import { home } from './home'
|
||||
import { image1 } from './image-1'
|
||||
@@ -15,8 +14,6 @@ const collections: CollectionSlug[] = [
|
||||
'media',
|
||||
'pages',
|
||||
'posts',
|
||||
'forms',
|
||||
'form-submissions',
|
||||
'search',
|
||||
]
|
||||
const globals: GlobalSlug[] = ['header', 'footer']
|
||||
@@ -257,14 +254,6 @@ export const seed = async ({
|
||||
},
|
||||
})
|
||||
|
||||
payload.logger.info(`— Seeding contact form...`)
|
||||
|
||||
const contactForm = await payload.create({
|
||||
collection: 'forms',
|
||||
depth: 0,
|
||||
data: contactFormData,
|
||||
})
|
||||
|
||||
payload.logger.info(`— Seeding pages...`)
|
||||
|
||||
const [_, contactPage] = await Promise.all([
|
||||
|
||||
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
52
apps/backend/src/jobs/cleanupAuditLogs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
/**
|
||||
* 清理 90 天前的稽核日誌
|
||||
* 此任務應該透過 cron job 定期執行(建議每天一次)
|
||||
*/
|
||||
export const cleanupAuditLogs = {
|
||||
slug: 'cleanup-audit-logs',
|
||||
label: '清理 90 天前的稽核日誌',
|
||||
handler: async ({ req }: { req: PayloadRequest }) => {
|
||||
try {
|
||||
// 計算 90 天前的日期
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
// 查找 90 天前的稽核日誌
|
||||
const oldLogs = await req.payload.find({
|
||||
collection: 'audit',
|
||||
where: {
|
||||
createdAt: {
|
||||
less_than: ninetyDaysAgo.toISOString(),
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1000, // 每次最多處理 1000 筆
|
||||
})
|
||||
|
||||
if (oldLogs.totalDocs > 0) {
|
||||
// 批量刪除舊日誌
|
||||
for (const log of oldLogs.docs) {
|
||||
await req.payload.delete({
|
||||
collection: 'audit',
|
||||
id: log.id,
|
||||
})
|
||||
}
|
||||
|
||||
req.payload.logger.info(`Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleaned up ${oldLogs.totalDocs} audit logs older than 90 days`,
|
||||
}
|
||||
} catch (error) {
|
||||
req.payload.logger.error('Audit log cleanup failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Audit log cleanup failed',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export interface Config {
|
||||
categories: Category;
|
||||
users: User;
|
||||
portfolio: Portfolio;
|
||||
audit: Audit;
|
||||
redirects: Redirect;
|
||||
search: Search;
|
||||
'payload-jobs': PayloadJob;
|
||||
@@ -88,6 +89,7 @@ export interface Config {
|
||||
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
portfolio: PortfolioSelect<false> | PortfolioSelect<true>;
|
||||
audit: AuditSelect<false> | AuditSelect<true>;
|
||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||
search: SearchSelect<false> | SearchSelect<true>;
|
||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||
@@ -112,6 +114,7 @@ export interface Config {
|
||||
};
|
||||
jobs: {
|
||||
tasks: {
|
||||
'cleanup-audit-logs': TaskCleanupAuditLogs;
|
||||
schedulePublish: TaskSchedulePublish;
|
||||
inline: {
|
||||
input: unknown;
|
||||
@@ -213,6 +216,10 @@ export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
heroImage?: (string | null) | Media;
|
||||
/**
|
||||
* Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px
|
||||
*/
|
||||
ogImage?: (string | null) | Media;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
@@ -228,8 +235,13 @@ export interface Post {
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* 顯示在文章列表頁,建議 150-200 字
|
||||
*/
|
||||
excerpt?: string | null;
|
||||
relatedPosts?: (string | Post)[] | null;
|
||||
categories?: (string | Category)[] | null;
|
||||
showInFooter?: boolean | null;
|
||||
meta?: {
|
||||
title?: string | null;
|
||||
/**
|
||||
@@ -248,6 +260,7 @@ export interface Post {
|
||||
| null;
|
||||
slug?: string | null;
|
||||
slugLock?: boolean | null;
|
||||
status?: ('draft' | 'review' | 'published') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
@@ -351,6 +364,22 @@ export interface Media {
|
||||
export interface Category {
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* 用於 URL 或國際化
|
||||
*/
|
||||
nameEn?: string | null;
|
||||
/**
|
||||
* 數字越小越靠前
|
||||
*/
|
||||
order?: number | null;
|
||||
/**
|
||||
* 十六進制顏色碼,例如 #000000
|
||||
*/
|
||||
textColor?: string | null;
|
||||
/**
|
||||
* 十六進制顏色碼,例如 #ffffff
|
||||
*/
|
||||
backgroundColor?: string | null;
|
||||
slug?: string | null;
|
||||
slugLock?: boolean | null;
|
||||
parent?: (string | null) | Category;
|
||||
@@ -372,6 +401,7 @@ export interface Category {
|
||||
export interface User {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
role: 'admin' | 'editor';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
@@ -561,6 +591,63 @@ export interface Portfolio {
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* 系統稽核日誌 - 自動記錄所有重要操作
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "audit".
|
||||
*/
|
||||
export interface Audit {
|
||||
id: string;
|
||||
action: 'login' | 'logout' | 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
||||
/**
|
||||
* 受影響的集合名稱
|
||||
*/
|
||||
collection: string;
|
||||
/**
|
||||
* 受影響的文件 ID
|
||||
*/
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* 受影響的文件標題
|
||||
*/
|
||||
documentTitle?: string | null;
|
||||
/**
|
||||
* 執行操作的使用者 ID
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 執行操作的使用者名稱
|
||||
*/
|
||||
userName?: string | null;
|
||||
/**
|
||||
* 執行操作的使用者信箱
|
||||
*/
|
||||
userEmail?: string | null;
|
||||
userRole?: ('admin' | 'editor') | null;
|
||||
/**
|
||||
* IP 位址
|
||||
*/
|
||||
ipAddress?: string | null;
|
||||
/**
|
||||
* 瀏覽器 User Agent
|
||||
*/
|
||||
userAgent?: string | null;
|
||||
/**
|
||||
* 變更內容詳細資訊
|
||||
*/
|
||||
changes?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects".
|
||||
@@ -670,7 +757,7 @@ export interface PayloadJob {
|
||||
| {
|
||||
executedAt: string;
|
||||
completedAt: string;
|
||||
taskSlug: 'inline' | 'schedulePublish';
|
||||
taskSlug: 'inline' | 'cleanup-audit-logs' | 'schedulePublish';
|
||||
taskID: string;
|
||||
input?:
|
||||
| {
|
||||
@@ -703,7 +790,7 @@ export interface PayloadJob {
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
taskSlug?: ('inline' | 'schedulePublish') | null;
|
||||
taskSlug?: ('inline' | 'cleanup-audit-logs' | 'schedulePublish') | null;
|
||||
queue?: string | null;
|
||||
waitUntil?: string | null;
|
||||
processing?: boolean | null;
|
||||
@@ -741,6 +828,10 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'portfolio';
|
||||
value: string | Portfolio;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'audit';
|
||||
value: string | Audit;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'redirects';
|
||||
value: string | Redirect;
|
||||
@@ -925,9 +1016,12 @@ export interface ArchiveBlockSelect<T extends boolean = true> {
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
heroImage?: T;
|
||||
ogImage?: T;
|
||||
content?: T;
|
||||
excerpt?: T;
|
||||
relatedPosts?: T;
|
||||
categories?: T;
|
||||
showInFooter?: T;
|
||||
meta?:
|
||||
| T
|
||||
| {
|
||||
@@ -945,6 +1039,7 @@ export interface PostsSelect<T extends boolean = true> {
|
||||
};
|
||||
slug?: T;
|
||||
slugLock?: T;
|
||||
status?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
@@ -1048,6 +1143,10 @@ export interface MediaSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface CategoriesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
nameEn?: T;
|
||||
order?: T;
|
||||
textColor?: T;
|
||||
backgroundColor?: T;
|
||||
slug?: T;
|
||||
slugLock?: T;
|
||||
parent?: T;
|
||||
@@ -1068,6 +1167,7 @@ export interface CategoriesSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
role?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
@@ -1107,6 +1207,25 @@ export interface PortfolioSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "audit_select".
|
||||
*/
|
||||
export interface AuditSelect<T extends boolean = true> {
|
||||
action?: T;
|
||||
collection?: T;
|
||||
documentId?: T;
|
||||
documentTitle?: T;
|
||||
userId?: T;
|
||||
userName?: T;
|
||||
userEmail?: T;
|
||||
userRole?: T;
|
||||
ipAddress?: T;
|
||||
userAgent?: T;
|
||||
changes?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "redirects_select".
|
||||
@@ -1351,6 +1470,14 @@ export interface FooterSelect<T extends boolean = true> {
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskCleanup-audit-logs".
|
||||
*/
|
||||
export interface TaskCleanupAuditLogs {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskSchedulePublish".
|
||||
|
||||
@@ -6,6 +6,7 @@ import sharp from 'sharp' // sharp-import
|
||||
import path from 'path'
|
||||
import { buildConfig, PayloadRequest } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { Audit } from './collections/Audit'
|
||||
import { Categories } from './collections/Categories'
|
||||
import { Media } from './collections/Media'
|
||||
import { Pages } from './collections/Pages'
|
||||
@@ -17,6 +18,7 @@ import { Header } from './Header/config'
|
||||
import { plugins } from './plugins'
|
||||
import { defaultLexical } from '@/fields/defaultLexical'
|
||||
import { getServerSideURL } from './utilities/getURL'
|
||||
import { cleanupAuditLogs } from './jobs/cleanupAuditLogs'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -63,7 +65,7 @@ export default buildConfig({
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
collections: [Pages, Posts, Media, Categories, Users, Portfolio],
|
||||
collections: [Pages, Posts, Media, Categories, Users, Portfolio, Audit],
|
||||
cors: [
|
||||
getServerSideURL(),
|
||||
'http://localhost:4321', // Astro dev server
|
||||
@@ -111,6 +113,6 @@ export default buildConfig({
|
||||
return authHeader === `Bearer ${process.env.CRON_SECRET}`
|
||||
},
|
||||
},
|
||||
tasks: [],
|
||||
tasks: [cleanupAuditLogs],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { payloadCloudPlugin } from '@payloadcms/payload-cloud'
|
||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder'
|
||||
import { nestedDocsPlugin } from '@payloadcms/plugin-nested-docs'
|
||||
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
||||
import { seoPlugin } from '@payloadcms/plugin-seo'
|
||||
@@ -7,7 +5,6 @@ import { searchPlugin } from '@payloadcms/plugin-search'
|
||||
import { Plugin } from 'payload'
|
||||
import { revalidateRedirects } from '@/hooks/revalidateRedirects'
|
||||
import { GenerateTitle, GenerateURL } from '@payloadcms/plugin-seo/types'
|
||||
import { FixedToolbarFeature, HeadingFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { searchFields } from '@/search/fieldOverrides'
|
||||
import { beforeSyncWithSearch } from '@/search/beforeSync'
|
||||
|
||||
@@ -55,32 +52,6 @@ export const plugins: Plugin[] = [
|
||||
generateTitle,
|
||||
generateURL,
|
||||
}),
|
||||
formBuilderPlugin({
|
||||
fields: {
|
||||
payment: false,
|
||||
},
|
||||
formOverrides: {
|
||||
fields: ({ defaultFields }) => {
|
||||
return defaultFields.map((field) => {
|
||||
if ('name' in field && field.name === 'confirmationMessage') {
|
||||
return {
|
||||
...field,
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
FixedToolbarFeature(),
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
]
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
return field
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
searchPlugin({
|
||||
collections: ['posts'],
|
||||
beforeSync: beforeSyncWithSearch,
|
||||
@@ -90,5 +61,4 @@ export const plugins: Plugin[] = [
|
||||
},
|
||||
},
|
||||
}),
|
||||
payloadCloudPlugin(),
|
||||
]
|
||||
|
||||
11
apps/backend/src/types/audit.ts
Normal file
11
apps/backend/src/types/audit.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 稽核日誌操作類型
|
||||
*/
|
||||
export type AuditAction =
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'publish'
|
||||
| 'unpublish'
|
||||
110
apps/backend/src/utilities/auditLogger.ts
Normal file
110
apps/backend/src/utilities/auditLogger.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
import { AuditAction } from '@/types/audit'
|
||||
|
||||
interface AuditLogData {
|
||||
action: AuditAction
|
||||
collection: string
|
||||
documentId?: string
|
||||
documentTitle?: string
|
||||
userId: string
|
||||
userName?: string
|
||||
userEmail?: string
|
||||
userRole?: string
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
changes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立稽核日誌
|
||||
* 此函數用於記錄系統中的重要操作,包括登入/登出、內容變更等
|
||||
*/
|
||||
export const auditLogger = async (req: PayloadRequest, data: AuditLogData): Promise<void> => {
|
||||
try {
|
||||
// 從請求中獲取 IP 和 User Agent
|
||||
const ipAddress = req.headers?.get('x-forwarded-for')?.split(',')[0] ||
|
||||
req.headers?.get('x-real-ip') ||
|
||||
undefined
|
||||
const userAgent = req.headers?.get('user-agent') || undefined
|
||||
|
||||
await req.payload.create({
|
||||
collection: 'audit',
|
||||
data: {
|
||||
action: data.action,
|
||||
collection: data.collection,
|
||||
documentId: data.documentId,
|
||||
documentTitle: data.documentTitle,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
userEmail: data.userEmail,
|
||||
userRole: data.userRole,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
changes: data.changes,
|
||||
},
|
||||
// 不觸發稽核日誌的稽核日誌(避免無限循環)
|
||||
depth: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
// 記錄錯誤但不中斷主要流程
|
||||
console.error('Audit log creation failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄登入事件
|
||||
*/
|
||||
export const logLogin = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||
const user = await req.payload.findByID({
|
||||
collection: 'users',
|
||||
id: userId,
|
||||
})
|
||||
|
||||
await auditLogger(req, {
|
||||
action: 'login',
|
||||
collection: 'users',
|
||||
documentId: userId,
|
||||
userId,
|
||||
userName: user.name as string,
|
||||
userEmail: user.email as string,
|
||||
userRole: String(user.role ?? ''),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄登出事件
|
||||
*/
|
||||
export const logLogout = async (req: PayloadRequest, userId: string): Promise<void> => {
|
||||
await auditLogger(req, {
|
||||
action: 'logout',
|
||||
collection: 'users',
|
||||
documentId: userId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 記錄文件變更(創建、更新、刪除)
|
||||
*/
|
||||
export const logDocumentChange = async (
|
||||
req: PayloadRequest,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
collection: string,
|
||||
docId?: string,
|
||||
docTitle?: string,
|
||||
changes?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
if (!req.user) return
|
||||
|
||||
await auditLogger(req, {
|
||||
action: operation,
|
||||
collection,
|
||||
documentId: docId,
|
||||
documentTitle: docTitle,
|
||||
userId: req.user.id,
|
||||
userName: req.user.name as string,
|
||||
userEmail: req.user.email as string,
|
||||
userRole: String(req.user.role ?? ''),
|
||||
changes,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user