@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.
- package/dist/index.d.ts +175 -19
- package/dist/index.js +304 -19
- 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
|
-
/**
|
|
368
|
-
type
|
|
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?:
|
|
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
|
-
|
|
483
|
-
type
|
|
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<
|
|
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
|
-
/**
|
|
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,
|
|
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<
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
757
|
+
const email2 = typeof args.email === "string" ? args.email.trim().toLowerCase() : "";
|
|
677
758
|
const password = typeof args.password === "string" ? args.password : "";
|
|
678
|
-
const key2 = `${
|
|
759
|
+
const key2 = `${email2}|${args.ip ?? ""}`;
|
|
679
760
|
await this.checkRateLimit(key2);
|
|
680
|
-
const [user] =
|
|
761
|
+
const [user] = email2 ? (await this.db.find({
|
|
681
762
|
collection: USERS,
|
|
682
|
-
where: { email: { equals:
|
|
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 [
|
|
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
|
|
1902
|
-
if (
|
|
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 = (
|
|
3884
|
+
const toRow = (email2, createdAt) => {
|
|
3649
3885
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3650
|
-
return { ...
|
|
3886
|
+
return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
|
|
3651
3887
|
};
|
|
3652
3888
|
return {
|
|
3653
|
-
add: async (
|
|
3654
|
-
update: async (id,
|
|
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(
|
|
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 {
|
|
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,
|