@easy-cms/core 0.23.0 → 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;
@@ -541,14 +616,17 @@ type StatusInput<T> = T extends {
541
616
  } ? {
542
617
  status?: 'draft' | 'published';
543
618
  } : unknown;
544
- /** 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
+ */
545
623
  type PasswordInput<S, Required extends boolean> = S extends 'users' ? Required extends true ? {
546
624
  password: string;
547
625
  } : {
548
626
  password?: string;
549
627
  } : unknown;
550
628
  /** 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>>;
629
+ type CreateInput<C extends Config, S extends CollectionSlug<C>> = Simplify<FieldsInput<CollectionBySlug<C, S>['fields']> & StatusInput<CollectionBySlug<C, S>> & PasswordInput<S, false>>;
552
630
  /** Data accepted by `update`: any subset of `CreateInput`. */
553
631
  type UpdateInput<C extends Config, S extends CollectionSlug<C>> = Partial<CreateInput<C, S>>;
554
632
  /** Data accepted by `updateGlobal`. */
@@ -1370,6 +1448,19 @@ interface AuthConfig {
1370
1448
  * Needed when the admin or frontend is served from another origin, or behind a proxy that rewrites the host.
1371
1449
  */
1372
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
+ };
1373
1464
  }
1374
1465
  /** What an endpoint's handler receives. */
1375
1466
  interface EndpointRequest {
@@ -1838,6 +1929,8 @@ interface AdminGlobal {
1838
1929
  }
1839
1930
  interface AdminSchema {
1840
1931
  locale: AdminLocale;
1932
+ /** Admins can email users links to set their password (needs `email` and `serverURL`). */
1933
+ passwordLinks: boolean;
1841
1934
  /** Menu order of collections (`admin.menu`); unlisted ones follow. */
1842
1935
  menu: string[];
1843
1936
  /** Content locales, when the config has `localization`. */
@@ -1886,4 +1979,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1886
1979
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1887
1980
  declare function validateConfig(config: Config): ConfigIssue[];
1888
1981
 
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 };
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
@@ -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;
@@ -1801,12 +2007,36 @@ function validateAuth(config, add) {
1801
2007
  add("auth.roles", 'must include "admin"', "e.g. roles: ['admin', 'editor']");
1802
2008
  }
1803
2009
  }
1804
- for (const key2 of ["tokenExpiration", "maxLoginAttempts", "lockWindow"]) {
2010
+ for (const key2 of [
2011
+ "tokenExpiration",
2012
+ "maxLoginAttempts",
2013
+ "lockWindow",
2014
+ "resetPasswordExpiration",
2015
+ "inviteExpiration"
2016
+ ]) {
1805
2017
  const value = auth[key2];
1806
2018
  if (value !== void 0 && !(Number.isInteger(value) && value > 0)) {
1807
2019
  add(`auth.${key2}`, "must be a positive integer");
1808
2020
  }
1809
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
+ }
1810
2040
  if (auth.trustedOrigins !== void 0) {
1811
2041
  for (const [i, origin] of auth.trustedOrigins.entries()) {
1812
2042
  if (typeof origin !== "string" || !/^https?:\/\/[^/]+$/.test(origin)) {
@@ -1898,8 +2128,8 @@ function validateLocalization(config, add) {
1898
2128
  for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1899
2129
  }
1900
2130
  function validateWebhooks(config, add) {
1901
- const email = config.email;
1902
- 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"))
1903
2133
  add(
1904
2134
  "email",
1905
2135
  "must be an email adapter with a send() function",
@@ -2400,7 +2630,10 @@ async function resolveConfig(input) {
2400
2630
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
2401
2631
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
2402
2632
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
2403
- 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 ?? {}
2404
2637
  },
2405
2638
  collections: [
2406
2639
  ...config.collections ?? [],
@@ -3645,18 +3878,18 @@ var EasyCMS = class {
3645
3878
  /** Emails queued until sent, stored in `email-deliveries`. */
3646
3879
  emailQueue() {
3647
3880
  const collection2 = EMAIL_DELIVERIES;
3648
- const toRow = (email, createdAt) => {
3881
+ const toRow = (email2, createdAt) => {
3649
3882
  const now = (/* @__PURE__ */ new Date()).toISOString();
3650
- return { ...email, createdAt: createdAt ?? now, updatedAt: now };
3883
+ return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
3651
3884
  };
3652
3885
  return {
3653
- add: async (email) => (await this.db.create({ collection: collection2, data: toRow(email) })).id,
3654
- update: async (id, email) => {
3886
+ add: async (email2) => (await this.db.create({ collection: collection2, data: toRow(email2) })).id,
3887
+ update: async (id, email2) => {
3655
3888
  const current = await this.db.findById({ collection: collection2, id });
3656
3889
  await this.db.update({
3657
3890
  collection: collection2,
3658
3891
  id,
3659
- data: toRow(email, current?.createdAt)
3892
+ data: toRow(email2, current?.createdAt)
3660
3893
  });
3661
3894
  },
3662
3895
  remove: async (id) => {
@@ -4041,9 +4274,6 @@ var EasyCMS = class {
4041
4274
  if (!allowed2) throw deny(guard.user);
4042
4275
  }
4043
4276
  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
4277
  const filtered = await filterInput(
4048
4278
  config.fields,
4049
4279
  input,
@@ -4622,12 +4852,13 @@ async function global(config, user, localized) {
4622
4852
  if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
4623
4853
  return result;
4624
4854
  }
4625
- async function adminSchema(cms, user) {
4855
+ async function adminSchema(cms, user, origin) {
4626
4856
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
4627
4857
  const localization = cms.config.localization;
4628
4858
  const localized = localization !== null;
4629
4859
  return {
4630
4860
  locale: cms.config.admin.locale,
4861
+ passwordLinks: cms.auth.canSendPasswordLinks(origin),
4631
4862
  menu: [...cms.config.admin.menu],
4632
4863
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
4633
4864
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
@@ -4937,7 +5168,46 @@ async function route(cms, ctx, method, segments, options) {
4937
5168
  return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
4938
5169
  }
4939
5170
  case "GET init":
4940
- 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
+ }
4941
5211
  case "POST first-register": {
4942
5212
  const body = await readJson(ctx.request);
4943
5213
  const session = await cms.auth.registerFirstUser({
@@ -4968,7 +5238,7 @@ async function route(cms, ctx, method, segments, options) {
4968
5238
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
4969
5239
  if (!ctx.user) throw new UnauthorizedError();
4970
5240
  if (second === "schema" && third === void 0)
4971
- return { body: await adminSchema(cms, ctx.user) };
5241
+ return { body: await adminSchema(cms, ctx.user, ctx.url.origin) };
4972
5242
  if (second === "modules" && third !== void 0 && segments.length === 3) {
4973
5243
  const index = /^(\d+)\.js$/.exec(third)?.[1];
4974
5244
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -5104,6 +5374,16 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
5104
5374
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
5105
5375
  const depth = parseDepth(ctx.url);
5106
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
+ }
5107
5387
  if (action === "versions" && versionId === void 0) {
5108
5388
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
5109
5389
  return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
@@ -5547,6 +5827,7 @@ export {
5547
5827
  DEFAULT_DEPTH,
5548
5828
  DEFAULT_MAX_FILE_SIZE,
5549
5829
  DEFAULT_MAX_VERSIONS,
5830
+ DEFAULT_PASSWORD_EMAILS,
5550
5831
  DEFAULT_TOKEN_EXPIRATION,
5551
5832
  EXTENSIONS,
5552
5833
  EasyCMS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.23.0",
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",