@easy-cms/core 0.22.2 → 0.24.0

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/dist/index.d.ts CHANGED
@@ -56,6 +56,30 @@ declare const anyone: Access;
56
56
  declare const isLoggedIn: Access;
57
57
  declare const isAdmin: Access;
58
58
 
59
+ /** What the email functions in `auth.emails` receive. */
60
+ interface PasswordEmailArgs {
61
+ readonly user: AuthUser;
62
+ /** The page that sets the password; absent for `passwordChanged`. */
63
+ readonly url?: string;
64
+ /** The admin language for the text: `en` or `th`. */
65
+ readonly locale: string;
66
+ /** When the link stops working. */
67
+ readonly expiresAt?: Date;
68
+ }
69
+ /** An email's content: a subject, and text and/or HTML. */
70
+ interface PasswordEmail {
71
+ readonly subject: string;
72
+ readonly text: string;
73
+ readonly html?: string;
74
+ }
75
+ type PasswordEmailFn = (args: PasswordEmailArgs) => PasswordEmail | Promise<PasswordEmail>;
76
+ /** The default emails, in English and Thai. */
77
+ declare const DEFAULT_PASSWORD_EMAILS: {
78
+ resetPassword: PasswordEmailFn;
79
+ invite: PasswordEmailFn;
80
+ passwordChanged: PasswordEmailFn;
81
+ };
82
+
59
83
  interface Logger {
60
84
  info(message: string): void;
61
85
  warn(message: string): void;
@@ -237,6 +261,8 @@ type PreviewTarget = {
237
261
  } | {
238
262
  readonly global: string;
239
263
  };
264
+ /** Why a password link was sent: a forgotten password, or an invitation to a new account. */
265
+ type PasswordPurpose = 'reset' | 'invite';
240
266
 
241
267
  interface LoginArgs {
242
268
  readonly email: string;
@@ -252,6 +278,13 @@ interface Session {
252
278
  readonly csrfToken: string;
253
279
  readonly expiresAt: string;
254
280
  }
281
+ /** Where a password link is opened from, and the language of its email. */
282
+ interface PasswordLinkOptions {
283
+ /** The request's origin: used for the link only in development, when `serverURL` is not set. */
284
+ readonly origin?: string | undefined;
285
+ /** `en` or `th`. Default: `admin.locale`. */
286
+ readonly locale?: string | undefined;
287
+ }
255
288
  /** Login, logout and session verification. Available as `cms.auth`. */
256
289
  declare class Auth {
257
290
  private readonly cms;
@@ -280,6 +313,48 @@ declare class Auth {
280
313
  createSession(userId: ID): Promise<Session>;
281
314
  /** Signs a user out everywhere. */
282
315
  revokeSessions(userId: ID): Promise<void>;
316
+ /**
317
+ * The origin password links point to: `serverURL`, or the request's origin in development. In
318
+ * production the request's Host can't be trusted (a forged one would send the link elsewhere).
319
+ */
320
+ passwordLinkBase(origin?: string): string | null;
321
+ /** Whether "forgot password" and invitations work: `email` is set and links have an origin. */
322
+ canSendPasswordLinks(origin?: string): boolean;
323
+ /**
324
+ * Emails a link to set a new password, if an active account has this email. Always resolves the
325
+ * same way, so callers can't tell whether the email has an account; extra requests within
326
+ * `lockWindow` are ignored.
327
+ */
328
+ requestPasswordReset(args: PasswordLinkOptions & {
329
+ email: string;
330
+ ip?: string | undefined;
331
+ }): Promise<void>;
332
+ /**
333
+ * Emails a user a link to set their password: an invitation if they have none yet, else a
334
+ * reset link. For admins (the REST API checks the role). Returns which one was sent.
335
+ */
336
+ sendPasswordLink(userId: ID, options?: PasswordLinkOptions): Promise<PasswordPurpose>;
337
+ /** The user and purpose of a valid, unused password link, or `null`. */
338
+ checkPasswordToken(token: string): Promise<{
339
+ user: AuthUser;
340
+ purpose: PasswordPurpose;
341
+ } | null>;
342
+ /**
343
+ * Sets the password from a link, ends every session of the account and starts one here.
344
+ * Throws `ValidationError` when the link expired or was used, or the password is too short.
345
+ */
346
+ resetPassword(args: {
347
+ token: string;
348
+ password: string;
349
+ ip?: string | undefined;
350
+ locale?: string | undefined;
351
+ }): Promise<Session & {
352
+ purpose: PasswordPurpose;
353
+ }>;
354
+ private passwordTokenUser;
355
+ private mailPasswordLink;
356
+ private localeOf;
357
+ private findByEmail;
283
358
  /** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
284
359
  private verifyApiKey;
285
360
  private startSession;
@@ -415,7 +490,9 @@ type FieldValue<F extends Field, C extends Config = Config> = F extends {
415
490
  readonly type: 'richText';
416
491
  } ? RichTextDocument : F extends {
417
492
  readonly type: 'upload';
418
- } ? ID | MediaDocument : F extends {
493
+ } ? F extends {
494
+ readonly hasMany: true;
495
+ } ? (ID | MediaDocument)[] : ID | MediaDocument : F extends {
419
496
  readonly type: 'relationship';
420
497
  readonly to: infer S;
421
498
  } ? F extends {
@@ -504,7 +581,9 @@ type InputValue<F extends Field> = F extends {
504
581
  readonly hasMany: true;
505
582
  } ? Readonly<SelectValue<F>> : F extends {
506
583
  readonly type: 'upload';
507
- } ? ID : F extends {
584
+ } ? F extends {
585
+ readonly hasMany: true;
586
+ } ? readonly ID[] : ID : F extends {
508
587
  readonly type: 'date';
509
588
  } ? string | Date : F extends {
510
589
  readonly type: 'array';
@@ -537,14 +616,17 @@ type StatusInput<T> = T extends {
537
616
  } ? {
538
617
  status?: 'draft' | 'published';
539
618
  } : unknown;
540
- /** Users are created with a password, which is hashed and never returned. */
619
+ /**
620
+ * A user's password, hashed and never returned. Optional when creating: a user without one sets
621
+ * it from an invitation link.
622
+ */
541
623
  type PasswordInput<S, Required extends boolean> = S extends 'users' ? Required extends true ? {
542
624
  password: string;
543
625
  } : {
544
626
  password?: string;
545
627
  } : unknown;
546
628
  /** Data accepted by `create`, e.g. `CreateInput<typeof config, 'posts'>`. */
547
- type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, true>>;
629
+ type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, false>>;
548
630
  /** Data accepted by `update`: any subset of `CreateInput`. */
549
631
  type UpdateInput<C extends Config, S extends CollectionSlug<C>> = Partial<CreateInput<C, S>>;
550
632
  /** Data accepted by `updateGlobal`. */
@@ -1037,8 +1119,19 @@ interface RichTextDocument {
1037
1119
  }
1038
1120
  interface RichTextField extends BaseField<'richText', RichTextDocument> {
1039
1121
  }
1040
- /** References a document in the built-in `media` collection. */
1122
+ /** References a document in the built-in `media` collection: a file or an image. */
1041
1123
  interface UploadField extends BaseField<'upload', never> {
1124
+ /** Several files, e.g. a gallery: the value is a list, in the order editors arrange. */
1125
+ readonly hasMany?: boolean;
1126
+ /** With `hasMany`: the fewest files. */
1127
+ readonly minRows?: number;
1128
+ /** With `hasMany`: the most files. */
1129
+ readonly maxRows?: number;
1130
+ /**
1131
+ * The file types allowed, e.g. `['image/*']` or `['image/*', 'application/pdf']`. The admin
1132
+ * offers only these, and saving refuses others.
1133
+ */
1134
+ readonly mimeTypes?: readonly string[];
1042
1135
  }
1043
1136
  interface FilterOptionsArgs {
1044
1137
  /** The document being edited; `undefined` while it is being created. */
@@ -1055,6 +1148,10 @@ interface RelationshipField extends BaseField<'relationship', never> {
1055
1148
  /** Slug of the target collection. */
1056
1149
  readonly to: string;
1057
1150
  readonly hasMany?: boolean;
1151
+ /** With `hasMany`: the fewest documents. */
1152
+ readonly minRows?: number;
1153
+ /** With `hasMany`: the most documents. */
1154
+ readonly maxRows?: number;
1058
1155
  readonly filterOptions?: FilterOptions;
1059
1156
  }
1060
1157
  interface ArrayField extends BaseField<'array', never> {
@@ -1084,6 +1181,10 @@ interface BlocksField extends BaseField<'blocks', never> {
1084
1181
  readonly minRows?: number;
1085
1182
  readonly maxRows?: number;
1086
1183
  }
1184
+ /** Fields with `hasMany`: their value is a list of options, documents or files. */
1185
+ declare const isHasMany: (field: Field) => boolean;
1186
+ /** Whether a MIME type is one of `patterns` (`image/*` matches every image). */
1187
+ declare function mimeAllowedBy(mimeType: string, patterns: readonly string[]): boolean;
1087
1188
  /** Fields whose value is a list of rows: `array` and `blocks`. */
1088
1189
  declare const hasRows: (field: Field) => field is ArrayField | BlocksField;
1089
1190
  /** The fields of one row of an `array` or `blocks` field (`undefined` for an unknown block). */
@@ -1347,6 +1448,19 @@ interface AuthConfig {
1347
1448
  * Needed when the admin or frontend is served from another origin, or behind a proxy that rewrites the host.
1348
1449
  */
1349
1450
  readonly trustedOrigins?: readonly string[];
1451
+ /**
1452
+ * How long a "forgot password" link works, in seconds. Default 1 hour. Links work once: setting
1453
+ * the password ends them. Needs `email` and, in production, `serverURL` for the link.
1454
+ */
1455
+ readonly resetPasswordExpiration?: number;
1456
+ /** How long an invitation link works, in seconds. Default 7 days. */
1457
+ readonly inviteExpiration?: number;
1458
+ /** Your own text for the password emails, e.g. in your brand's voice. Default: English or Thai. */
1459
+ readonly emails?: {
1460
+ readonly resetPassword?: PasswordEmailFn;
1461
+ readonly invite?: PasswordEmailFn;
1462
+ readonly passwordChanged?: PasswordEmailFn;
1463
+ };
1350
1464
  }
1351
1465
  /** What an endpoint's handler receives. */
1352
1466
  interface EndpointRequest {
@@ -1623,6 +1737,8 @@ interface Reference {
1623
1737
  readonly id: ID;
1624
1738
  /** The relationship's `filterOptions`, checked when saving. */
1625
1739
  readonly filterOptions?: FilterOptions;
1740
+ /** The upload's `mimeTypes`, checked when saving. */
1741
+ readonly mimeTypes?: readonly string[];
1626
1742
  }
1627
1743
  interface ValidateOptions {
1628
1744
  readonly operation: 'create' | 'update';
@@ -1732,6 +1848,8 @@ interface AdminField {
1732
1848
  }[];
1733
1849
  hasMany?: boolean;
1734
1850
  to?: string;
1851
+ /** An upload's allowed file types, e.g. `image/*`. */
1852
+ mimeTypes?: string[];
1735
1853
  /** A relationship with `filterOptions`: the picker asks the server which documents fit. */
1736
1854
  filtered?: boolean;
1737
1855
  from?: string;
@@ -1811,6 +1929,8 @@ interface AdminGlobal {
1811
1929
  }
1812
1930
  interface AdminSchema {
1813
1931
  locale: AdminLocale;
1932
+ /** Admins can email users links to set their password (needs `email` and `serverURL`). */
1933
+ passwordLinks: boolean;
1814
1934
  /** Menu order of collections (`admin.menu`); unlisted ones follow. */
1815
1935
  menu: string[];
1816
1936
  /** Content locales, when the config has `localization`. */
@@ -1859,4 +1979,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1859
1979
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1860
1980
  declare function validateConfig(config: Config): ConfigIssue[];
1861
1981
 
1862
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1982
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailField, type EmailMessage, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -209,6 +209,14 @@ var PayloadTooLargeError = class extends EasyCMSError {
209
209
  };
210
210
 
211
211
  // src/fields.ts
212
+ var isHasMany = (field) => (field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true;
213
+ function mimeAllowedBy(mimeType, patterns) {
214
+ const type = mimeType.toLowerCase();
215
+ return patterns.some((pattern) => {
216
+ const p = pattern.toLowerCase();
217
+ return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
218
+ });
219
+ }
212
220
  var hasRows = (field) => field.type === "array" || field.type === "blocks";
213
221
  function rowFields(field, row) {
214
222
  if (field.type === "array") return field.fields;
@@ -552,6 +560,58 @@ var emailDeliveriesCollection = {
552
560
  ]
553
561
  };
554
562
 
563
+ // src/auth/emails.ts
564
+ var esc = (value) => value.replace(
565
+ /[&<>"']/g,
566
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
567
+ );
568
+ function email(subject, lines, button) {
569
+ const text = [...lines, ...button ? ["", button.url] : []].join("\n");
570
+ const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2328">${lines.map((line) => `<p>${esc(line)}</p>`).join("")}${button ? `<p><a href="${esc(button.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2f6f5e;color:#fff;text-decoration:none;font-weight:600">${esc(button.label)}</a></p><p style="font-size:13px;color:#656d76">${esc(button.url)}</p>` : ""}</div>`;
571
+ return { subject, text, html };
572
+ }
573
+ var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
574
+ var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
575
+ var DEFAULT_PASSWORD_EMAILS = {
576
+ resetPassword: ({ url = "", locale, expiresAt }) => locale === "th" ? email(
577
+ "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48",
578
+ [
579
+ "\u0E21\u0E35\u0E04\u0E33\u0E02\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",
580
+ `\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E04\u0E23\u0E31\u0E49\u0E07\u0E40\u0E14\u0E35\u0E22\u0E27\u0E20\u0E32\u0E22\u0E43\u0E19 ${hours(expiresAt)} \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07 \u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E02\u0E2D \u0E44\u0E21\u0E48\u0E15\u0E49\u0E2D\u0E07\u0E17\u0E33\u0E2D\u0E30\u0E44\u0E23 \u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E14\u0E34\u0E21\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49`
581
+ ],
582
+ { label: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48", url }
583
+ ) : email(
584
+ "Reset your password",
585
+ [
586
+ "Someone asked to reset the password of your account.",
587
+ `The link works once, within ${hours(expiresAt)} hour(s). If it wasn't you, ignore this email: your password stays the same.`
588
+ ],
589
+ { label: "Set a new password", url }
590
+ ),
591
+ invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
592
+ "\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
593
+ [
594
+ `\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
595
+ `\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E19\u0E35\u0E49\u0E43\u0E0A\u0E49\u0E44\u0E14\u0E49\u0E20\u0E32\u0E22\u0E43\u0E19 ${days(expiresAt)} \u0E27\u0E31\u0E19`
596
+ ],
597
+ { label: "\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19", url }
598
+ ) : email(
599
+ "You're invited to the content admin",
600
+ [
601
+ `An account for ${user.email} is waiting for you. Set a password to start.`,
602
+ `The link works within ${days(expiresAt)} day(s).`
603
+ ],
604
+ { label: "Set your password", url }
605
+ ),
606
+ passwordChanged: ({ locale }) => locale === "th" ? email("\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E16\u0E39\u0E01\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E25\u0E49\u0E27", [
607
+ "\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E02\u0E2D\u0E07\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E04\u0E38\u0E13\u0E40\u0E1E\u0E34\u0E48\u0E07\u0E16\u0E39\u0E01\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19 \u0E41\u0E25\u0E30\u0E17\u0E38\u0E01\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E17\u0E35\u0E48 login \u0E2D\u0E22\u0E39\u0E48\u0E16\u0E39\u0E01\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A\u0E41\u0E25\u0E49\u0E27",
608
+ "\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E40\u0E2D\u0E07 \u0E43\u0E2B\u0E49\u0E02\u0E2D\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E43\u0E2B\u0E21\u0E48\u0E17\u0E31\u0E19\u0E17\u0E35\u0E41\u0E25\u0E30\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A"
609
+ ]) : email("Your password was changed", [
610
+ "The password of your account was just changed, and every device that was logged in has been signed out.",
611
+ "If it wasn't you, reset your password now and tell your administrator."
612
+ ])
613
+ };
614
+
555
615
  // src/auth/password.ts
556
616
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
557
617
  var MIN_PASSWORD_LENGTH = 8;
@@ -649,9 +709,38 @@ function verifyPreviewToken(secret, token, now = Date.now()) {
649
709
  return null;
650
710
  }
651
711
  }
712
+ function passwordFingerprint(passwordHash) {
713
+ return createHash2("sha256").update(typeof passwordHash === "string" ? passwordHash : "no-password").digest("base64url").slice(0, 22);
714
+ }
715
+ function signPasswordToken(secret, args) {
716
+ const payload = Buffer.from(
717
+ JSON.stringify({ u: args.userId, p: args.purpose, e: args.expiresAt })
718
+ ).toString("base64url");
719
+ return `${payload}.${hmac(secret, `password:${payload}:${args.fingerprint}`)}`;
720
+ }
721
+ function readPasswordToken(token) {
722
+ const dot = token.lastIndexOf(".");
723
+ if (dot <= 0) return null;
724
+ const payload = token.slice(0, dot);
725
+ try {
726
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
727
+ if (typeof data.u !== "string" || typeof data.e !== "number") return null;
728
+ if (data.p !== "reset" && data.p !== "invite") return null;
729
+ return { userId: data.u, purpose: data.p, expiresAt: data.e, payload };
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
734
+ function passwordTokenMatches(secret, token, fingerprint) {
735
+ const dot = token.lastIndexOf(".");
736
+ if (dot <= 0) return false;
737
+ const payload = token.slice(0, dot);
738
+ return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
739
+ }
652
740
 
653
741
  // src/auth/auth.ts
654
742
  var INVALID = "Invalid email or password";
743
+ var RESET_REQUESTS = 3;
655
744
  var Auth = class {
656
745
  constructor(cms) {
657
746
  this.cms = cms;
@@ -665,13 +754,13 @@ var Auth = class {
665
754
  }
666
755
  /** Checks credentials and starts a session. Throws `UnauthorizedError` or `TooManyRequestsError`. */
667
756
  async login(args) {
668
- const email = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
757
+ const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
669
758
  const password = typeof args.password === "string" ? args.password : "";
670
- const key2 = `${email}|${args.ip ?? ""}`;
759
+ const key2 = `${email2}|${args.ip ?? ""}`;
671
760
  await this.checkRateLimit(key2);
672
- const [user] = email ? (await this.db.find({
761
+ const [user] = email2 ? (await this.db.find({
673
762
  collection: USERS,
674
- where: { email: { equals: email } },
763
+ where: { email: { equals: email2 } },
675
764
  sort: [],
676
765
  limit: 1,
677
766
  page: 1
@@ -751,6 +840,131 @@ var Auth = class {
751
840
  for (const session of sessions.docs)
752
841
  await this.db.delete({ collection: SESSIONS, id: session.id });
753
842
  }
843
+ // --- Password links: forgotten passwords and invitations ------------------------------------
844
+ /**
845
+ * The origin password links point to: `serverURL`, or the request's origin in development. In
846
+ * production the request's Host can't be trusted (a forged one would send the link elsewhere).
847
+ */
848
+ passwordLinkBase(origin) {
849
+ const configured = this.config.serverURL;
850
+ if (configured) return configured.replace(/\/+$/, "");
851
+ if (process.env.NODE_ENV !== "production" && origin) return origin;
852
+ return null;
853
+ }
854
+ /** Whether "forgot password" and invitations work: `email` is set and links have an origin. */
855
+ canSendPasswordLinks(origin) {
856
+ return this.config.email !== void 0 && this.passwordLinkBase(origin) !== null;
857
+ }
858
+ /**
859
+ * Emails a link to set a new password, if an active account has this email. Always resolves the
860
+ * same way, so callers can't tell whether the email has an account; extra requests within
861
+ * `lockWindow` are ignored.
862
+ */
863
+ async requestPasswordReset(args) {
864
+ const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
865
+ if (!email2 || !this.canSendPasswordLinks(args.origin)) return;
866
+ const key2 = `reset:${email2}|${args.ip ?? ""}`;
867
+ const recent = await this.db.count({
868
+ collection: LOGIN_ATTEMPTS,
869
+ where: { key: { equals: key2 }, createdAt: { gt: this.windowStart() } }
870
+ });
871
+ if (recent >= RESET_REQUESTS) return;
872
+ await this.recordFailure(key2);
873
+ const user = await this.findByEmail(email2);
874
+ if (!user || user.active === false) return;
875
+ await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
876
+ }
877
+ /**
878
+ * Emails a user a link to set their password: an invitation if they have none yet, else a
879
+ * reset link. For admins (the REST API checks the role). Returns which one was sent.
880
+ */
881
+ async sendPasswordLink(userId, options = {}) {
882
+ if (!this.canSendPasswordLinks(options.origin))
883
+ throw new QueryError(
884
+ "Password links need `email` in the config, and `serverURL` in production"
885
+ );
886
+ const user = await this.db.findById({ collection: USERS, id: userId });
887
+ if (!user) throw new NotFoundError(USERS, userId);
888
+ if (user.active === false) throw new QueryError("This user is deactivated");
889
+ const purpose = user.passwordHash ? "reset" : "invite";
890
+ await this.mailPasswordLink(user, purpose, options);
891
+ return purpose;
892
+ }
893
+ /** The user and purpose of a valid, unused password link, or `null`. */
894
+ async checkPasswordToken(token) {
895
+ const found = await this.passwordTokenUser(token);
896
+ return found ? { user: await this.toAuthUser(found.user), purpose: found.purpose } : null;
897
+ }
898
+ /**
899
+ * Sets the password from a link, ends every session of the account and starts one here.
900
+ * Throws `ValidationError` when the link expired or was used, or the password is too short.
901
+ */
902
+ async resetPassword(args) {
903
+ const key2 = `reset-token|${args.ip ?? ""}`;
904
+ await this.checkRateLimit(key2);
905
+ const found = await this.passwordTokenUser(String(args.token ?? ""));
906
+ if (!found) {
907
+ await this.recordFailure(key2);
908
+ throw new ValidationError(USERS, [
909
+ { field: "token", message: "This link has expired or was already used" }
910
+ ]);
911
+ }
912
+ await this.cms.update(USERS, found.user.id, { password: args.password });
913
+ await this.clearFailures(key2);
914
+ const user = await this.db.findById({ collection: USERS, id: found.user.id });
915
+ const session = await this.startSession(user);
916
+ if (found.purpose === "reset") {
917
+ const content = await (this.config.auth.emails.passwordChanged ?? DEFAULT_PASSWORD_EMAILS.passwordChanged)({
918
+ user: await this.toAuthUser(user),
919
+ locale: this.localeOf(args.locale)
920
+ });
921
+ await this.cms.sendEmail({ to: String(user.email), ...content });
922
+ }
923
+ return { ...session, purpose: found.purpose };
924
+ }
925
+ async passwordTokenUser(token) {
926
+ const claims = readPasswordToken(token);
927
+ if (!claims || claims.expiresAt <= Date.now()) return null;
928
+ const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
929
+ const user = await this.db.findById({ collection: USERS, id });
930
+ if (!user || user.active === false) return null;
931
+ const fingerprint = passwordFingerprint(user.passwordHash);
932
+ if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
933
+ return { user, purpose: claims.purpose };
934
+ }
935
+ async mailPasswordLink(user, purpose, options) {
936
+ const base = this.passwordLinkBase(options.origin);
937
+ const seconds = purpose === "invite" ? this.config.auth.inviteExpiration : this.config.auth.resetPasswordExpiration;
938
+ const expiresAt = Date.now() + seconds * 1e3;
939
+ const token = signPasswordToken(this.config.secret, {
940
+ userId: String(user.id),
941
+ purpose,
942
+ expiresAt,
943
+ fingerprint: passwordFingerprint(user.passwordHash)
944
+ });
945
+ const url = `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
946
+ const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
947
+ const content = await write({
948
+ user: await this.toAuthUser(user),
949
+ url,
950
+ locale: this.localeOf(options.locale),
951
+ expiresAt: new Date(expiresAt)
952
+ });
953
+ await this.cms.sendEmail({ to: String(user.email), ...content });
954
+ }
955
+ localeOf(locale) {
956
+ return locale === "th" || locale === "en" ? locale : this.config.admin.locale;
957
+ }
958
+ async findByEmail(email2) {
959
+ const { docs } = await this.db.find({
960
+ collection: USERS,
961
+ where: { email: { equals: email2 } },
962
+ sort: [],
963
+ limit: 1,
964
+ page: 1
965
+ });
966
+ return docs[0];
967
+ }
754
968
  /** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
755
969
  async verifyApiKey(token) {
756
970
  if (!this.cms.config.collections.some((c) => c.slug === API_KEYS)) return null;
@@ -997,6 +1211,10 @@ async function copyDatabase(source, target, options = {}) {
997
1211
  import { randomUUID } from "crypto";
998
1212
  var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
999
1213
  var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1214
+ function itemsOf(field, n) {
1215
+ const unit = field.type === "upload" ? "file" : field.type === "relationship" ? "document" : "row";
1216
+ return `${n} ${unit}${n === 1 ? "" : "s"}`;
1217
+ }
1000
1218
  var isEmpty = (value) => value === void 0 || value === null || value === "";
1001
1219
  function slugify(text) {
1002
1220
  return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
@@ -1162,6 +1380,8 @@ async function validateFields(fields, input, options, prefix = "") {
1162
1380
  }
1163
1381
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
1164
1382
  if (field.required && !options.skipRequired) fail("is required");
1383
+ else if (!options.skipRequired && "minRows" in field && field.minRows)
1384
+ fail(`must have at least ${itemsOf(field, field.minRows)}`);
1165
1385
  data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
1166
1386
  if (!field.required) await runCustom(field, null, options, fail);
1167
1387
  continue;
@@ -1173,9 +1393,6 @@ async function validateFields(fields, input, options, prefix = "") {
1173
1393
  }
1174
1394
  return { data, errors, references };
1175
1395
  }
1176
- function isHasMany(field) {
1177
- return (field.type === "select" || field.type === "relationship") && field.hasMany === true;
1178
- }
1179
1396
  async function runCustom(field, value, options, fail) {
1180
1397
  if (!field.validate) return;
1181
1398
  const validate = field.validate;
@@ -1231,9 +1448,13 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1231
1448
  case "upload":
1232
1449
  case "relationship": {
1233
1450
  const target = field.type === "upload" ? "media" : field.to;
1234
- const many = field.type === "relationship" && field.hasMany === true;
1451
+ const many = field.hasMany === true;
1235
1452
  const values = many ? raw : [raw];
1236
1453
  if (!Array.isArray(values)) return fail("must be an array of ids");
1454
+ if (many && field.minRows !== void 0 && values.length < field.minRows)
1455
+ return fail(`must have at least ${itemsOf(field, field.minRows)}`);
1456
+ if (many && field.maxRows !== void 0 && values.length > field.maxRows)
1457
+ return fail(`must have at most ${itemsOf(field, field.maxRows)}`);
1237
1458
  const ids = [];
1238
1459
  for (const item of values) {
1239
1460
  const id = parseId(isPlainObject(item) ? item.id : item);
@@ -1244,7 +1465,8 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1244
1465
  field: path,
1245
1466
  collection: target,
1246
1467
  id,
1247
- ...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {}
1468
+ ...field.type === "relationship" && field.filterOptions ? { filterOptions: field.filterOptions } : {},
1469
+ ...field.type === "upload" && field.mimeTypes ? { mimeTypes: field.mimeTypes } : {}
1248
1470
  });
1249
1471
  }
1250
1472
  return many ? ids : ids[0];
@@ -1785,12 +2007,36 @@ function validateAuth(config, add) {
1785
2007
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
1786
2008
  }
1787
2009
  }
1788
- for (const key2 of ["tokenExpiration", "maxLoginAttempts", "lockWindow"]) {
2010
+ for (const key2 of [
2011
+ "tokenExpiration",
2012
+ "maxLoginAttempts",
2013
+ "lockWindow",
2014
+ "resetPasswordExpiration",
2015
+ "inviteExpiration"
2016
+ ]) {
1789
2017
  const value = auth[key2];
1790
2018
  if (value !== void 0 && !(Number.isInteger(value) && value > 0)) {
1791
2019
  add(`auth.${key2}`, "must be a positive integer");
1792
2020
  }
1793
2021
  }
2022
+ if (auth.emails !== void 0) {
2023
+ const emails = auth.emails;
2024
+ if (typeof emails !== "object" || emails === null) add("auth.emails", "must be an object");
2025
+ else
2026
+ for (const [name, fn] of Object.entries(emails)) {
2027
+ if (!["resetPassword", "invite", "passwordChanged"].includes(name))
2028
+ add(
2029
+ `auth.emails.${name}`,
2030
+ "unknown email",
2031
+ "use resetPassword, invite or passwordChanged"
2032
+ );
2033
+ else if (typeof fn !== "function")
2034
+ add(
2035
+ `auth.emails.${name}`,
2036
+ "must be a function ({ user, url, locale }) => { subject, text }"
2037
+ );
2038
+ }
2039
+ }
1794
2040
  if (auth.trustedOrigins !== void 0) {
1795
2041
  for (const [i, origin] of auth.trustedOrigins.entries()) {
1796
2042
  if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
@@ -1882,8 +2128,8 @@ function validateLocalization(config, add) {
1882
2128
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1883
2129
  }
1884
2130
  function validateWebhooks(config, add) {
1885
- const email = config.email;
1886
- if (email !== void 0 && (typeof email !== "object" || email === null || typeof email.send !== "function"))
2131
+ const email2 = config.email;
2132
+ if (email2 !== void 0 && (typeof email2 !== "object" || email2 === null || typeof email2.send !== "function"))
1887
2133
  add(
1888
2134
  "email",
1889
2135
  "must be an email adapter with a send() function",
@@ -2060,6 +2306,13 @@ function validateList(list, container, path, add) {
2060
2306
  );
2061
2307
  }
2062
2308
  }
2309
+ function validateManyRange(field, path, add) {
2310
+ if ((field.minRows !== void 0 || field.maxRows !== void 0) && !field.hasMany) {
2311
+ add(path, "minRows and maxRows need hasMany: true");
2312
+ return;
2313
+ }
2314
+ checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
2315
+ }
2063
2316
  function validateField(field, path, siblings, collectionSlugs, add) {
2064
2317
  const position = field.position;
2065
2318
  if (position !== void 0 && position !== "sidebar") {
@@ -2115,6 +2368,16 @@ function validateField(field, path, siblings, collectionSlugs, add) {
2115
2368
  `use one of: ${[...collectionSlugs].join(", ")}`
2116
2369
  );
2117
2370
  }
2371
+ validateManyRange(field, path, add);
2372
+ break;
2373
+ case "upload":
2374
+ validateManyRange(field, path, add);
2375
+ if (field.mimeTypes !== void 0) {
2376
+ const types = field.mimeTypes;
2377
+ if (!Array.isArray(types) || types.length === 0 || !types.every((t) => typeof t === "string" && /^[\w.+-]+\/(\*|[\w.+-]+)$/.test(t))) {
2378
+ add(`${path}.mimeTypes`, "must be a list of MIME types", "e.g. mimeTypes: ['image/*']");
2379
+ }
2380
+ }
2118
2381
  break;
2119
2382
  case "array":
2120
2383
  case "group":
@@ -2367,7 +2630,10 @@ async function resolveConfig(input) {
2367
2630
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
2368
2631
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
2369
2632
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
2370
- trustedOrigins: config.auth?.trustedOrigins ?? []
2633
+ trustedOrigins: config.auth?.trustedOrigins ?? [],
2634
+ resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
2635
+ inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
2636
+ emails: config.auth?.emails ?? {}
2371
2637
  },
2372
2638
  collections: [
2373
2639
  ...config.collections ?? [],
@@ -3612,18 +3878,18 @@ var EasyCMS = class {
3612
3878
  /** Emails queued until sent, stored in `email-deliveries`. */
3613
3879
  emailQueue() {
3614
3880
  const collection2 = EMAIL_DELIVERIES;
3615
- const toRow = (email, createdAt) => {
3881
+ const toRow = (email2, createdAt) => {
3616
3882
  const now = (/* @__PURE__ */ new Date()).toISOString();
3617
- return { ...email, createdAt: createdAt ?? now, updatedAt: now };
3883
+ return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
3618
3884
  };
3619
3885
  return {
3620
- add: async (email) => (await this.db.create({ collection: collection2, data: toRow(email) })).id,
3621
- update: async (id, email) => {
3886
+ add: async (email2) => (await this.db.create({ collection: collection2, data: toRow(email2) })).id,
3887
+ update: async (id, email2) => {
3622
3888
  const current = await this.db.findById({ collection: collection2, id });
3623
3889
  await this.db.update({
3624
3890
  collection: collection2,
3625
3891
  id,
3626
- data: toRow(email, current?.createdAt)
3892
+ data: toRow(email2, current?.createdAt)
3627
3893
  });
3628
3894
  },
3629
3895
  remove: async (id) => {
@@ -4008,9 +4274,6 @@ var EasyCMS = class {
4008
4274
  if (!allowed2) throw deny(guard.user);
4009
4275
  }
4010
4276
  const { input, password } = splitPassword(config, raw);
4011
- if (config.slug === USERS && password === void 0) {
4012
- throw new ValidationError(collection2, [{ field: "password", message: "is required" }]);
4013
- }
4014
4277
  const filtered = await filterInput(
4015
4278
  config.fields,
4016
4279
  input,
@@ -4343,10 +4606,20 @@ var EasyCMS = class {
4343
4606
  collection: collection2,
4344
4607
  ids: [...new Set(refs.map((r) => r.id))]
4345
4608
  });
4346
- const ids = new Set(found.map((d) => d.id));
4609
+ const byId = new Map(found.map((d) => [d.id, d]));
4347
4610
  for (const ref of refs) {
4348
- if (!ids.has(ref.id)) {
4611
+ const doc = byId.get(ref.id);
4612
+ if (!doc) {
4349
4613
  errors.push({ field: ref.field, message: `${collection2} ${ref.id} does not exist` });
4614
+ continue;
4615
+ }
4616
+ const type = typeof doc.mimeType === "string" ? doc.mimeType : "";
4617
+ if (ref.mimeTypes && !mimeAllowedBy(type, ref.mimeTypes)) {
4618
+ const images = ref.mimeTypes.every((t) => t.toLowerCase() === "image/*");
4619
+ errors.push({
4620
+ field: ref.field,
4621
+ message: images ? `must be an image (${doc.filename ?? ref.id} is ${type || "not one"})` : `must be ${ref.mimeTypes.join(", ")} (${doc.filename ?? ref.id} is ${type || "unknown"})`
4622
+ });
4350
4623
  }
4351
4624
  }
4352
4625
  }
@@ -4492,10 +4765,16 @@ async function serializeFields(fields, update, localized) {
4492
4765
  case "relationship":
4493
4766
  f.to = field.to;
4494
4767
  if (field.hasMany) f.hasMany = true;
4768
+ if (field.minRows !== void 0) f.minRows = field.minRows;
4769
+ if (field.maxRows !== void 0) f.maxRows = field.maxRows;
4495
4770
  if (field.filterOptions) f.filtered = true;
4496
4771
  break;
4497
4772
  case "upload":
4498
4773
  f.to = "media";
4774
+ if (field.hasMany) f.hasMany = true;
4775
+ if (field.minRows !== void 0) f.minRows = field.minRows;
4776
+ if (field.maxRows !== void 0) f.maxRows = field.maxRows;
4777
+ if (field.mimeTypes) f.mimeTypes = [...field.mimeTypes];
4499
4778
  break;
4500
4779
  case "array":
4501
4780
  if (field.minRows !== void 0) f.minRows = field.minRows;
@@ -4573,12 +4852,13 @@ async function global(config, user, localized) {
4573
4852
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
4574
4853
  return result;
4575
4854
  }
4576
- async function adminSchema(cms, user) {
4855
+ async function adminSchema(cms, user, origin) {
4577
4856
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
4578
4857
  const localization = cms.config.localization;
4579
4858
  const localized = localization !== null;
4580
4859
  return {
4581
4860
  locale: cms.config.admin.locale,
4861
+ passwordLinks: cms.auth.canSendPasswordLinks(origin),
4582
4862
  menu: [...cms.config.admin.menu],
4583
4863
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
4584
4864
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
@@ -4888,7 +5168,46 @@ async function route(cms, ctx, method, segments, options) {
4888
5168
  return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
4889
5169
  }
4890
5170
  case "GET init":
4891
- return { body: { hasUsers: await cms.auth.hasUsers() } };
5171
+ return {
5172
+ body: {
5173
+ hasUsers: await cms.auth.hasUsers(),
5174
+ // The login page offers "Forgot password?" only when the email can be sent.
5175
+ passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
5176
+ }
5177
+ };
5178
+ // Forgot password: the same answer whether or not the email has an account.
5179
+ case "POST forgot-password": {
5180
+ const body = await readJson(ctx.request);
5181
+ await cms.auth.requestPasswordReset({
5182
+ email: String(body.email ?? ""),
5183
+ ip: options.getClientIp?.(ctx.request),
5184
+ origin: ctx.url.origin,
5185
+ locale: typeof body.locale === "string" ? body.locale : void 0
5186
+ });
5187
+ return { body: { message: "If an account has this email, a link is on its way." } };
5188
+ }
5189
+ // The page behind a password link: which account, and whether the link still works.
5190
+ case "GET reset-password": {
5191
+ const found = await cms.auth.checkPasswordToken(ctx.url.searchParams.get("token") ?? "");
5192
+ if (!found)
5193
+ throw new ValidationError(USERS, [
5194
+ { field: "token", message: "This link has expired or was already used" }
5195
+ ]);
5196
+ return { body: { email: found.user.email, purpose: found.purpose } };
5197
+ }
5198
+ case "POST reset-password": {
5199
+ const body = await readJson(ctx.request);
5200
+ const session = await cms.auth.resetPassword({
5201
+ token: String(body.token ?? ""),
5202
+ password: String(body.password ?? ""),
5203
+ ip: options.getClientIp?.(ctx.request),
5204
+ locale: typeof body.locale === "string" ? body.locale : void 0
5205
+ });
5206
+ setSessionCookies(cms, ctx, session);
5207
+ return {
5208
+ body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
5209
+ };
5210
+ }
4892
5211
  case "POST first-register": {
4893
5212
  const body = await readJson(ctx.request);
4894
5213
  const session = await cms.auth.registerFirstUser({
@@ -4919,7 +5238,7 @@ async function route(cms, ctx, method, segments, options) {
4919
5238
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
4920
5239
  if (!ctx.user) throw new UnauthorizedError();
4921
5240
  if (second === "schema" && third === void 0)
4922
- return { body: await adminSchema(cms, ctx.user) };
5241
+ return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
4923
5242
  if (second === "modules" && third !== void 0 && segments.length === 3) {
4924
5243
  const index = /^(\d+)\.js$/.exec(third)?.[1];
4925
5244
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -5055,6 +5374,16 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
5055
5374
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
5056
5375
  const depth = parseDepth(ctx.url);
5057
5376
  const [action, versionId, extra] = path;
5377
+ if (collection2 === USERS && action === "password-link" && path.length === 1) {
5378
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
5379
+ if (ctx.user?.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
5380
+ const body = await readJson(ctx.request).catch(() => ({}));
5381
+ const sent = await cms.auth.sendPasswordLink(/^\d+$/.test(id) ? Number(id) : id, {
5382
+ origin: ctx.url.origin,
5383
+ locale: typeof body.locale === "string" ? body.locale : void 0
5384
+ });
5385
+ return { body: { sent } };
5386
+ }
5058
5387
  if (action === "versions" && versionId === void 0) {
5059
5388
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
5060
5389
  return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
@@ -5370,8 +5699,10 @@ function fieldType(field, n, indent) {
5370
5699
  const union = field.options.map((o) => quote(typeof o === "string" ? o : o.value)).join(" | ") || "string";
5371
5700
  return field.hasMany ? `(${union})[]` : union;
5372
5701
  }
5373
- case "upload":
5374
- return `ID | ${n.collections.get(MEDIA) ?? "Record<string, unknown>"}`;
5702
+ case "upload": {
5703
+ const one = `ID | ${n.collections.get(MEDIA) ?? "Record<string, unknown>"}`;
5704
+ return field.hasMany ? `(${one})[]` : one;
5705
+ }
5375
5706
  case "relationship": {
5376
5707
  const target = n.collections.get(field.to);
5377
5708
  const one = target ? `ID | ${target}` : "ID";
@@ -5403,7 +5734,7 @@ function fieldLines(fields, n, indent) {
5403
5734
  for (const field of fields) {
5404
5735
  if (field.hidden) continue;
5405
5736
  const type = fieldType(field, n, indent);
5406
- const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
5737
+ const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || isHasMany(field);
5407
5738
  if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
5408
5739
  else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
5409
5740
  }
@@ -5496,6 +5827,7 @@ export {
5496
5827
  DEFAULT_DEPTH,
5497
5828
  DEFAULT_MAX_FILE_SIZE,
5498
5829
  DEFAULT_MAX_VERSIONS,
5830
+ DEFAULT_PASSWORD_EMAILS,
5499
5831
  DEFAULT_TOKEN_EXPIRATION,
5500
5832
  EXTENSIONS,
5501
5833
  EasyCMS,
@@ -5536,12 +5868,14 @@ export {
5536
5868
  imageDimensions,
5537
5869
  importConfig,
5538
5870
  isAdmin,
5871
+ isHasMany,
5539
5872
  isLoggedIn,
5540
5873
  keyAllows,
5541
5874
  loadConfig,
5542
5875
  localStorage,
5543
5876
  mergeForUpdate,
5544
5877
  mimeAllowed,
5878
+ mimeAllowedBy,
5545
5879
  parseId,
5546
5880
  populate,
5547
5881
  readCookie,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.22.2",
3
+ "version": "0.24.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",