From 81d217f5f65691f348bad0f8df4b10caecbca8a3 Mon Sep 17 00:00:00 2001 From: webiny-bot Date: Wed, 1 Jul 2026 16:12:46 +0000 Subject: [PATCH 01/15] chore: start release 6.4.4 Empty commit to allow PR creation. From e23ba8ffb6862509afcadb1383c6bc75e2db6c8e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 1 Jul 2026 18:16:13 +0200 Subject: [PATCH 02/15] fix: export useParentField and ParentFieldProvider --- .../app-headless-cms/src/exports/admin/cms/entry/editor.ts | 5 +++++ packages/webiny/package.json | 2 +- packages/webiny/src/admin/cms/entry/editor.ts | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/app-headless-cms/src/exports/admin/cms/entry/editor.ts b/packages/app-headless-cms/src/exports/admin/cms/entry/editor.ts index a84666a65b0..281c2eb24a6 100644 --- a/packages/app-headless-cms/src/exports/admin/cms/entry/editor.ts +++ b/packages/app-headless-cms/src/exports/admin/cms/entry/editor.ts @@ -1,3 +1,8 @@ +export { + useParentField, + ParentFieldProvider +} from "~/admin/components/ContentEntryForm/ParentValue.js"; + export { ContentEntryForm } from "~/admin/components/ContentEntryForm/ContentEntryForm.js"; export { Header as ContentEntryFormHeader } from "~/admin/components/ContentEntryForm/Header/index.js"; export { ContentEntryFormPreview } from "~/admin/components/ContentEntryForm/ContentEntryFormPreview.js"; diff --git a/packages/webiny/package.json b/packages/webiny/package.json index a74f5ee6b68..08eff5a54d4 100644 --- a/packages/webiny/package.json +++ b/packages/webiny/package.json @@ -142,7 +142,7 @@ "./extensions": "./extensions.js", "./api/tenant-manager": "./api/tenant-manager.js" }, - "exportGenerationHash": "954f9dd590fd3048683029196e8f6fc8311f5096e3f1e6ad1a5352c2ea48b4ea", + "exportGenerationHash": "44d0f3eea02d34b3f40d2a801ee420460a5a5311c749454a580c8318e8aee92c", "webiny": { "publishFrom": "dist" } diff --git a/packages/webiny/src/admin/cms/entry/editor.ts b/packages/webiny/src/admin/cms/entry/editor.ts index 2c783f74ec5..07a859a2133 100644 --- a/packages/webiny/src/admin/cms/entry/editor.ts +++ b/packages/webiny/src/admin/cms/entry/editor.ts @@ -1,3 +1,7 @@ +export { + useParentField, + ParentFieldProvider +} from "@webiny/app-headless-cms/admin/components/ContentEntryForm/ParentValue.js"; export { ContentEntryForm } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/ContentEntryForm.js"; export { Header as ContentEntryFormHeader } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/Header/index.js"; export { ContentEntryFormPreview } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/ContentEntryFormPreview.js"; From 12a095c78068f45065f7be194b12bf8cbbba63f8 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 3 Jul 2026 10:09:38 +0200 Subject: [PATCH 03/15] fix: add missing rules definition methods --- extensions/models/FieldRulesModel.ts | 140 ++++++++++ .../src/domain/contentModel/schemas.ts | 2 +- .../modelBuilder/fields/BaseFieldBuilder.ts | 8 +- .../modelBuilder/fields/DataFieldBuilder.ts | 3 +- .../modelBuilder/fields/UiAlertFieldType.ts | 3 +- .../fields/UiSeparatorFieldType.ts | 3 +- .../modelBuilder/fields/UiTabsFieldType.ts | 19 +- .../api-headless-cms/src/types/modelField.ts | 2 +- skills/user-skills/content-models/SKILL.md | 260 +++++++++++++++++- 9 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 extensions/models/FieldRulesModel.ts diff --git a/extensions/models/FieldRulesModel.ts b/extensions/models/FieldRulesModel.ts new file mode 100644 index 00000000000..1326587a194 --- /dev/null +++ b/extensions/models/FieldRulesModel.ts @@ -0,0 +1,140 @@ +import { ModelFactory } from "webiny/api/cms/model"; + +export const FIELD_RULES_MODEL_ID = "fieldRulesTest"; + +class FieldRulesModelImpl implements ModelFactory.Interface { + async execute(builder: ModelFactory.Builder) { + return [ + builder + .public({ + modelId: FIELD_RULES_MODEL_ID, + name: "Field Rules Test", + group: "ungrouped" + }) + .description("Test model for field rules defined via code") + .fields(fields => ({ + status: fields + .text() + .renderer("radioButtons") + .label("Status") + .required() + .predefinedValues([ + { label: "Draft", value: "draft" }, + { label: "Published", value: "published" } + ]), + title: fields + .text() + .renderer("textInput") + .label("Title") + .required() + .rules([ + { + type: "accessControl", + target: "identity", + operator: "matches", + value: "team:marketing", + action: "disable" + } + ]), + seo: fields + .object() + .renderer("objectAccordionSingle") + .label("SEO") + .rules([ + { + type: "condition", + target: "status", + operator: "!=", + value: "published", + action: "hide" + } + ]) + .fields(sub => ({ + seoTitle: sub.text().renderer("textInput").label("SEO Title"), + seoDescription: sub + .longText() + .renderer("textarea") + .label("SEO Description") + .rules([ + { + type: "condition", + target: "seo.seoTitle", + operator: "isEmpty", + value: null, + action: "disable" + } + ]) + })) + .layout([["seoTitle"], ["seoDescription"]]), + separator: fields + .uiSeparator() + .label("Admin Section") + .rules([ + { + type: "accessControl", + target: "identity", + operator: "matches", + value: "team:admins", + action: "hide" + } + ]), + alert: fields + .uiAlert() + .label("This content is in draft mode.") + .alertType("warning") + .rules([ + { + type: "condition", + target: "status", + operator: "==", + value: "published", + action: "hide" + } + ]), + tabs: fields + .uiTabs() + .tab("content", { + label: "Content", + fields: sub => ({ + body: sub.richText().renderer("lexicalEditor").label("Body") + }), + layout: [["body"]] + }) + .tab("advanced", { + label: "Advanced", + fields: sub => ({ + slug: sub.text().renderer("textInput").label("Slug").unique() + }), + layout: [["slug"]], + rules: [ + { + type: "accessControl", + target: "identity", + operator: "matches", + value: "team:developers", + action: "hide" + } + ] + }) + .rules([ + { + type: "condition", + target: "title", + operator: "isEmpty", + value: null, + action: "hide" + } + ]) + })) + .layout([["status"], ["title"], ["alert"], ["seo"], ["separator"], ["tabs"]]) + .titleFieldId("title") + .singularApiName("FieldRulesTest") + .pluralApiName("FieldRulesTests") + ]; + } +} + +export const FieldRulesModel = ModelFactory.createImplementation({ + implementation: FieldRulesModelImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/domain/contentModel/schemas.ts b/packages/api-headless-cms/src/domain/contentModel/schemas.ts index f2438e4acaa..78ff4ef14e5 100644 --- a/packages/api-headless-cms/src/domain/contentModel/schemas.ts +++ b/packages/api-headless-cms/src/domain/contentModel/schemas.ts @@ -139,7 +139,7 @@ const fieldSchema = zod.object({ rules: zod .array( zod.object({ - type: zod.enum(["accessControl", "entryValue"]), + type: zod.enum(["accessControl", "condition"]), target: shortString, operator: shortString, value: zod.union([zod.string(), zod.number(), zod.boolean(), zod.null()]), diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/BaseFieldBuilder.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/BaseFieldBuilder.ts index 719b883f177..0e39177c128 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/BaseFieldBuilder.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/BaseFieldBuilder.ts @@ -1,4 +1,4 @@ -import type { CmsModelField, CmsModelLayoutCell } from "~/types/index.js"; +import type { CmsModelField, CmsModelLayoutCell, FieldRule } from "~/types/index.js"; export interface DataFieldBuildResult { type: "data"; @@ -19,6 +19,7 @@ export interface BaseFieldBuilderConfig { description?: string | null; note?: string | null; _fieldId?: string; + rules?: FieldRule[]; } /** @@ -68,6 +69,11 @@ export abstract class BaseFieldBuilder { return this; } + public rules(rules: FieldRule[]): this { + this.config.rules = rules; + return this; + } + protected setSubType(subType: string): this { this._fieldSubType = subType; return this; diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/DataFieldBuilder.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/DataFieldBuilder.ts index ad1549b9a67..8a356e71b6f 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/DataFieldBuilder.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/DataFieldBuilder.ts @@ -401,7 +401,8 @@ export class DataFieldBuilder extends BaseFieldBu note: this.config.note || null, renderer: this.config.renderer || null, settings: this.config.settings || {}, - tags: this.config.tags || [] + tags: this.config.tags || [], + rules: this.config.rules } }; } diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/UiAlertFieldType.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/UiAlertFieldType.ts index bcbc6f605c1..d06607187e5 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/UiAlertFieldType.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/UiAlertFieldType.ts @@ -26,7 +26,8 @@ class AlertFieldBuilder extends LayoutFieldBuilder<"uiAlert"> implements IUiAler layoutCell: { type: "alert", label: this.config.label, - alertType: this._alertType + alertType: this._alertType, + rules: this.config.rules } }; } diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/UiSeparatorFieldType.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/UiSeparatorFieldType.ts index aa7c07b6bf6..2b15f504fb9 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/UiSeparatorFieldType.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/UiSeparatorFieldType.ts @@ -18,7 +18,8 @@ class SeparatorFieldBuilder layoutCell: { type: "separator", label: this.config.label, - description: this.config.description + description: this.config.description, + rules: this.config.rules } }; } diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/UiTabsFieldType.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/UiTabsFieldType.ts index 4946a501ada..4ae3c3178f8 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/UiTabsFieldType.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/UiTabsFieldType.ts @@ -6,7 +6,13 @@ import { } from "./BaseFieldBuilder.js"; import { LayoutFieldBuilder } from "./LayoutFieldBuilder.js"; import { type IFieldBuilderRegistry } from "../abstractions.js"; -import type { CmsIcon, CmsModelField, CmsModelLayout, CmsModelLayoutCell } from "~/types/index.js"; +import type { + CmsIcon, + CmsModelField, + CmsModelLayout, + CmsModelLayoutCell, + FieldRule +} from "~/types/index.js"; interface ITab { id: string; @@ -15,6 +21,7 @@ interface ITab { description: string; fields: CmsModelField[]; layout: CmsModelLayout; + rules?: FieldRule[]; } interface ITabConfig { @@ -23,6 +30,7 @@ interface ITabConfig { description?: string; fields: (registry: IFieldBuilderRegistry) => Record>; layout?: string[][]; + rules?: FieldRule[]; } export interface IUiTabsFieldBuilder extends LayoutFieldBuilder<"uiTabs"> { @@ -65,7 +73,8 @@ class TabsFieldBuilder extends LayoutFieldBuilder<"uiTabs"> implements IUiTabsFi icon: config.icon, description: config.description || "", fields, - layout + layout, + rules: config.rules }); return this; @@ -81,7 +90,8 @@ class TabsFieldBuilder extends LayoutFieldBuilder<"uiTabs"> implements IUiTabsFi id: tab.id, label: tab.label, icon: tab.icon || null, - layout: tab.layout + layout: tab.layout, + rules: tab.rules }); } @@ -92,7 +102,8 @@ class TabsFieldBuilder extends LayoutFieldBuilder<"uiTabs"> implements IUiTabsFi label: this.config.label, description: this.config.description || null, help: this.config.help || null, - tabs: layoutTabs + tabs: layoutTabs, + rules: this.config.rules }, fields: hoistedFields }; diff --git a/packages/api-headless-cms/src/types/modelField.ts b/packages/api-headless-cms/src/types/modelField.ts index 24cbb6ef500..6092cc25ff7 100644 --- a/packages/api-headless-cms/src/types/modelField.ts +++ b/packages/api-headless-cms/src/types/modelField.ts @@ -5,7 +5,7 @@ import type { CmsDynamicZoneTemplate } from "~/types/fields/dynamicZoneField.js" export type FieldRuleAction = "hide" | "disable" | string; export interface FieldRule { - type: "accessControl" | "entryValue"; + type: "accessControl" | "condition"; target: string; operator: string; value: string | number | boolean | null; diff --git a/skills/user-skills/content-models/SKILL.md b/skills/user-skills/content-models/SKILL.md index 93ff9e703cb..1cc622c7284 100644 --- a/skills/user-skills/content-models/SKILL.md +++ b/skills/user-skills/content-models/SKILL.md @@ -12,7 +12,8 @@ description: > (text, longText, number, boolean, datetime, file, ref, object, richText, dynamicZone), list (array) fields via .list() and the singular-vs-plural renderer rule, validation (required, unique, email, pattern, minLength, maxLength, gte, predefinedValues), - single-entry (singleton) models via .singleEntry(), and model/field tags via .tags(). + single-entry (singleton) models via .singleEntry(), model/field tags via .tags(), + and field rules via .rules() for access-control and conditional visibility/editability. Includes the correct `fields` projection syntax when querying entries via the SDK: `ref` fields use double-`values.` nesting (e.g. `values.author.values.name`) because they resolve to another entry, while `object` and `dynamicZone` sub-fields are inline @@ -261,6 +262,95 @@ The same rule applies to every field type that has both variants: For `ref()` fields the pluralization rule is the same but the singular/multiple renderers have distinct names (e.g. `refDialogSingle` → `refDialogMultiple`) — see the table. +## Layout Fields + +Layout fields are UI-only elements that do not store data. They decorate the editor +form with visual structure. Place them in `.fields()` like data fields, and reference +them by key in `.layout()`. + +All layout fields inherit the base methods: `.label()`, `.help()`, `.description()`, +`.note()`, `.fieldId()`, `.rules()`. + +| Builder Method | Description | Extra Methods | +| ---------------------- | ----------------------------------------------- | ---------------------------------------------------------- | +| `fields.uiSeparator()` | Horizontal divider with an optional label | — | +| `fields.uiAlert()` | Colored banner (info, success, warning, danger) | `.alertType("info" \| "success" \| "warning" \| "danger")` | +| `fields.uiTabs()` | Tabbed container that groups fields into tabs | `.tab(id, config)` — see below | + +### uiSeparator + +A horizontal line to visually separate field groups. The label appears as section text. + +```typescript +.fields(fields => ({ + name: fields.text().renderer("textInput").label("Name"), + divider: fields.uiSeparator().label("Additional Info"), + bio: fields.longText().renderer("textarea").label("Bio") +})) +.layout([["name"], ["divider"], ["bio"]]) +``` + +### uiAlert + +A colored callout banner. Use `.alertType()` to set the severity. + +```typescript +.fields(fields => ({ + warning: fields + .uiAlert() + .label("Changes to this section require approval.") + .alertType("warning"), + title: fields.text().renderer("textInput").label("Title") +})) +.layout([["warning"], ["title"]]) +``` + +### uiTabs + +Groups fields into tabs. Each tab has its own fields and layout. Fields inside tabs +are hoisted to the model level (flat field list), but visually grouped under their tab. + +`.tab(id, config)` accepts: + +| Config Property | Type | Required | Description | +| --------------- | ------------------------------------------------ | -------- | ----------------------------- | +| `label` | `string` | yes | Tab label | +| `icon` | `CmsIcon` | no | Tab icon | +| `description` | `string` | no | Tab description | +| `fields` | `(registry) => Record` | yes | Fields inside this tab | +| `layout` | `string[][]` | no | Layout for fields in this tab | +| `rules` | `FieldRule[]` | no | Rules for this individual tab | + +```typescript +.fields(fields => ({ + tabs: fields + .uiTabs() + .tab("general", { + label: "General", + fields: sub => ({ + title: sub.text().renderer("textInput").label("Title").required(), + slug: sub.text().renderer("textInput").label("Slug").required() + }), + layout: [["title", "slug"]] + }) + .tab("seo", { + label: "SEO", + fields: sub => ({ + seoTitle: sub.text().renderer("textInput").label("SEO Title"), + seoDescription: sub.longText().renderer("textarea").label("SEO Description") + }), + layout: [["seoTitle"], ["seoDescription"]], + rules: [ + { type: "accessControl", target: "identity", operator: "matches", value: "team:marketing", action: "hide" } + ] + }) +})) +.layout([["tabs"]]) +``` + +The outer `.layout()` references `"tabs"` as a single cell. Tab-internal layouts only +reference their own field keys (same scoping rule as object/dynamicZone layouts). + ## Field Validators (Chainable) | Validator | Description | Example | @@ -284,6 +374,174 @@ have distinct names (e.g. `refDialogSingle` → `refDialogMultiple`) — see the | `.list()` | Make the field accept multiple values (arrays). Requires a multi-value renderer variant — see Field Types table. | | `.models([{ modelId: "..." }])` | For `ref()` fields: which models can be referenced | | `.tags(["tag1"])` | Assign tags to a field (e.g., `"$bulk-edit"`) | +| `.rules([...])` | Conditional visibility/editability rules — see [Field Rules](#field-rules) section | + +## Field Rules + +`.rules()` accepts an array of `FieldRule` objects that control field visibility and +editability in the Admin UI. Rules are evaluated client-side. Available on **all** field +types (data fields and layout fields — separators, alerts, tabs). + +Each rule has the shape: + +```typescript +interface FieldRule { + type: "accessControl" | "condition"; + target: string; + operator: string; + value: string | number | boolean | null; + action: "hide" | "disable"; +} +``` + +### Rule types + +| `type` | Purpose | `target` | `value` | +| ----------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------- | +| `"accessControl"` | Show/hide or enable/disable a field based on the current user's identity or team membership | `"identity"` | `"admin:"` or `"team:"` | +| `"condition"` | Show/hide or enable/disable a field based on the current entry's field values (reactive, updates live) | Field path, e.g. `"status"` or `"seo.title"` | The value to compare against (type depends on operator) | + +### Actions + +| `action` | Effect | +| ----------- | ----------------------------------------------------------------- | +| `"hide"` | Hides the field entirely from the editor | +| `"disable"` | Shows the field but makes it read-only (greyed out, not editable) | + +### Condition operators + +Operators available for `type: "condition"` rules, grouped by target field type: + +| Operator | Label | Applicable to | `value` | +| -------------- | ---------------- | ------------------------------- | ------------------------- | +| `"=="` | Equals | text, number, boolean, datetime | The value to match | +| `"!="` | Not equals | text, number, boolean, datetime | The value to not match | +| `">"` | Greater than | number, datetime | Numeric/date threshold | +| `"<"` | Less than | number, datetime | Numeric/date threshold | +| `">="` | Greater or equal | number, datetime | Numeric/date threshold | +| `"<="` | Less or equal | number, datetime | Numeric/date threshold | +| `"contains"` | Contains | text, long-text | Substring to search for | +| `"startsWith"` | Starts with | text, long-text | Prefix to match | +| `"endsWith"` | Ends with | text, long-text | Suffix to match | +| `"isEmpty"` | Is empty | all field types | `null` (value is ignored) | +| `"isNotEmpty"` | Is not empty | all field types | `null` (value is ignored) | + +### Access control operators + +| Operator | Description | +| ----------- | ------------------------------------------------------------- | +| `"matches"` | Checks if the current user's identity or team matches `value` | + +### Target field paths (condition rules) + +The `target` for condition rules is a **dot-separated field path** relative to the form +root. Use the field's `fieldId` (the key in the `.fields()` callback). + +- Simple field: `"status"` +- Nested inside object: `"seo.title"`, `"address.city"` +- Inside a list item (current index): `"items.$.name"` — the `$` resolves to the + current list index at evaluation time +- Array length: `"items.length"` — evaluates to the number of items in the array + +### Examples + +**Access control — hide field from non-marketing team:** + +```typescript +title: fields + .text() + .renderer("textInput") + .label("Marketing Title") + .rules([ + { + type: "accessControl", + target: "identity", + operator: "matches", + value: "team:marketing", + action: "hide" + } + ]); +``` + +**Condition — disable field when another field is empty:** + +```typescript +seoDescription: fields + .longText() + .renderer("textarea") + .label("SEO Description") + .rules([ + { type: "condition", target: "seoTitle", operator: "isEmpty", value: null, action: "disable" } + ]); +``` + +**Condition — show field only when status equals "published":** + +```typescript +publishDate: fields + .datetime() + .renderer("dateTimeInput") + .label("Publish Date") + .rules([ + { type: "condition", target: "status", operator: "!=", value: "published", action: "hide" } + ]); +``` + +**Multiple rules on a single field:** + +```typescript +internalNotes: fields + .longText() + .renderer("textarea") + .label("Internal Notes") + .rules([ + { + type: "accessControl", + target: "identity", + operator: "matches", + value: "team:editors", + action: "hide" + }, + { type: "condition", target: "status", operator: "==", value: "draft", action: "disable" } + ]); +``` + +**Rules on layout fields (separator, alert, tabs):** + +```typescript +.fields(fields => ({ + adminAlert: fields + .uiAlert() + .label("Admin only") + .alertType("warning") + .rules([ + { type: "accessControl", target: "identity", operator: "matches", value: "team:admins", action: "hide" } + ]), +})) +``` + +**Rules on individual tabs:** + +```typescript +.fields(fields => ({ + tabs: fields + .uiTabs() + .tab("general", { + label: "General", + fields: sub => ({ /* ... */ }), + layout: [/* ... */] + }) + .tab("advanced", { + label: "Advanced", + fields: sub => ({ /* ... */ }), + layout: [/* ... */], + rules: [ + { type: "accessControl", target: "identity", operator: "matches", value: "team:developers", action: "hide" } + ] + }) + .rules([/* rules on the entire tabs container */]) +})) +``` ## Querying `ref`, `object`, and `dynamicZone` fields From 844601e2e9e8e8630aca0072012794fba472b17f Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 6 Jul 2026 20:21:45 +0200 Subject: [PATCH 04/15] feat: add support for relative rule targets using $ --- extensions/models/BannerModel.ts | 150 ++++++++++++++++++ .../fields/DynamicZoneFieldType.ts | 24 ++- .../src/types/fields/dynamicZoneField.ts | 4 +- .../app-admin/src/features/formModel/Field.ts | 11 ++ .../src/features/formModel/FormModel.test.ts | 58 +++++++ .../src/Fields/evaluateExpression.ts | 5 + skills/user-skills/content-models/SKILL.md | 57 +++++++ 7 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 extensions/models/BannerModel.ts diff --git a/extensions/models/BannerModel.ts b/extensions/models/BannerModel.ts new file mode 100644 index 00000000000..622c96c50a3 --- /dev/null +++ b/extensions/models/BannerModel.ts @@ -0,0 +1,150 @@ +import { ModelFactory } from "webiny/api/cms/model"; + +class BannerModelImpl implements ModelFactory.Interface { + async execute(builder: ModelFactory.Builder) { + return [ + builder + .public({ + modelId: "siteBanner", + name: "Banner", + group: "ungrouped" + }) + .description("Site and special offer banners.") + .fields(fields => ({ + name: fields + .text() + .renderer("textInput") + .label("Name") + .required("Name is required.") + .minLength(2) + .maxLength(100), + bannerTypes: fields + .dynamicZone() + .renderer("dynamicZone") + .label("Banner Type") + .template("siteBanner", { + name: "Site Banner", + gqlTypeName: "SiteBannerBlock", + fields: templateFields => ({ + enabled: templateFields + .boolean() + .renderer("switch") + .label("Enabled"), + text: templateFields + .richText() + .renderer("lexicalEditor") + .label("Text"), + backgroundColor: templateFields + .text() + .renderer("dropdown") + .label("Background Color") + .predefinedValues([ + { label: "Teal: #009CA6", value: "#009CA6" }, + { label: "Navy: #00205B", value: "#00205B" }, + { label: "Yellow: #EDE04B", value: "#EDE04B" } + ]), + additionalInformation: templateFields + .boolean() + .renderer("switch") + .label("Additional Information") + .description( + "Enable this option to add additional information for the banner." + ), + additionalInfoTab: templateFields + .uiTabs() + .label("Tab") + .tab("Additional Information", { + label: "Additional Information", + description: "Additional information for the banner.", + fields: tabFields => ({ + additionalInformationText: tabFields + .richText() + .renderer("lexicalEditor") + .label("Additional Information Text") + }), + layout: [["additionalInformationText"]] + }) + .rules([ + { + type: "condition", + target: "$.additionalInformation", + operator: "!=", + value: true, + action: "hide" + } + ]), + global: templateFields + .boolean() + .renderer("switch") + .label("Global") + .description( + "Global banners will be displayed on all pages of the website." + ), + locationTab: templateFields + .uiTabs() + .label("Location") + .tab("Location", { + label: "Location", + description: "Location for the banner.", + fields: tabFields => ({ + location: tabFields + .text() + .label("Location") + .renderer("textInput") + .required("Location is required.") + }), + layout: [["location"]] + }) + .rules([ + { + type: "condition", + target: "$.global", + operator: "==", + value: true, + action: "hide" + } + ]) + }), + layout: [ + ["enabled"], + ["text"], + ["backgroundColor"], + ["additionalInformation"], + ["additionalInfoTab"], + ["global"], + ["locationTab"] + ] + }) + .template("specialOfferBanner", { + name: "Special Offer Banner", + gqlTypeName: "SpecialOfferBannerBlock", + fields: templateFields => ({ + enabled: templateFields + .boolean() + .renderer("switch") + .label("Enabled"), + text: templateFields + .richText() + .renderer("lexicalEditor") + .label("Text"), + ctaLabel: templateFields + .text() + .renderer("textInput") + .label("CTA Label"), + ctaUrl: templateFields.text().renderer("textInput").label("CTA URL") + }), + layout: [["enabled"], ["text"], ["ctaLabel", "ctaUrl"]] + }) + })) + .layout([["name"], ["bannerTypes"]]) + .titleFieldId("name") + .singularApiName("Banner") + .pluralApiName("Banners") + ]; + } +} + +export default ModelFactory.createImplementation({ + implementation: BannerModelImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/modelBuilder/fields/DynamicZoneFieldType.ts b/packages/api-headless-cms/src/features/modelBuilder/fields/DynamicZoneFieldType.ts index 5ecaa5b8381..c0193404a16 100644 --- a/packages/api-headless-cms/src/features/modelBuilder/fields/DynamicZoneFieldType.ts +++ b/packages/api-headless-cms/src/features/modelBuilder/fields/DynamicZoneFieldType.ts @@ -2,7 +2,12 @@ import { FieldType, type IFieldTypeFactory } from "./abstractions.js"; import { type FieldBuildResult } from "./BaseFieldBuilder.js"; import { DataFieldBuilder, type BaseFieldBuilder } from "./FieldBuilder.js"; import { type IFieldBuilderRegistry } from "../abstractions.js"; -import type { CmsIcon, CmsModelField, CmsModelFieldValidation } from "~/types/index.js"; +import type { + CmsIcon, + CmsModelField, + CmsModelFieldValidation, + CmsModelLayoutCell +} from "~/types/index.js"; interface IDynamicZoneTemplate { id: string; @@ -11,7 +16,7 @@ interface IDynamicZoneTemplate { icon: CmsIcon | undefined; description: string; fields: any[]; - layout: string[][]; + layout: CmsModelLayoutCell[][]; validation: CmsModelFieldValidation[]; } @@ -60,17 +65,24 @@ class DynamicZoneFieldBuilder public template(id: string, config: IDynamicZoneFieldBuilderTemplateConfig): this { const fieldBuilders = config.fields(this.registry); const fields: CmsModelField[] = []; + const layoutReplacements = new Map(); for (const [key, fieldBuilder] of Object.entries(fieldBuilders)) { fieldBuilder.fieldId(key); const result: FieldBuildResult = (fieldBuilder as any).build(); - if (result.type === "data") { + if (result.type === "layout") { + layoutReplacements.set(key, result.layoutCell); + if (result.fields) { + fields.push(...result.fields); + } + } else { fields.push(result.field); - } else if (result.fields) { - fields.push(...result.fields); } } + const rawLayout: string[][] = config.layout || []; + const layout = rawLayout.map(row => row.map(cell => layoutReplacements.get(cell) ?? cell)); + this.templates.push({ id, name: config.name, @@ -78,7 +90,7 @@ class DynamicZoneFieldBuilder icon: config.icon, description: config.description || "", fields, - layout: config.layout || [], + layout, validation: [] }); diff --git a/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts b/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts index 7262aea558d..9a6ab1935ef 100644 --- a/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts +++ b/packages/api-headless-cms/src/types/fields/dynamicZoneField.ts @@ -1,5 +1,5 @@ import type { CmsModelField, CmsModelFieldValidation } from "../modelField.js"; -import type { CmsIcon } from "~/types/index.js"; +import type { CmsIcon, CmsModelLayoutCell } from "~/types/index.js"; export interface CmsDynamicZoneTemplate { id: string; @@ -8,7 +8,7 @@ export interface CmsDynamicZoneTemplate { description: string; icon?: CmsIcon; fields: CmsModelField[]; - layout: string[][]; + layout: CmsModelLayoutCell[][]; validation: CmsModelFieldValidation[]; tags?: string[]; } diff --git a/packages/app-admin/src/features/formModel/Field.ts b/packages/app-admin/src/features/formModel/Field.ts index 0bf3c1ccbd1..31744e99d23 100644 --- a/packages/app-admin/src/features/formModel/Field.ts +++ b/packages/app-admin/src/features/formModel/Field.ts @@ -41,6 +41,7 @@ export class Field implements IField { private _isUIChange = false; private _focusRequested = false; private _qualifiedName: string = ""; + private _parentPath: string = ""; private _form: IFormModel | null = null; private _ancestorRules: IRule[] = []; @@ -169,6 +170,15 @@ export class Field implements IField { if (all.length === 0) { return { visible: true, disabled: false }; } + if (this._parentPath) { + const resolved = all.map(rule => { + if (rule.target.startsWith("$.")) { + return { ...rule, target: `${this._parentPath}.${rule.target.slice(2)}` }; + } + return rule; + }); + return this._form.evaluateRules(resolved); + } return this._form.evaluateRules(all); } @@ -276,6 +286,7 @@ export class Field implements IField { setForm(form: IFormModel, parentPath?: string): void { this._form = form; + this._parentPath = parentPath || ""; this._qualifiedName = parentPath ? `${parentPath}.${this.config.name}` : this.config.name; } diff --git a/packages/app-admin/src/features/formModel/FormModel.test.ts b/packages/app-admin/src/features/formModel/FormModel.test.ts index caebd0fa7b5..9fb0fe5369b 100644 --- a/packages/app-admin/src/features/formModel/FormModel.test.ts +++ b/packages/app-admin/src/features/formModel/FormModel.test.ts @@ -4049,4 +4049,62 @@ describe("FormModel", () => { expect(log).toEqual([["a", "b"], ["b"]]); }); }); + + describe("$. static references in rules", () => { + it("$. resolves rule targets relative to the parent object", () => { + const form = createForm({ + fields: fields => ({ + wrapper: fields + .object() + .renderer("passthrough") + .fields(f => ({ + status: f.text().defaultValue("draft"), + details: f.text().rules([ + { + type: "condition", + target: "$.status", + operator: "eq", + value: "draft", + action: "hide" + } + ]) + })) + }), + layout: l => [l.row("wrapper")] + }); + + expect(form.field("wrapper.details").visible).toBe(false); + + form.field("wrapper.status").setValue("published"); + expect(form.field("wrapper.details").visible).toBe(true); + }); + + it("$. in rules with disable action", () => { + const form = createForm({ + fields: fields => ({ + wrapper: fields + .object() + .renderer("passthrough") + .fields(f => ({ + locked: f.text().defaultValue("yes"), + editable: f.text().rules([ + { + type: "condition", + target: "$.locked", + operator: "eq", + value: "yes", + action: "disable" + } + ]) + })) + }), + layout: l => [l.row("wrapper")] + }); + + expect(form.field("wrapper.editable").disabled).toBe(true); + + form.field("wrapper.locked").setValue("no"); + expect(form.field("wrapper.editable").disabled).toBe(false); + }); + }); }); diff --git a/packages/app-headless-cms-common/src/Fields/evaluateExpression.ts b/packages/app-headless-cms-common/src/Fields/evaluateExpression.ts index c6c0c567fcb..323c8d32928 100644 --- a/packages/app-headless-cms-common/src/Fields/evaluateExpression.ts +++ b/packages/app-headless-cms-common/src/Fields/evaluateExpression.ts @@ -31,6 +31,11 @@ export function resolveFieldPath(fieldPath: string, bindParentName?: string): st return fieldPath; } + if (fieldPath.startsWith("$.")) { + const relative = fieldPath.slice(2); + return bindParentName ? `${bindParentName}.${relative}` : relative; + } + if (!bindParentName) { // No parent context, replace $ with 0 as fallback return fieldPath.replace(/\$/g, "0"); diff --git a/skills/user-skills/content-models/SKILL.md b/skills/user-skills/content-models/SKILL.md index 1cc622c7284..ff0eb594757 100644 --- a/skills/user-skills/content-models/SKILL.md +++ b/skills/user-skills/content-models/SKILL.md @@ -442,6 +442,13 @@ root. Use the field's `fieldId` (the key in the `.fields()` callback). - Inside a list item (current index): `"items.$.name"` — the `$` resolves to the current list index at evaluation time - Array length: `"items.length"` — evaluates to the number of items in the array +- **Static relative path**: `"$.fieldId"` — resolves relative to the **parent object or + template** that contains the field. Use this inside `object`, `dynamicZone` template, + or `uiTabs` scopes to reference a sibling field without hard-coding the full path. + For example, inside a dynamicZone template, `"$.enabled"` resolves to the sibling + `enabled` field within the same template instance. This is the recommended approach + for rules inside nested scopes — it keeps the rule portable and avoids coupling to + the parent field's name. ### Examples @@ -543,6 +550,56 @@ internalNotes: fields })) ``` +**Static `$.` paths — referencing siblings inside a nested scope:** + +Use `$.` to target a sibling field within the same parent object or dynamicZone +template. The `$` resolves to the parent path at runtime, so the rule stays portable +regardless of the outer structure. + +```typescript +.fields(fields => ({ + bannerTypes: fields + .dynamicZone() + .label("Banner Type") + .template("siteBanner", { + name: "Site Banner", + gqlTypeName: "SiteBannerBlock", + fields: t => ({ + enabled: t.boolean().renderer("switch").label("Enabled"), + text: t.richText().renderer("lexicalEditor").label("Text"), + global: t + .boolean() + .renderer("switch") + .label("Global"), + locationTab: t + .uiTabs() + .tab("Location", { + label: "Location", + fields: tabFields => ({ + location: tabFields.text().label("Location") + }), + layout: [["location"]] + }) + .rules([ + { + type: "condition", + target: "$.global", // resolves to sibling "global" in this template + operator: "==", + value: true, + action: "hide" + } + ]) + }), + layout: [["enabled"], ["text"], ["global"], ["locationTab"]] + }) +})) +``` + +In this example, `"$.global"` resolves to the `global` field within the same +dynamicZone template instance. Without the `$.` prefix, you would need to hard-code the +full path (e.g. `"bannerTypes.0.global"`), which breaks across list indices. The same +pattern works inside `object` fields and `uiTabs` scopes. + ## Querying `ref`, `object`, and `dynamicZone` fields When you read entries via the Webiny SDK (or GraphQL), the `fields` array tells the API From a6f6a70e3b0bf646f4cd59ccb5db40caebb298aa Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Tue, 7 Jul 2026 11:03:29 +0200 Subject: [PATCH 05/15] fix: honor configured production environments in Pulumi apps (#5371) --- packages/pulumi/src/constants.ts | 1 - packages/pulumi/src/createPulumiApp.ts | 10 ++++++---- packages/pulumi/src/types.ts | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 packages/pulumi/src/constants.ts diff --git a/packages/pulumi/src/constants.ts b/packages/pulumi/src/constants.ts deleted file mode 100644 index 5fd254b181f..00000000000 --- a/packages/pulumi/src/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_PROD_ENV_NAMES = ["prod", "production"]; diff --git a/packages/pulumi/src/createPulumiApp.ts b/packages/pulumi/src/createPulumiApp.ts index 1111f265d50..71ad5a13747 100644 --- a/packages/pulumi/src/createPulumiApp.ts +++ b/packages/pulumi/src/createPulumiApp.ts @@ -23,7 +23,7 @@ import type { } from "~/types.js"; import type { PulumiAppRemoteResource } from "~/PulumiAppRemoteResource.js"; import cloneDeep from "lodash/cloneDeep.js"; -import { DEFAULT_PROD_ENV_NAMES } from "./constants.js"; +import { getProjectSdk } from "@webiny/project"; export function createPulumiApp>( params: CreatePulumiAppParams @@ -79,9 +79,11 @@ export function createPulumiApp>( async run(config) { app.params.run = config; - // Add environment-related variables. - const productionEnvironments = - app.params.create.productionEnvironments || DEFAULT_PROD_ENV_NAMES; + // Add environment-related variables. The list of production environments is + // resolved from the project SDK, which merges the built-in defaults with any + // environments registered via the `Infra.ProductionEnvironments` extension. + const sdk = await getProjectSdk(); + const productionEnvironments = await sdk.getProductionEnvironments(); const isProduction = productionEnvironments.includes(app.params.run.env); app.env = { diff --git a/packages/pulumi/src/types.ts b/packages/pulumi/src/types.ts index 17616a29bf0..7b765deda37 100644 --- a/packages/pulumi/src/types.ts +++ b/packages/pulumi/src/types.ts @@ -64,9 +64,9 @@ export interface PulumiApp> { name: string; // Set to `true` if the environment into which the application is being deployed - // is considered a production environment. The list of environment names - // that are considered production environments is defined via the - // `productionEnvironments` parameter. Learn more: + // is considered a production environment. The list of environment names that are + // considered production environments consists of the built-in defaults merged with + // any registered via the `Infra.ProductionEnvironments` extension. Learn more: // https://www.webiny.com/docs/infrastructure/basics/modify-cloud-infrastructure#defining-multiple-production-environments isProduction: boolean; }; From c80c2588c4703368178acf4fd610d8cb8d2fda6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Tue, 7 Jul 2026 14:01:07 +0200 Subject: [PATCH 06/15] fix: prevent second-order prototype pollution in transformWhereToNested (#5373) --- .yarnrc.yml | 2 + .../graphql/schema/cms/helpers.test.ts | 125 ++++++++++++++++++ .../cms/helpers/transformWhereToNested.ts | 66 +++++---- 3 files changed, 167 insertions(+), 26 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index d286f8acff0..9c9fcdf9377 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,6 +1,8 @@ approvedGitRepositories: - "https://github.com/webiny/webiny-upgrades-v6" +enableGlobalCache: true + compressionLevel: mixed enableScripts: false diff --git a/packages/api-headless-cms/__tests__/graphql/schema/cms/helpers.test.ts b/packages/api-headless-cms/__tests__/graphql/schema/cms/helpers.test.ts index 1f073847b4a..2d58a34b5ce 100644 --- a/packages/api-headless-cms/__tests__/graphql/schema/cms/helpers.test.ts +++ b/packages/api-headless-cms/__tests__/graphql/schema/cms/helpers.test.ts @@ -116,5 +116,130 @@ describe("CMS Schema Helpers", () => { AND: [{ values: { name: "Keyboard" } }, { values: { price: 150 } }] }); }); + + /** + * CVE proof: second-order prototype pollution via inherited builtins. + * + * A fresh `{}` inherits Object.prototype methods (toString, hasOwnProperty, valueOf). + * When the dot-notation key head matches one of these inherited names, the + * `result[head] === undefined` guard sees a function (not undefined) and skips + * creating a fresh own object. `nested` then becomes the real shared builtin, + * and `Object.assign(nested, ...)` overwrites its properties process-wide. + * + * This is a second-order prototype pollution: no `__proto__`, `constructor`, or + * `prototype` key is used, so classic 3-key blocklists do not catch it. + * + * IMPORTANT: each test must restore the corrupted builtin BEFORE calling expect(), + * because vitest internals use Object.prototype.toString.call() and will hang/crash + * if it's corrupted when expect() runs. + */ + describe("second-order prototype pollution via inherited builtins", () => { + it("should not corrupt Object.prototype.toString via 'toString.call' key", () => { + const originalCall = Object.prototype.toString.call; + + transformWhereToNested({ "toString.call": "pwned" }); + + // Capture corruption state BEFORE restoring. + const callType = typeof Object.prototype.toString.call; + + // Restore IMMEDIATELY — before expect() triggers vitest internals. + Object.prototype.toString.call = originalCall; + + // Now safe to assert. + expect(callType).toBe("function"); + }); + + it("should not corrupt Object.prototype.hasOwnProperty via 'hasOwnProperty.call' key", () => { + const originalCall = Object.prototype.hasOwnProperty.call; + + transformWhereToNested({ "hasOwnProperty.call": "pwned" }); + + const callType = typeof Object.prototype.hasOwnProperty.call; + Object.prototype.hasOwnProperty.call = originalCall; + + expect(callType).toBe("function"); + }); + + it("should not corrupt Object.prototype.valueOf via 'valueOf.bind' key", () => { + const originalBind = Object.prototype.valueOf.bind; + + transformWhereToNested({ "valueOf.bind": "pwned" }); + + const bindType = typeof Object.prototype.valueOf.bind; + Object.prototype.valueOf.bind = originalBind; + + expect(bindType).toBe("function"); + }); + + it("should produce correct nested output for builtin-named head keys", () => { + const originalCall = Object.prototype.toString.call; + + const result = transformWhereToNested({ "toString.call": "x" }); + + // Capture what we need before restoring. + const hasOwnToString = Object.prototype.hasOwnProperty.call(result, "toString"); + const toStringType = typeof result!.toString; + + Object.prototype.toString.call = originalCall; + + // Result must have its own 'toString' property (a plain object, not inherited fn). + expect(hasOwnToString).toBe(true); + expect(toStringType).toBe("object"); + }); + + it("should merge multiple dotted keys under a builtin-named head", () => { + const result = transformWhereToNested({ + "toString.a": 1, + "toString.b": 2 + }); + + expect(result).toEqual({ toString: { a: 1, b: 2 } }); + }); + }); + + describe("forbidden keys", () => { + it("should throw on __proto__ as top-level key", () => { + const input = Object.fromEntries([["__proto__", "x"]]); + expect(() => transformWhereToNested(input)).toThrow( + 'Invalid where key: "__proto__".' + ); + }); + + it("should throw on constructor as top-level key", () => { + expect(() => transformWhereToNested({ constructor: "x" })).toThrow( + 'Invalid where key: "constructor".' + ); + }); + + it("should throw on prototype as top-level key", () => { + expect(() => transformWhereToNested({ prototype: "x" })).toThrow( + 'Invalid where key: "prototype".' + ); + }); + + it("should throw on __proto__ as dotted head segment", () => { + expect(() => transformWhereToNested({ "__proto__.polluted": "x" })).toThrow( + 'Invalid where key: "__proto__".' + ); + }); + + it("should throw on constructor as dotted head segment", () => { + expect(() => transformWhereToNested({ "constructor.polluted": "x" })).toThrow( + 'Invalid where key: "constructor".' + ); + }); + + it("should throw on __proto__ in nested tail segment", () => { + expect(() => transformWhereToNested({ "a.__proto__": "x" })).toThrow( + 'Invalid where key: "__proto__".' + ); + }); + + it("should throw on constructor in deep tail segment", () => { + expect(() => transformWhereToNested({ "a.constructor.b": "x" })).toThrow( + 'Invalid where key: "constructor".' + ); + }); + }); }); }); diff --git a/packages/api-headless-cms/src/graphql/schema/cms/helpers/transformWhereToNested.ts b/packages/api-headless-cms/src/graphql/schema/cms/helpers/transformWhereToNested.ts index 08003ee82b5..f9366228410 100644 --- a/packages/api-headless-cms/src/graphql/schema/cms/helpers/transformWhereToNested.ts +++ b/packages/api-headless-cms/src/graphql/schema/cms/helpers/transformWhereToNested.ts @@ -1,3 +1,5 @@ +const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]); + /** * Transforms a flat where object with dot-notation keys into a nested object. * Keys without dots are passed through unchanged. @@ -21,40 +23,52 @@ export const transformWhereToNested = ( return undefined; } - const result: Record = {}; - - for (const [key, value] of Object.entries(where)) { - // Handle logical operators recursively. + return Object.entries(where).reduce>((result, [key, value]) => { if (key === "AND" || key === "OR") { - if (Array.isArray(value)) { - result[key] = value.map(item => - transformWhereToNested(item as Record) - ); - } else { - result[key] = value; + if (!Array.isArray(value)) { + return { + ...result, + [key]: value + }; } - continue; + return { + ...result, + [key]: value.map(item => transformWhereToNested(item)) + }; } const dotIndex = key.indexOf("."); + if (dotIndex === -1) { - // No dot — pass through unchanged. - result[key] = value; - } else { - // Dot-notation key — expand into nested object. - const head = key.slice(0, dotIndex); - const tail = key.slice(dotIndex + 1); - - if (result[head] === undefined) { - result[head] = {}; + if (FORBIDDEN_KEYS.has(key)) { + throw new Error(`Invalid where key: "${key}".`); } + return { + ...result, + [key]: value + }; + } - const nested = result[head] as Record; - // Recursively expand in case of multiple levels of nesting. - const expanded = transformWhereToNested({ [tail]: value }) as Record; - Object.assign(nested, expanded); + const head = key.slice(0, dotIndex); + const tail = key.slice(dotIndex + 1); + + if (FORBIDDEN_KEYS.has(head)) { + throw new Error(`Invalid where key: "${head}".`); + } + + if (Object.hasOwn(result, head)) { + return { + ...result, + [head]: { + ...(result[head] as Record), + ...transformWhereToNested({ [tail]: value }) + } + }; } - } - return result; + return { + ...result, + [head]: transformWhereToNested({ [tail]: value }) + }; + }, {}); }; From 4eff74558bad161f38841378ac73f9ca1d92d3eb Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Tue, 7 Jul 2026 21:41:04 +0200 Subject: [PATCH 07/15] feat: add stack output cache with destroy invalidation (#5375) --- .../src/abstractions/features/DestroyApp.ts | 4 +- .../services/StackOutputCacheService.ts | 15 +++ .../src/abstractions/services/index.ts | 1 + .../project/src/createProjectSdkContainer.ts | 2 + .../DestroyAppClearStackOutputCache.ts | 43 +++++++++ packages/project/src/decorators/index.ts | 1 + .../InitProjectSdkService.ts | 2 + .../PulumiGetStackOutputService.ts | 68 +------------- .../StackOutputCacheService.ts | 93 +++++++++++++++++++ .../services/StackOutputCacheService/index.ts | 1 + packages/project/src/services/index.ts | 1 + 11 files changed, 166 insertions(+), 65 deletions(-) create mode 100644 packages/project/src/abstractions/services/StackOutputCacheService.ts create mode 100644 packages/project/src/decorators/DestroyAppClearStackOutputCache.ts create mode 100644 packages/project/src/services/StackOutputCacheService/StackOutputCacheService.ts create mode 100644 packages/project/src/services/StackOutputCacheService/index.ts diff --git a/packages/project/src/abstractions/features/DestroyApp.ts b/packages/project/src/abstractions/features/DestroyApp.ts index ddc31798e9d..16ad5e4316b 100644 --- a/packages/project/src/abstractions/features/DestroyApp.ts +++ b/packages/project/src/abstractions/features/DestroyApp.ts @@ -4,11 +4,11 @@ import { type ExecaChildProcess } from "execa"; type IPulumiProcess = ExecaChildProcess; -interface IDestroyAppParams { +export interface IDestroyAppParams { app: AppName; } -interface IDestroyApp { +export interface IDestroyApp { execute(params: IDestroyAppParams): Promise<{ pulumiProcess: IPulumiProcess }>; } diff --git a/packages/project/src/abstractions/services/StackOutputCacheService.ts b/packages/project/src/abstractions/services/StackOutputCacheService.ts new file mode 100644 index 00000000000..0924e56b43c --- /dev/null +++ b/packages/project/src/abstractions/services/StackOutputCacheService.ts @@ -0,0 +1,15 @@ +import { createAbstraction } from "~/abstractions/createAbstraction.js"; +import { type IAppModel } from "~/abstractions/models/IAppModel.js"; + +export interface IStackOutputCacheService { + read(app: IAppModel): Promise | null>; + write(app: IAppModel, data: Record): Promise; + delete(app: IAppModel): Promise; +} + +export const StackOutputCacheService = + createAbstraction("StackOutputCacheService"); + +export namespace StackOutputCacheService { + export type Interface = IStackOutputCacheService; +} diff --git a/packages/project/src/abstractions/services/index.ts b/packages/project/src/abstractions/services/index.ts index 5466ed99a0a..6c6fe03b514 100644 --- a/packages/project/src/abstractions/services/index.ts +++ b/packages/project/src/abstractions/services/index.ts @@ -29,6 +29,7 @@ export { PulumiGetStackOutputService } from "./PulumiGetStackOutputService.js"; export { PulumiLoginService } from "./PulumiLoginService.js"; export { PulumiSelectStackService } from "./PulumiSelectStackService.js"; export { SetProjectIdService } from "./SetProjectIdService.js"; +export { StackOutputCacheService } from "./StackOutputCacheService.js"; export { StdioService } from "./StdioService.js"; export { UiService } from "./UiService.js"; export { ValidateProjectConfigService } from "./ValidateProjectConfigService.js"; diff --git a/packages/project/src/createProjectSdkContainer.ts b/packages/project/src/createProjectSdkContainer.ts index 8a409e8ba9d..5f395df4492 100644 --- a/packages/project/src/createProjectSdkContainer.ts +++ b/packages/project/src/createProjectSdkContainer.ts @@ -83,6 +83,7 @@ import { pulumiLoginService, pulumiSelectStackService, setProjectIdService, + stackOutputCacheService, stdioService, uiService, validateProjectConfigService, @@ -143,6 +144,7 @@ export const createProjectSdkContainer = async ( container.register(pulumiLoginService).inSingletonScope(); container.register(pulumiSelectStackService).inSingletonScope(); container.register(setProjectIdService).inSingletonScope(); + container.register(stackOutputCacheService).inSingletonScope(); container.register(stdioService).inSingletonScope(); container.register(uiService).inSingletonScope(); container.register(validateProjectConfigService).inSingletonScope(); diff --git a/packages/project/src/decorators/DestroyAppClearStackOutputCache.ts b/packages/project/src/decorators/DestroyAppClearStackOutputCache.ts new file mode 100644 index 00000000000..1558eb71a38 --- /dev/null +++ b/packages/project/src/decorators/DestroyAppClearStackOutputCache.ts @@ -0,0 +1,43 @@ +import { + DestroyApp, + GetApp, + LoggerService, + StackOutputCacheService +} from "~/abstractions/index.js"; + +/** + * Decorator that clears the stack output cache when an app is destroyed. + * + * Without this, the cache file written during deploy would remain on disk after a + * destroy, and a subsequent deploy (or any stack output read) would use it and behave + * as if the stack were still deployed. + * + * The cache is cleared before the destroy runs. Since the cache is only a performance + * optimization, this is safe even if the destroy fails and the stack remains: the next + * read simply fetches fresh output from Pulumi again. + */ +export class DestroyAppClearStackOutputCache implements DestroyApp.Interface { + constructor( + private getApp: GetApp.Interface, + private logger: LoggerService.Interface, + private stackOutputCacheService: StackOutputCacheService.Interface, + private decoratee: DestroyApp.Interface + ) {} + + async execute(params: DestroyApp.Params) { + try { + const app = this.getApp.execute(params.app); + await this.stackOutputCacheService.delete(app); + } catch (error) { + // Cache clearing failure shouldn't prevent the destroy from running. + this.logger.error("Failed to clear stack output cache before destroy.", error); + } + + return this.decoratee.execute(params); + } +} + +export const destroyAppClearStackOutputCache = DestroyApp.createDecorator({ + decorator: DestroyAppClearStackOutputCache, + dependencies: [GetApp, LoggerService, StackOutputCacheService] +}); diff --git a/packages/project/src/decorators/index.ts b/packages/project/src/decorators/index.ts index eb3221f2c9a..f56220b00cc 100644 --- a/packages/project/src/decorators/index.ts +++ b/packages/project/src/decorators/index.ts @@ -3,6 +3,7 @@ export * from "./DeployAppClearWatchedLambdaFunctions.js"; export * from "./DeployAppRefreshStackOutputCache.js"; export * from "./DeployAppWithHooks.js"; export * from "./DeployAppWithWatchedLambdaReplacement.js"; +export * from "./DestroyAppClearStackOutputCache.js"; export * from "./WatchWithHooks.js"; export * from "./GetPulumiServiceWithDownloadInfo.js"; export * from "./GetFeatureFlagsWithLicense.js"; diff --git a/packages/project/src/services/InitProjectSdkService/InitProjectSdkService.ts b/packages/project/src/services/InitProjectSdkService/InitProjectSdkService.ts index 5447b60829c..be63f32f8fe 100644 --- a/packages/project/src/services/InitProjectSdkService/InitProjectSdkService.ts +++ b/packages/project/src/services/InitProjectSdkService/InitProjectSdkService.ts @@ -10,6 +10,7 @@ import { deployAppRefreshStackOutputCache, deployAppWithHooks, deployAppWithWatchedLambdaReplacement, + destroyAppClearStackOutputCache, watchWithHooks, getPulumiServiceWithDownloadInfo } from "~/decorators/index.js"; @@ -49,6 +50,7 @@ export class DefaultInitProjectSdkService implements InitProjectSdkService.Inter container.registerDecorator(deployAppWithWatchedLambdaReplacement); container.registerDecorator(deployAppClearWatchedLambdaFunctions); container.registerDecorator(deployAppRefreshStackOutputCache); + container.registerDecorator(destroyAppClearStackOutputCache); container.registerDecorator(deployAppWithHooks); container.registerDecorator(watchWithHooks); container.registerDecorator(getPulumiServiceWithDownloadInfo); diff --git a/packages/project/src/services/PulumiGetStackOutputService/PulumiGetStackOutputService.ts b/packages/project/src/services/PulumiGetStackOutputService/PulumiGetStackOutputService.ts index 2aed7bca3ff..4439a04ec24 100644 --- a/packages/project/src/services/PulumiGetStackOutputService/PulumiGetStackOutputService.ts +++ b/packages/project/src/services/PulumiGetStackOutputService/PulumiGetStackOutputService.ts @@ -1,29 +1,21 @@ import { createImplementation } from "@webiny/di"; import { GetPulumiService, - GetProjectService, LoggerService, PulumiGetStackOutputService, PulumiSelectStackService, - ProjectSdkParamsService + StackOutputCacheService } from "~/abstractions/index.js"; import { type AppModel } from "~/models/index.js"; import { createEnvConfiguration, withPulumiConfigPassphrase } from "~/utils/env/index.js"; import { mapStackOutput } from "./mapStackOutput.js"; -import fs from "fs/promises"; -import path from "path"; export class DefaultPulumiGetStackOutputService implements PulumiGetStackOutputService.Interface { - private static readonly DEFAULT_ENV = "dev"; - private static readonly DEFAULT_REGION = "default"; - private static readonly DEFAULT_VARIANT = "default"; - constructor( private getPulumiService: GetPulumiService.Interface, private pulumiSelectStackService: PulumiSelectStackService.Interface, private loggerService: LoggerService.Interface, - private getProjectService: GetProjectService.Interface, - private projectSdkParamsService: ProjectSdkParamsService.Interface + private stackOutputCacheService: StackOutputCacheService.Interface ) {} async execute = Record>( @@ -32,12 +24,8 @@ export class DefaultPulumiGetStackOutputService implements PulumiGetStackOutputS ): Promise { // Try to read from cache if skipCache is not true if (!params?.skipCache) { - const cachedOutput = await this.readFromCache(app); + const cachedOutput = await this.stackOutputCacheService.read(app); if (cachedOutput !== null) { - this.loggerService.debug( - { app: app.name, cacheKey: this.getCacheKey(app) }, - "Stack output read from cache" - ); return this.applyMapping(cachedOutput, params?.map) as TOutput; } } @@ -64,12 +52,7 @@ export class DefaultPulumiGetStackOutputService implements PulumiGetStackOutputS return null; } - // Write to cache - await this.writeToCache(app, stackOutputJson); - this.loggerService.debug("Stack output stored to cache", { - app: app.name, - cacheKey: this.getCacheKey(app) - }); + await this.stackOutputCacheService.write(app, stackOutputJson); return this.applyMapping(stackOutputJson, params?.map) as TOutput; } catch { @@ -93,46 +76,6 @@ export class DefaultPulumiGetStackOutputService implements PulumiGetStackOutputS // If a mapping is provided, we map the output to the specified structure. return mapStackOutput(data, map); } - - private getCacheKey(app: AppModel): string { - const sdkParams = this.projectSdkParamsService.get(); - const env = sdkParams.env || DefaultPulumiGetStackOutputService.DEFAULT_ENV; - const region = sdkParams.region || DefaultPulumiGetStackOutputService.DEFAULT_REGION; - const variant = sdkParams.variant || DefaultPulumiGetStackOutputService.DEFAULT_VARIANT; - return `${app.name}-${env}-${region}-${variant}.json`; - } - - private getCachePath(app: AppModel): string { - const project = this.getProjectService.execute(); - const cacheDir = project.paths.dotWebinyFolder.join("caches", "stack-output").toString(); - const cacheKey = this.getCacheKey(app); - return path.join(cacheDir, cacheKey); - } - - private async readFromCache(app: AppModel): Promise | null> { - const cachePath = this.getCachePath(app); - - try { - const content = await fs.readFile(cachePath, "utf-8"); - return JSON.parse(content); - } catch { - // File doesn't exist or couldn't be read/parsed - this is expected on first run - return null; - } - } - - private async writeToCache(app: AppModel, data: Record): Promise { - const cachePath = this.getCachePath(app); - const cacheDir = path.dirname(cachePath); - - try { - // Create cache directory if it doesn't exist - await fs.mkdir(cacheDir, { recursive: true }); - await fs.writeFile(cachePath, JSON.stringify(data, null, 2), "utf-8"); - } catch (error) { - this.loggerService.error("Could not write to cache file.", cachePath, error); - } - } } export const pulumiGetStackOutputService = createImplementation({ @@ -142,7 +85,6 @@ export const pulumiGetStackOutputService = createImplementation({ GetPulumiService, PulumiSelectStackService, LoggerService, - GetProjectService, - ProjectSdkParamsService + StackOutputCacheService ] }); diff --git a/packages/project/src/services/StackOutputCacheService/StackOutputCacheService.ts b/packages/project/src/services/StackOutputCacheService/StackOutputCacheService.ts new file mode 100644 index 00000000000..0d81bebd21f --- /dev/null +++ b/packages/project/src/services/StackOutputCacheService/StackOutputCacheService.ts @@ -0,0 +1,93 @@ +import { createImplementation } from "@webiny/di"; +import { + GetProjectService, + LoggerService, + ProjectSdkParamsService, + StackOutputCacheService +} from "~/abstractions/index.js"; +import { type AppModel } from "~/models/index.js"; +import fs from "fs/promises"; + +export class DefaultStackOutputCacheService implements StackOutputCacheService.Interface { + private static readonly DEFAULT_ENV = "dev"; + private static readonly DEFAULT_REGION = "default"; + private static readonly DEFAULT_VARIANT = "default"; + + constructor( + private getProjectService: GetProjectService.Interface, + private projectSdkParamsService: ProjectSdkParamsService.Interface, + private loggerService: LoggerService.Interface + ) {} + + async read(app: AppModel): Promise | null> { + const cachePath = this.getCachePath(app); + + try { + const content = await fs.readFile(cachePath, "utf-8"); + const data = JSON.parse(content); + this.loggerService.debug( + { app: app.name, cacheKey: this.getCacheKey(app) }, + "Stack output read from cache" + ); + return data; + } catch { + // File doesn't exist or couldn't be read/parsed - this is expected on first run. + return null; + } + } + + async write(app: AppModel, data: Record): Promise { + const cachePath = this.getCachePath(app); + const cacheDir = this.getCacheDir().toString(); + + try { + // Create cache directory if it doesn't exist. + await fs.mkdir(cacheDir, { recursive: true }); + await fs.writeFile(cachePath, JSON.stringify(data, null, 2), "utf-8"); + this.loggerService.debug("Stack output stored to cache", { + app: app.name, + cacheKey: this.getCacheKey(app) + }); + } catch (error) { + this.loggerService.error("Could not write to cache file.", cachePath, error); + } + } + + async delete(app: AppModel): Promise { + const cachePath = this.getCachePath(app); + + try { + // `force: true` makes this a no-op if the cache file doesn't exist. + await fs.rm(cachePath, { force: true }); + this.loggerService.debug("Stack output cache deleted.", { + app: app.name, + cacheKey: this.getCacheKey(app) + }); + } catch (error) { + this.loggerService.error("Could not delete stack output cache file.", cachePath, error); + } + } + + private getCacheKey(app: AppModel): string { + const sdkParams = this.projectSdkParamsService.get(); + const env = sdkParams.env || DefaultStackOutputCacheService.DEFAULT_ENV; + const region = sdkParams.region || DefaultStackOutputCacheService.DEFAULT_REGION; + const variant = sdkParams.variant || DefaultStackOutputCacheService.DEFAULT_VARIANT; + return `${app.name}-${env}-${region}-${variant}.json`; + } + + private getCacheDir() { + const project = this.getProjectService.execute(); + return project.paths.dotWebinyFolder.join("caches", "stack-output"); + } + + private getCachePath(app: AppModel): string { + return this.getCacheDir().join(this.getCacheKey(app)).toString(); + } +} + +export const stackOutputCacheService = createImplementation({ + abstraction: StackOutputCacheService, + implementation: DefaultStackOutputCacheService, + dependencies: [GetProjectService, ProjectSdkParamsService, LoggerService] +}); diff --git a/packages/project/src/services/StackOutputCacheService/index.ts b/packages/project/src/services/StackOutputCacheService/index.ts new file mode 100644 index 00000000000..9375b56a54c --- /dev/null +++ b/packages/project/src/services/StackOutputCacheService/index.ts @@ -0,0 +1 @@ +export * from "./StackOutputCacheService.js"; diff --git a/packages/project/src/services/index.ts b/packages/project/src/services/index.ts index 44c042ddecd..2534cfaf402 100644 --- a/packages/project/src/services/index.ts +++ b/packages/project/src/services/index.ts @@ -29,6 +29,7 @@ export * from "./PulumiGetStackOutputService/index.js"; export * from "./PulumiLoginService/index.js"; export * from "./PulumiSelectStackService/index.js"; export * from "./SetProjectIdService/index.js"; +export * from "./StackOutputCacheService/index.js"; export * from "./StdioService/index.js"; export * from "./UiService/index.js"; export * from "./ValidateProjectConfigService/index.js"; From c6222c67d7f8c181d637b920e091d5663bdd025d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 8 Jul 2026 11:25:48 +0200 Subject: [PATCH 08/15] feat: add support for Cognito Federation and MFA (#5376) --- extensions/idp/entraid/EntraIdApiConfig.ts | 15 + extensions/idp/entraid/Extension.tsx | 39 ++ .../src/ui/views/Teams/TeamsForm.tsx | 19 +- packages/cognito/package.json | 3 +- packages/cognito/src/Cognito.tsx | 69 ++- .../src/admin/DefaultCognitoSignInConfig.ts | 50 ++ packages/cognito/src/admin/Extension.tsx | 10 +- packages/cognito/src/admin/index.ts | 2 + .../Cognito/CognitoLoginScreen.tsx | 17 + .../presentation/Cognito/CognitoPresenter.ts | 146 +++++- .../Cognito/CognitoSignInConfig.ts | 33 ++ .../presentation/Cognito/abstractions.ts | 25 +- .../Cognito/components/ConfirmTotpCode.tsx | 67 +++ .../Cognito/components/Divider.tsx | 5 +- .../Cognito/components/FederatedLogin.tsx | 66 ++- .../Cognito/components/FooterSignIn.tsx | 28 +- .../components/PasswordResetCodeSent.tsx | 98 ++-- .../components/RequestPasswordResetCode.tsx | 98 ++-- .../Cognito/components/RequireNewPassword.tsx | 123 ++--- .../Cognito/components/SetNewPassword.tsx | 133 ++--- .../Cognito/components/SetupTotp.tsx | 87 ++++ .../Cognito/components/SignIn.tsx | 104 ++-- .../presentation/Cognito/components/index.ts | 27 + .../presentation/Cognito/signInFeature.ts | 9 + .../src/admin/ui/views/Users/UsersForm.tsx | 30 +- .../admin/ui/views/Users/hooks/useUserForm.ts | 4 +- .../CognitoIdp/CognitoIdentityProvider.ts | 35 +- .../api/features/CognitoIdp/abstractions.ts | 2 +- packages/cognito/src/api/index.ts | 1 + packages/cognito/src/index.ts | 1 - .../src/infra/CognitoFederationPulumi.ts | 22 + .../src/pulumi/apps/core/CoreCognito.ts | 4 +- .../cognitoIdentityProviders/configure.ts | 13 +- .../pulumi/apps/core/createCorePulumiApp.ts | 30 +- .../user-skills/cognito-federation/SKILL.md | 468 ++++++++++++++++++ skills/user-skills/configure-entraid/SKILL.md | 369 ++++++++++++++ webiny.config.tsx | 4 +- 37 files changed, 1897 insertions(+), 359 deletions(-) create mode 100644 extensions/idp/entraid/EntraIdApiConfig.ts create mode 100644 extensions/idp/entraid/Extension.tsx create mode 100644 packages/cognito/src/admin/DefaultCognitoSignInConfig.ts create mode 100644 packages/cognito/src/admin/index.ts create mode 100644 packages/cognito/src/admin/presentation/Cognito/CognitoSignInConfig.ts create mode 100644 packages/cognito/src/admin/presentation/Cognito/components/ConfirmTotpCode.tsx create mode 100644 packages/cognito/src/admin/presentation/Cognito/components/SetupTotp.tsx create mode 100644 packages/cognito/src/admin/presentation/Cognito/components/index.ts create mode 100644 packages/cognito/src/admin/presentation/Cognito/signInFeature.ts create mode 100644 packages/cognito/src/api/index.ts create mode 100644 packages/cognito/src/infra/CognitoFederationPulumi.ts create mode 100644 skills/user-skills/cognito-federation/SKILL.md create mode 100644 skills/user-skills/configure-entraid/SKILL.md diff --git a/extensions/idp/entraid/EntraIdApiConfig.ts b/extensions/idp/entraid/EntraIdApiConfig.ts new file mode 100644 index 00000000000..c362dae6885 --- /dev/null +++ b/extensions/idp/entraid/EntraIdApiConfig.ts @@ -0,0 +1,15 @@ +import { CognitoIdpConfig } from "@webiny/cognito/api"; + +class EntraIdConfig implements CognitoIdpConfig.Interface { + getIdentity(_token: CognitoIdpConfig.JwtPayload) { + return { + roles: ["full-access"], + teams: [] + }; + } +} + +export default CognitoIdpConfig.createImplementation({ + implementation: EntraIdConfig, + dependencies: [] +}); diff --git a/extensions/idp/entraid/Extension.tsx b/extensions/idp/entraid/Extension.tsx new file mode 100644 index 00000000000..3e12c36c4e6 --- /dev/null +++ b/extensions/idp/entraid/Extension.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Cognito } from "@webiny/cognito"; + +export const CognitoFederation = () => { + return ( + + ); +}; diff --git a/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx b/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx index 1f31b1b5ef7..d97456bbff0 100644 --- a/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx +++ b/packages/app-security-access-management/src/ui/views/Teams/TeamsForm.tsx @@ -124,8 +124,10 @@ export const TeamsForm = ({ newEntry, id }: TeamsFormProps) => { ); } + const team = { ...data, roles: (data.roles ?? []).map((r: any) => r.id) }; + return ( -
+ {({ data, form }) => { return ( @@ -233,11 +235,16 @@ const FormContent = (props: FormContentProps) => { - + {({ value, onChange, validation }) => ( + + )} diff --git a/packages/cognito/package.json b/packages/cognito/package.json index 8d1c783cd70..f965185fc48 100644 --- a/packages/cognito/package.json +++ b/packages/cognito/package.json @@ -6,7 +6,8 @@ "sideEffects": false, "exports": { ".": "./index.js", - "./*": "./*" + "./api": "./api/index.js", + "./admin": "./admin/index.js" }, "description": "Security plugins for Cognito", "author": "Webiny Ltd.", diff --git a/packages/cognito/src/Cognito.tsx b/packages/cognito/src/Cognito.tsx index f5937552729..eb154445e64 100644 --- a/packages/cognito/src/Cognito.tsx +++ b/packages/cognito/src/Cognito.tsx @@ -3,25 +3,92 @@ import { defineExtension } from "@webiny/project/defineExtension/index.js"; import { Api, Admin, Infra } from "@webiny/project-aws"; import { z } from "zod"; +const identityProviderSchema = z.object({ + type: z.enum(["google", "facebook", "amazon", "apple", "oidc"]), + name: z.string().optional(), + label: z.string(), + providerDetails: z.record(z.string(), z.any()), + idpIdentifiers: z.array(z.string()).optional(), + attributeMapping: z.record(z.string(), z.string()).optional() +}); + +const federationSchema = z.object({ + domain: z.string().describe("Cognito User Pool domain prefix."), + callbackUrls: z.array(z.string()).describe("OAuth callback URLs."), + logoutUrls: z.array(z.string()).optional(), + responseType: z.enum(["code", "token"]).default("code"), + allowCredentialsLogin: z.boolean().default(true), + identityProviders: z.array(identityProviderSchema) +}); + export const Cognito = defineExtension({ type: "Project/Cognito", tags: { runtimeContext: "project" }, description: "Enable and configure Cognito authentication.", paramsSchema: z.object({ - apiConfig: z.string().describe("Path to API configuration.").optional() + apiConfig: z.string().describe("Path to API configuration.").optional(), + adminConfig: z.string().describe("Path to Admin configuration.").optional(), + federation: federationSchema.optional(), + mfa: z.boolean().describe("Enable TOTP MFA for all users.").default(false) }), render: props => { + const federation = props.federation; + return ( <> + {props.mfa ? : null} + {/* Api extensions */} {props.apiConfig ? : null} + {/* Admin extensions */} + {props.adminConfig ? : null} + + {/* Federation infra + admin config */} + {federation ? ( + <> + {/* Pulumi: create User Pool Domain, IdP resources, OAuth client */} + ({ + type: idp.type, + name: idp.name, + providerDetails: idp.providerDetails, + idpIdentifiers: idp.idpIdentifiers, + attributeMapping: idp.attributeMapping + })) + })} + /> + + + {/* Admin: pass federation config as build param */} + ({ + name: idp.name || idp.type, + label: idp.label + })) + }} + /> + + ) : null} ); } diff --git a/packages/cognito/src/admin/DefaultCognitoSignInConfig.ts b/packages/cognito/src/admin/DefaultCognitoSignInConfig.ts new file mode 100644 index 00000000000..c3f99857ef3 --- /dev/null +++ b/packages/cognito/src/admin/DefaultCognitoSignInConfig.ts @@ -0,0 +1,50 @@ +import { BuildParams } from "@webiny/app-admin/features/buildParams/index.js"; +import { + CognitoSignInConfig, + type ICognitoSignInConfig +} from "./presentation/Cognito/CognitoSignInConfig.js"; + +interface FederationBuildParam { + callbackUrls: string[]; + logoutUrls: string[]; + responseType: "code" | "token"; + allowCredentialsLogin: boolean; + providers: { name: string; label: string }[]; +} + +class DefaultCognitoSignInConfigImpl implements ICognitoSignInConfig { + constructor(private buildParams: BuildParams.Interface) {} + + async getConfig() { + const config = this.buildParams.get("cognitoFederation"); + + if (!config) { + return { + oauth: { + scopes: ["profile", "email", "openid"], + redirectSignIn: [window.location.origin], + redirectSignOut: [window.location.origin], + responseType: "code" as const + }, + allowCredentialsLogin: true, + providers: [] + }; + } + + return { + oauth: { + scopes: ["profile", "email", "openid"], + redirectSignIn: config.callbackUrls, + redirectSignOut: config.logoutUrls, + responseType: config.responseType + }, + allowCredentialsLogin: config.allowCredentialsLogin, + providers: config.providers + }; + } +} + +export const DefaultCognitoSignInConfig = CognitoSignInConfig.createImplementation({ + implementation: DefaultCognitoSignInConfigImpl, + dependencies: [BuildParams] +}); diff --git a/packages/cognito/src/admin/Extension.tsx b/packages/cognito/src/admin/Extension.tsx index b4aceb53c4f..9f4fce23be3 100644 --- a/packages/cognito/src/admin/Extension.tsx +++ b/packages/cognito/src/admin/Extension.tsx @@ -3,6 +3,7 @@ import { RegisterFeature } from "@webiny/app-admin"; import { CognitoFeature } from "./presentation/Cognito/feature.js"; import { CognitoAdmin } from "./Cognito.js"; import { CognitoPermissionsFeature } from "./features/permissions/feature.js"; +import { CognitoSignInFeature } from "./presentation/Cognito/signInFeature.js"; export const Extension = () => { const region = process.env.REACT_APP_USER_POOL_REGION || ""; @@ -13,13 +14,8 @@ export const Extension = () => { <> - + + ); }; diff --git a/packages/cognito/src/admin/index.ts b/packages/cognito/src/admin/index.ts new file mode 100644 index 00000000000..35d6e6cf324 --- /dev/null +++ b/packages/cognito/src/admin/index.ts @@ -0,0 +1,2 @@ +export { CognitoSignInConfig } from "./presentation/Cognito/CognitoSignInConfig.js"; +export { Components as CognitoComponents } from "./presentation/Cognito/components/index.js"; diff --git a/packages/cognito/src/admin/presentation/Cognito/CognitoLoginScreen.tsx b/packages/cognito/src/admin/presentation/Cognito/CognitoLoginScreen.tsx index bdc0fb6232a..52c4e069a46 100644 --- a/packages/cognito/src/admin/presentation/Cognito/CognitoLoginScreen.tsx +++ b/packages/cognito/src/admin/presentation/Cognito/CognitoLoginScreen.tsx @@ -8,6 +8,8 @@ import { RequireNewPassword } from "./components/RequireNewPassword.js"; import { RequestPasswordResetCode } from "./components/RequestPasswordResetCode.js"; import { SetNewPassword } from "./components/SetNewPassword.js"; import { PasswordResetCodeSent } from "~/admin/presentation/Cognito/components/PasswordResetCodeSent.js"; +import { ConfirmTotpCode } from "./components/ConfirmTotpCode.js"; +import { SetupTotp } from "./components/SetupTotp.js"; export interface CognitoLoginScreenProps { region: string; @@ -77,6 +79,21 @@ export const CognitoLoginScreen = observer((props: CognitoLoginScreenProps) => { /> )} + {vm.authState === "confirmTotpCode" && ( + presenter.confirmTotpCode(code)} + onCancel={() => presenter.showSignIn()} + /> + )} + + {vm.authState === "setupTotp" && ( + presenter.verifyTotpSetup(code)} + /> + )} + {(vm.authState === "signIn" || vm.authState === "signedOut") && ( { + this.signInTitle = config.title ?? "Sign in"; + this.signInDescription = config.description; + + if (!config.description && !config.allowCredentialsLogin) { + this.signInDescription = federatedDescription; + } + + this.allowCredentialsLogin = config.allowCredentialsLogin; + this.federatedProviders = config.providers; + }); + + const domain = process.env.REACT_APP_USER_POOL_DOMAIN; + if (domain) { + oauthConfig = { + Cognito: { + userPoolId: params.userPoolId, + userPoolClientId: params.clientId, + loginWith: { + oauth: { + domain, + redirectSignIn: config.oauth.redirectSignIn, + redirectSignOut: config.oauth.redirectSignOut, + scopes: config.oauth.scopes, + responseType: config.oauth.responseType + } + } + } + }; + } + } + Amplify.configure({ - Auth: { + Auth: oauthConfig ?? { Cognito: { userPoolId: params.userPoolId, userPoolClientId: params.clientId @@ -106,6 +172,20 @@ class CognitoPresenterImpl implements CognitoPresenterAbstraction.Interface { }; this.formLoading = false; }); + } else if (nextStep.signInStep === "CONFIRM_SIGN_IN_WITH_TOTP_CODE") { + runInAction(() => { + this.authState = "confirmTotpCode"; + this.formLoading = false; + }); + } else if (nextStep.signInStep === "CONTINUE_SIGN_IN_WITH_TOTP_SETUP") { + const totpSetup = nextStep.totpSetupDetails; + const uri = totpSetup.getSetupUri("Webiny").toString(); + runInAction(() => { + this.totpSharedSecret = totpSetup.sharedSecret; + this.totpQrCodeUri = uri; + this.authState = "setupTotp"; + this.formLoading = false; + }); } else { await this.handleSignedIn(); runInAction(() => { @@ -233,6 +313,54 @@ class CognitoPresenterImpl implements CognitoPresenterAbstraction.Interface { } } + async confirmTotpCode(code: string): Promise { + runInAction(() => { + this.formLoading = true; + this.message = null; + }); + + try { + await confirmSignIn({ challengeResponse: code }); + await this.handleSignedIn(); + } catch (error) { + runInAction(() => { + this.message = { + title: "Verification Failed", + text: error.message, + type: "danger" + }; + }); + } finally { + runInAction(() => { + this.formLoading = false; + }); + } + } + + async verifyTotpSetup(code: string): Promise { + runInAction(() => { + this.formLoading = true; + this.message = null; + }); + + try { + await confirmSignIn({ challengeResponse: code }); + await this.handleSignedIn(); + } catch (error) { + runInAction(() => { + this.message = { + title: "Setup Failed", + text: error.message, + type: "danger" + }; + }); + } finally { + runInAction(() => { + this.formLoading = false; + }); + } + } + showSignIn(): void { this.authState = "signIn"; this.authData = null; @@ -290,16 +418,6 @@ class CognitoPresenterImpl implements CognitoPresenterAbstraction.Interface { } private async checkUrl(): Promise { - const query = new URLSearchParams(window.location.search); - const queryData: Record = {}; - query.forEach((value, key) => (queryData[key] = value)); - const { state } = queryData; - - if (state) { - // Handle state from URL if needed - return; - } - return this.checkSession(); } @@ -326,5 +444,5 @@ class CognitoPresenterImpl implements CognitoPresenterAbstraction.Interface { export const CognitoPresenter = CognitoPresenterAbstraction.createImplementation({ implementation: CognitoPresenterImpl, - dependencies: [IdentityContext, LogInUseCase] + dependencies: [IdentityContext, LogInUseCase, [CognitoSignInConfig, { optional: true }]] }); diff --git a/packages/cognito/src/admin/presentation/Cognito/CognitoSignInConfig.ts b/packages/cognito/src/admin/presentation/Cognito/CognitoSignInConfig.ts new file mode 100644 index 00000000000..d06c374eb20 --- /dev/null +++ b/packages/cognito/src/admin/presentation/Cognito/CognitoSignInConfig.ts @@ -0,0 +1,33 @@ +import type React from "react"; +import { createAbstraction } from "@webiny/feature/admin"; + +export interface SignInProps { + signIn: () => void; +} + +export type IFederatedProvider = + | { name: string; label: string } + | { name: string; component: React.FC }; + +export interface ICognitoSignInConfig { + getConfig(): Promise<{ + oauth: { + scopes: string[]; + redirectSignIn: string[]; + redirectSignOut: string[]; + responseType: "code" | "token"; + }; + allowCredentialsLogin: boolean; + providers: IFederatedProvider[]; + title?: string; + description?: string; + }>; +} + +export const CognitoSignInConfig = createAbstraction("CognitoSignInConfig"); + +export namespace CognitoSignInConfig { + export type Interface = ICognitoSignInConfig; + export type Config = Awaited>; + export type FederatedProvider = IFederatedProvider; +} diff --git a/packages/cognito/src/admin/presentation/Cognito/abstractions.ts b/packages/cognito/src/admin/presentation/Cognito/abstractions.ts index 8bd0d4552b6..43d8ef88b72 100644 --- a/packages/cognito/src/admin/presentation/Cognito/abstractions.ts +++ b/packages/cognito/src/admin/presentation/Cognito/abstractions.ts @@ -1,4 +1,5 @@ import { createAbstraction } from "@webiny/feature/admin"; +import type { IFederatedProvider } from "./CognitoSignInConfig.js"; export type AuthState = | "signIn" @@ -7,7 +8,9 @@ export type AuthState = | "requestPasswordResetCode" | "passwordResetCodeSent" | "setNewPassword" - | "requireNewPassword"; + | "requireNewPassword" + | "confirmTotpCode" + | "setupTotp"; export interface AuthDataVerified { email?: string; @@ -49,6 +52,10 @@ export interface ICognitoInitParams { export interface SignInVM { isLoading: boolean; message: AuthMessage | null; + title: string; + description: string | undefined; + allowCredentialsLogin: boolean; + federatedProviders: IFederatedProvider[]; } export interface RequireNewPasswordVM { @@ -71,6 +78,18 @@ export interface SetNewPasswordVM { message: AuthMessage | null; } +export interface ConfirmTotpCodeVM { + isLoading: boolean; + message: AuthMessage | null; +} + +export interface SetupTotpVM { + isLoading: boolean; + sharedSecret: string; + qrCodeUri: string; + message: AuthMessage | null; +} + export interface ICognitoPresenter { vm: { authState: AuthState; @@ -82,6 +101,8 @@ export interface ICognitoPresenter { requestPasswordResetCode: RequestPasswordResetCodeVM; passwordResetCodeSent: PasswordResetCodeSentVM; setNewPassword: SetNewPasswordVM; + confirmTotpCode: ConfirmTotpCodeVM; + setupTotp: SetupTotpVM; }; // Lifecycle @@ -93,6 +114,8 @@ export interface ICognitoPresenter { requestPasswordReset(username: string): Promise; resendPasswordResetCode(): Promise; confirmPasswordReset(code: string, password: string): Promise; + confirmTotpCode(code: string): Promise; + verifyTotpSetup(code: string): Promise; // Navigation actions showSignIn(): void; diff --git a/packages/cognito/src/admin/presentation/Cognito/components/ConfirmTotpCode.tsx b/packages/cognito/src/admin/presentation/Cognito/components/ConfirmTotpCode.tsx new file mode 100644 index 00000000000..ee69b5c814c --- /dev/null +++ b/packages/cognito/src/admin/presentation/Cognito/components/ConfirmTotpCode.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Button, Grid, Input, Alert } from "@webiny/admin-ui"; +import { makeDecoratable } from "@webiny/app-admin"; +import { Form, Bind } from "@webiny/form"; +import { validation } from "@webiny/validation"; +import { View } from "./View.js"; +import type { ConfirmTotpCodeVM } from "~/admin/presentation/Cognito/abstractions.js"; +import { FooterSignIn } from "~/admin/presentation/Cognito/components/FooterSignIn.js"; + +export interface ConfirmTotpCodeProps { + vm: ConfirmTotpCodeVM; + onSubmit: (code: string) => void; + onCancel: () => void; +} + +export const ConfirmTotpCode = makeDecoratable( + "CognitoConfirmTotpCode", + (props: ConfirmTotpCodeProps) => { + const { vm, onSubmit, onCancel } = props; + + return ( + + onSubmit={data => onSubmit(data.code)} submitOnEnter> + {({ submit }) => ( + + + + {vm.message && ( +
+ + {vm.message.text} + +
+ )} + + + + + + + + +
+ +
+
+
+
+ )} + +
+ ); + } +); diff --git a/packages/cognito/src/admin/presentation/Cognito/components/Divider.tsx b/packages/cognito/src/admin/presentation/Cognito/components/Divider.tsx index 342925f24f7..e59aa529680 100644 --- a/packages/cognito/src/admin/presentation/Cognito/components/Divider.tsx +++ b/packages/cognito/src/admin/presentation/Cognito/components/Divider.tsx @@ -1,7 +1,8 @@ import React from "react"; import { Separator, Text } from "@webiny/admin-ui"; +import { makeDecoratable } from "@webiny/app-admin"; -export const Divider = () => { +export const Divider = makeDecoratable("CognitoDivider", () => { return (
@@ -14,4 +15,4 @@ export const Divider = () => {
); -}; +}); diff --git a/packages/cognito/src/admin/presentation/Cognito/components/FederatedLogin.tsx b/packages/cognito/src/admin/presentation/Cognito/components/FederatedLogin.tsx index 5f5aabf3176..f647c94370d 100644 --- a/packages/cognito/src/admin/presentation/Cognito/components/FederatedLogin.tsx +++ b/packages/cognito/src/admin/presentation/Cognito/components/FederatedLogin.tsx @@ -1,38 +1,56 @@ import React from "react"; import { signInWithRedirect } from "aws-amplify/auth"; -import type { FederatedIdentityProvider } from "~/admin/federatedIdentityProviders.js"; +import { Button } from "@webiny/admin-ui"; +import { makeDecoratable } from "@webiny/app-admin"; import { federatedIdentityProviders } from "~/admin/federatedIdentityProviders.js"; import { FederatedProviders } from "./FederatedProviders.js"; +import { CognitoSignInConfig } from "~/admin/index.js"; type AuthProvider = "Amazon" | "Apple" | "Facebook" | "Google"; const builtInProviders = new Set(["Amazon", "Apple", "Facebook", "Google"]); -interface FederatedLoginProps { - providers: FederatedIdentityProvider[]; +export interface FederatedLoginProps { + providers: CognitoSignInConfig.FederatedProvider[]; } -export const FederatedLogin = ({ providers }: FederatedLoginProps) => { - return ( - - {providers.map(({ name, component: Component }) => { - const cognitoProviderName = federatedIdentityProviders[name] ?? name; - const isBuiltIn = builtInProviders.has(cognitoProviderName); +export const FederatedLogin = makeDecoratable( + "CognitoFederatedLogin", + ({ providers }: FederatedLoginProps) => { + return ( + + {providers.map(provider => { + const cognitoProviderName = + federatedIdentityProviders[provider.name] ?? provider.name; + const isBuiltIn = builtInProviders.has(cognitoProviderName); - const signIn = () => { - if (isBuiltIn) { - signInWithRedirect({ - provider: cognitoProviderName as AuthProvider - }); - } else { - signInWithRedirect({ - provider: { custom: cognitoProviderName } - }); + const signIn = () => { + if (isBuiltIn) { + signInWithRedirect({ + provider: cognitoProviderName as AuthProvider + }); + } else { + signInWithRedirect({ + provider: { custom: cognitoProviderName } + }); + } + }; + + if ("component" in provider) { + const Component = provider.component; + return ; } - }; - return ; - })} - - ); -}; + return ( +