@easy-cms/core 1.19.0 → 1.20.1

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.
@@ -972,7 +972,10 @@ var AuditLog = class {
972
972
  }
973
973
  await auditContext.run({ via: "scheduler", user: null }, () => this.verify());
974
974
  }
975
- /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
975
+ /**
976
+ * What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
977
+ * last day, entries not written, a failed check.
978
+ */
976
979
  async attention(now = /* @__PURE__ */ new Date()) {
977
980
  if (!this.enabled) return [];
978
981
  const out = [];
@@ -984,6 +987,20 @@ var AuditLog = class {
984
987
  }
985
988
  });
986
989
  if (failed >= this.settings.failedLogins) out.push({ id: "failed-logins", count: failed });
990
+ const day = new Date(now.getTime() - 864e5).toISOString();
991
+ const breakGlass = await this.cms.db.find({
992
+ collection: AUDIT_LOGS,
993
+ where: { and: [{ action: { equals: "login.breakGlass" } }, { createdAt: { gte: day } }] },
994
+ sort: ["-id"],
995
+ limit: 0,
996
+ page: 1
997
+ });
998
+ if (breakGlass.docs.length > 0)
999
+ out.push({
1000
+ id: "break-glass",
1001
+ count: breakGlass.docs.length,
1002
+ emails: [...new Set(breakGlass.docs.map((row) => String(row.actorEmail ?? "")))]
1003
+ });
987
1004
  if (this.failures > 0) out.push({ id: "audit-failures", count: this.failures });
988
1005
  const last = await this.lastVerification();
989
1006
  if (last && last.invalid > 0)
@@ -1394,6 +1411,9 @@ function unseal(secrets, value) {
1394
1411
  return null;
1395
1412
  }
1396
1413
  }
1414
+ function passwordFor(policy) {
1415
+ return policy === true ? "everyone" : policy === false ? "admins" : "breakGlass";
1416
+ }
1397
1417
  var SingleSignOn = class {
1398
1418
  constructor(cms) {
1399
1419
  this.cms = cms;
@@ -1412,9 +1432,19 @@ var SingleSignOn = class {
1412
1432
  icon: p.icon ?? "key"
1413
1433
  }));
1414
1434
  }
1415
- /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
1435
+ /**
1436
+ * Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
1437
+ * only break-glass accounts (`{ breakGlass }`).
1438
+ */
1416
1439
  passwordAllowed(user) {
1417
- return this.config.auth.password || user.role === "admin";
1440
+ const policy = this.config.auth.password;
1441
+ if (policy === true) return true;
1442
+ if (policy === false) return user.role === "admin";
1443
+ return typeof user.email === "string" && policy.breakGlass.includes(user.email.toLowerCase());
1444
+ }
1445
+ /** Whether a user signs in with a password only as a break-glass account. */
1446
+ isBreakGlass(user) {
1447
+ return typeof this.config.auth.password === "object" && this.passwordAllowed(user);
1418
1448
  }
1419
1449
  /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
1420
1450
  callbackURL(provider, origin) {
@@ -1607,7 +1637,8 @@ var SingleSignOn = class {
1607
1637
  callbackURL: this.callbackURL(p.id, origin),
1608
1638
  details: { ...p.describe?.() ?? {} }
1609
1639
  })),
1610
- password: this.config.auth.password ? "everyone" : "admins",
1640
+ password: passwordFor(this.config.auth.password),
1641
+ ...typeof this.config.auth.password === "object" ? { breakGlass: [...this.config.auth.password.breakGlass] } : {},
1611
1642
  signUp: providerSignUp.domains.length > 0 ? {
1612
1643
  domains: [...providerSignUp.domains],
1613
1644
  role: providerSignUp.role ?? this.defaultRole()
@@ -1845,7 +1876,11 @@ var Auth = class {
1845
1876
  }
1846
1877
  await this.deleteExpiredSessions(user.id);
1847
1878
  const session = await this.startSession(user);
1848
- await this.cms.audit.record({ action: "login", target: "auth", user: session.user });
1879
+ await this.cms.audit.record({
1880
+ action: this.sso.isBreakGlass(user) ? "login.breakGlass" : "login",
1881
+ target: "auth",
1882
+ user: session.user
1883
+ });
1849
1884
  return session;
1850
1885
  }
1851
1886
  /**
@@ -2019,6 +2054,58 @@ var Auth = class {
2019
2054
  const path = target ?? `${this.config.admin.path}/${page === "verifyEmail" ? "verify-email" : "reset-password"}`;
2020
2055
  return `${base}${path.startsWith("/") ? "" : "/"}${path}${path.includes("?") ? "&" : "?"}${query}`;
2021
2056
  }
2057
+ /**
2058
+ * At start: when `auth.password` differs from the last start, records the change and ends the
2059
+ * sessions of accounts that may no longer use a password (single sign-on users sign in once
2060
+ * more). With break-glass accounts, warns when none of them can sign in with a password.
2061
+ * @internal
2062
+ */
2063
+ async applyPasswordPolicy() {
2064
+ const policy = passwordPolicyName(this.config.auth.password);
2065
+ const stored = await this.db.findGlobal({ slug: PASSWORD_POLICY });
2066
+ const before = typeof stored?.policy === "string" ? stored.policy : "everyone";
2067
+ if (before !== policy) {
2068
+ let ended = 0;
2069
+ if (policy !== "everyone") {
2070
+ const users = /* @__PURE__ */ new Map();
2071
+ const { docs } = await this.db.find({ collection: SESSIONS, sort: [], limit: 0, page: 1 });
2072
+ for (const session of docs) {
2073
+ const id = String(session.user);
2074
+ if (!users.has(id))
2075
+ users.set(id, await this.db.findById({ collection: USERS, id: session.user }));
2076
+ const user = users.get(id);
2077
+ if (user && this.sso.passwordAllowed(user)) continue;
2078
+ await this.db.delete({ collection: SESSIONS, id: session.id });
2079
+ ended++;
2080
+ }
2081
+ }
2082
+ const at = (/* @__PURE__ */ new Date()).toISOString();
2083
+ await this.db.updateGlobal({ slug: PASSWORD_POLICY, data: { policy, updatedAt: at } });
2084
+ await this.cms.audit.record({
2085
+ action: "auth.password.policy",
2086
+ target: "auth",
2087
+ user: null,
2088
+ detail: { from: before, to: policy, sessionsEnded: ended }
2089
+ });
2090
+ this.cms.logger.info(
2091
+ `Password sign-in: ${before} \u2192 ${policy}${ended ? `; ${ended} session(s) ended` : ""}.`
2092
+ );
2093
+ }
2094
+ const password = this.config.auth.password;
2095
+ if (typeof password === "object" && await this.hasUsers()) {
2096
+ const { docs } = await this.db.find({
2097
+ collection: USERS,
2098
+ where: { email: { in: [...password.breakGlass] } },
2099
+ sort: [],
2100
+ limit: 0,
2101
+ page: 1
2102
+ });
2103
+ if (!docs.some((user) => user.passwordHash && user.active !== false))
2104
+ this.cms.logger.warn(
2105
+ "auth.password.breakGlass: none of these accounts exists with a password, so nobody can sign in if the provider is down. Create one with `easy-cms admin:create`."
2106
+ );
2107
+ }
2108
+ }
2022
2109
  async hasUsers() {
2023
2110
  return await this.db.count({ collection: USERS }) > 0;
2024
2111
  }
@@ -2388,6 +2475,12 @@ var Auth = class {
2388
2475
  await this.db.delete({ collection: LOGIN_ATTEMPTS, id: attempt.id });
2389
2476
  }
2390
2477
  };
2478
+ var PASSWORD_POLICY = "easy-cms:password-policy";
2479
+ function passwordPolicyName(policy) {
2480
+ if (policy === true) return "everyone";
2481
+ if (policy === false) return "admins";
2482
+ return `breakGlass:${[...policy.breakGlass].sort().join(",")}`;
2483
+ }
2391
2484
 
2392
2485
  // src/database.ts
2393
2486
  var ADAPTER_API_VERSION = 1;
@@ -5960,8 +6053,16 @@ function validateAuth(config, add) {
5960
6053
  if (auth === void 0) return;
5961
6054
  if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
5962
6055
  add("auth.rbac", "must be true or false");
5963
- if (auth.password !== void 0 && typeof auth.password !== "boolean")
5964
- add("auth.password", "must be true or false");
6056
+ if (typeof auth.password === "object" && auth.password !== null) {
6057
+ const { breakGlass } = auth.password;
6058
+ if (!Array.isArray(breakGlass) || breakGlass.length === 0 || breakGlass.some((email2) => typeof email2 !== "string" || !email2.includes("@")))
6059
+ add(
6060
+ "auth.password.breakGlass",
6061
+ "must list the emails of the accounts that may still use a password",
6062
+ "e.g. { breakGlass: ['emergency@example.com'] }"
6063
+ );
6064
+ } else if (auth.password !== void 0 && typeof auth.password !== "boolean")
6065
+ add("auth.password", "must be true, false or { breakGlass: [emails] }");
5965
6066
  if (auth.setupCode !== void 0 && typeof auth.setupCode !== "string")
5966
6067
  add("auth.setupCode", "must be a string");
5967
6068
  const providers = auth.providers;
@@ -6012,8 +6113,12 @@ function validateAuth(config, add) {
6012
6113
  if (!Array.isArray(providers) || providers.length === 0)
6013
6114
  add("auth.providerSignUp", "needs auth.providers");
6014
6115
  }
6015
- if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
6016
- add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
6116
+ if (auth.password !== void 0 && auth.password !== true && (!Array.isArray(providers) || providers.length === 0))
6117
+ add(
6118
+ "auth.password",
6119
+ "turning passwords off needs auth.providers",
6120
+ "otherwise nobody else could sign in"
6121
+ );
6017
6122
  if (auth.roles !== void 0) {
6018
6123
  const roles = auth.roles;
6019
6124
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -7223,7 +7328,7 @@ async function resolveConfig(input) {
7223
7328
  rbac: config.auth?.rbac === true,
7224
7329
  providers: config.auth?.providers ?? [],
7225
7330
  providerSignUp: config.auth?.providerSignUp ?? { domains: [] },
7226
- password: config.auth?.password !== false,
7331
+ password: typeof config.auth?.password === "object" ? { breakGlass: config.auth.password.breakGlass.map((email2) => email2.toLowerCase()) } : config.auth?.password !== false,
7227
7332
  setupCode: config.auth?.setupCode ?? process.env.EASY_CMS_SETUP_CODE ?? "",
7228
7333
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
7229
7334
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
@@ -7317,7 +7422,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
7317
7422
  }
7318
7423
 
7319
7424
  // src/version.ts
7320
- var VERSION = true ? "1.19.0" : "0.0.0-dev";
7425
+ var VERSION = true ? "1.20.1" : "0.0.0-dev";
7321
7426
 
7322
7427
  // src/telemetry.ts
7323
7428
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -7551,6 +7656,10 @@ async function createEasyCMS(config, options = {}) {
7551
7656
  if (options.scheduler !== false) cms.startSearch();
7552
7657
  const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0 || resolved3.email !== void 0 || resolved3.jobs.length > 0 || resolved3.outbox === true;
7553
7658
  if (scheduling && options.scheduler !== false) cms.startScheduler();
7659
+ if (options.schema !== "skip")
7660
+ await cms.auth.applyPasswordPolicy().catch(
7661
+ (error) => logger.error(`Password sign-in policy: ${error.message}`, { error })
7662
+ );
7554
7663
  if (process.env.NODE_ENV === "production" && !resolved3.auth.setupCode && await cms.auth.hasUsers().catch(() => true) === false)
7555
7664
  logger.warn(
7556
7665
  "No admin exists yet and no setup code is set: whoever opens the admin first becomes the admin. Set EASY_CMS_SETUP_CODE, or create the admin with `easy-cms admin:create`."
@@ -8556,7 +8665,10 @@ var EasyCMS = class {
8556
8665
  return out2;
8557
8666
  }
8558
8667
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
8559
- if (password !== void 0) prepared.passwordHash = await hashPassword(password);
8668
+ if (password !== void 0) {
8669
+ await this.guardPassword({ ...existing, ...prepared });
8670
+ prepared.passwordHash = await hashPassword(password);
8671
+ }
8560
8672
  if (collection2 === MEDIA_FOLDERS) {
8561
8673
  await this.validateFolder(prepared, parsed);
8562
8674
  if (prepared.private !== existing.private || String(prepared.parent ?? null) !== String(existing.parent ?? null))
@@ -9895,7 +10007,10 @@ var EasyCMS = class {
9895
10007
  { ...base, operation: "create" },
9896
10008
  prepared
9897
10009
  );
9898
- if (password !== void 0) prepared.passwordHash = await hashPassword(password);
10010
+ if (password !== void 0) {
10011
+ await this.guardPassword(prepared);
10012
+ prepared.passwordHash = await hashPassword(password);
10013
+ }
9899
10014
  if (collection2 === MEDIA_FOLDERS) await this.validateFolder(prepared, void 0);
9900
10015
  const now = (/* @__PURE__ */ new Date()).toISOString();
9901
10016
  const row = { ...prepared, createdAt: now, updatedAt: now };
@@ -10215,6 +10330,20 @@ var EasyCMS = class {
10215
10330
  return clean2;
10216
10331
  }
10217
10332
  /** Refuses changes that would leave no active admin (and lock everyone out). */
10333
+ /**
10334
+ * With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
10335
+ * they couldn't use it. The first account, made before anyone can sign in with a provider, may.
10336
+ */
10337
+ async guardPassword(user) {
10338
+ if (typeof this.config.auth.password !== "object" || this.auth.sso.passwordAllowed(user)) return;
10339
+ if (!await this.auth.hasUsers()) return;
10340
+ throw new ValidationError(USERS, [
10341
+ {
10342
+ field: "password",
10343
+ message: "passwords are off: this account signs in with single sign-on"
10344
+ }
10345
+ ]);
10346
+ }
10218
10347
  async guardLastAdmin(id, before, after) {
10219
10348
  const wasAdmin = before.role === "admin" && before.active !== false;
10220
10349
  const staysAdmin = after.role === "admin" && after.active !== false;
@@ -11777,6 +11906,8 @@ async function adminSchema(cms, user, origin, context = {}) {
11777
11906
  locale: cms.config.admin.locale,
11778
11907
  switcher: cms.config.admin.switcher || null,
11779
11908
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
11909
+ passwordFor: passwordFor(cms.config.auth.password),
11910
+ ...typeof cms.config.auth.password === "object" && isSystemAdmin(user) ? { breakGlass: [...cms.config.auth.password.breakGlass] } : {},
11780
11911
  nav: buildNav(cms, adminCollections, adminGlobals, adminPages, views),
11781
11912
  commands: cms.config.admin.commands.map((c) => JSON.parse(JSON.stringify(c))),
11782
11913
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
@@ -12593,7 +12724,14 @@ async function route(cms, ctx, method, path, options) {
12593
12724
  const csrfToken2 = ctx.via === "cookie" && ctx.token ? cms.auth.csrfFor(ctx.token) : void 0;
12594
12725
  if (csrfToken2)
12595
12726
  ctx.headers.append("set-cookie", cookie(ctx, CSRF_COOKIE, csrfToken2, { httpOnly: false }));
12596
- return { body: { user: ctx.user, ...csrfToken2 ? { csrfToken: csrfToken2 } : {} } };
12727
+ return {
12728
+ body: {
12729
+ user: ctx.user,
12730
+ // Whether this user may set and use a password (`auth.password`).
12731
+ ...ctx.user ? { password: cms.auth.sso.passwordAllowed(ctx.user) } : {},
12732
+ ...csrfToken2 ? { csrfToken: csrfToken2 } : {}
12733
+ }
12734
+ };
12597
12735
  }
12598
12736
  case "GET init":
12599
12737
  return {
@@ -12601,9 +12739,11 @@ async function route(cms, ctx, method, path, options) {
12601
12739
  hasUsers: await cms.auth.hasUsers(),
12602
12740
  // The login page offers "Forgot password?" only when the email can be sent.
12603
12741
  passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
12604
- // Buttons to sign in with outside accounts; `password: false`: only admins use one.
12742
+ // Buttons to sign in with outside accounts; `password: false`: only some use one
12743
+ // (`passwordFor`: admins, or break-glass accounts), behind a folded form.
12605
12744
  providers: cms.auth.sso.providers(),
12606
- password: cms.config.auth.password,
12745
+ password: cms.config.auth.password === true,
12746
+ passwordFor: passwordFor(cms.config.auth.password),
12607
12747
  // The first admin needs the setup code (`EASY_CMS_SETUP_CODE`).
12608
12748
  setupCode: cms.config.auth.setupCode !== ""
12609
12749
  }
@@ -432,8 +432,10 @@ interface AdminSso {
432
432
  callbackURL: string | null;
433
433
  details: Record<string, string>;
434
434
  })[];
435
- /** Who may sign in with a password: everyone, or only admins. */
436
- password: 'everyone' | 'admins';
435
+ /** Who may sign in with a password: everyone, only admins, or only break-glass accounts. */
436
+ password: 'everyone' | 'admins' | 'breakGlass';
437
+ /** The accounts that may still use a password (`auth.password: { breakGlass }`). */
438
+ breakGlass?: string[];
437
439
  /** Email domains whose people get an account when they first sign in. */
438
440
  signUp: {
439
441
  domains: string[];
@@ -461,10 +463,17 @@ declare class SingleSignOn {
461
463
  private get config();
462
464
  get enabled(): boolean;
463
465
  providers(): SsoProviderRef[];
464
- /** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
466
+ /**
467
+ * Whether a user may sign in with a password: everyone, only admins (`auth.password: false`), or
468
+ * only break-glass accounts (`{ breakGlass }`).
469
+ */
465
470
  passwordAllowed(user: {
466
471
  readonly [key: string]: unknown;
467
472
  }): boolean;
473
+ /** Whether a user signs in with a password only as a break-glass account. */
474
+ isBreakGlass(user: {
475
+ readonly [key: string]: unknown;
476
+ }): boolean;
468
477
  /** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
469
478
  callbackURL(provider: string, origin?: string): string | null;
470
479
  /**
@@ -2331,6 +2340,11 @@ declare class EasyCMS<C extends Config = Config> {
2331
2340
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
2332
2341
  private prepare;
2333
2342
  /** Refuses changes that would leave no active admin (and lock everyone out). */
2343
+ /**
2344
+ * With break-glass accounts (`auth.password: { breakGlass }`), other accounts get no password:
2345
+ * they couldn't use it. The first account, made before anyone can sign in with a provider, may.
2346
+ */
2347
+ private guardPassword;
2334
2348
  private guardLastAdmin;
2335
2349
  private makeSlugsUnique;
2336
2350
  private checkUnique;
@@ -2484,10 +2498,17 @@ declare class AuditLog {
2484
2498
  resigned: number;
2485
2499
  invalid: ID[];
2486
2500
  }>;
2487
- /** What the dashboard warns about: many failed logins, entries not written, a failed check. */
2501
+ /**
2502
+ * What the dashboard warns about: many failed logins, sign-ins of break-glass accounts in the
2503
+ * last day, entries not written, a failed check.
2504
+ */
2488
2505
  attention(now?: Date): Promise<({
2489
2506
  id: 'failed-logins';
2490
2507
  count: number;
2508
+ } | {
2509
+ id: 'break-glass';
2510
+ count: number;
2511
+ emails: string[];
2491
2512
  } | {
2492
2513
  id: 'audit-failures';
2493
2514
  count: number;
@@ -3344,10 +3365,13 @@ interface AuthConfig {
3344
3365
  readonly role?: string;
3345
3366
  };
3346
3367
  /**
3347
- * `false`: only admins may sign in with a password (a way in when the provider is down);
3348
- * everyone else signs in with `providers`. Default `true`.
3368
+ * Who may sign in with a password; everyone else signs in with `providers`. Default `true`:
3369
+ * everyone. `false`: only admins (a way in when the provider is down). `{ breakGlass: [emails] }`:
3370
+ * only those accounts, for organisations that require single sign-on (and its MFA) for every
3371
+ * other account, admins included. A change ends the sessions of accounts that may no longer use a
3372
+ * password, and is recorded in the audit log, as is each sign-in of a break-glass account.
3349
3373
  */
3350
- readonly password?: boolean;
3374
+ readonly password?: boolean | PasswordPolicy;
3351
3375
  /**
3352
3376
  * A code the first admin must enter to create their account, so nobody else can claim a
3353
3377
  * freshly deployed site. Default: the `EASY_CMS_SETUP_CODE` environment variable; without it,
@@ -3366,6 +3390,11 @@ interface AuthConfig {
3366
3390
  readonly passwordChanged?: PasswordEmailFn;
3367
3391
  };
3368
3392
  }
3393
+ /** `auth.password` with break-glass accounts: the only ones that sign in with a password. */
3394
+ interface PasswordPolicy {
3395
+ /** Emails of the accounts that may still use a password, e.g. one kept for when the provider is down. */
3396
+ readonly breakGlass: readonly string[];
3397
+ }
3369
3398
  interface RateLimitConfig {
3370
3399
  /** Seconds each count lasts. Default 60. */
3371
3400
  readonly window?: number;
@@ -1,4 +1,4 @@
1
- import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-U1mRwD4t.js';
1
+ import { I as ID, C as Config, E as EasyCMS, L as LocalizationConfig, F as Field, a as FieldError, b as FilterOptions, c as CreateEasyCMSOptions, R as ResolvedConfig } from './config-BuKlngv6.js';
2
2
 
3
3
  /** Decrypts a file from `encryptBackup`; throws for another key or a changed file. */
4
4
  declare function decryptBackup(data: Uint8Array, encryptionKey: string): Uint8Array;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-U1mRwD4t.js';
2
- export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-U1mRwD4t.js';
3
- export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-DRV5HA4a.js';
4
- export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-D2y4WSSM.js';
1
+ import { P as PaginatedDocs, I as ID, d as EmailSetting, W as WebhookPayload } from './config-BuKlngv6.js';
2
+ export { A as ADAPTER_API_VERSION, e as ADMIN_ICONS, f as Access, g as AccessArgs, h as AccessOptions, i as AdminBrand, j as AdminCommand, k as AdminComponent, l as AdminConfig, m as AdminFieldArgs, n as AdminIcon, o as AdminLocale, p as AdminPage, q as AdminRole, r as AdminRoleChange, s as AdminRoleField, t as AdminRoleTarget, u as AdminRoleView, v as AdminRoles, w as AdminSso, x as AdminSwitcher, y as AdminViewAccess, z as AfterChangeHook, B as AfterDeleteHook, D as AfterReadHook, G as ApiKeyContext, H as ApiKeyGlobalOperation, J as ApiKeyOperation, K as ApiKeyPermissions, M as ArrayField, N as AuditChange, O as AuditConfig, Q as AuditEntry, S as AuditLog, T as AuditPage, U as AuditVerification, V as Auth, X as AuthConfig, Y as AuthProvider, Z as AuthProviderProfile, _ as AuthProviderRequest, $ as AuthUser, a0 as BackupsConfig, a1 as BeforeChangeHook, a2 as BeforeDeleteHook, a3 as BeforeValidateHook, a4 as Block, a5 as BlocksField, a6 as BooleanField, a7 as BuiltinApiKeysCollection, a8 as BuiltinMediaCollection, a9 as BuiltinMediaFoldersCollection, aa as BuiltinUsersCollection, ab as BundleHints, ac as CliCommand, ad as CollectionAccess, ae as CollectionAdmin, af as CollectionConfig, ag as CollectionDocument, ah as CollectionHooks, ai as CollectionListAdmin, aj as CollectionSearch, ak as CollectionSlug, C as Config, al as ConfigError, am as ConfigIssue, an as ContainerAdmin, c as CreateEasyCMSOptions, ao as CreateInput, ap as CreateMigrationResult, aq as CursorDocs, ar as CustomField, as as CustomFieldTypes, at as DashboardWidget, au as Database, av as DatabaseAdapter, aw as DatabaseInitArgs, ax as DatabaseTransfer, ay as DateField, az as DepthOptions, aA as DirectUpload, aB as DiskStorageOptions, aC as DocumentOf, E as EasyCMS, aD as EasyCMSError, aE as EmailAdapter, aF as EmailField, aG as EmailMessage, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, aK as FIELD_TYPES, F as Field, aL as FieldAccess, aM as FieldAccessFn, aN as FieldAdmin, a as FieldError, aO as FieldRule, aP as FieldType, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, aT as FieldValidate, aU as FieldValidateContext, aV as FieldValue, aW as FieldsInput, aX as FieldsValue, b as FilterOptions, aY as FilterOptionsArgs, aZ as FindAfterArgs, a_ as FindArgs, a$ as FindOptions, b0 as FolderLevel, b1 as FolderPermissions, b2 as ForbiddenError, b3 as GlobalAccess, b4 as GlobalConfig, b5 as GlobalDocument, b6 as GlobalHooks, b7 as GlobalInput, b8 as GlobalSlug, b9 as GroupField, ba as ImageSize, bb as IncrementOptions, bc as InferCollection, bd as InferGlobal, be as JobConfig, bf as JsonField, bg as KeysetPage, bh as Label, bi as LayoutNode, bj as LivePreview, bk as LocaleOf, L as LocalizationConfig, bl as LogDetails, bm as Logger, bn as LoginArgs, bo as LogsConfig, bp as MediaDocument, bq as MediaSize, br as MembersConfig, bs as MembersSignup, bt as MigrationInfo, bu as NavGroup, bv as NotFoundError, bw as NumberField, bx as OnRequest, by as OnRequestArgs, bz as Operation, bA as OutboxRow, bB as PLUGIN_API_VERSION, bC as PasswordEmail, bD as PasswordEmailArgs, bE as PasswordEmailFn, bF as PasswordLinkOptions, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bK as PreviewTarget, bL as PreviewURL, bM as QueryError, bN as ROLE_OPERATIONS, bO as RawDocument, bP as ReadOptions, bQ as RelationshipField, bR as RequestContext, R as ResolvedConfig, bS as RichTextDocument, bT as RichTextField, bU as Role, bV as RoleOperation, bW as RolePermissions, bX as Roles, bY as RoutesConfig, bZ as ScheduledPublish, b_ as SchemaError, b$ as SchemaMode, c0 as SearchAdapter, c1 as SearchEntry, c2 as SearchHits, c3 as SearchOptions, c4 as SearchQuery, c5 as SearchResults, c6 as SearchSetting, c7 as SelectField, c8 as SelectOption, c9 as Session, ca as SidebarPanel, cb as SignupArgs, cc as SignupResult, cd as SingleSignOn, ce as SlugField, cf as SlugOf, cg as SsoOutcome, ch as SsoProviderRef, ci as StorageAdapter, cj as StoredFile, ck as TRIGRAM_LIKE_MIN, cl as TelemetryConfig, cm as TextField, cn as TextareaField, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cr as UncountedDocs, cs as UpdateInput, ct as UpdateOptions, cu as UploadConfig, cv as UploadField, cw as UploadFromURLConfig, cx as UserIdentity, cy as ValidationError, cz as Version, cA as VersionSummary, cB as VersionsConfig, cC as WEBHOOK_EVENTS, cD as WebhookConfig, cE as WebhookEvent, cF as Where, cG as WhereOperators, cH as anyone, cI as consoleEmail, cJ as consoleLogger, cK as createEasyCMS, cL as defineConfig, cM as defineFieldType, cN as definePlugin, cO as diskStorage, cP as hasRows, cQ as isAdmin, cR as isHasMany, cS as isSignedIn, cT as isStaff, cU as isSystemAdmin, cV as jsonLogger, cW as memorySearch, cX as mimeAllowedBy, cY as rowFields, cZ as silentLogger, c_ as uniqueWithinOf } from './config-BuKlngv6.js';
3
+ export { A as ADMIN_API_VERSION, a as AdminCollection, b as AdminComponentRef, c as AdminElementProps, d as AdminField, e as AdminGlobal, f as AdminLayoutNode, g as AdminNavGroup, h as AdminNavItem, i as AdminNavNode, j as AdminPageRef, k as AdminPageRoute, l as AdminSchema, m as AdminWidgetRef, S as SetFieldDetail, w as warnDeprecated } from './plugin-zOdLJ1oh.js';
4
+ export { A as AdminBackup, a as AdminBackups, b as ApiHandlerOptions, B as BackupState, L as LoadConfigOptions, R as RestHandler, c as RestHandlerOptions, d as createApiHandler, e as createRestHandler, l as loadConfig, r as resolveConfig, s as sharedEasyCMS, f as slugify } from './handler-DuOjJu_X.js';
5
5
  export { FieldCondition, matchesCondition } from './conditions.js';
6
6
 
7
7
  type DeliveryKind = 'webhook' | 'email';
@@ -196,6 +196,12 @@ type AdminAttention =
196
196
  id: 'failed-logins';
197
197
  count: number;
198
198
  }
199
+ /** Break-glass accounts signed in with a password in the last day (`auth.password`). */
200
+ | {
201
+ id: 'break-glass';
202
+ count: number;
203
+ emails: string[];
204
+ }
199
205
  /** Audit log entries that could not be written since the server started. */
200
206
  | {
201
207
  id: 'audit-failures';
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  silentLogger,
29
29
  slugify,
30
30
  verifyWebhook
31
- } from "./chunk-FZCQXH5M.js";
31
+ } from "./chunk-R2YJ2K56.js";
32
32
  import {
33
33
  matchesCondition
34
34
  } from "./chunk-U266LV4M.js";
@@ -1,6 +1,6 @@
1
1
  import { Config, Database, EasyCMS, NavGroup, CollectionConfig, ID, RawDocument, Field, ResolvedConfig, ConfigIssue } from './index.js';
2
- export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, d8 as Secrets, ck as TRIGRAM_LIKE_MIN, d9 as codeOfStatus, cP as hasRows, cR as isHasMany, da as isJsonLogger, db as keyAllows, dc as logFormat, cX as mimeAllowedBy, cY as rowFields, dd as secretsOf, c_ as uniqueWithinOf } from './config-U1mRwD4t.js';
3
- export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-D2y4WSSM.js';
2
+ export { c$ as API_KEYS, d0 as API_KEY_GLOBAL_OPERATIONS, d1 as API_KEY_OPERATIONS, d2 as DEFAULT_MAX_VERSIONS, d3 as DEFAULT_PASSWORD_EMAILS, d4 as FIELD_TYPE_BASES, d5 as FOLDER_LEVELS, d6 as MediaFolders, d7 as SSO_COOKIE, d8 as Secrets, ck as TRIGRAM_LIKE_MIN, d9 as codeOfStatus, cP as hasRows, cR as isHasMany, da as isJsonLogger, db as keyAllows, dc as logFormat, cX as mimeAllowedBy, cY as rowFields, dd as secretsOf, c_ as uniqueWithinOf } from './config-BuKlngv6.js';
3
+ export { C as CONFIG_FILE_NAMES, g as CSRF_COOKIE, h as CSRF_HEADER, D as DEFAULT_ADMIN_PATH, i as DEFAULT_API_PATH, j as DEFAULT_MAX_FILE_SIZE, k as DEFAULT_TOKEN_EXPIRATION, m as applyDefaults, n as applyPlugins, o as createRootEndpointHandler, p as decryptBackup, q as fillMissing, t as findConfigFile, u as forwardedClientIp, v as generateSlugs, w as importConfig, x as isEmail, y as mergeForUpdate, z as parseId, E as platformClientIp, F as readCookie, G as validateFields, H as writeBackupFile } from './handler-DuOjJu_X.js';
4
4
  export { conditionIssues } from './conditions.js';
5
5
 
6
6
  /** The session cookie the REST API sets at login. */
package/dist/internal.js CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  verifyPassword,
59
59
  verifyTurnstile,
60
60
  writeBackupFile
61
- } from "./chunk-FZCQXH5M.js";
61
+ } from "./chunk-R2YJ2K56.js";
62
62
  import {
63
63
  conditionIssues
64
64
  } from "./chunk-U266LV4M.js";
@@ -1,4 +1,4 @@
1
- import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-U1mRwD4t.js';
1
+ import { bh as Label, F as Field, I as ID, o as AdminLocale, x as AdminSwitcher, ch as SsoProviderRef } from './config-BuKlngv6.js';
2
2
  import { FieldCondition } from './conditions.js';
3
3
 
4
4
  /** A Web Component from an admin module, with its `props` (plain JSON). */
@@ -211,6 +211,12 @@ interface AdminSchema {
211
211
  switcher: AdminSwitcher | null;
212
212
  /** Admins can email users links to set their password (needs `email` and `serverURL`). */
213
213
  passwordLinks: boolean;
214
+ /**
215
+ * Who may have a password (`auth.password`); with `breakGlass`, the accounts, for admins of the
216
+ * whole system: others get no password field.
217
+ */
218
+ passwordFor: 'everyone' | 'admins' | 'breakGlass';
219
+ breakGlass?: string[];
214
220
  /** The menu: groups and what this user may open in them, in order. */
215
221
  nav: AdminNavNode[];
216
222
  /** More entries of the command palette (`admin.commands`). */
package/dist/plugin.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-U1mRwD4t.js';
2
- export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-DRV5HA4a.js';
1
+ export { e as ADMIN_ICONS, k as AdminComponent, n as AdminIcon, $ as AuthUser, ac as CliCommand, ad as CollectionAccess, af as CollectionConfig, C as Config, al as ConfigError, at as DashboardWidget, aD as EasyCMSError, aH as Endpoint, aI as EndpointRequest, aJ as ErrorCode, F as Field, aL as FieldAccess, a as FieldError, aQ as FieldTypeBase, aR as FieldTypeDefinition, aS as FieldTypeValidateContext, b2 as ForbiddenError, b4 as GlobalConfig, I as ID, be as JobConfig, bh as Label, bu as NavGroup, bv as NotFoundError, bx as OnRequest, by as OnRequestArgs, bB as PLUGIN_API_VERSION, bG as PayloadTooLargeError, bH as Plugin, bI as PluginInfo, bJ as PluginTypes, bM as QueryError, bR as RequestContext, co as TooManyRequestsError, cp as TypedPlugin, cq as UnauthorizedError, cy as ValidationError, cF as Where, cM as defineFieldType, cN as definePlugin } from './config-BuKlngv6.js';
2
+ export { A as ADMIN_API_VERSION, c as AdminElementProps, k as AdminPageRoute, S as SetFieldDetail, w as warnDeprecated } from './plugin-zOdLJ1oh.js';
3
3
  import './conditions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "1.19.0",
3
+ "version": "1.20.1",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",