@easy-cms/core 0.23.0 → 0.25.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +175 -19
  2. package/dist/index.js +304 -19
  3. package/package.json +1 -1
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;
@@ -364,16 +439,41 @@ interface BuiltinApiKeysCollection {
364
439
  type ApiKeysOf<C extends Config> = C extends {
365
440
  readonly apiKeys: true;
366
441
  } ? BuiltinApiKeysCollection : never;
367
- /** Every collection of a config, including the built-in ones. */
368
- type AllCollections<C extends Config> = NonNullable<C['collections']>[number] | BuiltinUsersCollection | BuiltinMediaCollection | ApiKeysOf<C>;
442
+ /** The `PluginTypes` of each plugin in the config (a union). */
443
+ type PluginTypesOf<C extends Config> = C extends {
444
+ readonly plugins: readonly (infer P)[];
445
+ } ? P extends TypedPlugin<infer T extends PluginTypes> ? T : never : never;
446
+ /** Fields that plugins add to the collection (or global) `S`, as a union. */
447
+ type AddedFields<T, S> = T extends {
448
+ readonly [K in 'fields' | 'globalFields']?: infer M;
449
+ } ? M extends {
450
+ readonly [slug: string]: readonly Field[];
451
+ } ? string extends keyof M ? never : S extends keyof M ? M[S][number] : never : never : never;
452
+ type PluginFields<C extends Config, S, Key extends 'fields' | 'globalFields'> = AddedFields<PluginTypesOf<C> extends infer T ? T extends PluginTypes ? Pick<T, Key & keyof T> : never : never, S>;
453
+ /** Collections that plugins add. */
454
+ type PluginCollections<C extends Config> = PluginTypesOf<C> extends infer T ? T extends {
455
+ readonly collections: readonly (infer K extends CollectionConfig)[];
456
+ } ? K : never : never;
457
+ /** A collection or global with the fields plugins add to it. */
458
+ type WithPluginFields<C extends Config, T extends {
459
+ readonly slug: string;
460
+ readonly fields: readonly Field[];
461
+ }, Key extends 'fields' | 'globalFields'> = [PluginFields<C, T['slug'], Key>] extends [never] ? T : Omit<T, 'fields'> & {
462
+ readonly fields: readonly (T['fields'][number] | PluginFields<C, T['slug'], Key>)[];
463
+ };
464
+ /** Every collection of a config, including the built-in ones and those plugins add. */
465
+ type AllCollections<C extends Config> = NonNullable<C['collections']>[number] | BuiltinUsersCollection | BuiltinMediaCollection | ApiKeysOf<C> | PluginCollections<C>;
369
466
  /** The built-in media collection; its documents are typed as `MediaDocument`. */
370
467
  interface BuiltinMediaCollection {
371
468
  readonly slug: 'media';
372
469
  readonly fields: readonly [];
373
470
  }
374
- type CollectionBySlug<C extends Config, S> = Extract<AllCollections<C>, {
471
+ type CollectionBySlug<C extends Config, S> = WithPluginFields<C, Extract<AllCollections<C>, {
375
472
  readonly slug: S;
376
- }>;
473
+ }>, 'fields'>;
474
+ type GlobalBySlug<C extends Config, S> = WithPluginFields<C, Extract<NonNullable<C['globals']>[number], {
475
+ readonly slug: S;
476
+ }>, 'globalFields'>;
377
477
  /** Relationships are ids at depth 0 and documents when populated. */
378
478
  type RelationValue<C extends Config, S> = [CollectionBySlug<C, S>] extends [never] ? ID | Record<string, unknown> : ID | CollectionDocument<C, S & string>;
379
479
  interface MediaSize {
@@ -469,7 +569,7 @@ type FieldsValue<Fs extends readonly Field[], C extends Config = Config> = Simpl
469
569
  -readonly [F in OptionalFields<Fs> as F['name']]?: FieldValue<F, C> | null;
470
570
  }>;
471
571
  type SystemFields<T extends {
472
- readonly drafts?: boolean;
572
+ readonly drafts?: unknown;
473
573
  }> = {
474
574
  id: ID;
475
575
  createdAt: string;
@@ -479,8 +579,13 @@ type SystemFields<T extends {
479
579
  } ? {
480
580
  status: 'draft' | 'published';
481
581
  } : unknown);
482
- type InferCollection<T extends CollectionConfig, C extends Config = Config> = Simplify<SystemFields<T> & FieldsValue<T['fields'], C>>;
483
- type InferGlobal<T extends GlobalConfig, C extends Config = Config> = Simplify<
582
+ /** What document types are inferred from: a collection's or global's fields and drafts. */
583
+ type FieldsOwner = {
584
+ readonly fields: readonly Field[];
585
+ readonly drafts?: unknown;
586
+ };
587
+ type InferCollection<T extends FieldsOwner, C extends Config = Config> = Simplify<SystemFields<T> & FieldsValue<T['fields'], C>>;
588
+ type InferGlobal<T extends FieldsOwner, C extends Config = Config> = Simplify<
484
589
  /** `null` until the global is saved for the first time. */
485
590
  {
486
591
  updatedAt: string | null;
@@ -489,14 +594,12 @@ type InferGlobal<T extends GlobalConfig, C extends Config = Config> = Simplify<
489
594
  } ? {
490
595
  status: 'draft' | 'published';
491
596
  } : unknown) & FieldsValue<T['fields'], C>>;
492
- type CollectionSlug<C extends Config> = NonNullable<C['collections']>[number]['slug'] | 'users' | 'media' | ApiKeysOf<C>['slug'];
597
+ type CollectionSlug<C extends Config> = NonNullable<C['collections']>[number]['slug'] | 'users' | 'media' | ApiKeysOf<C>['slug'] | PluginCollections<C>['slug'];
493
598
  type GlobalSlug<C extends Config> = NonNullable<C['globals']>[number]['slug'];
494
599
  /** Document type of a collection, e.g. `CollectionDocument<typeof config, 'posts'>`. */
495
600
  type CollectionDocument<C extends Config, S extends CollectionSlug<C>> = S extends 'media' ? MediaDocument : InferCollection<CollectionBySlug<C, S>, C>;
496
601
  /** Data type of a global, e.g. `GlobalDocument<typeof config, 'site'>`. */
497
- type GlobalDocument<C extends Config, S extends GlobalSlug<C>> = InferGlobal<Extract<NonNullable<C['globals']>[number], {
498
- readonly slug: S;
499
- }>, C>;
602
+ type GlobalDocument<C extends Config, S extends GlobalSlug<C>> = InferGlobal<GlobalBySlug<C, S>, C>;
500
603
  type InputValue<F extends Field> = F extends {
501
604
  readonly type: 'relationship';
502
605
  } ? F extends {
@@ -541,22 +644,21 @@ type StatusInput<T> = T extends {
541
644
  } ? {
542
645
  status?: 'draft' | 'published';
543
646
  } : unknown;
544
- /** Users are created with a password, which is hashed and never returned. */
647
+ /**
648
+ * A user's password, hashed and never returned. Optional when creating: a user without one sets
649
+ * it from an invitation link.
650
+ */
545
651
  type PasswordInput<S, Required extends boolean> = S extends 'users' ? Required extends true ? {
546
652
  password: string;
547
653
  } : {
548
654
  password?: string;
549
655
  } : unknown;
550
656
  /** Data accepted by `create`, e.g. `CreateInput<typeof config, 'posts'>`. */
551
- type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, true>>;
657
+ type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, false>>;
552
658
  /** Data accepted by `update`: any subset of `CreateInput`. */
553
659
  type UpdateInput<C extends Config, S extends CollectionSlug<C>> = Partial<CreateInput<C, S>>;
554
660
  /** Data accepted by `updateGlobal`. */
555
- type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<FieldsInput<Extract<NonNullable<C['globals']>[number], {
556
- readonly slug: S;
557
- }>['fields']> & StatusInput<Extract<NonNullable<C['globals']>[number], {
558
- readonly slug: S;
559
- }>>>>;
661
+ type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<FieldsInput<GlobalBySlug<C, S>['fields']> & StatusInput<GlobalBySlug<C, S>>>>;
560
662
 
561
663
  interface StoredFile {
562
664
  readonly body: Uint8Array;
@@ -612,6 +714,13 @@ type Create<C extends Config, S> = string extends CollectionSlug<C> ? Data$2 : C
612
714
  type Update<C extends Config, S> = string extends CollectionSlug<C> ? Data$2 : UpdateInput<C, S & CollectionSlug<C>>;
613
715
  type GDoc<C extends Config, S> = string extends GlobalSlug<C> ? Data$2 : GlobalDocument<C, S & GlobalSlug<C>>;
614
716
  type GInput<C extends Config, S> = string extends GlobalSlug<C> ? Data$2 : GlobalInput<C, S & GlobalSlug<C>>;
717
+ /** A collection slug of the config `C` (any string when the config isn't a literal). */
718
+ type SlugOf<C extends Config> = Slug<C>;
719
+ /**
720
+ * A document of collection `S` as the Local API returns it, e.g. for helpers that take an
721
+ * `EasyCMS<C>`: inferred from a literal config, loose otherwise.
722
+ */
723
+ type DocumentOf<C extends Config, S> = Doc<C, S>;
615
724
  interface AccessOptions {
616
725
  /**
617
726
  * The Local API trusts its caller and skips access rules by default.
@@ -1370,6 +1479,19 @@ interface AuthConfig {
1370
1479
  * Needed when the admin or frontend is served from another origin, or behind a proxy that rewrites the host.
1371
1480
  */
1372
1481
  readonly trustedOrigins?: readonly string[];
1482
+ /**
1483
+ * How long a "forgot password" link works, in seconds. Default 1 hour. Links work once: setting
1484
+ * the password ends them. Needs `email` and, in production, `serverURL` for the link.
1485
+ */
1486
+ readonly resetPasswordExpiration?: number;
1487
+ /** How long an invitation link works, in seconds. Default 7 days. */
1488
+ readonly inviteExpiration?: number;
1489
+ /** Your own text for the password emails, e.g. in your brand's voice. Default: English or Thai. */
1490
+ readonly emails?: {
1491
+ readonly resetPassword?: PasswordEmailFn;
1492
+ readonly invite?: PasswordEmailFn;
1493
+ readonly passwordChanged?: PasswordEmailFn;
1494
+ };
1373
1495
  }
1374
1496
  /** What an endpoint's handler receives. */
1375
1497
  interface EndpointRequest {
@@ -1428,6 +1550,38 @@ interface Endpoint {
1428
1550
  }
1429
1551
  /** Receives the config and returns a modified copy. Runs before validation. */
1430
1552
  type Plugin = (config: Config) => MaybePromise<Config>;
1553
+ /** Holds a plugin's `PluginTypes` at the type level only: nothing is stored at runtime. */
1554
+ declare const PLUGIN_TYPES: unique symbol;
1555
+ /**
1556
+ * What a plugin adds, for the document types inferred from the config: fields on collections
1557
+ * and globals (by slug), and whole collections. Field types are written like config fields,
1558
+ * e.g. `{ fields: { posts: readonly [{ readonly name: 'meta'; readonly type: 'group'; … }] } }`.
1559
+ */
1560
+ interface PluginTypes {
1561
+ readonly fields?: {
1562
+ readonly [collection: string]: readonly Field[];
1563
+ };
1564
+ readonly globalFields?: {
1565
+ readonly [global: string]: readonly Field[];
1566
+ };
1567
+ readonly collections?: readonly CollectionConfig[];
1568
+ }
1569
+ /** A plugin that tells the type system what it adds (see `definePlugin`). */
1570
+ type TypedPlugin<T extends PluginTypes> = Plugin & {
1571
+ readonly [PLUGIN_TYPES]?: T;
1572
+ };
1573
+ /**
1574
+ * Returns the plugin unchanged, with what it adds as types, so `cms.find()` and
1575
+ * `CollectionDocument` know the plugin's fields:
1576
+ *
1577
+ * ```ts
1578
+ * export const colorPlugin = (options: { collections: readonly string[] }) =>
1579
+ * definePlugin<{ fields: { posts: readonly [{ readonly name: 'color'; readonly type: 'text' }] } }>(
1580
+ * (config) => ({ ...config, collections: … }),
1581
+ * )
1582
+ * ```
1583
+ */
1584
+ declare function definePlugin<const T extends PluginTypes = Record<never, never>>(plugin: Plugin): TypedPlugin<T>;
1431
1585
  interface Config {
1432
1586
  /** Signs sessions. At least 32 characters; read it from `process.env.EASY_CMS_SECRET`. */
1433
1587
  readonly secret: string;
@@ -1838,6 +1992,8 @@ interface AdminGlobal {
1838
1992
  }
1839
1993
  interface AdminSchema {
1840
1994
  locale: AdminLocale;
1995
+ /** Admins can email users links to set their password (needs `email` and `serverURL`). */
1996
+ passwordLinks: boolean;
1841
1997
  /** Menu order of collections (`admin.menu`); unlisted ones follow. */
1842
1998
  menu: string[];
1843
1999
  /** Content locales, when the config has `localization`. */
@@ -1886,4 +2042,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1886
2042
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1887
2043
  declare function validateConfig(config: Config): ConfigIssue[];
1888
2044
 
1889
- 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, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
2045
+ 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, type DocumentOf, 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 PluginTypes, 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 SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, 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, definePlugin, 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
@@ -560,6 +560,58 @@ var emailDeliveriesCollection = {
560
560
  ]
561
561
  };
562
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
+
563
615
  // src/auth/password.ts
564
616
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
565
617
  var MIN_PASSWORD_LENGTH = 8;
@@ -657,9 +709,38 @@ function verifyPreviewToken(secret, token, now = Date.now()) {
657
709
  return null;
658
710
  }
659
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
+ }
660
740
 
661
741
  // src/auth/auth.ts
662
742
  var INVALID = "Invalid email or password";
743
+ var RESET_REQUESTS = 3;
663
744
  var Auth = class {
664
745
  constructor(cms) {
665
746
  this.cms = cms;
@@ -673,13 +754,13 @@ var Auth = class {
673
754
  }
674
755
  /** Checks credentials and starts a session. Throws `UnauthorizedError` or `TooManyRequestsError`. */
675
756
  async login(args) {
676
- const email = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
757
+ const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
677
758
  const password = typeof args.password === "string" ? args.password : "";
678
- const key2 = `${email}|${args.ip ?? ""}`;
759
+ const key2 = `${email2}|${args.ip ?? ""}`;
679
760
  await this.checkRateLimit(key2);
680
- const [user] = email ? (await this.db.find({
761
+ const [user] = email2 ? (await this.db.find({
681
762
  collection: USERS,
682
- where: { email: { equals: email } },
763
+ where: { email: { equals: email2 } },
683
764
  sort: [],
684
765
  limit: 1,
685
766
  page: 1
@@ -759,6 +840,131 @@ var Auth = class {
759
840
  for (const session of sessions.docs)
760
841
  await this.db.delete({ collection: SESSIONS, id: session.id });
761
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
+ }
762
968
  /** The owner of an API key, limited by the key; `null` when unknown, expired or disabled. */
763
969
  async verifyApiKey(token) {
764
970
  if (!this.cms.config.collections.some((c) => c.slug === API_KEYS)) return null;
@@ -934,6 +1140,9 @@ var ADMIN_ICONS = [
934
1140
  "camera",
935
1141
  "key"
936
1142
  ];
1143
+ function definePlugin(plugin) {
1144
+ return plugin;
1145
+ }
937
1146
  function defineConfig(config) {
938
1147
  return config;
939
1148
  }
@@ -1801,12 +2010,36 @@ function validateAuth(config, add) {
1801
2010
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
1802
2011
  }
1803
2012
  }
1804
- for (const key2 of ["tokenExpiration", "maxLoginAttempts", "lockWindow"]) {
2013
+ for (const key2 of [
2014
+ "tokenExpiration",
2015
+ "maxLoginAttempts",
2016
+ "lockWindow",
2017
+ "resetPasswordExpiration",
2018
+ "inviteExpiration"
2019
+ ]) {
1805
2020
  const value = auth[key2];
1806
2021
  if (value !== void 0 && !(Number.isInteger(value) && value > 0)) {
1807
2022
  add(`auth.${key2}`, "must be a positive integer");
1808
2023
  }
1809
2024
  }
2025
+ if (auth.emails !== void 0) {
2026
+ const emails = auth.emails;
2027
+ if (typeof emails !== "object" || emails === null) add("auth.emails", "must be an object");
2028
+ else
2029
+ for (const [name, fn] of Object.entries(emails)) {
2030
+ if (!["resetPassword", "invite", "passwordChanged"].includes(name))
2031
+ add(
2032
+ `auth.emails.${name}`,
2033
+ "unknown email",
2034
+ "use resetPassword, invite or passwordChanged"
2035
+ );
2036
+ else if (typeof fn !== "function")
2037
+ add(
2038
+ `auth.emails.${name}`,
2039
+ "must be a function ({ user, url, locale }) => { subject, text }"
2040
+ );
2041
+ }
2042
+ }
1810
2043
  if (auth.trustedOrigins !== void 0) {
1811
2044
  for (const [i, origin] of auth.trustedOrigins.entries()) {
1812
2045
  if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
@@ -1898,8 +2131,8 @@ function validateLocalization(config, add) {
1898
2131
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1899
2132
  }
1900
2133
  function validateWebhooks(config, add) {
1901
- const email = config.email;
1902
- if (email !== void 0 && (typeof email !== "object" || email === null || typeof email.send !== "function"))
2134
+ const email2 = config.email;
2135
+ if (email2 !== void 0 && (typeof email2 !== "object" || email2 === null || typeof email2.send !== "function"))
1903
2136
  add(
1904
2137
  "email",
1905
2138
  "must be an email adapter with a send() function",
@@ -2400,7 +2633,10 @@ async function resolveConfig(input) {
2400
2633
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
2401
2634
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
2402
2635
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
2403
- trustedOrigins: config.auth?.trustedOrigins ?? []
2636
+ trustedOrigins: config.auth?.trustedOrigins ?? [],
2637
+ resetPasswordExpiration: config.auth?.resetPasswordExpiration ?? 60 * 60,
2638
+ inviteExpiration: config.auth?.inviteExpiration ?? 7 * 24 * 60 * 60,
2639
+ emails: config.auth?.emails ?? {}
2404
2640
  },
2405
2641
  collections: [
2406
2642
  ...config.collections ?? [],
@@ -3645,18 +3881,18 @@ var EasyCMS = class {
3645
3881
  /** Emails queued until sent, stored in `email-deliveries`. */
3646
3882
  emailQueue() {
3647
3883
  const collection2 = EMAIL_DELIVERIES;
3648
- const toRow = (email, createdAt) => {
3884
+ const toRow = (email2, createdAt) => {
3649
3885
  const now = (/* @__PURE__ */ new Date()).toISOString();
3650
- return { ...email, createdAt: createdAt ?? now, updatedAt: now };
3886
+ return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
3651
3887
  };
3652
3888
  return {
3653
- add: async (email) => (await this.db.create({ collection: collection2, data: toRow(email) })).id,
3654
- update: async (id, email) => {
3889
+ add: async (email2) => (await this.db.create({ collection: collection2, data: toRow(email2) })).id,
3890
+ update: async (id, email2) => {
3655
3891
  const current = await this.db.findById({ collection: collection2, id });
3656
3892
  await this.db.update({
3657
3893
  collection: collection2,
3658
3894
  id,
3659
- data: toRow(email, current?.createdAt)
3895
+ data: toRow(email2, current?.createdAt)
3660
3896
  });
3661
3897
  },
3662
3898
  remove: async (id) => {
@@ -4041,9 +4277,6 @@ var EasyCMS = class {
4041
4277
  if (!allowed2) throw deny(guard.user);
4042
4278
  }
4043
4279
  const { input, password } = splitPassword(config, raw);
4044
- if (config.slug === USERS && password === void 0) {
4045
- throw new ValidationError(collection2, [{ field: "password", message: "is required" }]);
4046
- }
4047
4280
  const filtered = await filterInput(
4048
4281
  config.fields,
4049
4282
  input,
@@ -4622,12 +4855,13 @@ async function global(config, user, localized) {
4622
4855
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
4623
4856
  return result;
4624
4857
  }
4625
- async function adminSchema(cms, user) {
4858
+ async function adminSchema(cms, user, origin) {
4626
4859
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
4627
4860
  const localization = cms.config.localization;
4628
4861
  const localized = localization !== null;
4629
4862
  return {
4630
4863
  locale: cms.config.admin.locale,
4864
+ passwordLinks: cms.auth.canSendPasswordLinks(origin),
4631
4865
  menu: [...cms.config.admin.menu],
4632
4866
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
4633
4867
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
@@ -4937,7 +5171,46 @@ async function route(cms, ctx, method, segments, options) {
4937
5171
  return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
4938
5172
  }
4939
5173
  case "GET init":
4940
- return { body: { hasUsers: await cms.auth.hasUsers() } };
5174
+ return {
5175
+ body: {
5176
+ hasUsers: await cms.auth.hasUsers(),
5177
+ // The login page offers "Forgot password?" only when the email can be sent.
5178
+ passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
5179
+ }
5180
+ };
5181
+ // Forgot password: the same answer whether or not the email has an account.
5182
+ case "POST forgot-password": {
5183
+ const body = await readJson(ctx.request);
5184
+ await cms.auth.requestPasswordReset({
5185
+ email: String(body.email ?? ""),
5186
+ ip: options.getClientIp?.(ctx.request),
5187
+ origin: ctx.url.origin,
5188
+ locale: typeof body.locale === "string" ? body.locale : void 0
5189
+ });
5190
+ return { body: { message: "If an account has this email, a link is on its way." } };
5191
+ }
5192
+ // The page behind a password link: which account, and whether the link still works.
5193
+ case "GET reset-password": {
5194
+ const found = await cms.auth.checkPasswordToken(ctx.url.searchParams.get("token") ?? "");
5195
+ if (!found)
5196
+ throw new ValidationError(USERS, [
5197
+ { field: "token", message: "This link has expired or was already used" }
5198
+ ]);
5199
+ return { body: { email: found.user.email, purpose: found.purpose } };
5200
+ }
5201
+ case "POST reset-password": {
5202
+ const body = await readJson(ctx.request);
5203
+ const session = await cms.auth.resetPassword({
5204
+ token: String(body.token ?? ""),
5205
+ password: String(body.password ?? ""),
5206
+ ip: options.getClientIp?.(ctx.request),
5207
+ locale: typeof body.locale === "string" ? body.locale : void 0
5208
+ });
5209
+ setSessionCookies(cms, ctx, session);
5210
+ return {
5211
+ body: { user: session.user, exp: session.expiresAt, csrfToken: session.csrfToken }
5212
+ };
5213
+ }
4941
5214
  case "POST first-register": {
4942
5215
  const body = await readJson(ctx.request);
4943
5216
  const session = await cms.auth.registerFirstUser({
@@ -4968,7 +5241,7 @@ async function route(cms, ctx, method, segments, options) {
4968
5241
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
4969
5242
  if (!ctx.user) throw new UnauthorizedError();
4970
5243
  if (second === "schema" && third === void 0)
4971
- return { body: await adminSchema(cms, ctx.user) };
5244
+ return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
4972
5245
  if (second === "modules" && third !== void 0 && segments.length === 3) {
4973
5246
  const index = /^(\d+)\.js$/.exec(third)?.[1];
4974
5247
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -5104,6 +5377,16 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
5104
5377
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
5105
5378
  const depth = parseDepth(ctx.url);
5106
5379
  const [action, versionId, extra] = path;
5380
+ if (collection2 === USERS && action === "password-link" && path.length === 1) {
5381
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
5382
+ if (ctx.user?.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
5383
+ const body = await readJson(ctx.request).catch(() => ({}));
5384
+ const sent = await cms.auth.sendPasswordLink(/^\d+$/.test(id) ? Number(id) : id, {
5385
+ origin: ctx.url.origin,
5386
+ locale: typeof body.locale === "string" ? body.locale : void 0
5387
+ });
5388
+ return { body: { sent } };
5389
+ }
5107
5390
  if (action === "versions" && versionId === void 0) {
5108
5391
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
5109
5392
  return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
@@ -5547,6 +5830,7 @@ export {
5547
5830
  DEFAULT_DEPTH,
5548
5831
  DEFAULT_MAX_FILE_SIZE,
5549
5832
  DEFAULT_MAX_VERSIONS,
5833
+ DEFAULT_PASSWORD_EMAILS,
5550
5834
  DEFAULT_TOKEN_EXPIRATION,
5551
5835
  EXTENSIONS,
5552
5836
  EasyCMS,
@@ -5578,6 +5862,7 @@ export {
5578
5862
  createRestHandler,
5579
5863
  createRootEndpointHandler,
5580
5864
  defineConfig,
5865
+ definePlugin,
5581
5866
  fillMissing,
5582
5867
  findConfigFile,
5583
5868
  generateSlugs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",