@zitadel/config 0.1.0-alpha.18 → 1.0.0-alpha.20

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.
Files changed (40) hide show
  1. package/README.md +27 -0
  2. package/defaults/README-branding.md +36 -2
  3. package/defaults/README-flows.md +21 -4
  4. package/defaults/README-schemas.md +9 -2
  5. package/defaults/branding/centered/login.liquid +30 -14
  6. package/defaults/branding/hero/login.liquid +33 -17
  7. package/defaults/branding/minimal/login.liquid +25 -14
  8. package/defaults/branding/split/login.liquid +34 -15
  9. package/defaults/branding/split-right/login.liquid +34 -15
  10. package/defaults/default-human-user.json +3 -4
  11. package/defaults/default-login.json +20 -0
  12. package/defaults/presets/passkey-first/human-user.json +3 -4
  13. package/defaults/presets/passkey-first/login.json +30 -0
  14. package/dist/branding-url.d.mts +14 -0
  15. package/dist/branding-url.d.mts.map +1 -0
  16. package/dist/branding-url.mjs +23 -0
  17. package/dist/branding-url.mjs.map +1 -0
  18. package/dist/{defaults-CXTnFMHj.mjs → defaults-DPq0o3iU.mjs} +132 -77
  19. package/dist/{defaults-CXTnFMHj.mjs.map → defaults-DPq0o3iU.mjs.map} +1 -1
  20. package/dist/defaults.mjs +1 -1
  21. package/dist/index.d.mts +2 -1
  22. package/dist/index.mjs +4 -3
  23. package/dist/{meta-schemas-B4YiHAX_.mjs → meta-schemas-z4IlqbLd.mjs} +41 -34
  24. package/dist/{meta-schemas-B4YiHAX_.mjs.map → meta-schemas-z4IlqbLd.mjs.map} +1 -1
  25. package/dist/meta-schemas.mjs +1 -1
  26. package/dist/normalize.d.mts +11 -9
  27. package/dist/normalize.d.mts.map +1 -1
  28. package/dist/normalize.mjs +25 -19
  29. package/dist/normalize.mjs.map +1 -1
  30. package/dist/schemas.d.mts.map +1 -1
  31. package/dist/schemas.mjs +8 -6
  32. package/dist/schemas.mjs.map +1 -1
  33. package/dist/validate.mjs +72 -13
  34. package/dist/validate.mjs.map +1 -1
  35. package/meta-schemas/auth-method.json +2 -7
  36. package/meta-schemas/branding.json +8 -16
  37. package/meta-schemas/flow-definition.json +20 -1
  38. package/meta-schemas/user-property.json +2 -23
  39. package/meta-schemas/user-schema.json +14 -0
  40. package/package.json +7 -2
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @zitadel/config
2
+
3
+ Versioned Zitadel local config schemas and defaults — the contract behind the
4
+ `.zitadel/` directory that `zitadel setup` scaffolds into your project.
5
+
6
+ ## What's in here
7
+
8
+ | Import | What it carries |
9
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
10
+ | `@zitadel/config/schemas` | Zod schemas for the local config files (`schemaConfigSchema`, `flowConfigSchema`, `brandingConfigSchema`) |
11
+ | `@zitadel/config/branding-url` | Canonical loopback HTTP URL predicate shared by config and browser render gates |
12
+ | `@zitadel/config/validate` | Validation entry points the CLI runs on `plan`/`apply` |
13
+ | `@zitadel/config/normalize` | Normalization used to compare local files against server state |
14
+ | `@zitadel/config/defaults` | The versioned default user schema, login flow, and branding designs |
15
+ | `@zitadel/config/meta-schemas` | The JSON meta-schemas the defaults pin |
16
+ | `@zitadel/config/template` | Liquid template helpers |
17
+ | `@zitadel/config/defaults/default-login.json` | The default login flow — the authority for flow step shape |
18
+ | `@zitadel/config/defaults/default-human-user.json` | The default user schema |
19
+
20
+ ## The customer-facing part
21
+
22
+ `zitadel setup` copies the defaults into your repo as
23
+ `.zitadel/{schemas,flows,branding}/`, each with a README explaining how to
24
+ edit it (`defaults/README-schemas.md`, `README-flows.md`,
25
+ `README-branding.md` in this package become those files). Those files are
26
+ yours to edit; `zitadel plan` / `zitadel apply` reconcile them with the
27
+ server, and edits publish new revisions rather than mutating state in place.
@@ -6,7 +6,7 @@ and asset URLs) plus `login.liquid`, the LiquidJS template the
6
6
 
7
7
  ## Workflow
8
8
 
9
- 1. Edit `login.liquid` (and `branding.json` for logo/font/hero URLs).
9
+ 1. Edit `login.liquid` (and `branding.json` for logo/hero URLs).
10
10
  2. `zitadel plan` — validates the template (LiquidJS parse, banned patterns,
11
11
  the required `{% mandatory_gates %}` tag) and shows the pending revision.
12
12
  3. `zitadel apply` — publishes an immutable branding revision. The login UI
@@ -27,6 +27,40 @@ by re-applying an earlier template.
27
27
 
28
28
  ## Make it yours
29
29
 
30
+ - Brand asset URLs go in `branding.json` and must use `https://`. Each template
31
+ decides which fields it renders: `centered` and `hero` use `logo_url`; `split`
32
+ and `split-right` use both `logo_url` and `hero_url`; `minimal` uses neither
33
+ until you add them to `login.liquid`. The `hero` design's brand pane is
34
+ editable markup rather than a `hero_url` background. Custom fonts are not
35
+ configurable here yet; load them from the embedding page.
36
+
37
+ ```json
38
+ {
39
+ "$schema": "../meta/branding.json",
40
+ "layout": "split",
41
+ "liquid_template_file": "./login.liquid",
42
+ "logo_url": "https://example.com/logo.svg",
43
+ "hero_url": "https://example.com/hero.jpg"
44
+ }
45
+ ```
46
+
47
+ A well-formed URL that serves nothing is the one branding mistake nothing
48
+ else catches — it passes validation, publishes a revision, and then renders
49
+ as an invisible image. So `zitadel plan` probes each asset URL and warns
50
+ (never fails) when it is unreachable, bodyless, or not an image, and the
51
+ login UI hides an asset that fails to load, restoring the shipped design's
52
+ no-asset content rather than leaving a gap. If the host is only
53
+ reachable from where the login page renders — or you are offline — set
54
+ `ZITADEL_SKIP_ASSET_PROBE=1` to skip the check. The probe contacts public
55
+ HTTPS destinations only and re-checks redirects; loopback/private/internal
56
+ targets are left to the browser-side fallback instead of being requested by
57
+ the machine running `plan`.
58
+
59
+ `layout` is the degrade preset (`centered` or `split`), **not** the complete
60
+ design catalog. All named designs (`centered`, `split`, `split-right`, `hero`,
61
+ `minimal`) are delivered as templates and map onto those two values. Switch
62
+ designs with `zitadel branding eject --design <name>`, don't edit `layout`.
63
+
30
64
  - In the split-family designs (`split`, `split-right`, `hero`) the
31
65
  `.zl-split__brand` pane is yours: structural HTML plus inline `style=""`
32
66
  attributes are allowed; `button`, `input`, and `form` tags are stripped —
@@ -38,7 +72,7 @@ by re-applying an earlier template.
38
72
  - The split chrome is tunable from your template root's `style` attribute
39
73
  (`--zl-split-columns`, `--zl-split-align`, `--zl-split-brand-mobile`), and
40
74
  `zl-split--right` on the wrapper mirrors the panes. Knob reference:
41
- `docs/design/branding/templates.md` in the Zitadel repo.
75
+ [`docs/design/branding/templates.md`](https://github.com/zitadel/nextgen/blob/main/docs/design/branding/templates.md).
42
76
  - The "Secured with Zitadel" attribution is licence-gated and on by default.
43
77
  - Back-navigation: the engine injects a `kind: "back"` action on steps
44
78
  that can return to their predecessor, and the browser's back gesture
@@ -37,8 +37,25 @@ Typical edits:
37
37
  tokens).
38
38
  - **Add or remove a step** — extend `steps[]`.
39
39
  - **Rewire transitions** — edit `steps[].transitions` to point at a
40
- different next step, or use `action: switch` / `pivot` to jump to
41
- another flow.
40
+ different next step. Cross-flow jumps (`action: switch` / `pivot`)
41
+ validate in a definition but are **not yet executed by the runtime** —
42
+ submitting through one fails with a 400 (`code: "flow.unsupported"`);
43
+ keep transitions within one flow, using a purpose switch (next bullet)
44
+ for login ↔ register navigation.
45
+ - **Offer a purpose switch in the card** — pair a `kind: navigate`
46
+ action with a transition that declares a local `purpose`. The default
47
+ login flow ships this in both directions:
48
+
49
+ ```json
50
+ "register": { "target": "register", "purpose": "register" }
51
+ ```
52
+
53
+ Navigation skips field validation (an empty email box can't block the
54
+ link), and the declared purpose is what makes the landing leg run with
55
+ the right semantics — register creates the user instead of trying to
56
+ verify one. Rules: the purpose must be one this flow's `purposes`
57
+ serves, the target must be that purpose's entry step, and `purpose`
58
+ never combines with `action` (which targets another flow instead).
42
59
  - **Add another flow** — drop a new JSON file with its own `purposes`
43
60
  and a distinct `name` (e.g. a per-team login). See
44
61
  [Multiple flows](#multiple-flows) for how it gets selected at runtime.
@@ -70,8 +87,8 @@ experiment can't silently take over `/login` — scope it or pin
70
87
 
71
88
  ## Presets
72
89
 
73
- `zitadel setup` scaffolds this folder from a preset (`--preset
74
- password-first` or `--preset passkey-first`). The passkey-first flow
90
+ `zitadel setup` scaffolds this folder from a preset: `password-first` (the
91
+ default root files) or `--preset passkey-first`. The passkey-first flow
75
92
  enters login on a fields-less passkey step with an email → password
76
93
  fallback path. The preset only decides the starting point — edit
77
94
  anything here afterwards.
@@ -16,7 +16,13 @@ Here's a simplified example:
16
16
  ``` json
17
17
  {
18
18
  "objectType": "customer",
19
+ "x-identifier": "email",
19
20
  "properties": {
21
+ "email": {
22
+ "type": "string",
23
+ "format": "email",
24
+ "x-unique": "project"
25
+ },
20
26
  "firstName": {
21
27
  "type": "string"
22
28
  },
@@ -25,11 +31,12 @@ Here's a simplified example:
25
31
  }
26
32
  },
27
33
  "required": [
34
+ "email",
28
35
  "firstName"
29
36
  ],
30
37
  "x-auth-methods": {
31
- "password": { "enabled": true, "position": 1 },
32
- "passkey": { "enabled": true, "position": 2 }
38
+ "password": { "enabled": true },
39
+ "passkey": { "enabled": true }
33
40
  }
34
41
  }
35
42
  ```
@@ -11,18 +11,9 @@
11
11
  {% endif %}
12
12
  {% endif %}
13
13
 
14
- {% if errors and errors.size > 0 %}
15
- {% for err in errors %}
16
- {% if err | formLevelError %}
17
- {% assign alert_heading = err.text_key | alertHeading %}
18
- {% assign alert_body = err.text_key | alertBody %}
19
- <zl-alert severity="error" dismissible data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
20
- {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
21
- </zl-alert>
22
- {% endif %}
23
- {% endfor %}
24
- {% endif %}
25
-
14
+ {% assign recover = actions | where: "name", "recover" | first %}
15
+ {% assign password_field = fields | where: "type", "password" | first %}
16
+ <div class="zl-field-group">
26
17
  {% for f in fields %}
27
18
  {% assign field_placeholder = f.text_key | fieldPlaceholder %}
28
19
  {% assign field_help = f.text_key | fieldHelp %}
@@ -67,6 +58,7 @@
67
58
  {% if f.required %}required{% endif %}
68
59
  {% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
69
60
  {% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
61
+ {% if recover and f.type == 'password' %}forgot-password-href="#" forgot-password-action="{{ recover.name }}" forgot-password-label="{{ recover.text_key | t }}"{% endif %}
70
62
  >
71
63
  {% if field_help != "" %}
72
64
  <span slot="help">{{ field_help }}</span>
@@ -75,8 +67,31 @@
75
67
  {% endcase %}
76
68
  {% endfor %}
77
69
 
78
- {% assign recover = actions | where: "name", "recover" | first %}
79
- {% if recover %}
70
+ {% comment %}
71
+ Form-level errors close the field group rather than opening the card: the
72
+ design puts the alert directly above the actions, where it reads as a
73
+ reason the last attempt failed rather than a banner about the screen.
74
+ {% endcomment %}
75
+ {% if errors and errors.size > 0 %}
76
+ {% for err in errors %}
77
+ {% if err | formLevelError %}
78
+ {% assign alert_heading = err.text_key | alertHeading %}
79
+ {% assign alert_body = err.text_key | alertBody %}
80
+ <zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
81
+ {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
82
+ </zl-alert>
83
+ {% endif %}
84
+ {% endfor %}
85
+ {% endif %}
86
+ </div>
87
+
88
+ <div class="zl-card-actions">
89
+ {% comment %}
90
+ A step can offer recovery without asking for a password (an identifier-only
91
+ screen, say). With no label row to sit on, the affordance keeps its own row
92
+ rather than disappearing.
93
+ {% endcomment %}
94
+ {% if recover and password_field == nil %}
80
95
  <p class="zl-card-forgot">
81
96
  <a href="#" class="zl-card-forgot__link" data-action="recover">{{ recover.text_key | t }}</a>
82
97
  </p>
@@ -139,6 +154,7 @@
139
154
  {{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
140
155
  </p>
141
156
  {% endif %}
157
+ </div>
142
158
 
143
159
  {% if challenge %}
144
160
  {% if challenge.method == "passkey" or challenge.method == "passkey_register" %}
@@ -19,7 +19,8 @@
19
19
  <nav class="zl-hero__nav">
20
20
  <span class="zl-hero__brand">
21
21
  {% if branding.logo_url %}
22
- <img class="zl-hero__brand-logo" src="{{ branding.logo_url }}" alt="" />
22
+ <img class="zl-hero__brand-logo" src="{{ branding.logo_url }}" alt="" data-zl-asset-fallback />
23
+ <span class="zl-hero__brand-mark" aria-hidden="true" hidden data-zl-asset-fallback-content></span>
23
24
  {% else %}
24
25
  <span class="zl-hero__brand-mark" aria-hidden="true"></span>
25
26
  {% endif %}
@@ -54,7 +55,8 @@
54
55
  </aside>
55
56
  <section class="zl-split__form">
56
57
  {% if branding.logo_url %}
57
- <img class="zl-split__compact" src="{{ branding.logo_url }}" alt="" />
58
+ <img class="zl-split__compact" src="{{ branding.logo_url }}" alt="" data-zl-asset-fallback />
59
+ <p class="zl-split__compact zl-hero__compact-brand" hidden data-zl-asset-fallback-content>Your brand</p>
58
60
  {% else %}
59
61
  <p class="zl-split__compact zl-hero__compact-brand">Your brand</p>
60
62
  {% endif %}
@@ -67,18 +69,9 @@
67
69
  {% endif %}
68
70
  {% endif %}
69
71
 
70
- {% if errors and errors.size > 0 %}
71
- {% for err in errors %}
72
- {% if err | formLevelError %}
73
- {% assign alert_heading = err.text_key | alertHeading %}
74
- {% assign alert_body = err.text_key | alertBody %}
75
- <zl-alert severity="error" dismissible data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
76
- {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
77
- </zl-alert>
78
- {% endif %}
79
- {% endfor %}
80
- {% endif %}
81
-
72
+ {% assign recover = actions | where: "name", "recover" | first %}
73
+ {% assign password_field = fields | where: "type", "password" | first %}
74
+ <div class="zl-field-group">
82
75
  {% for f in fields %}
83
76
  {% assign field_placeholder = f.text_key | fieldPlaceholder %}
84
77
  {% assign field_help = f.text_key | fieldHelp %}
@@ -123,6 +116,7 @@
123
116
  {% if f.required %}required{% endif %}
124
117
  {% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
125
118
  {% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
119
+ {% if recover and f.type == 'password' %}forgot-password-href="#" forgot-password-action="{{ recover.name }}" forgot-password-label="{{ recover.text_key | t }}"{% endif %}
126
120
  >
127
121
  {% if field_help != "" %}
128
122
  <span slot="help">{{ field_help }}</span>
@@ -131,8 +125,26 @@
131
125
  {% endcase %}
132
126
  {% endfor %}
133
127
 
134
- {% assign recover = actions | where: "name", "recover" | first %}
135
- {% if recover %}
128
+ {% if errors and errors.size > 0 %}
129
+ {% for err in errors %}
130
+ {% if err | formLevelError %}
131
+ {% assign alert_heading = err.text_key | alertHeading %}
132
+ {% assign alert_body = err.text_key | alertBody %}
133
+ <zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
134
+ {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
135
+ </zl-alert>
136
+ {% endif %}
137
+ {% endfor %}
138
+ {% endif %}
139
+ </div>
140
+
141
+ <div class="zl-card-actions">
142
+ {% comment %}
143
+ A step can offer recovery without asking for a password (an identifier-only
144
+ screen, say). With no label row to sit on, the affordance keeps its own row
145
+ rather than disappearing.
146
+ {% endcomment %}
147
+ {% if recover and password_field == nil %}
136
148
  <p class="zl-card-forgot">
137
149
  <a href="#" class="zl-card-forgot__link" data-action="recover">{{ recover.text_key | t }}</a>
138
150
  </p>
@@ -181,7 +193,6 @@
181
193
  {% endunless %}
182
194
  {% endif %}
183
195
 
184
-
185
196
  {% assign register = actions | where: "name", "register" | first %}
186
197
  {% if register %}
187
198
  <p class="zl-card-nav">
@@ -195,6 +206,7 @@
195
206
  {{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
196
207
  </p>
197
208
  {% endif %}
209
+ </div>
198
210
 
199
211
  {% if challenge %}
200
212
  {% if challenge.method == "passkey" or challenge.method == "passkey_register" %}
@@ -217,6 +229,10 @@
217
229
 
218
230
  {% mandatory_gates %}
219
231
  </zl-card>
232
+ {% comment %} The orchestrator swaps this for the trustmark, so it sits with
233
+ the card rather than under the seam between the panes. Removing it falls
234
+ back to the page-shell footer. {% endcomment %}
235
+ <div data-zl-attribution-anchor></div>
220
236
  </section>
221
237
  </div>
222
238
  </zl-page-shell>
@@ -2,18 +2,9 @@
2
2
  <section class="zl-minimal">
3
3
  <h1 class="zl-card-title">{{ step.texts.title_key | t: identity.display_name }}</h1>
4
4
 
5
- {% if errors and errors.size > 0 %}
6
- {% for err in errors %}
7
- {% if err | formLevelError %}
8
- {% assign alert_heading = err.text_key | alertHeading %}
9
- {% assign alert_body = err.text_key | alertBody %}
10
- <zl-alert severity="error" dismissible data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
11
- {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
12
- </zl-alert>
13
- {% endif %}
14
- {% endfor %}
15
- {% endif %}
16
-
5
+ {% assign recover = actions | where: "name", "recover" | first %}
6
+ {% assign password_field = fields | where: "type", "password" | first %}
7
+ <div class="zl-field-group">
17
8
  {% for f in fields %}
18
9
  {% assign field_placeholder = f.text_key | fieldPlaceholder %}
19
10
  {% assign field_help = f.text_key | fieldHelp %}
@@ -58,6 +49,7 @@
58
49
  {% if f.required %}required{% endif %}
59
50
  {% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
60
51
  {% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
52
+ {% if recover and f.type == 'password' %}forgot-password-href="#" forgot-password-action="{{ recover.name }}" forgot-password-label="{{ recover.text_key | t }}"{% endif %}
61
53
  >
62
54
  {% if field_help != "" %}
63
55
  <span slot="help">{{ field_help }}</span>
@@ -66,8 +58,26 @@
66
58
  {% endcase %}
67
59
  {% endfor %}
68
60
 
69
- {% assign recover = actions | where: "name", "recover" | first %}
70
- {% if recover %}
61
+ {% if errors and errors.size > 0 %}
62
+ {% for err in errors %}
63
+ {% if err | formLevelError %}
64
+ {% assign alert_heading = err.text_key | alertHeading %}
65
+ {% assign alert_body = err.text_key | alertBody %}
66
+ <zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
67
+ {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
68
+ </zl-alert>
69
+ {% endif %}
70
+ {% endfor %}
71
+ {% endif %}
72
+ </div>
73
+
74
+ <div class="zl-card-actions">
75
+ {% comment %}
76
+ A step can offer recovery without asking for a password (an identifier-only
77
+ screen, say). With no label row to sit on, the affordance keeps its own row
78
+ rather than disappearing.
79
+ {% endcomment %}
80
+ {% if recover and password_field == nil %}
71
81
  <p class="zl-card-forgot">
72
82
  <a href="#" class="zl-card-forgot__link" data-action="recover">{{ recover.text_key | t }}</a>
73
83
  </p>
@@ -129,6 +139,7 @@
129
139
  {{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
130
140
  </p>
131
141
  {% endif %}
142
+ </div>
132
143
 
133
144
  {% if challenge %}
134
145
  {% if challenge.method == "passkey" or challenge.method == "passkey_register" %}
@@ -7,6 +7,11 @@
7
7
  {% if branding.hero_url %}
8
8
  <img class="zl-split__hero" src="{{ branding.hero_url }}" alt="" />
9
9
  {% endif %}
10
+ {% unless branding.logo_url or branding.hero_url %}
11
+ {% comment %} Until branding.json names an asset, a decorative panel
12
+ keeps the split composition readable. {% endcomment %}
13
+ <div class="zl-split__placeholder" aria-hidden="true"></div>
14
+ {% endunless %}
10
15
  </aside>
11
16
  <section class="zl-split__form">
12
17
  {% if branding.logo_url %}
@@ -23,18 +28,9 @@
23
28
  {% endif %}
24
29
  {% endif %}
25
30
 
26
- {% if errors and errors.size > 0 %}
27
- {% for err in errors %}
28
- {% if err | formLevelError %}
29
- {% assign alert_heading = err.text_key | alertHeading %}
30
- {% assign alert_body = err.text_key | alertBody %}
31
- <zl-alert severity="error" dismissible data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
32
- {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
33
- </zl-alert>
34
- {% endif %}
35
- {% endfor %}
36
- {% endif %}
37
-
31
+ {% assign recover = actions | where: "name", "recover" | first %}
32
+ {% assign password_field = fields | where: "type", "password" | first %}
33
+ <div class="zl-field-group">
38
34
  {% for f in fields %}
39
35
  {% assign field_placeholder = f.text_key | fieldPlaceholder %}
40
36
  {% assign field_help = f.text_key | fieldHelp %}
@@ -79,6 +75,7 @@
79
75
  {% if f.required %}required{% endif %}
80
76
  {% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
81
77
  {% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
78
+ {% if recover and f.type == 'password' %}forgot-password-href="#" forgot-password-action="{{ recover.name }}" forgot-password-label="{{ recover.text_key | t }}"{% endif %}
82
79
  >
83
80
  {% if field_help != "" %}
84
81
  <span slot="help">{{ field_help }}</span>
@@ -87,8 +84,26 @@
87
84
  {% endcase %}
88
85
  {% endfor %}
89
86
 
90
- {% assign recover = actions | where: "name", "recover" | first %}
91
- {% if recover %}
87
+ {% if errors and errors.size > 0 %}
88
+ {% for err in errors %}
89
+ {% if err | formLevelError %}
90
+ {% assign alert_heading = err.text_key | alertHeading %}
91
+ {% assign alert_body = err.text_key | alertBody %}
92
+ <zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
93
+ {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
94
+ </zl-alert>
95
+ {% endif %}
96
+ {% endfor %}
97
+ {% endif %}
98
+ </div>
99
+
100
+ <div class="zl-card-actions">
101
+ {% comment %}
102
+ A step can offer recovery without asking for a password (an identifier-only
103
+ screen, say). With no label row to sit on, the affordance keeps its own row
104
+ rather than disappearing.
105
+ {% endcomment %}
106
+ {% if recover and password_field == nil %}
92
107
  <p class="zl-card-forgot">
93
108
  <a href="#" class="zl-card-forgot__link" data-action="recover">{{ recover.text_key | t }}</a>
94
109
  </p>
@@ -137,7 +152,6 @@
137
152
  {% endunless %}
138
153
  {% endif %}
139
154
 
140
-
141
155
  {% assign register = actions | where: "name", "register" | first %}
142
156
  {% if register %}
143
157
  <p class="zl-card-nav">
@@ -151,6 +165,7 @@
151
165
  {{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
152
166
  </p>
153
167
  {% endif %}
168
+ </div>
154
169
 
155
170
  {% if challenge %}
156
171
  {% if challenge.method == "passkey" or challenge.method == "passkey_register" %}
@@ -173,6 +188,10 @@
173
188
 
174
189
  {% mandatory_gates %}
175
190
  </zl-card>
191
+ {% comment %} The orchestrator swaps this for the trustmark, so it sits with
192
+ the card rather than under the seam between the panes. Removing it falls
193
+ back to the page-shell footer. {% endcomment %}
194
+ <div data-zl-attribution-anchor></div>
176
195
  </section>
177
196
  </div>
178
197
  </zl-page-shell>
@@ -7,6 +7,11 @@
7
7
  {% if branding.hero_url %}
8
8
  <img class="zl-split__hero" src="{{ branding.hero_url }}" alt="" />
9
9
  {% endif %}
10
+ {% unless branding.logo_url or branding.hero_url %}
11
+ {% comment %} Until branding.json names an asset, a decorative panel
12
+ keeps the split composition readable. {% endcomment %}
13
+ <div class="zl-split__placeholder" aria-hidden="true"></div>
14
+ {% endunless %}
10
15
  </aside>
11
16
  <section class="zl-split__form">
12
17
  {% if branding.logo_url %}
@@ -23,18 +28,9 @@
23
28
  {% endif %}
24
29
  {% endif %}
25
30
 
26
- {% if errors and errors.size > 0 %}
27
- {% for err in errors %}
28
- {% if err | formLevelError %}
29
- {% assign alert_heading = err.text_key | alertHeading %}
30
- {% assign alert_body = err.text_key | alertBody %}
31
- <zl-alert severity="error" dismissible data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
32
- {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
33
- </zl-alert>
34
- {% endif %}
35
- {% endfor %}
36
- {% endif %}
37
-
31
+ {% assign recover = actions | where: "name", "recover" | first %}
32
+ {% assign password_field = fields | where: "type", "password" | first %}
33
+ <div class="zl-field-group">
38
34
  {% for f in fields %}
39
35
  {% assign field_placeholder = f.text_key | fieldPlaceholder %}
40
36
  {% assign field_help = f.text_key | fieldHelp %}
@@ -79,6 +75,7 @@
79
75
  {% if f.required %}required{% endif %}
80
76
  {% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
81
77
  {% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
78
+ {% if recover and f.type == 'password' %}forgot-password-href="#" forgot-password-action="{{ recover.name }}" forgot-password-label="{{ recover.text_key | t }}"{% endif %}
82
79
  >
83
80
  {% if field_help != "" %}
84
81
  <span slot="help">{{ field_help }}</span>
@@ -87,8 +84,26 @@
87
84
  {% endcase %}
88
85
  {% endfor %}
89
86
 
90
- {% assign recover = actions | where: "name", "recover" | first %}
91
- {% if recover %}
87
+ {% if errors and errors.size > 0 %}
88
+ {% for err in errors %}
89
+ {% if err | formLevelError %}
90
+ {% assign alert_heading = err.text_key | alertHeading %}
91
+ {% assign alert_body = err.text_key | alertBody %}
92
+ <zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
93
+ {% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
94
+ </zl-alert>
95
+ {% endif %}
96
+ {% endfor %}
97
+ {% endif %}
98
+ </div>
99
+
100
+ <div class="zl-card-actions">
101
+ {% comment %}
102
+ A step can offer recovery without asking for a password (an identifier-only
103
+ screen, say). With no label row to sit on, the affordance keeps its own row
104
+ rather than disappearing.
105
+ {% endcomment %}
106
+ {% if recover and password_field == nil %}
92
107
  <p class="zl-card-forgot">
93
108
  <a href="#" class="zl-card-forgot__link" data-action="recover">{{ recover.text_key | t }}</a>
94
109
  </p>
@@ -137,7 +152,6 @@
137
152
  {% endunless %}
138
153
  {% endif %}
139
154
 
140
-
141
155
  {% assign register = actions | where: "name", "register" | first %}
142
156
  {% if register %}
143
157
  <p class="zl-card-nav">
@@ -151,6 +165,7 @@
151
165
  {{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
152
166
  </p>
153
167
  {% endif %}
168
+ </div>
154
169
 
155
170
  {% if challenge %}
156
171
  {% if challenge.method == "passkey" or challenge.method == "passkey_register" %}
@@ -173,6 +188,10 @@
173
188
 
174
189
  {% mandatory_gates %}
175
190
  </zl-card>
191
+ {% comment %} The orchestrator swaps this for the trustmark, so it sits with
192
+ the card rather than under the seam between the panes. Removing it falls
193
+ back to the page-shell footer. {% endcomment %}
194
+ <div data-zl-attribution-anchor></div>
176
195
  </section>
177
196
  </div>
178
197
  </zl-page-shell>
@@ -6,15 +6,14 @@
6
6
  "objectType": "human-user",
7
7
  "kind": "user-schema",
8
8
  "type": "object",
9
+ "x-identifier": "email",
9
10
  "description": "The default editable schema for human users.",
10
11
  "x-auth-methods": {
11
12
  "password": {
12
- "enabled": true,
13
- "position": 1
13
+ "enabled": true
14
14
  },
15
15
  "passkey": {
16
- "enabled": true,
17
- "position": 2
16
+ "enabled": true
18
17
  }
19
18
  },
20
19
  "required": [
@@ -25,6 +25,12 @@
25
25
  "kind": "passkey",
26
26
  "primary": false,
27
27
  "text_key": "identifier.action.passkey"
28
+ },
29
+ {
30
+ "name": "register",
31
+ "kind": "navigate",
32
+ "primary": false,
33
+ "text_key": "identifier.action.register.link"
28
34
  }
29
35
  ],
30
36
  "transitions": {
@@ -36,6 +42,10 @@
36
42
  },
37
43
  "user_not_found": {
38
44
  "target": "register"
45
+ },
46
+ "register": {
47
+ "target": "register",
48
+ "purpose": "register"
39
49
  }
40
50
  }
41
51
  },
@@ -84,6 +94,12 @@
84
94
  "kind": "passkey_register",
85
95
  "primary": false,
86
96
  "text_key": "register.action.passkey"
97
+ },
98
+ {
99
+ "name": "sign_in",
100
+ "kind": "navigate",
101
+ "primary": false,
102
+ "text_key": "register.action.sign_in.link"
87
103
  }
88
104
  ],
89
105
  "transitions": {
@@ -95,6 +111,10 @@
95
111
  },
96
112
  "user_already_exists": {
97
113
  "target": "password"
114
+ },
115
+ "sign_in": {
116
+ "target": "identifier",
117
+ "purpose": "login"
98
118
  }
99
119
  }
100
120
  },