@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3

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 (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. package/package.json +17 -15
@@ -0,0 +1,423 @@
1
+ <#import "field.ftl" as field>
2
+ <#import "footer.ftl" as loginFooter>
3
+ <#import "theme-resources.ftl" as themeResourceTags>
4
+ <#macro username>
5
+ <#assign label>
6
+ <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
7
+ </#assign>
8
+ <@field.group name="username" label=label>
9
+ <div class="${properties.kcInputGroup}">
10
+ <div class="${properties.kcInputGroupItemClass} ${properties.kcFill}">
11
+ <span class="${properties.kcInputClass} ${properties.kcFormReadOnlyClass}">
12
+ <input id="kc-attempted-username" value="${auth.attemptedUsername}" readonly>
13
+ </span>
14
+ </div>
15
+ <div class="${properties.kcInputGroupItemClass}">
16
+ <button id="reset-login" class="${properties.kcFormPasswordVisibilityButtonClass} kc-login-tooltip" type="button"
17
+ aria-label="${msg('restartLoginTooltip')}" onclick="location.href='${url.loginRestartFlowUrl}'">
18
+ <i class="fa-sync-alt fas" aria-hidden="true"></i>
19
+ <span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
20
+ </button>
21
+ </div>
22
+ </div>
23
+ </@field.group>
24
+ </#macro>
25
+
26
+ <#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
27
+ <!DOCTYPE html>
28
+ <#-- NODEFONY : `data-theme` dit à login.css le thème que KEYCLOAK a choisi — le
29
+ domaine peut refuser le sombre (`darkMode`), et la feuille partagée ne doit
30
+ jamais suivre le système contre lui. -->
31
+ <html class="${properties.kcHtmlClass!}"<#if !darkMode> data-theme="light"</#if> lang="${lang}"<#if realm.internationalizationEnabled> dir="${(locale.rtl)?then('rtl','ltr')}"</#if>>
32
+
33
+ <head>
34
+ <meta charset="utf-8">
35
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
36
+ <meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
37
+ <meta name="viewport" content="width=device-width, initial-scale=1">
38
+
39
+ <#if properties.meta?has_content>
40
+ <#list properties.meta?split(' ') as meta>
41
+ <meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
42
+ </#list>
43
+ </#if>
44
+ <title>${title!}</title>
45
+ <#-- NODEFONY : la page est `noindex` (Keycloak), mais un agent ou un lecteur
46
+ d'onglets lit encore sa description. -->
47
+ <meta name="description" content="${msg("nfMetaDescription", (realm.displayName!realm.name))}">
48
+ <#if themeResources?? && themeResources.favicons?has_content>
49
+ <@themeResourceTags.renderFavicons themeResources.favicons url.resourcesPath />
50
+ <#else>
51
+ <link rel="icon" href="${url.resourcesPath}/img/favicon.ico" />
52
+ </#if>
53
+ <#if themeResources?? && themeResources.stylesCommon?has_content>
54
+ <@themeResourceTags.renderStyles themeResources.stylesCommon url.resourcesCommonPath />
55
+ <#elseif properties.stylesCommon?has_content>
56
+ <#list properties.stylesCommon?split(' ') as style>
57
+ <link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" />
58
+ </#list>
59
+ </#if>
60
+ <#if themeResources?? && themeResources.styles?has_content>
61
+ <@themeResourceTags.renderStyles themeResources.styles url.resourcesPath />
62
+ <#elseif properties.styles?has_content>
63
+ <#list properties.styles?split(' ') as style>
64
+ <link href="${url.resourcesPath}/${style}" rel="stylesheet" />
65
+ </#list>
66
+ </#if>
67
+ <script type="importmap">
68
+ {
69
+ "imports": {
70
+ "rfc4648": "${url.resourcesCommonPath}/vendor/rfc4648/rfc4648.js"
71
+ }
72
+ }
73
+ </script>
74
+ <#if darkMode>
75
+ <script type="module" async blocking="render">
76
+ <#outputformat "JavaScript">
77
+ const DARK_MODE_CLASS = ${properties.kcDarkModeClass?c};
78
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
79
+
80
+ // NODEFONY : l'application transmet le thème qu'elle a RENDU (`nf_theme`,
81
+ // posé par @nodefony/security sur la demande d'autorisation). Il vaut pour
82
+ // tout le flux : retenu en session d'onglet, puisque les pages suivantes
83
+ // (mot de passe erroné, second facteur) n'ont plus ce paramètre. Liste
84
+ // fermée ; sans lui, le système décide, comme avant.
85
+ const NF_THEME_KEY = "nf.theme";
86
+ let nfForced = null;
87
+ try {
88
+ const asked = new URLSearchParams(window.location.search).get("nf_theme");
89
+ if (asked === "light" || asked === "dark") sessionStorage.setItem(NF_THEME_KEY, asked);
90
+ const kept = sessionStorage.getItem(NF_THEME_KEY);
91
+ if (kept === "light" || kept === "dark") nfForced = kept;
92
+ } catch {
93
+ // Stockage refusé (navigation privée stricte) : le système décide.
94
+ }
95
+
96
+ updateDarkMode(nfForced ? nfForced === "dark" : mediaQuery.matches);
97
+ mediaQuery.addEventListener("change", (event) => {
98
+ if (!nfForced) updateDarkMode(event.matches);
99
+ });
100
+
101
+ function updateDarkMode(isEnabled) {
102
+ const { classList, dataset } = document.documentElement;
103
+ // NODEFONY : login.css suit la même décision que PatternFly.
104
+ dataset.theme = isEnabled ? "dark" : "light";
105
+
106
+ if (isEnabled) {
107
+ classList.add(DARK_MODE_CLASS);
108
+ } else {
109
+ classList.remove(DARK_MODE_CLASS);
110
+ }
111
+ }
112
+ </#outputformat>
113
+ </script>
114
+ </#if>
115
+ <#if themeResources?? && themeResources.scripts?has_content>
116
+ <@themeResourceTags.renderScripts themeResources.scripts url.resourcesPath "text/javascript" />
117
+ <#elseif properties.scripts?has_content>
118
+ <#list properties.scripts?split(' ') as script>
119
+ <script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
120
+ </#list>
121
+ </#if>
122
+ <#if scripts??>
123
+ <#list scripts as script>
124
+ <script src="${script}" type="text/javascript"></script>
125
+ </#list>
126
+ </#if>
127
+ <script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
128
+ <script type="module">
129
+ <#outputformat "JavaScript">
130
+ import { startSessionPolling } from ${(url.resourcesPath + "/js/authChecker.js")?c};
131
+
132
+ startSessionPolling(
133
+ ${url.ssoLoginInOtherTabsUrl?c}
134
+ );
135
+ </#outputformat>
136
+ </script>
137
+ <script type="module">
138
+ document.addEventListener("click", (event) => {
139
+ const link = event.target.closest("a[data-once-link]");
140
+
141
+ if (!link) {
142
+ return;
143
+ }
144
+
145
+ if (link.getAttribute("aria-disabled") === "true") {
146
+ event.preventDefault();
147
+ return;
148
+ }
149
+
150
+ const { disabledClass } = link.dataset;
151
+
152
+ if (disabledClass) {
153
+ link.classList.add(...disabledClass.trim().split(/\s+/));
154
+ }
155
+
156
+ link.setAttribute("role", "link");
157
+ link.setAttribute("aria-disabled", "true");
158
+ });
159
+ </script>
160
+ <#if authenticationSession??>
161
+ <script type="module">
162
+ <#outputformat "JavaScript">
163
+ import { checkAuthSession } from ${(url.resourcesPath + "/js/authChecker.js")?c};
164
+
165
+ checkAuthSession(
166
+ ${authenticationSession.authSessionIdHash?c}
167
+ );
168
+ </#outputformat>
169
+ </script>
170
+ </#if>
171
+ <script>
172
+ // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
173
+ const isFirefox = true;
174
+ </script>
175
+ </head>
176
+
177
+ <#-- ── NODEFONY : le balisage de la page /login du framework ─────────────────
178
+ `nf-layout` · `nf-hero` · `nf-stage` · `nf-card` : les classes de
179
+ `nodefony/login.css`, dont `css/login.css` est la copie conforme. La mise en
180
+ page suit `nfLayout` (theme.properties : split | card | bare) et l'habillage
181
+ `nfSkin` : les thèmes enfants `nodefony-<habillage>` posent les deux, comme
182
+ `loginPage.skin` sur la page /login — un thème de l'application peut aussi
183
+ les surcharger.
184
+ Seuls ce bloc d'ouverture et sa fermeture diffèrent du cadre `keycloak.v2` :
185
+ messages, choix de langue, scripts de session et formulaires imbriqués
186
+ restent ceux de Keycloak. -->
187
+ <body id="keycloak-bg" class="${properties.kcBodyClass!} nf-kc" data-skin="${properties.nfSkin!'frontispiece'}" data-layout="${properties.nfLayout!'split'}" data-page-id="login-${pageId}">
188
+ <div class="nf-layout">
189
+ <#-- NODEFONY : le panneau de gauche montre le REALM et l'APPLICATION qui
190
+ demande la connexion — uniquement avec ce que Keycloak expose aux
191
+ gabarits, donc réglable depuis SA console, sans toucher au thème :
192
+ · realm.displayName / name (Realm settings › General) — jamais
193
+ `displayNameHtml` : il porte le logo d'un thème qui n'en a pas (le
194
+ realm `master` y met celui de Keycloak, qui chevauchait le nôtre)
195
+ · client.name / client.description (Clients › Settings)
196
+ · capacités du realm, lues et non décrites (Realm settings › Login)
197
+ · textes `nf*` surchargeables par realm et par langue
198
+ (Realm settings › Localization › Realm overrides). -->
199
+ <#assign nfRealmLabel = (realm.displayName?has_content)?then(realm.displayName, realm.name)>
200
+ <aside class="nf-hero">
201
+ <div class="nf-hero-brand">
202
+ <img src="${url.resourcesPath}/img/logo.png" alt="" class="nf-logo" width="26" height="42"/>
203
+ <span class="nf-hero-name">${nfRealmLabel}</span>
204
+ </div>
205
+ <div class="nf-hero-body">
206
+ <span class="nf-pill">${msg("nfRealmPill", realm.name)}</span>
207
+ <#if client?? && (client.name?has_content || client.clientId?has_content)>
208
+ <p class="nf-hero-title">${msg("nfLoginTo", (client.name?has_content)?then(advancedMsg(client.name), client.clientId))}</p>
209
+ <#if client.description?has_content>
210
+ <p class="nf-hero-lead">${advancedMsg(client.description)}</p>
211
+ <#elseif msg("nfRealmDescription")?has_content && msg("nfRealmDescription") != "nfRealmDescription">
212
+ <p class="nf-hero-lead">${msg("nfRealmDescription")}</p>
213
+ </#if>
214
+ <#else>
215
+ <p class="nf-hero-title">${nfRealmLabel}</p>
216
+ </#if>
217
+ <p class="nf-caps-title">${msg("nfCapsTitle", nfRealmLabel)}</p>
218
+ <ul class="nf-caps">
219
+ <li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapSso")}</span></li>
220
+ <#if realm.identityFederationEnabled><li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapFederation")}</span></li></#if>
221
+ <#if realm.resetPasswordAllowed><li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapReset")}</span></li></#if>
222
+ <#if realm.registrationAllowed><li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapRegister")}</span></li><#else><li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapManaged")}</span></li></#if>
223
+ <#if realm.rememberMe><li><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /> <path d="M9 12l2 2l4 -4" /> </svg><span>${msg("nfCapRemember")}</span></li></#if>
224
+ </ul>
225
+ </div>
226
+ <div class="nf-hero-footer">
227
+ <span>${msg("nfRealmFooter", realm.name)}</span>
228
+ <span>${msg("nfThemeCredit")}</span>
229
+ </div>
230
+ </aside>
231
+ <div class="nf-stage">
232
+ <#-- NODEFONY : revenir à l'application qui a demandé la connexion — pour
233
+ choisir une autre méthode (mot de passe local, passkey, autre
234
+ fournisseur). GÉNÉRIQUE : aucune adresse n'est écrite ni configurée.
235
+ Le bouton ANNULE la connexion (champ `cancel`, que traite le
236
+ formulaire identifiant/mot de passe de Keycloak) : Keycloak renvoie
237
+ alors vers le `redirect_uri` DE LA REQUÊTE EN COURS avec
238
+ `error=access_denied` + `state` (OAuth 2.0, RFC 6749 §4.1.2.1) — c'est
239
+ l'application appelante, quelle qu'elle soit, qui décide où atterrir.
240
+ Seule la page identifiant/mot de passe connaît `cancel` : ailleurs,
241
+ pas de bouton plutôt qu'un bouton sans effet.
242
+ Jamais sur les consoles de Keycloak lui-même (administration,
243
+ compte) : elles n'ont pas d'autre méthode vers laquelle revenir, et
244
+ reçoivent `access_denied` en affichant « Something went wrong ». -->
245
+ <#assign nfKeycloakConsole = client?? && ["security-admin-console", "account-console"]?seq_contains(client.clientId!"")>
246
+ <#if pageId == "login" && url.loginAction?has_content && !nfKeycloakConsole>
247
+ <form class="nf-back-form" action="${url.loginAction}" method="post" novalidate="novalidate">
248
+ <input type="hidden" name="cancel" value="on"/>
249
+ <button type="submit" class="nf-back">
250
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M5 12l14 0" /> <path d="M5 12l6 6" /> <path d="M5 12l6 -6" /> </svg>
251
+ <span>
252
+ <strong>${msg("nfBackTo", (client?? && client.name?has_content)?then(advancedMsg(client.name), (client.clientId)!""))}</strong>
253
+ <small>${msg("nfBackHint")}</small>
254
+ </span>
255
+ </button>
256
+ </form>
257
+ </#if>
258
+ <div class="${properties.kcLogin!}">
259
+ <div class="${properties.kcLoginContainer!} nf-card">
260
+ <header id="kc-header" class="pf-v5-c-login__header">
261
+ <div id="kc-header-wrapper" class="nf-compact-brand">
262
+ <img src="${url.resourcesPath}/img/logo.png" alt="" class="nf-logo" width="19" height="30"/>
263
+ <span>${msg("nfBrandName")}</span>
264
+ </div>
265
+ </header>
266
+ <main class="${properties.kcLoginMain!}">
267
+ <div class="${properties.kcLoginMainHeader!}">
268
+ <#-- NODEFONY : le badge dit d'emblée qu'on entre par le SSO, pas dans une seule application. -->
269
+ <div class="nf-heading">
270
+ <span class="nf-eyebrow"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06" /> <path d="M15 19l2 2l4 -4" /> </svg>${msg("nfSsoBadge")}</span>
271
+ <h1 class="${properties.kcLoginMainTitle!}" id="kc-page-title"><#nested "header"></h1>
272
+ <#if pageId == "login"><p class="nf-subtitle">${msg("nfSubtitle", (realm.displayName!''))}</p></#if>
273
+ </div>
274
+ <#if realm.internationalizationEnabled && locale.supported?size gt 1>
275
+ <div class="${properties.kcLoginMainHeaderUtilities!}">
276
+ <div class="${properties.kcInputClass!}">
277
+ <select
278
+ aria-label="${msg("languages")}"
279
+ id="login-select-toggle"
280
+ onchange="if (this.value) window.location.href=this.value"
281
+ >
282
+ <#list locale.supported?sort_by("label") as l>
283
+ <option
284
+ value="${l.url}"
285
+ ${(l.languageTag == locale.currentLanguageTag)?then('selected','')}
286
+ >
287
+ ${l.label}
288
+ </option>
289
+ </#list>
290
+ </select>
291
+ <span class="${properties.kcFormControlUtilClass}">
292
+ <span class="${properties.kcFormControlToggleIcon!}">
293
+ <svg
294
+ class="pf-v5-svg"
295
+ viewBox="0 0 320 512"
296
+ fill="currentColor"
297
+ aria-hidden="true"
298
+ role="img"
299
+ width="1em"
300
+ height="1em"
301
+ >
302
+ <path
303
+ d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
304
+ >
305
+ </path>
306
+ </svg>
307
+ </span>
308
+ </span>
309
+ </div>
310
+ </div>
311
+ </#if>
312
+ </div>
313
+ <div class="${properties.kcLoginMainBody!}">
314
+ <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
315
+ <#if displayRequiredFields>
316
+ <div class="${properties.kcContentWrapperClass!}">
317
+ <div class="${properties.kcLabelWrapperClass!} subtitle">
318
+ <span class="${properties.kcInputHelperTextItemTextClass!}">
319
+ <span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
320
+ </span>
321
+ </div>
322
+ </div>
323
+ </#if>
324
+ <#else>
325
+ <#if displayRequiredFields>
326
+ <div class="${properties.kcContentWrapperClass!}">
327
+ <div class="${properties.kcLabelWrapperClass!} subtitle">
328
+ <span class="${properties.kcInputHelperTextItemTextClass!}">
329
+ <span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
330
+ </span>
331
+ </div>
332
+ <div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
333
+ <#nested "show-username">
334
+ <@username />
335
+ </div>
336
+ </div>
337
+ <#else>
338
+ <div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
339
+ <#nested "show-username">
340
+ <@username />
341
+ </div>
342
+ </#if>
343
+ </#if>
344
+
345
+ <#-- App-initiated actions should not see warning messages about the need to complete the action -->
346
+ <#-- during login. -->
347
+ <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
348
+ <div class="${properties.kcAlertClass!} pf-m-${(message.type = 'error')?then('danger', message.type)}">
349
+ <div class="${properties.kcAlertIconClass!}">
350
+ <#if message.type = 'success'><span class="${properties.kcFeedbackSuccessIcon!}"></span></#if>
351
+ <#if message.type = 'warning'><span class="${properties.kcFeedbackWarningIcon!}"></span></#if>
352
+ <#if message.type = 'error'><span class="${properties.kcFeedbackErrorIcon!}"></span></#if>
353
+ <#if message.type = 'info'><span class="${properties.kcFeedbackInfoIcon!}"></span></#if>
354
+ </div>
355
+ <span class="${properties.kcAlertTitleClass!} kc-feedback-text">${message.summary}</span>
356
+ </div>
357
+ </#if>
358
+
359
+ <#nested "form">
360
+
361
+ <#if auth?has_content && auth.showTryAnotherWayLink()>
362
+ <form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post" novalidate="novalidate">
363
+ <input type="hidden" name="tryAnotherWay" value="on"/>
364
+ <a id="try-another-way" href="javascript:document.forms['kc-select-try-another-way-form'].requestSubmit()"
365
+ class="${properties.kcButtonSecondaryClass} ${properties.kcButtonBlockClass} ${properties.kcMarginTopClass}">
366
+ ${msg("doTryAnotherWay")}
367
+ </a>
368
+ </form>
369
+ </#if>
370
+
371
+ <#if switchOrganizationEnabled?? && switchOrganizationEnabled>
372
+ <form id="kc-switch-organization-form" action="${url.loginAction}" method="post" novalidate="novalidate">
373
+ <input type="hidden" name="switchOrganization" value="true"/>
374
+ <a id="switch-organization" href="javascript:document.forms['kc-switch-organization-form'].requestSubmit()"
375
+ class="${properties.kcButtonSecondaryClass} ${properties.kcButtonBlockClass} ${properties.kcMarginTopClass}">
376
+ ${msg("doSwitchOrganization")}
377
+ </a>
378
+ </form>
379
+ </#if>
380
+
381
+ <div class="${properties.kcLoginMainFooter!}">
382
+ <#nested "socialProviders">
383
+
384
+ <#if displayInfo>
385
+ <div id="kc-info" class="${properties.kcLoginMainFooterBand!} ${properties.kcFormClass}">
386
+ <div id="kc-info-wrapper" class="${properties.kcLoginMainFooterBandItem!}">
387
+ <#nested "info">
388
+ </div>
389
+ </div>
390
+ </#if>
391
+ </div>
392
+ </div>
393
+
394
+ <div class="${properties.kcLoginMainFooter!}">
395
+ <@loginFooter.content/>
396
+ </div>
397
+ </main>
398
+ </div>
399
+ </div>
400
+ <#-- NODEFONY : rappel du périmètre SSO, comme la ligne d'état de Studio. -->
401
+ <footer class="nf-panel-footer">
402
+ <span class="nf-sso-scope"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="nf-icon" aria-hidden="true" > <path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /> <path d="M3.6 9h16.8" /> <path d="M3.6 15h16.8" /> <path d="M11.5 3a17 17 0 0 0 0 18" /> <path d="M12.5 3a17 17 0 0 1 0 18" /> </svg>${msg("nfSsoFooter", (realm.displayName!realm.name))}</span>
403
+ <span class="nf-provider"><span class="nf-dot" aria-hidden="true"></span>${msg("nfProvidedBy")}</span>
404
+ </footer>
405
+ </div>
406
+ </div>
407
+ <#-- NODEFONY : un libellé dont la cible `for` n'existe pas est rattaché au champ
408
+ de son groupe. Keycloak 26.8 écrit `for="form-vertical-name"` sur les deux
409
+ libellés de `login-config-totp.ftl` : axe relève deux champs sans nom
410
+ accessible (critique). Réparer ici plutôt que recopier ce gabarit : la copie
411
+ masquerait ses évolutions, et ce geste est inerte quand la cible existe. -->
412
+ <script>
413
+ for (const label of document.querySelectorAll("label[for]")) {
414
+ if (document.getElementById(label.htmlFor)) continue;
415
+ const field = label
416
+ .closest(".pf-v5-c-form__group")
417
+ ?.querySelector("input:not([type=hidden]), select, textarea");
418
+ if (field?.id) label.htmlFor = field.id;
419
+ }
420
+ </script>
421
+ </body>
422
+ </html>
423
+ </#macro>
@@ -0,0 +1,17 @@
1
+ # Thème de connexion Nodefony — habille le parcours Keycloak aux couleurs de
2
+ # la console d'administration (Nodefony Studio).
3
+ #
4
+ # HÉRITE de `keycloak.v2` : seules l'enveloppe de la page (template.ftl), une
5
+ # feuille de style et les textes de marque sont posés ici. Les ~40 formulaires
6
+ # du parcours (mot de passe, mot de passe oublié, double facteur, profil,
7
+ # erreurs…) restent ceux de Keycloak, donc justes à chaque mise à jour.
8
+ parent=keycloak.v2
9
+ import=common/keycloak
10
+ styles=css/styles.css css/login.css css/nodefony.css
11
+ # Langues dont le thème porte les textes de marque (messages/messages_<l>).
12
+ locales=fr,en
13
+
14
+ # Mise en page de la page de connexion — la même que la page /login du
15
+ # framework (`loginPage.layout`) : split | card | bare. Un thème enfant de
16
+ # l'application (`parent=nodefony`) la surcharge, avec ses couleurs.
17
+ nfLayout=split
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Page d'accueil du serveur Keycloak aux couleurs de Nodefony Studio.
3
+ *
4
+ * Le gabarit du parent pose `logo.svg` et `background.svg` en dur : l'image
5
+ * est remplacée par `content` (pas de copie du gabarit à resynchroniser), le
6
+ * fond par le dégradé du panneau de marque de Studio (`AuthLayout.tsx`).
7
+ */
8
+
9
+ :root {
10
+ --pf-v5-global--primary-color--100: #0067ba;
11
+ --pf-v5-global--primary-color--200: #00579c;
12
+ --pf-v5-global--link--Color: #0067ba;
13
+ --pf-v5-global--link--Color--hover: #00579c;
14
+ }
15
+
16
+ .pf-v5-c-background-image {
17
+ --pf-v5-c-background-image--BackgroundImage: none !important;
18
+ background: linear-gradient(135deg, #00467e 0%, #0067ba 55%, #4792cd 100%);
19
+ }
20
+
21
+ /* Pastille claire : l'arc extérieur du logo (#0067ba) se fondait dans le
22
+ dégradé, qui en part. */
23
+ .kc-brand {
24
+ content: url("../img/logo.png");
25
+ box-sizing: content-box;
26
+ width: auto;
27
+ height: 72px;
28
+ padding: 16px 22px;
29
+ background: #ffffff;
30
+ border-radius: 16px;
31
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
32
+ }
@@ -0,0 +1,11 @@
1
+ # Page d'accueil du serveur Keycloak aux couleurs de Nodefony.
2
+ #
3
+ # Le seul type qui ne se choisit PAS par realm : le serveur le désigne par
4
+ # l'option `spi-theme--welcome-theme` (variable `KC_SPI_THEME__WELCOME_THEME`
5
+ # dans le compose). Elle ne s'affiche qu'avant le premier administrateur —
6
+ # ensuite, le parent redirige vers la console d'administration.
7
+ # HÉRITE de `keycloak` : logo et fond remplacés par la feuille, rien d'autre.
8
+ parent=keycloak
9
+ import=common/keycloak
10
+ stylesCommon=vendor/patternfly-v5/patternfly.min.css vendor/patternfly-v5/patternfly-addons.css
11
+ styles=css/welcome.css css/nodefony.css
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « blueprint » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "blueprint"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/blueprint.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/blueprint.css
11
+ nfSkin=blueprint
12
+ nfLayout=card
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « console » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "console"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/console.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/console.css
11
+ nfSkin=console
12
+ nfLayout=card
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « dots » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "dots"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/dots.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/dots.css
11
+ nfSkin=dots
12
+ nfLayout=card
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « enterprise » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "enterprise"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/enterprise.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/enterprise.css
11
+ nfSkin=enterprise
12
+ nfLayout=card
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « horizon » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "horizon"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/horizon.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/horizon.css
11
+ nfSkin=horizon
12
+ nfLayout=card
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « ledger » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "ledger"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/ledger.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/ledger.css
11
+ nfSkin=ledger
12
+ nfLayout=bare
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « minimal » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "minimal"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/minimal.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/minimal.css
11
+ nfSkin=minimal
12
+ nfLayout=bare
@@ -0,0 +1,12 @@
1
+ # Thème de connexion Nodefony, habillage « photo-card » — le même que la page
2
+ # /login du framework avec `loginPage.skin: "photo-card"`.
3
+ #
4
+ # HÉRITE du thème `nodefony` : gabarit, textes, images et propriétés viennent
5
+ # de lui ; seuls la feuille d'habillage (copie conforme de
6
+ # `nodefony/login/skins/photo-card.css`, éprouvée par le test des copies) et la
7
+ # mise en page de l'habillage sont posées ici. Le realm le choisit par
8
+ # `loginTheme` (`security:keycloak:realm --write` le dérive de la config).
9
+ parent=nodefony
10
+ styles=css/styles.css css/login.css css/nodefony.css css/skins/photo-card.css
11
+ nfSkin=photo-card
12
+ nfLayout=card