@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 +96 -3
- package/dist/index.js +300 -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;
|
|
@@ -541,14 +616,17 @@ type StatusInput<T> = T extends {
|
|
|
541
616
|
} ? {
|
|
542
617
|
status?: 'draft' | 'published';
|
|
543
618
|
} : unknown;
|
|
544
|
-
/**
|
|
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,
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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;
|
|
@@ -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 [
|
|
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
|
|
1902
|
-
if (
|
|
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 = (
|
|
3881
|
+
const toRow = (email2, createdAt) => {
|
|
3649
3882
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3650
|
-
return { ...
|
|
3883
|
+
return { ...email2, createdAt: createdAt ?? now, updatedAt: now };
|
|
3651
3884
|
};
|
|
3652
3885
|
return {
|
|
3653
|
-
add: async (
|
|
3654
|
-
update: async (id,
|
|
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(
|
|
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 {
|
|
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,
|