create-mercato-app 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentic/shared/ai/skills/backend-ui-design/references/ui-components.md +27 -5
- package/dist/agentic/shared/ai/skills/backend-ui-design/references/ui-components.md +27 -5
- package/package.json +1 -1
- package/template/.env.example +61 -0
- package/template/src/app/globals.css +26 -0
- package/template/src/components/DemoFeedbackWidget.tsx +2 -2
- package/template/src/lib/customDomainCache.ts +239 -0
- package/template/src/lib/customDomainResolver.ts +195 -0
- package/template/src/modules/example/__integration__/TC-UMES-013.spec.ts +2 -2
|
@@ -68,13 +68,35 @@ Complete reference of all available UI components in `@open-mercato/ui`.
|
|
|
68
68
|
|
|
69
69
|
### Input Components (`@open-mercato/ui/backend/inputs/*`)
|
|
70
70
|
|
|
71
|
+
Specialized inputs that ship with the framework. For each, the full props table, MUST rules, and anti-patterns live in `.ai/ui-components.md` under the section linked from the component name.
|
|
72
|
+
|
|
73
|
+
| Component | Import Path | Purpose | Key Props |
|
|
74
|
+
|-----------|-------------|---------|-----------|
|
|
75
|
+
| **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Single-value typeahead with sync/async suggestions; allows free-form custom values by default | `value`, `onChange`, `suggestions`, `loadSuggestions`, `resolveLabel`, `allowCustomValues` |
|
|
76
|
+
| **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-value (`string[]`) version of `ComboboxInput` with rich `{ value, label, description }` triples | `value`, `onChange`, `suggestions`, `loadSuggestions`, `selectedOptions`, `resolveLabel` |
|
|
77
|
+
| **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Rich card-list search/select with title/subtitle/icon/badge per row; returns selected id | `value`, `onChange`, `fetchItems`, `options`, `minQuery`, `actionSlot` |
|
|
78
|
+
| **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Compound country-picker + national-number input matching Figma `Text Input [1.1]` Phone variant. Validates + normalizes on blur, optional duplicate lookup | `value`, `onValueChange`, `onDigitsChange`, `countries`, `defaultCountryIso2`, `onDuplicateLookup` |
|
|
79
|
+
| **EventSelect** | `@open-mercato/ui/backend/inputs/EventSelect` | Strict select for declared platform events, grouped by module. **Mandated by `packages/ui/AGENTS.md`** for event selection | `value`, `onChange`, `categories`, `modules`, `excludeTriggerExcluded`, `size` |
|
|
80
|
+
| **EventPatternInput** | `@open-mercato/ui/backend/inputs/EventPatternInput` | `ComboboxInput` preloaded with declared events that allows wildcard patterns (e.g. `sales.orders.*`) | `value`, `onChange`, `categories`, `modules` |
|
|
81
|
+
| **TimeInput** | `@open-mercato/ui/backend/inputs/TimeInput` | Bare `HH:MM` editor (two `<input type="number">` cells, no popover). Low-level atom — most flows want `TimePicker` | `value`, `onChange`, `minuteStep`, `hourLabel`, `minuteLabel` |
|
|
82
|
+
| **DatePicker** *(@deprecated shim — import from `primitives/date-picker`)* | `@open-mercato/ui/backend/inputs/DatePicker` | Backward-compat re-export of the `DatePicker` primitive. New code MUST import from `@open-mercato/ui/primitives/date-picker`. | — |
|
|
83
|
+
| **DateTimePicker** *(@deprecated shim — use `DatePicker withTime`)* | `@open-mercato/ui/backend/inputs/DateTimePicker` | Thin wrapper that always sets `withTime` on the primitive. New code: `<DatePicker withTime />` | — |
|
|
84
|
+
| **TimePicker** *(@deprecated shim — import from `primitives/time-picker`)* | `@open-mercato/ui/backend/inputs/TimePicker` | Legacy popover-anchored time picker wrapping the new `TimePicker` primitive. New code: `@open-mercato/ui/primitives/time-picker`. | — |
|
|
85
|
+
| **SwitchableMarkdownInput** *(@deprecated — use `RichEditor`)* | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Markdown ⇄ plain textarea toggle. Kept only for backward compatibility with Markdown-backed surfaces. New rich-text fields MUST use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` (sanitized HTML). | `value`, `onChange`, `isMarkdownEnabled` |
|
|
86
|
+
|
|
87
|
+
### Input Variants (Figma `Text Input [1.1]` — `@open-mercato/ui/primitives/*`)
|
|
88
|
+
|
|
89
|
+
Foundation-level input variants matching Figma type-specific designs. Use these instead of raw `<Input type="email">`, `<Input type="password">`, etc. — they ship correct visual chrome (leading icons, prefix boxes, trailing buttons, brand badges) and i18n-resolved labels.
|
|
90
|
+
|
|
71
91
|
| Component | Import Path | Purpose | Key Props |
|
|
72
92
|
|-----------|-------------|---------|-----------|
|
|
73
|
-
| **
|
|
74
|
-
| **
|
|
75
|
-
| **
|
|
76
|
-
| **
|
|
77
|
-
| **
|
|
93
|
+
| **EmailInput** | `@open-mercato/ui/primitives/email-input` | `Input` wrapper with leading Mail icon, `type="email"`, `autoComplete="email"` | `value`, `onChange`, `showIcon` |
|
|
94
|
+
| **SearchInput** | `@open-mercato/ui/primitives/search-input` | Leading Search icon + trailing × clear button (when value non-empty). Use for DataTable global filter, command palette, list-view live filter | `value`, `onChange`, `onClear`, `clearable` |
|
|
95
|
+
| **PasswordInput** | `@open-mercato/ui/primitives/password-input` | Leading Lock icon + trailing Eye/EyeOff reveal toggle. Controlled or uncontrolled reveal state | `value`, `onChange`, `revealable`, `revealed`, `onRevealedChange`, `showLockIcon` |
|
|
96
|
+
| **WebsiteInput** | `@open-mercato/ui/primitives/website-input` | Left "https://" prefix box + divider + URL input. `type="url"` | `value`, `onChange`, `prefix`, `showPrefix` |
|
|
97
|
+
| **AmountInput** | `@open-mercato/ui/primitives/amount-input` | Leading currency symbol + numeric input + trailing ISO currency picker (10 markets default) | `value: { amount, currency }`, `onChange`, `currencies`, `showCurrency` |
|
|
98
|
+
| **ButtonInput** | `@open-mercato/ui/primitives/button-input` | Input with optional `leftIcon` + divider + required `trailingAction` slot for interactive button (copy URL, send, regenerate) | `leftIcon`, `trailingAction` (required) |
|
|
99
|
+
| **CardInput** | `@open-mercato/ui/primitives/card-input` | Credit-card-number input with regex-based brand auto-detection (Visa/MC/Amex/Discover/Diners/JCB/UnionPay), format masking, brand badge | `value` (digits-only), `onChange`, `onBrandChange`, `brands` |
|
|
78
100
|
|
|
79
101
|
### Detail Page Components (`@open-mercato/ui/backend/detail/*`)
|
|
80
102
|
|
|
@@ -68,13 +68,35 @@ Complete reference of all available UI components in `@open-mercato/ui`.
|
|
|
68
68
|
|
|
69
69
|
### Input Components (`@open-mercato/ui/backend/inputs/*`)
|
|
70
70
|
|
|
71
|
+
Specialized inputs that ship with the framework. For each, the full props table, MUST rules, and anti-patterns live in `.ai/ui-components.md` under the section linked from the component name.
|
|
72
|
+
|
|
73
|
+
| Component | Import Path | Purpose | Key Props |
|
|
74
|
+
|-----------|-------------|---------|-----------|
|
|
75
|
+
| **ComboboxInput** | `@open-mercato/ui/backend/inputs/ComboboxInput` | Single-value typeahead with sync/async suggestions; allows free-form custom values by default | `value`, `onChange`, `suggestions`, `loadSuggestions`, `resolveLabel`, `allowCustomValues` |
|
|
76
|
+
| **TagsInput** | `@open-mercato/ui/backend/inputs/TagsInput` | Multi-value (`string[]`) version of `ComboboxInput` with rich `{ value, label, description }` triples | `value`, `onChange`, `suggestions`, `loadSuggestions`, `selectedOptions`, `resolveLabel` |
|
|
77
|
+
| **LookupSelect** | `@open-mercato/ui/backend/inputs/LookupSelect` | Rich card-list search/select with title/subtitle/icon/badge per row; returns selected id | `value`, `onChange`, `fetchItems`, `options`, `minQuery`, `actionSlot` |
|
|
78
|
+
| **PhoneNumberField** | `@open-mercato/ui/backend/inputs/PhoneNumberField` | Compound country-picker + national-number input matching Figma `Text Input [1.1]` Phone variant. Validates + normalizes on blur, optional duplicate lookup | `value`, `onValueChange`, `onDigitsChange`, `countries`, `defaultCountryIso2`, `onDuplicateLookup` |
|
|
79
|
+
| **EventSelect** | `@open-mercato/ui/backend/inputs/EventSelect` | Strict select for declared platform events, grouped by module. **Mandated by `packages/ui/AGENTS.md`** for event selection | `value`, `onChange`, `categories`, `modules`, `excludeTriggerExcluded`, `size` |
|
|
80
|
+
| **EventPatternInput** | `@open-mercato/ui/backend/inputs/EventPatternInput` | `ComboboxInput` preloaded with declared events that allows wildcard patterns (e.g. `sales.orders.*`) | `value`, `onChange`, `categories`, `modules` |
|
|
81
|
+
| **TimeInput** | `@open-mercato/ui/backend/inputs/TimeInput` | Bare `HH:MM` editor (two `<input type="number">` cells, no popover). Low-level atom — most flows want `TimePicker` | `value`, `onChange`, `minuteStep`, `hourLabel`, `minuteLabel` |
|
|
82
|
+
| **DatePicker** *(@deprecated shim — import from `primitives/date-picker`)* | `@open-mercato/ui/backend/inputs/DatePicker` | Backward-compat re-export of the `DatePicker` primitive. New code MUST import from `@open-mercato/ui/primitives/date-picker`. | — |
|
|
83
|
+
| **DateTimePicker** *(@deprecated shim — use `DatePicker withTime`)* | `@open-mercato/ui/backend/inputs/DateTimePicker` | Thin wrapper that always sets `withTime` on the primitive. New code: `<DatePicker withTime />` | — |
|
|
84
|
+
| **TimePicker** *(@deprecated shim — import from `primitives/time-picker`)* | `@open-mercato/ui/backend/inputs/TimePicker` | Legacy popover-anchored time picker wrapping the new `TimePicker` primitive. New code: `@open-mercato/ui/primitives/time-picker`. | — |
|
|
85
|
+
| **SwitchableMarkdownInput** *(@deprecated — use `RichEditor`)* | `@open-mercato/ui/backend/inputs/SwitchableMarkdownInput` | Markdown ⇄ plain textarea toggle. Kept only for backward compatibility with Markdown-backed surfaces. New rich-text fields MUST use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` (sanitized HTML). | `value`, `onChange`, `isMarkdownEnabled` |
|
|
86
|
+
|
|
87
|
+
### Input Variants (Figma `Text Input [1.1]` — `@open-mercato/ui/primitives/*`)
|
|
88
|
+
|
|
89
|
+
Foundation-level input variants matching Figma type-specific designs. Use these instead of raw `<Input type="email">`, `<Input type="password">`, etc. — they ship correct visual chrome (leading icons, prefix boxes, trailing buttons, brand badges) and i18n-resolved labels.
|
|
90
|
+
|
|
71
91
|
| Component | Import Path | Purpose | Key Props |
|
|
72
92
|
|-----------|-------------|---------|-----------|
|
|
73
|
-
| **
|
|
74
|
-
| **
|
|
75
|
-
| **
|
|
76
|
-
| **
|
|
77
|
-
| **
|
|
93
|
+
| **EmailInput** | `@open-mercato/ui/primitives/email-input` | `Input` wrapper with leading Mail icon, `type="email"`, `autoComplete="email"` | `value`, `onChange`, `showIcon` |
|
|
94
|
+
| **SearchInput** | `@open-mercato/ui/primitives/search-input` | Leading Search icon + trailing × clear button (when value non-empty). Use for DataTable global filter, command palette, list-view live filter | `value`, `onChange`, `onClear`, `clearable` |
|
|
95
|
+
| **PasswordInput** | `@open-mercato/ui/primitives/password-input` | Leading Lock icon + trailing Eye/EyeOff reveal toggle. Controlled or uncontrolled reveal state | `value`, `onChange`, `revealable`, `revealed`, `onRevealedChange`, `showLockIcon` |
|
|
96
|
+
| **WebsiteInput** | `@open-mercato/ui/primitives/website-input` | Left "https://" prefix box + divider + URL input. `type="url"` | `value`, `onChange`, `prefix`, `showPrefix` |
|
|
97
|
+
| **AmountInput** | `@open-mercato/ui/primitives/amount-input` | Leading currency symbol + numeric input + trailing ISO currency picker (10 markets default) | `value: { amount, currency }`, `onChange`, `currencies`, `showCurrency` |
|
|
98
|
+
| **ButtonInput** | `@open-mercato/ui/primitives/button-input` | Input with optional `leftIcon` + divider + required `trailingAction` slot for interactive button (copy URL, send, regenerate) | `leftIcon`, `trailingAction` (required) |
|
|
99
|
+
| **CardInput** | `@open-mercato/ui/primitives/card-input` | Credit-card-number input with regex-based brand auto-detection (Visa/MC/Amex/Discover/Diners/JCB/UnionPay), format masking, brand badge | `value` (digits-only), `onChange`, `onBrandChange`, `brands` |
|
|
78
100
|
|
|
79
101
|
### Detail Page Components (`@open-mercato/ui/backend/detail/*`)
|
|
80
102
|
|
package/package.json
CHANGED
package/template/.env.example
CHANGED
|
@@ -480,3 +480,64 @@ OCR_MODEL=gpt-4o
|
|
|
480
480
|
|
|
481
481
|
# Index prefix for tenant isolation (default: om)
|
|
482
482
|
# MEILISEARCH_INDEX_PREFIX=om
|
|
483
|
+
|
|
484
|
+
# ============================================================================
|
|
485
|
+
# Customer Portal Custom Domain (Phase 1–3)
|
|
486
|
+
# ============================================================================
|
|
487
|
+
# See .ai/specs/2026-04-08-portal-custom-domain-routing.md and
|
|
488
|
+
# docker/traefik/README.md. Required when custom-domain mappings are in use.
|
|
489
|
+
|
|
490
|
+
# Primary platform host — excluded from the Traefik domain-check middleware
|
|
491
|
+
# and treated as the platform pass-through host by the Node middleware.
|
|
492
|
+
PLATFORM_PRIMARY_HOST=openmercato.local
|
|
493
|
+
|
|
494
|
+
# Mandatory shared secret for /api/customer_accounts/domain-check
|
|
495
|
+
# (Traefik ForwardAuth gate). Generate with: openssl rand -hex 32
|
|
496
|
+
DOMAIN_CHECK_SECRET=
|
|
497
|
+
|
|
498
|
+
# Mandatory shared secret for the Node middleware's calls to
|
|
499
|
+
# /api/customer_accounts/domain-resolve. Generate with: openssl rand -hex 32
|
|
500
|
+
DOMAIN_RESOLVE_SECRET=
|
|
501
|
+
|
|
502
|
+
# Internal origin the proxy uses to call domain-resolve. Defaults to
|
|
503
|
+
# http://app:3000 inside docker; set to your local app for `yarn dev`.
|
|
504
|
+
INTERNAL_APP_ORIGIN=http://127.0.0.1:3000
|
|
505
|
+
|
|
506
|
+
# DNS targets surfaced to admins in the back office.
|
|
507
|
+
CUSTOM_DOMAIN_CNAME_TARGET=portal.openmercato.local
|
|
508
|
+
CUSTOM_DOMAIN_A_RECORD_TARGET=
|
|
509
|
+
|
|
510
|
+
# Optional cache tuning (defaults: 60 / 300 / 10000)
|
|
511
|
+
# DOMAIN_CACHE_TTL_SECONDS=60
|
|
512
|
+
# DOMAIN_NEGATIVE_CACHE_TTL_SECONDS=300
|
|
513
|
+
# DOMAIN_CACHE_MAX_ENTRIES=10000
|
|
514
|
+
|
|
515
|
+
# Optional override for the marker header set on /_next/health
|
|
516
|
+
# CUSTOMER_DOMAIN_ORIGIN_HEADER=X-Open-Mercato-Origin
|
|
517
|
+
|
|
518
|
+
# Background verification (Phase 5) — both workers are scheduled
|
|
519
|
+
# automatically by `mercato init` via the system scheduler.
|
|
520
|
+
# DNS auto-verification scan interval (default 300s = 5 min).
|
|
521
|
+
# DOMAIN_AUTO_VERIFY_INTERVAL_SECONDS=300
|
|
522
|
+
# TLS retry scan interval (default 1800s = 30 min).
|
|
523
|
+
# DOMAIN_TLS_RETRY_INTERVAL_SECONDS=1800
|
|
524
|
+
# Per-run cap for the TLS retry worker (default 50).
|
|
525
|
+
# DOMAIN_TLS_RETRY_BATCH=50
|
|
526
|
+
# Failure-rate threshold (0–1) that triggers worker-level backoff (default 0.8).
|
|
527
|
+
# DOMAIN_TLS_RETRY_FAILURE_THRESHOLD=0.8
|
|
528
|
+
# Maximum tls_retry_count before the worker stops retrying (default 6).
|
|
529
|
+
# DOMAIN_TLS_MAX_RETRIES=6
|
|
530
|
+
|
|
531
|
+
# --- Traefik (only for docker-compose.fullapp{,.dev}.yml) ---
|
|
532
|
+
# Let's Encrypt contact address.
|
|
533
|
+
ACME_EMAIL=
|
|
534
|
+
# Defaults to LE production. Dev compose defaults to LE staging.
|
|
535
|
+
# TRAEFIK_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory
|
|
536
|
+
# TRAEFIK_HTTP_PORT=80
|
|
537
|
+
# TRAEFIK_HTTPS_PORT=443
|
|
538
|
+
# TRAEFIK_DASHBOARD_PORT=8081
|
|
539
|
+
|
|
540
|
+
# --- Test-only Host spoof (NEVER set in production) ---
|
|
541
|
+
# Activates only when NODE_ENV=test. Used by Playwright to drive arbitrary
|
|
542
|
+
# Host values without DNS. Generate with: openssl rand -hex 32
|
|
543
|
+
# FORCE_HOST_SECRET=
|
|
@@ -22,6 +22,14 @@ TODO: Fix that latter to have reference by the package names
|
|
|
22
22
|
z-index: var(--z-index-popover);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/* Explicitly written outside @theme — Tailwind 4 only emits @theme inline
|
|
26
|
+
variables that some compiled utility references, so writing the value
|
|
27
|
+
inline here guarantees the rule lands in the bundle even if the source
|
|
28
|
+
scanner misses the className literal. */
|
|
29
|
+
@utility z-modal-elevated {
|
|
30
|
+
z-index: var(--z-index-modal-elevated, 55);
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
@theme inline {
|
|
26
34
|
--color-background: var(--background);
|
|
27
35
|
--color-foreground: var(--foreground);
|
|
@@ -93,6 +101,11 @@ TODO: Fix that latter to have reference by the package names
|
|
|
93
101
|
--color-status-neutral-border: var(--status-neutral-border);
|
|
94
102
|
--color-status-neutral-icon: var(--status-neutral-icon);
|
|
95
103
|
|
|
104
|
+
--color-status-pink-bg: var(--status-pink-bg);
|
|
105
|
+
--color-status-pink-text: var(--status-pink-text);
|
|
106
|
+
--color-status-pink-border: var(--status-pink-border);
|
|
107
|
+
--color-status-pink-icon: var(--status-pink-icon);
|
|
108
|
+
|
|
96
109
|
/* ═══ Design System: Accent Colors ═══ */
|
|
97
110
|
--color-accent-indigo: var(--accent-indigo);
|
|
98
111
|
--color-accent-indigo-foreground: var(--accent-indigo-foreground);
|
|
@@ -121,6 +134,7 @@ TODO: Fix that latter to have reference by the package names
|
|
|
121
134
|
--z-index-overlay: 30;
|
|
122
135
|
--z-index-modal: 40;
|
|
123
136
|
--z-index-popover: 45;
|
|
137
|
+
--z-index-modal-elevated: 55;
|
|
124
138
|
--z-index-toast: 50;
|
|
125
139
|
--z-index-tooltip: 60;
|
|
126
140
|
--z-index-banner: 70;
|
|
@@ -250,6 +264,12 @@ TODO: Fix that latter to have reference by the package names
|
|
|
250
264
|
--status-neutral-text: oklch(0.445 0 0);
|
|
251
265
|
--status-neutral-border: oklch(0.850 0 0);
|
|
252
266
|
--status-neutral-icon: oklch(0.556 0 0); /* = --muted-foreground */
|
|
267
|
+
|
|
268
|
+
/* Pink (hue ~340° — Figma SPEC-048 ACCENT / PINK Stage badges; Tailwind pink-500/-200/-700/-50) */
|
|
269
|
+
--status-pink-bg: oklch(0.965 0.020 340); /* pink-50 #FDF2F8 */
|
|
270
|
+
--status-pink-text: oklch(0.388 0.180 340); /* pink-700 #BE185D */
|
|
271
|
+
--status-pink-border: oklch(0.840 0.090 340);/* pink-200 #FBCFE8 */
|
|
272
|
+
--status-pink-icon: oklch(0.633 0.238 340); /* pink-500 #EC4899 */
|
|
253
273
|
}
|
|
254
274
|
|
|
255
275
|
/* Opt-in Figma-style focus ring: 2px inner ring + 4px outer ring */
|
|
@@ -358,6 +378,12 @@ TODO: Fix that latter to have reference by the package names
|
|
|
358
378
|
--status-neutral-text: oklch(0.750 0 0);
|
|
359
379
|
--status-neutral-border: oklch(0.380 0 0);
|
|
360
380
|
--status-neutral-icon: oklch(0.708 0 0); /* = dark --muted-foreground */
|
|
381
|
+
|
|
382
|
+
/* Pink (dark mode) — Figma SPEC-048 ACCENT / PINK Stage badges */
|
|
383
|
+
--status-pink-bg: oklch(0.225 0.045 340);
|
|
384
|
+
--status-pink-text: oklch(0.815 0.135 340);
|
|
385
|
+
--status-pink-border: oklch(0.405 0.155 340);
|
|
386
|
+
--status-pink-icon: oklch(0.700 0.225 340);
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
@layer base {
|
|
@@ -7,6 +7,7 @@ import { MessageCircle, Send } from 'lucide-react'
|
|
|
7
7
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@open-mercato/ui/primitives/dialog'
|
|
8
8
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
9
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
10
|
+
import { EmailInput } from '@open-mercato/ui/primitives/email-input'
|
|
10
11
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
11
12
|
import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
|
|
12
13
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
@@ -279,9 +280,8 @@ export function DemoFeedbackWidget({ demoModeEnabled }: { demoModeEnabled: boole
|
|
|
279
280
|
)}
|
|
280
281
|
|
|
281
282
|
<div className="grid gap-1">
|
|
282
|
-
<
|
|
283
|
+
<EmailInput
|
|
283
284
|
id="feedback-email"
|
|
284
|
-
type="email"
|
|
285
285
|
placeholder={t('demoFeedback.form.email', 'Your email')}
|
|
286
286
|
value={email}
|
|
287
287
|
onChange={(e) => setEmail(e.target.value)}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { normalizeHostname, tryNormalizeHostname } from '@open-mercato/core/modules/customer_accounts/lib/hostname'
|
|
2
|
+
|
|
3
|
+
export type DomainResolution = {
|
|
4
|
+
hostname: string
|
|
5
|
+
tenantId: string
|
|
6
|
+
organizationId: string
|
|
7
|
+
orgSlug: string | null
|
|
8
|
+
status: 'active'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type PositiveEntry = {
|
|
12
|
+
isNegative: false
|
|
13
|
+
data: DomainResolution
|
|
14
|
+
expiresAt: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type NegativeEntry = {
|
|
18
|
+
isNegative: true
|
|
19
|
+
expiresAt: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type CacheEntry = PositiveEntry | NegativeEntry
|
|
23
|
+
|
|
24
|
+
export type CacheLookup =
|
|
25
|
+
| { kind: 'fresh-hit'; data: DomainResolution | null }
|
|
26
|
+
| { kind: 'stale-hit'; data: DomainResolution | null }
|
|
27
|
+
| { kind: 'cold-miss' }
|
|
28
|
+
|
|
29
|
+
export type DomainResolver = (hostname: string) => Promise<DomainResolution | null>
|
|
30
|
+
|
|
31
|
+
export type CustomDomainCacheOptions = {
|
|
32
|
+
positiveTtlMs: number
|
|
33
|
+
negativeTtlMs: number
|
|
34
|
+
maxEntries: number
|
|
35
|
+
resolver: DomainResolver
|
|
36
|
+
now?: () => number
|
|
37
|
+
onWarmupError?: (error: unknown) => void
|
|
38
|
+
onResolveError?: (hostname: string, error: unknown) => void
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of in-flight resolver promises. When exceeded, new unique
|
|
41
|
+
* hostnames are dropped (resolved as `null`) instead of starting another
|
|
42
|
+
* fetch. Prevents unbounded `inFlight` growth under hostname-flood probing.
|
|
43
|
+
* Defaults to `maxEntries` when omitted.
|
|
44
|
+
*/
|
|
45
|
+
maxInFlight?: number
|
|
46
|
+
/**
|
|
47
|
+
* Invoked once when an in-flight slot is denied to a new hostname because
|
|
48
|
+
* the cap is saturated. The cache rate-limits its own warning, so this hook
|
|
49
|
+
* fires every time but the default warning logger does not spam.
|
|
50
|
+
*/
|
|
51
|
+
onInFlightCapReached?: (hostname: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const POSITIVE_TTL_DEFAULT_SECONDS = 60
|
|
55
|
+
const NEGATIVE_TTL_DEFAULT_SECONDS = 60
|
|
56
|
+
// Hard ceiling for the per-process negative TTL. There is no in-process
|
|
57
|
+
// invalidation hook between Next.js workers — only the shared cache is
|
|
58
|
+
// invalidated when a domain becomes active — so a longer negative TTL means
|
|
59
|
+
// a newly registered domain can stay unresolved on a given process up to
|
|
60
|
+
// this many seconds. Keep it under one minute to bound that propagation
|
|
61
|
+
// delay for real customers while still absorbing unknown-hostname probing.
|
|
62
|
+
const NEGATIVE_TTL_MAX_SECONDS = 60
|
|
63
|
+
const MAX_ENTRIES_DEFAULT = 10_000
|
|
64
|
+
|
|
65
|
+
function readNumberEnv(name: string, fallbackSeconds: number, maxSeconds?: number): number {
|
|
66
|
+
const raw = process.env[name]
|
|
67
|
+
if (!raw) return fallbackSeconds * 1000
|
|
68
|
+
const parsed = Number(raw)
|
|
69
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallbackSeconds * 1000
|
|
70
|
+
const seconds = maxSeconds !== undefined ? Math.min(parsed, maxSeconds) : parsed
|
|
71
|
+
return seconds * 1000
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readMaxEntriesEnv(): number {
|
|
75
|
+
const raw = process.env.DOMAIN_CACHE_MAX_ENTRIES
|
|
76
|
+
if (!raw) return MAX_ENTRIES_DEFAULT
|
|
77
|
+
const parsed = Number(raw)
|
|
78
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return MAX_ENTRIES_DEFAULT
|
|
79
|
+
return Math.floor(parsed)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const IN_FLIGHT_CAP_WARN_INTERVAL_MS = 60_000
|
|
83
|
+
|
|
84
|
+
export function createCustomDomainCache(options: CustomDomainCacheOptions) {
|
|
85
|
+
const {
|
|
86
|
+
positiveTtlMs,
|
|
87
|
+
negativeTtlMs,
|
|
88
|
+
maxEntries,
|
|
89
|
+
resolver,
|
|
90
|
+
now = Date.now,
|
|
91
|
+
onResolveError,
|
|
92
|
+
onInFlightCapReached,
|
|
93
|
+
} = options
|
|
94
|
+
const maxInFlight = Math.max(1, Math.floor(options.maxInFlight ?? maxEntries))
|
|
95
|
+
|
|
96
|
+
// Map preserves insertion order — re-inserting on access yields LRU semantics.
|
|
97
|
+
const store = new Map<string, CacheEntry>()
|
|
98
|
+
const inFlight = new Map<string, Promise<DomainResolution | null>>()
|
|
99
|
+
let lastCapWarnAt = 0
|
|
100
|
+
|
|
101
|
+
function touch(key: string, entry: CacheEntry): void {
|
|
102
|
+
store.delete(key)
|
|
103
|
+
store.set(key, entry)
|
|
104
|
+
while (store.size > maxEntries) {
|
|
105
|
+
const oldest = store.keys().next().value
|
|
106
|
+
if (oldest === undefined) break
|
|
107
|
+
store.delete(oldest)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readEntry(hostname: string): CacheLookup {
|
|
112
|
+
const entry = store.get(hostname)
|
|
113
|
+
if (!entry) return { kind: 'cold-miss' }
|
|
114
|
+
// Re-insert to keep LRU order on each access.
|
|
115
|
+
store.delete(hostname)
|
|
116
|
+
store.set(hostname, entry)
|
|
117
|
+
const fresh = entry.expiresAt > now()
|
|
118
|
+
const data = entry.isNegative ? null : entry.data
|
|
119
|
+
return fresh ? { kind: 'fresh-hit', data } : { kind: 'stale-hit', data }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writePositive(hostname: string, data: DomainResolution): void {
|
|
123
|
+
touch(hostname, { isNegative: false, data, expiresAt: now() + positiveTtlMs })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeNegative(hostname: string): void {
|
|
127
|
+
touch(hostname, { isNegative: true, expiresAt: now() + negativeTtlMs })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reportInFlightCap(hostname: string): void {
|
|
131
|
+
if (onInFlightCapReached) {
|
|
132
|
+
onInFlightCapReached(hostname)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const ts = now()
|
|
136
|
+
if (ts - lastCapWarnAt >= IN_FLIGHT_CAP_WARN_INTERVAL_MS) {
|
|
137
|
+
lastCapWarnAt = ts
|
|
138
|
+
console.warn('[customDomainCache] inFlight cap reached, dropping fetch for', hostname)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function fetchAndStore(hostname: string): Promise<DomainResolution | null> {
|
|
143
|
+
const existing = inFlight.get(hostname)
|
|
144
|
+
if (existing) return existing
|
|
145
|
+
if (inFlight.size >= maxInFlight) {
|
|
146
|
+
// Cap saturated: refuse to start another fetch. Returning null mirrors
|
|
147
|
+
// the resolver's "unknown host" path (HTTP 404) so callers degrade
|
|
148
|
+
// gracefully without poisoning the cache or pinning more promises.
|
|
149
|
+
reportInFlightCap(hostname)
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
const promise = (async () => {
|
|
153
|
+
try {
|
|
154
|
+
const result = await resolver(hostname)
|
|
155
|
+
if (result) writePositive(hostname, result)
|
|
156
|
+
else writeNegative(hostname)
|
|
157
|
+
return result
|
|
158
|
+
} catch (err) {
|
|
159
|
+
onResolveError?.(hostname, err)
|
|
160
|
+
// Do not poison the cache on transient resolver errors — let the next
|
|
161
|
+
// request retry. Stale entries (if any) keep serving via SWR.
|
|
162
|
+
throw err
|
|
163
|
+
} finally {
|
|
164
|
+
inFlight.delete(hostname)
|
|
165
|
+
}
|
|
166
|
+
})()
|
|
167
|
+
inFlight.set(hostname, promise)
|
|
168
|
+
return promise
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function resolve(rawHostname: string): Promise<DomainResolution | null> {
|
|
172
|
+
const normalized = tryNormalizeHostname(rawHostname)
|
|
173
|
+
if (!normalized) return null
|
|
174
|
+
const lookup = readEntry(normalized)
|
|
175
|
+
if (lookup.kind === 'fresh-hit') {
|
|
176
|
+
return lookup.data
|
|
177
|
+
}
|
|
178
|
+
if (lookup.kind === 'stale-hit') {
|
|
179
|
+
// Serve stale immediately; trigger a non-blocking refresh.
|
|
180
|
+
void fetchAndStore(normalized).catch(() => {
|
|
181
|
+
/* error already reported via onResolveError */
|
|
182
|
+
})
|
|
183
|
+
return lookup.data
|
|
184
|
+
}
|
|
185
|
+
// Cold miss: synchronous fetch as fallback.
|
|
186
|
+
return fetchAndStore(normalized)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function primeFromList(entries: Iterable<DomainResolution>): void {
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const normalized = tryNormalizeHostname(entry.hostname)
|
|
192
|
+
if (!normalized) continue
|
|
193
|
+
writePositive(normalized, { ...entry, hostname: normalized })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function clear(): void {
|
|
198
|
+
store.clear()
|
|
199
|
+
inFlight.clear()
|
|
200
|
+
lastCapWarnAt = 0
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function size(): number {
|
|
204
|
+
return store.size
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function peek(hostname: string): CacheEntry | undefined {
|
|
208
|
+
const normalized = tryNormalizeHostname(hostname) ?? hostname
|
|
209
|
+
return store.get(normalized)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
resolve,
|
|
214
|
+
primeFromList,
|
|
215
|
+
clear,
|
|
216
|
+
size,
|
|
217
|
+
peek,
|
|
218
|
+
// Exposed for tests / instrumentation only.
|
|
219
|
+
_internals: { store, inFlight, writePositive, writeNegative, normalize: normalizeHostname },
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type CustomDomainCache = ReturnType<typeof createCustomDomainCache>
|
|
224
|
+
|
|
225
|
+
export function readPositiveTtlMs(): number {
|
|
226
|
+
return readNumberEnv('DOMAIN_CACHE_TTL_SECONDS', POSITIVE_TTL_DEFAULT_SECONDS)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function readNegativeTtlMs(): number {
|
|
230
|
+
return readNumberEnv(
|
|
231
|
+
'DOMAIN_NEGATIVE_CACHE_TTL_SECONDS',
|
|
232
|
+
NEGATIVE_TTL_DEFAULT_SECONDS,
|
|
233
|
+
NEGATIVE_TTL_MAX_SECONDS,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function readMaxEntries(): number {
|
|
238
|
+
return readMaxEntriesEnv()
|
|
239
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { platformDomains } from '@open-mercato/core/modules/customer_accounts/lib/platformDomains'
|
|
2
|
+
import {
|
|
3
|
+
createCustomDomainCache,
|
|
4
|
+
readMaxEntries,
|
|
5
|
+
readNegativeTtlMs,
|
|
6
|
+
readPositiveTtlMs,
|
|
7
|
+
type CustomDomainCache,
|
|
8
|
+
type DomainResolution,
|
|
9
|
+
} from './customDomainCache'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 5_000
|
|
12
|
+
|
|
13
|
+
type ResolveResponse =
|
|
14
|
+
| { ok: true; tenantId: string; organizationId: string; orgSlug: string | null; status: 'active' }
|
|
15
|
+
| { ok: false; error: string }
|
|
16
|
+
|
|
17
|
+
type ResolveAllResponse =
|
|
18
|
+
| {
|
|
19
|
+
ok: true
|
|
20
|
+
domains: Array<{
|
|
21
|
+
hostname: string
|
|
22
|
+
tenantId: string
|
|
23
|
+
organizationId: string
|
|
24
|
+
orgSlug: string | null
|
|
25
|
+
status: 'active'
|
|
26
|
+
}>
|
|
27
|
+
}
|
|
28
|
+
| { ok: false; error: string }
|
|
29
|
+
|
|
30
|
+
function readInternalAppOrigin(): string | null {
|
|
31
|
+
const candidates = [
|
|
32
|
+
process.env.INTERNAL_APP_ORIGIN,
|
|
33
|
+
process.env.NEXT_INTERNAL_APP_ORIGIN,
|
|
34
|
+
].filter((value): value is string => Boolean(value && value.trim().length > 0))
|
|
35
|
+
if (candidates.length > 0) return candidates[0]!.replace(/\/$/, '')
|
|
36
|
+
const port = process.env.PORT?.trim() || '3000'
|
|
37
|
+
return `http://127.0.0.1:${port}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readResolveSecret(): string | null {
|
|
41
|
+
const secret = process.env.DOMAIN_RESOLVE_SECRET?.trim()
|
|
42
|
+
return secret && secret.length > 0 ? secret : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fetchWithTimeout(
|
|
46
|
+
fetchImpl: typeof fetch,
|
|
47
|
+
url: string,
|
|
48
|
+
init: RequestInit,
|
|
49
|
+
timeoutMs: number,
|
|
50
|
+
): Promise<Response> {
|
|
51
|
+
const controller = new AbortController()
|
|
52
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
53
|
+
try {
|
|
54
|
+
return await fetchImpl(url, { ...init, signal: controller.signal })
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type CustomDomainRouterDeps = {
|
|
61
|
+
origin?: string | null
|
|
62
|
+
secret?: string | null
|
|
63
|
+
fetchTimeoutMs?: number
|
|
64
|
+
fetchImpl?: typeof fetch
|
|
65
|
+
logger?: Pick<Console, 'warn' | 'error'>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CustomDomainRouter = {
|
|
69
|
+
cache: CustomDomainCache
|
|
70
|
+
resolve(hostname: string): Promise<DomainResolution | null>
|
|
71
|
+
warmUp(): Promise<{ primed: number } | { primed: 0; error: string }>
|
|
72
|
+
reset(): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createCustomDomainRouter(deps: CustomDomainRouterDeps = {}): CustomDomainRouter {
|
|
76
|
+
const origin = (deps.origin ?? readInternalAppOrigin())?.replace(/\/$/, '') ?? null
|
|
77
|
+
const secret = deps.secret ?? readResolveSecret()
|
|
78
|
+
const timeoutMs = deps.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
|
|
79
|
+
const fetcher = deps.fetchImpl ?? fetch
|
|
80
|
+
const logger = deps.logger ?? console
|
|
81
|
+
|
|
82
|
+
async function singleResolve(hostname: string): Promise<DomainResolution | null> {
|
|
83
|
+
if (!origin || !secret) return null
|
|
84
|
+
const url = `${origin}/api/customer_accounts/domain-resolve?host=${encodeURIComponent(hostname)}`
|
|
85
|
+
let response: Response
|
|
86
|
+
try {
|
|
87
|
+
response = await fetchWithTimeout(
|
|
88
|
+
fetcher,
|
|
89
|
+
url,
|
|
90
|
+
{
|
|
91
|
+
method: 'GET',
|
|
92
|
+
headers: { 'X-Domain-Resolve-Secret': secret, accept: 'application/json' },
|
|
93
|
+
},
|
|
94
|
+
timeoutMs,
|
|
95
|
+
)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error(`domain-resolve fetch failed: ${(err as Error)?.message ?? 'unknown'}`)
|
|
98
|
+
}
|
|
99
|
+
if (response.status === 404) return null
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new Error(`domain-resolve returned HTTP ${response.status}`)
|
|
102
|
+
}
|
|
103
|
+
const body = (await response.json()) as ResolveResponse
|
|
104
|
+
if (!body.ok) return null
|
|
105
|
+
return {
|
|
106
|
+
hostname,
|
|
107
|
+
tenantId: body.tenantId,
|
|
108
|
+
organizationId: body.organizationId,
|
|
109
|
+
orgSlug: body.orgSlug,
|
|
110
|
+
status: body.status,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const cache = createCustomDomainCache({
|
|
115
|
+
positiveTtlMs: readPositiveTtlMs(),
|
|
116
|
+
negativeTtlMs: readNegativeTtlMs(),
|
|
117
|
+
maxEntries: readMaxEntries(),
|
|
118
|
+
resolver: singleResolve,
|
|
119
|
+
onResolveError: (hostname, err) => {
|
|
120
|
+
logger.warn?.(`[custom-domain] resolve failed for ${hostname}: ${(err as Error)?.message ?? err}`)
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
async function warmUp(): Promise<{ primed: number } | { primed: 0; error: string }> {
|
|
125
|
+
if (!origin || !secret) {
|
|
126
|
+
return { primed: 0, error: 'INTERNAL_APP_ORIGIN or DOMAIN_RESOLVE_SECRET is not configured' }
|
|
127
|
+
}
|
|
128
|
+
const url = `${origin}/api/customer_accounts/domain-resolve/all`
|
|
129
|
+
try {
|
|
130
|
+
const response = await fetchWithTimeout(
|
|
131
|
+
fetcher,
|
|
132
|
+
url,
|
|
133
|
+
{
|
|
134
|
+
method: 'GET',
|
|
135
|
+
headers: { 'X-Domain-Resolve-Secret': secret, accept: 'application/json' },
|
|
136
|
+
},
|
|
137
|
+
timeoutMs,
|
|
138
|
+
)
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
return { primed: 0, error: `warm-up returned HTTP ${response.status}` }
|
|
141
|
+
}
|
|
142
|
+
const body = (await response.json()) as ResolveAllResponse
|
|
143
|
+
if (!body.ok) return { primed: 0, error: body.error || 'warm-up returned ok=false' }
|
|
144
|
+
cache.primeFromList(body.domains)
|
|
145
|
+
return { primed: body.domains.length }
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { primed: 0, error: (err as Error)?.message ?? 'unknown' }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
cache,
|
|
153
|
+
resolve: cache.resolve,
|
|
154
|
+
warmUp,
|
|
155
|
+
reset: cache.clear,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let sharedRouter: CustomDomainRouter | null = null
|
|
160
|
+
let sharedWarmUpPromise: Promise<unknown> | null = null
|
|
161
|
+
|
|
162
|
+
export function getSharedCustomDomainRouter(): CustomDomainRouter {
|
|
163
|
+
if (!sharedRouter) {
|
|
164
|
+
sharedRouter = createCustomDomainRouter()
|
|
165
|
+
}
|
|
166
|
+
return sharedRouter
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function ensureWarmUp(): Promise<unknown> {
|
|
170
|
+
if (!sharedWarmUpPromise) {
|
|
171
|
+
const router = getSharedCustomDomainRouter()
|
|
172
|
+
sharedWarmUpPromise = router.warmUp().then((result) => {
|
|
173
|
+
if ('error' in result && result.primed === 0) {
|
|
174
|
+
console.warn(`[custom-domain] warm-up skipped: ${result.error}`)
|
|
175
|
+
} else if ('primed' in result) {
|
|
176
|
+
console.info(`[custom-domain] warm-up primed ${result.primed} domain(s)`)
|
|
177
|
+
}
|
|
178
|
+
return result
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return sharedWarmUpPromise
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function resetSharedRouterForTests(): void {
|
|
185
|
+
sharedRouter = null
|
|
186
|
+
sharedWarmUpPromise = null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function platformDomainList(): string[] {
|
|
190
|
+
return platformDomains()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function isPlatformHost(hostname: string): boolean {
|
|
194
|
+
return platformDomainList().includes(hostname.toLowerCase())
|
|
195
|
+
}
|
|
@@ -16,8 +16,8 @@ test.describe('TC-UMES-013: Ephemeral login page suppresses global notice bars',
|
|
|
16
16
|
await expect(page.getByText(/this instance is provided for demo purposes only/i)).toHaveCount(0)
|
|
17
17
|
|
|
18
18
|
await page.getByLabel('Email').fill(DEFAULT_CREDENTIALS.admin.email)
|
|
19
|
-
await page.getByLabel('Password').fill(DEFAULT_CREDENTIALS.admin.password)
|
|
20
|
-
await page.getByLabel('Password').press('Enter')
|
|
19
|
+
await page.getByLabel('Password', { exact: true }).fill(DEFAULT_CREDENTIALS.admin.password)
|
|
20
|
+
await page.getByLabel('Password', { exact: true }).press('Enter')
|
|
21
21
|
|
|
22
22
|
await expect(page).toHaveURL(/\/backend(?:\/.*)?$/, { timeout: 20_000 })
|
|
23
23
|
})
|