create-authenik8-app 2.4.11 → 2.4.13

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 (235) hide show
  1. package/README.md +160 -57
  2. package/assets/logo.svg +53 -0
  3. package/dist/src/bin/index.js +199 -125
  4. package/dist/src/bin/index.js.map +1 -1
  5. package/dist/src/lib/args.d.ts +18 -0
  6. package/dist/src/lib/args.d.ts.map +1 -0
  7. package/dist/src/lib/args.js +82 -0
  8. package/dist/src/lib/args.js.map +1 -0
  9. package/dist/src/lib/constants.d.ts +1 -0
  10. package/dist/src/lib/constants.d.ts.map +1 -1
  11. package/dist/src/lib/constants.js +10 -11
  12. package/dist/src/lib/constants.js.map +1 -1
  13. package/dist/src/lib/preflight.d.ts +3 -0
  14. package/dist/src/lib/preflight.d.ts.map +1 -0
  15. package/dist/src/lib/preflight.js +21 -0
  16. package/dist/src/lib/preflight.js.map +1 -0
  17. package/dist/src/lib/process.d.ts +6 -2
  18. package/dist/src/lib/process.d.ts.map +1 -1
  19. package/dist/src/lib/process.js +66 -7
  20. package/dist/src/lib/process.js.map +1 -1
  21. package/dist/src/lib/projectName.d.ts +2 -0
  22. package/dist/src/lib/projectName.d.ts.map +1 -0
  23. package/dist/src/lib/projectName.js +6 -0
  24. package/dist/src/lib/projectName.js.map +1 -0
  25. package/dist/src/lib/schemas.d.ts +192 -0
  26. package/dist/src/lib/schemas.d.ts.map +1 -0
  27. package/dist/src/lib/schemas.js +171 -0
  28. package/dist/src/lib/schemas.js.map +1 -0
  29. package/dist/src/lib/state.d.ts.map +1 -1
  30. package/dist/src/lib/state.js +20 -3
  31. package/dist/src/lib/state.js.map +1 -1
  32. package/dist/src/lib/types.d.ts +6 -13
  33. package/dist/src/lib/types.d.ts.map +1 -1
  34. package/dist/src/lib/ui.d.ts +8 -3
  35. package/dist/src/lib/ui.d.ts.map +1 -1
  36. package/dist/src/lib/ui.js +76 -112
  37. package/dist/src/lib/ui.js.map +1 -1
  38. package/dist/src/steps/configurePrisma.d.ts.map +1 -1
  39. package/dist/src/steps/configurePrisma.js +83 -9
  40. package/dist/src/steps/configurePrisma.js.map +1 -1
  41. package/dist/src/steps/createProject.d.ts +2 -1
  42. package/dist/src/steps/createProject.d.ts.map +1 -1
  43. package/dist/src/steps/createProject.js +149 -28
  44. package/dist/src/steps/createProject.js.map +1 -1
  45. package/dist/src/steps/finalSetup.d.ts +3 -3
  46. package/dist/src/steps/finalSetup.d.ts.map +1 -1
  47. package/dist/src/steps/finalSetup.js +24 -28
  48. package/dist/src/steps/finalSetup.js.map +1 -1
  49. package/dist/src/steps/installAuth.d.ts +1 -1
  50. package/dist/src/steps/installAuth.d.ts.map +1 -1
  51. package/dist/src/steps/installAuth.js +5 -4
  52. package/dist/src/steps/installAuth.js.map +1 -1
  53. package/dist/src/steps/installDeps.d.ts +11 -4
  54. package/dist/src/steps/installDeps.d.ts.map +1 -1
  55. package/dist/src/steps/installDeps.js +45 -77
  56. package/dist/src/steps/installDeps.js.map +1 -1
  57. package/dist/src/steps/prompts.d.ts +1 -1
  58. package/dist/src/steps/prompts.d.ts.map +1 -1
  59. package/dist/src/steps/prompts.js +42 -30
  60. package/dist/src/steps/prompts.js.map +1 -1
  61. package/dist/src/utils/jwk.d.ts +2 -0
  62. package/dist/src/utils/jwk.d.ts.map +1 -0
  63. package/dist/src/utils/jwk.js +44 -0
  64. package/dist/src/utils/jwk.js.map +1 -0
  65. package/dist/src/utils/output.d.ts +1 -1
  66. package/dist/src/utils/output.d.ts.map +1 -1
  67. package/dist/src/utils/output.js +75 -68
  68. package/dist/src/utils/output.js.map +1 -1
  69. package/package.json +17 -9
  70. package/templates/AGENT_IDENTITY.md +112 -0
  71. package/templates/THREAT_MODEL.md +27 -5
  72. package/templates/express-auth/README.md +13 -10
  73. package/templates/express-auth/docker-compose.yml +10 -0
  74. package/templates/express-auth/gitignore.template +6 -0
  75. package/templates/express-auth/package.json +13 -6
  76. package/templates/express-auth/src/app.ts +2 -0
  77. package/templates/express-auth/src/controllers/auth.controller.ts +2 -6
  78. package/templates/express-auth/src/controllers/protected.controller.ts +25 -10
  79. package/templates/express-auth/src/middleware/auth.middleware.ts +1 -0
  80. package/templates/express-auth/src/routes/protected.routes.ts +3 -1
  81. package/templates/express-auth/src/server.ts +6 -12
  82. package/templates/express-auth/src/utils/security.ts +119 -32
  83. package/templates/express-auth+/README.md +13 -10
  84. package/templates/express-auth+/docker-compose.yml +10 -0
  85. package/templates/express-auth+/gitignore.template +6 -0
  86. package/templates/express-auth+/package.json +13 -6
  87. package/templates/express-auth+/src/auth/auth.ts +3 -2
  88. package/templates/express-auth+/src/auth/controllers/password.controller.ts +3 -7
  89. package/templates/express-auth+/src/auth/controllers/protected.controller.ts +26 -11
  90. package/templates/express-auth+/src/auth/identity.adapter.ts +90 -0
  91. package/templates/express-auth+/src/auth/middleware/auth.middleware.ts +3 -4
  92. package/templates/express-auth+/src/auth/routes/protected.routes.ts +2 -2
  93. package/templates/express-auth+/src/oauth-providers/github/src/auth/auth.ts +3 -2
  94. package/templates/express-auth+/src/oauth-providers/google/src/auth/auth.ts +3 -2
  95. package/templates/express-auth+/src/oauth-providers/google-github/src/auth/auth.ts +3 -2
  96. package/templates/express-auth+/src/server.ts +5 -10
  97. package/templates/express-auth+/src/utils/security.ts +109 -28
  98. package/templates/express-base/README.md +13 -6
  99. package/templates/express-base/app.ts +2 -0
  100. package/templates/express-base/controllers/base.controller.ts +33 -19
  101. package/templates/express-base/docker-compose.yml +10 -0
  102. package/templates/express-base/gitignore.template +6 -0
  103. package/templates/express-base/package.json +11 -5
  104. package/templates/express-base/routes/base.routes.ts +1 -1
  105. package/templates/express-base/src/.env.example +7 -2
  106. package/templates/express-base/src/server.ts +6 -12
  107. package/templates/express-base/tsconfig.json +7 -40
  108. package/templates/express-base/utils/security.ts +111 -15
  109. package/templates/fullstack/.env.example +24 -0
  110. package/templates/fullstack/PRESET_CONTRACT.md +46 -0
  111. package/templates/fullstack/README.md +65 -0
  112. package/templates/fullstack/THREAT_MODEL.md +30 -0
  113. package/templates/fullstack/apps/api/package.json +48 -0
  114. package/templates/fullstack/apps/api/prisma/schema.prisma +124 -0
  115. package/templates/fullstack/apps/api/prisma/seed.ts +36 -0
  116. package/templates/fullstack/apps/api/prisma.config.ts +18 -0
  117. package/templates/fullstack/apps/api/src/app.ts +68 -0
  118. package/templates/fullstack/apps/api/src/auth/auth.controller.ts +125 -0
  119. package/templates/fullstack/apps/api/src/auth/auth.routes.ts +50 -0
  120. package/templates/fullstack/apps/api/src/auth/auth.service.ts +257 -0
  121. package/templates/fullstack/apps/api/src/auth/authenik8.ts +67 -0
  122. package/templates/fullstack/apps/api/src/auth/cookies.ts +36 -0
  123. package/templates/fullstack/apps/api/src/config/env.ts +72 -0
  124. package/templates/fullstack/apps/api/src/config/logger.ts +17 -0
  125. package/templates/fullstack/apps/api/src/config/mailer.ts +32 -0
  126. package/templates/fullstack/apps/api/src/config/origins.ts +27 -0
  127. package/templates/fullstack/apps/api/src/config/prisma.ts +9 -0
  128. package/templates/fullstack/apps/api/src/middleware/authenticate.ts +35 -0
  129. package/templates/fullstack/apps/api/src/middleware/csrf.ts +60 -0
  130. package/templates/fullstack/apps/api/src/middleware/origin.ts +12 -0
  131. package/templates/fullstack/apps/api/src/middleware/request-id.ts +9 -0
  132. package/templates/fullstack/apps/api/src/modules/admin/admin.controller.ts +17 -0
  133. package/templates/fullstack/apps/api/src/modules/admin/admin.routes.ts +14 -0
  134. package/templates/fullstack/apps/api/src/modules/admin/admin.service.ts +54 -0
  135. package/templates/fullstack/apps/api/src/modules/projects/project.controller.ts +22 -0
  136. package/templates/fullstack/apps/api/src/modules/projects/project.policy.ts +14 -0
  137. package/templates/fullstack/apps/api/src/modules/projects/project.repository.ts +20 -0
  138. package/templates/fullstack/apps/api/src/modules/projects/project.routes.ts +21 -0
  139. package/templates/fullstack/apps/api/src/modules/projects/project.service.ts +41 -0
  140. package/templates/fullstack/apps/api/src/modules/users/user.controller.ts +26 -0
  141. package/templates/fullstack/apps/api/src/modules/users/user.presenter.ts +13 -0
  142. package/templates/fullstack/apps/api/src/modules/users/user.routes.ts +21 -0
  143. package/templates/fullstack/apps/api/src/modules/users/user.service.ts +71 -0
  144. package/templates/fullstack/apps/api/src/openapi.ts +32 -0
  145. package/templates/fullstack/apps/api/src/server.ts +29 -0
  146. package/templates/fullstack/apps/api/src/types.ts +15 -0
  147. package/templates/fullstack/apps/api/src/utils/crypto.ts +4 -0
  148. package/templates/fullstack/apps/api/src/utils/http.ts +33 -0
  149. package/templates/fullstack/apps/api/src/utils/sealed-value.ts +37 -0
  150. package/templates/fullstack/apps/api/tests/project.policy.test.ts +26 -0
  151. package/templates/fullstack/apps/api/tests/registration.test.ts +69 -0
  152. package/templates/fullstack/apps/api/tests/security.test.ts +98 -0
  153. package/templates/fullstack/apps/api/tests/validation.test.ts +37 -0
  154. package/templates/fullstack/apps/api/tsconfig.json +16 -0
  155. package/templates/fullstack/apps/api/vitest.config.ts +9 -0
  156. package/templates/fullstack/apps/web/index.html +14 -0
  157. package/templates/fullstack/apps/web/package.json +31 -0
  158. package/templates/fullstack/apps/web/public/authenik8-logo.svg +53 -0
  159. package/templates/fullstack/apps/web/src/App.tsx +45 -0
  160. package/templates/fullstack/apps/web/src/auth/AuthProvider.tsx +53 -0
  161. package/templates/fullstack/apps/web/src/auth/RouteGuards.tsx +16 -0
  162. package/templates/fullstack/apps/web/src/auth/providers.ts +3 -0
  163. package/templates/fullstack/apps/web/src/components/AppShell.tsx +80 -0
  164. package/templates/fullstack/apps/web/src/components/AuthShell.tsx +26 -0
  165. package/templates/fullstack/apps/web/src/components/Page.tsx +30 -0
  166. package/templates/fullstack/apps/web/src/features/projects/ProjectPages.tsx +79 -0
  167. package/templates/fullstack/apps/web/src/main.tsx +24 -0
  168. package/templates/fullstack/apps/web/src/pages/DashboardPage.tsx +48 -0
  169. package/templates/fullstack/apps/web/src/pages/StatusPages.tsx +8 -0
  170. package/templates/fullstack/apps/web/src/pages/admin/AuditPage.tsx +19 -0
  171. package/templates/fullstack/apps/web/src/pages/admin/UsersPage.tsx +24 -0
  172. package/templates/fullstack/apps/web/src/pages/auth/LoginPage.tsx +51 -0
  173. package/templates/fullstack/apps/web/src/pages/auth/OAuthCallbackPage.tsx +26 -0
  174. package/templates/fullstack/apps/web/src/pages/auth/RecoveryPages.tsx +53 -0
  175. package/templates/fullstack/apps/web/src/pages/auth/RegisterPage.tsx +86 -0
  176. package/templates/fullstack/apps/web/src/pages/auth/VerifyEmailPage.tsx +24 -0
  177. package/templates/fullstack/apps/web/src/pages/settings/ProfilePage.tsx +34 -0
  178. package/templates/fullstack/apps/web/src/pages/settings/SecurityPage.tsx +57 -0
  179. package/templates/fullstack/apps/web/src/styles.css +289 -0
  180. package/templates/fullstack/apps/web/src/token-storage.test.ts +12 -0
  181. package/templates/fullstack/apps/web/tsconfig.app.json +19 -0
  182. package/templates/fullstack/apps/web/tsconfig.json +7 -0
  183. package/templates/fullstack/apps/web/tsconfig.node.json +10 -0
  184. package/templates/fullstack/apps/web/vite.config.ts +18 -0
  185. package/templates/fullstack/docker-compose.yml +35 -0
  186. package/templates/fullstack/docs/PRODUCTION.md +30 -0
  187. package/templates/fullstack/gitignore.template +8 -0
  188. package/templates/fullstack/package.json +39 -0
  189. package/templates/fullstack/packages/api-client/package.json +20 -0
  190. package/templates/fullstack/packages/api-client/src/index.ts +203 -0
  191. package/templates/fullstack/packages/api-client/tsconfig.json +14 -0
  192. package/templates/fullstack/packages/contracts/package.json +20 -0
  193. package/templates/fullstack/packages/contracts/src/index.ts +146 -0
  194. package/templates/fullstack/packages/contracts/tsconfig.json +13 -0
  195. package/templates/fullstack/packages/ui/package.json +22 -0
  196. package/templates/fullstack/packages/ui/src/index.tsx +33 -0
  197. package/templates/fullstack/packages/ui/tsconfig.json +14 -0
  198. package/templates/prisma/postgresql/.env.example +6 -2
  199. package/templates/prisma/postgresql/client.ts +17 -0
  200. package/templates/prisma/postgresql/prisma.config.ts +12 -0
  201. package/templates/prisma/postgresql/schema.prisma +0 -1
  202. package/templates/prisma/sqlite/.env.example +6 -2
  203. package/templates/prisma/sqlite/client.ts +16 -0
  204. package/templates/prisma/sqlite/prisma.config.ts +12 -0
  205. package/templates/prisma/sqlite/schema.prisma +0 -1
  206. package/dist/src/tests/templated-routes.test.d.ts +0 -2
  207. package/dist/src/tests/templated-routes.test.d.ts.map +0 -1
  208. package/dist/src/tests/templated-routes.test.js +0 -325
  209. package/dist/src/tests/templated-routes.test.js.map +0 -1
  210. package/dist/templates/THREAT_MODEL.md +0 -138
  211. package/dist/templates/express-auth/src/controllers/protected.controller.d.ts +0 -8
  212. package/dist/templates/express-auth/src/controllers/protected.controller.d.ts.map +0 -1
  213. package/dist/templates/express-auth/src/controllers/protected.controller.js +0 -50
  214. package/dist/templates/express-auth/src/controllers/protected.controller.js.map +0 -1
  215. package/dist/templates/express-auth/src/routes/protected.routes.d.ts +0 -2
  216. package/dist/templates/express-auth/src/routes/protected.routes.d.ts.map +0 -1
  217. package/dist/templates/express-auth/src/routes/protected.routes.js +0 -16
  218. package/dist/templates/express-auth/src/routes/protected.routes.js.map +0 -1
  219. package/dist/templates/express-auth/src/utils/security.d.ts +0 -8
  220. package/dist/templates/express-auth/src/utils/security.d.ts.map +0 -1
  221. package/dist/templates/express-auth/src/utils/security.js +0 -55
  222. package/dist/templates/express-auth/src/utils/security.js.map +0 -1
  223. package/dist/templates/express-base/controllers/base.controller.d.ts +0 -12
  224. package/dist/templates/express-base/controllers/base.controller.d.ts.map +0 -1
  225. package/dist/templates/express-base/controllers/base.controller.js +0 -77
  226. package/dist/templates/express-base/controllers/base.controller.js.map +0 -1
  227. package/dist/templates/express-base/routes/base.routes.d.ts +0 -2
  228. package/dist/templates/express-base/routes/base.routes.d.ts.map +0 -1
  229. package/dist/templates/express-base/routes/base.routes.js +0 -20
  230. package/dist/templates/express-base/routes/base.routes.js.map +0 -1
  231. package/dist/templates/express-base/utils/security.d.ts +0 -5
  232. package/dist/templates/express-base/utils/security.d.ts.map +0 -1
  233. package/dist/templates/express-base/utils/security.js +0 -40
  234. package/dist/templates/express-base/utils/security.js.map +0 -1
  235. package/dist/templates/package.json +0 -27
@@ -0,0 +1,289 @@
1
+ :root {
2
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3
+ color: #27302b;
4
+ background: #f3f5f2;
5
+ font-synthesis: none;
6
+ text-rendering: optimizeLegibility;
7
+ --ink: #27302b;
8
+ --muted: #69736c;
9
+ --line: #dce3dd;
10
+ --surface: #ffffff;
11
+ --navy: #123524;
12
+ --navy-soft: #1a4630;
13
+ --cyan: #218653;
14
+ --cyan-light: #e4f4e9;
15
+ --violet: #4d6880;
16
+ --violet-light: #e8eff4;
17
+ --green: #246a43;
18
+ --green-light: #e2f3e7;
19
+ --amber: #9a6512;
20
+ --amber-light: #fff1ce;
21
+ --red: #bd384c;
22
+ --red-light: #fde8ec;
23
+ }
24
+
25
+ * { box-sizing: border-box; letter-spacing: 0; }
26
+ html, body, #root { min-height: 100%; margin: 0; }
27
+ body { min-width: 320px; background: #f3f5f2; }
28
+ button, input, textarea, select { font: inherit; }
29
+ button, a { -webkit-tap-highlight-color: transparent; }
30
+ a { color: var(--cyan); text-decoration: none; }
31
+ a:hover { color: #185f3c; }
32
+ h1, h2, h3, p { margin-top: 0; }
33
+ h1 { margin-bottom: 7px; font-size: 28px; line-height: 1.2; color: #1c2721; }
34
+ h2 { margin-bottom: 5px; font-size: 17px; line-height: 1.35; color: #243029; }
35
+ p { color: var(--muted); line-height: 1.6; }
36
+ code { overflow-wrap: anywhere; color: #246a43; font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; }
37
+
38
+ .button {
39
+ min-height: 40px;
40
+ padding: 9px 15px;
41
+ border: 1px solid transparent;
42
+ border-radius: 6px;
43
+ display: inline-flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ gap: 8px;
47
+ color: #fff;
48
+ background: var(--cyan);
49
+ font-weight: 700;
50
+ font-size: 14px;
51
+ line-height: 1;
52
+ cursor: pointer;
53
+ transition: background-color 150ms, border-color 150ms, box-shadow 150ms;
54
+ }
55
+ .button:hover { color: #fff; background: #176b41; }
56
+ .button:focus-visible, .input:focus-visible, .table-select:focus-visible, a:focus-visible { outline: 3px solid rgba(57, 201, 120, .24); outline-offset: 2px; }
57
+ .button:disabled { cursor: not-allowed; opacity: .55; }
58
+ .button-secondary { color: #344039; background: #fff; border-color: #cbd5cd; }
59
+ .button-secondary:hover { color: #1c2721; background: #f1f5f1; border-color: #aab8ad; }
60
+ .button-danger { background: var(--red); }
61
+ .button-danger:hover { background: #9e2e40; }
62
+ .button-ghost { color: #626e66; background: transparent; }
63
+ .button-ghost:hover { color: #26332b; background: #e9eeea; }
64
+ .icon-button { width: 40px; min-width: 40px; padding: 0; }
65
+ .full-width { width: 100%; }
66
+
67
+ .input {
68
+ width: 100%;
69
+ min-height: 42px;
70
+ padding: 9px 11px;
71
+ border: 1px solid #cbd6cd;
72
+ border-radius: 5px;
73
+ color: var(--ink);
74
+ background: #fff;
75
+ outline: 0;
76
+ }
77
+ .input:hover { border-color: #aab9ad; }
78
+ .input:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(57, 201, 120, .14); }
79
+ .input:disabled { color: #858f88; background: #eef1ee; }
80
+ .textarea { min-height: 130px; resize: vertical; line-height: 1.5; }
81
+ .field { display: grid; gap: 7px; color: #364139; font-size: 13px; font-weight: 700; }
82
+ .field small { color: var(--red); font-weight: 600; }
83
+ .field-hint { margin: -6px 0 3px; font-size: 12px; }
84
+ .form-stack { display: grid; gap: 16px; }
85
+ .form-row { display: flex; gap: 12px; align-items: center; font-size: 13px; }
86
+ .form-row.end { justify-content: flex-end; margin-top: -7px; }
87
+ .form-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 8px; }
88
+ .two-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
89
+
90
+ .card, .panel, .form-panel, .detail-grid { border: 1px solid var(--line); border-radius: 7px; background: var(--surface); }
91
+ .badge { display: inline-flex; align-items: center; min-height: 24px; padding: 4px 8px; border-radius: 5px; color: #59635c; background: #ebefec; font-size: 11px; font-weight: 800; text-transform: capitalize; }
92
+ .badge-success { color: #176a46; background: var(--green-light); }
93
+ .badge-warning { color: #80500b; background: var(--amber-light); }
94
+ .badge-danger { color: #982b3d; background: var(--red-light); }
95
+ .badge-accent { color: #3f5f78; background: var(--violet-light); }
96
+ .spinner { width: 22px; height: 22px; display: inline-block; border: 3px solid #d6dbe5; border-top-color: var(--cyan); border-radius: 50%; animation: spin .7s linear infinite; }
97
+ @keyframes spin { to { transform: rotate(360deg); } }
98
+ .screen-loader { min-height: 50vh; display: grid; place-items: center; }
99
+ .panel-loading, .center-status { min-height: 180px; display: grid; place-items: center; }
100
+
101
+ .app-layout { min-height: 100vh; display: grid; grid-template-columns: 236px minmax(0, 1fr); }
102
+ .sidebar, .mobile-sidebar { background: var(--navy); color: #e2eee6; }
103
+ .sidebar { position: sticky; top: 0; height: 100vh; padding: 22px 14px 16px; display: flex; flex-direction: column; }
104
+ .brand { min-width: 0; display: flex; align-items: center; gap: 10px; color: #fff; font-weight: 800; }
105
+ .brand:hover { color: #fff; }
106
+ .brand img { width: 44px; height: 44px; margin: -5px; flex: 0 0 auto; }
107
+ .brand span { min-width: 0; display: grid; font-size: 15px; }
108
+ .brand small { color: #93b5a1; font-size: 10px; font-weight: 600; }
109
+ .side-nav { margin-top: 32px; display: grid; gap: 4px; }
110
+ .nav-label { margin: 18px 10px 5px; color: #769583; font-size: 10px; font-weight: 800; text-transform: uppercase; }
111
+ .side-nav a { min-height: 42px; padding: 10px 11px; border-radius: 5px; display: flex; align-items: center; gap: 11px; color: #b3c9bb; font-size: 13px; font-weight: 650; }
112
+ .side-nav a:hover { color: #fff; background: #19472f; }
113
+ .side-nav a.active { color: #fff; background: #20563a; box-shadow: inset 3px 0 0 #7ce8aa; }
114
+ .sidebar-account { min-width: 0; margin-top: auto; padding: 13px 7px 0; border-top: 1px solid #28533a; display: flex; align-items: center; gap: 10px; }
115
+ .avatar { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 6px; display: grid; place-items: center; color: #17492f; background: #a7dfbf; font-size: 11px; font-weight: 900; }
116
+ .sidebar-account > div:last-child { min-width: 0; display: grid; gap: 2px; }
117
+ .sidebar-account strong, .sidebar-account span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
118
+ .sidebar-account strong { color: #f2faf5; font-size: 12px; }
119
+ .sidebar-account span { color: #8fad9a; font-size: 10px; }
120
+ .mobile-sidebar, .mobile-scrim { display: none; }
121
+
122
+ .app-main { min-width: 0; }
123
+ .topbar { height: 64px; padding: 0 28px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 14px; background: rgba(255,255,255,.95); position: sticky; top: 0; z-index: 20; }
124
+ .breadcrumb { min-width: 0; display: flex; align-items: center; gap: 8px; color: #626d65; font-size: 13px; font-weight: 700; }
125
+ .breadcrumb svg { color: var(--green); }
126
+ .topbar > .icon-button:last-child { margin-left: auto; }
127
+ .menu-button { display: none; }
128
+ .page { width: 100%; max-width: 1480px; margin: 0 auto; padding: 32px; }
129
+ .page-header { min-height: 62px; margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
130
+ .page-header p { margin-bottom: 0; font-size: 14px; }
131
+ .page-actions, .action-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; }
132
+
133
+ .stat-grid { margin-bottom: 22px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
134
+ .stat-card { min-height: 92px; padding: 17px; display: flex; align-items: center; gap: 14px; }
135
+ .stat-card > div { min-width: 0; display: grid; gap: 5px; }
136
+ .stat-card span { color: #717c74; font-size: 12px; font-weight: 650; }
137
+ .stat-card strong { overflow-wrap: anywhere; color: #27302b; font-size: 21px; line-height: 1.1; }
138
+ .stat-icon { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 6px; display: grid; place-items: center; }
139
+ .stat-icon.cyan { color: #1d7549; background: var(--cyan-light); }
140
+ .stat-icon.violet { color: #46647b; background: var(--violet-light); }
141
+ .stat-icon.green { color: #246a43; background: var(--green-light); }
142
+ .stat-icon.amber { color: #8a5a0f; background: var(--amber-light); }
143
+ .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .7fr); gap: 18px; align-items: start; }
144
+ .panel { min-width: 0; overflow: hidden; }
145
+ .panel-heading { min-height: 72px; padding: 18px 20px; border-bottom: 1px solid #e8eaf0; display: flex; justify-content: space-between; align-items: center; gap: 16px; }
146
+ .panel-heading h2, .panel-heading p { margin-bottom: 0; }
147
+ .panel-heading p { margin-top: 2px; font-size: 12px; }
148
+ .text-link, .back-link { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 750; }
149
+ .back-link { margin-bottom: 13px; }
150
+ .security-panel { padding-bottom: 18px; }
151
+ .check-list { margin: 0; padding: 8px 20px 18px; list-style: none; }
152
+ .check-list li { padding: 14px 0; border-bottom: 1px solid #eceef3; display: flex; align-items: center; gap: 11px; }
153
+ .check-list svg { color: var(--green); }
154
+ .check-list li.pending svg { color: var(--amber); }
155
+ .check-list span { display: grid; gap: 3px; }
156
+ .check-list strong { font-size: 13px; }
157
+ .check-list small { color: var(--muted); font-size: 11px; }
158
+ .security-panel > .button { margin: 0 20px; width: calc(100% - 40px); }
159
+
160
+ .table-wrap { max-width: 100%; overflow-x: auto; }
161
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
162
+ th, td { padding: 13px 18px; border-bottom: 1px solid #eceef3; text-align: left; vertical-align: middle; }
163
+ th { color: #737b8d; background: #fafbfc; font-size: 10px; font-weight: 850; text-transform: uppercase; white-space: nowrap; }
164
+ tbody tr:last-child td { border-bottom: 0; }
165
+ tbody tr:hover { background: #fbfcfd; }
166
+ .table-link { display: block; color: #2a352e; font-weight: 750; }
167
+ .table-subtitle { max-width: 320px; margin-top: 3px; display: block; overflow: hidden; color: #858c9b; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
168
+ .icon-link { width: 34px; height: 34px; border-radius: 5px; display: grid; place-items: center; color: #667084; }
169
+ .icon-link:hover { color: var(--cyan); background: var(--cyan-light); }
170
+ .table-select { min-width: 104px; min-height: 34px; padding: 5px 8px; border: 1px solid #d6dae4; border-radius: 5px; color: #343a4d; background: #fff; }
171
+ .empty-state { min-height: 310px; padding: 42px 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
172
+ .empty-state h2 { margin-bottom: 4px; }
173
+ .empty-state p { max-width: 390px; margin-bottom: 18px; font-size: 13px; }
174
+ .empty-icon, .status-icon { width: 48px; height: 48px; margin-bottom: 14px; border-radius: 7px; display: grid; place-items: center; color: #246a43; background: var(--green-light); }
175
+
176
+ .form-panel { width: min(100%, 760px); padding: 24px; display: grid; gap: 19px; }
177
+ .form-section-heading { padding-bottom: 18px; border-bottom: 1px solid #e8eaf0; display: flex; align-items: center; gap: 13px; }
178
+ .form-section-heading h2, .form-section-heading p { margin: 0; }
179
+ .form-section-heading p { margin-top: 2px; font-size: 12px; }
180
+ .detail-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(270px, .6fr); overflow: hidden; }
181
+ .detail-main { min-height: 270px; padding: 28px; border-right: 1px solid var(--line); }
182
+ .detail-main > p:last-child { color: #373d50; white-space: pre-wrap; }
183
+ .detail-label { color: #82899a; font-size: 11px; font-weight: 800; text-transform: uppercase; }
184
+ .detail-meta { margin: 0; padding: 13px 24px; }
185
+ .detail-meta > div { padding: 16px 0; border-bottom: 1px solid #eceef3; }
186
+ .detail-meta > div:last-child { border-bottom: 0; }
187
+ .detail-meta dt { margin-bottom: 7px; color: #858c9c; font-size: 10px; font-weight: 800; text-transform: uppercase; }
188
+ .detail-meta dd { margin: 0; color: #353b4e; font-size: 13px; }
189
+
190
+ .settings-layout { display: grid; grid-template-columns: 180px minmax(0, 760px); gap: 24px; align-items: start; }
191
+ .settings-nav { display: grid; gap: 4px; }
192
+ .settings-nav a { min-height: 39px; padding: 9px 12px; border-left: 3px solid transparent; color: #68736b; font-size: 13px; font-weight: 700; }
193
+ .settings-nav a:hover { color: #26332b; background: #e9eeea; }
194
+ .settings-nav a.active { color: #1c6841; border-color: var(--cyan); background: var(--cyan-light); }
195
+ .settings-stack { min-width: 0; display: grid; gap: 18px; }
196
+ .settings-panel { padding: 24px; }
197
+ .provider-list, .session-list { margin-top: 3px; display: grid; }
198
+ .provider-row, .session-row { min-width: 0; padding: 15px 0; border-bottom: 1px solid #eceef3; display: flex; align-items: center; gap: 12px; }
199
+ .provider-row:last-child, .session-row:last-child { border-bottom: 0; }
200
+ .provider-icon, .device-icon { width: 36px; height: 36px; flex: 0 0 auto; border: 1px solid #dfe3eb; border-radius: 6px; display: grid; place-items: center; color: #4d5569; }
201
+ .provider-row > div, .session-row > div { min-width: 0; margin-right: auto; display: grid; gap: 3px; }
202
+ .provider-row strong, .session-row strong { overflow: hidden; color: #353b4e; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
203
+ .provider-row small, .session-row small { overflow: hidden; color: #82899a; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
204
+ .provider-g { color: #3775d6; font-weight: 900; }
205
+
206
+ .notice { min-height: 42px; padding: 10px 12px; border: 1px solid; border-radius: 5px; display: flex; align-items: flex-start; gap: 9px; font-size: 13px; line-height: 1.5; }
207
+ .notice svg { flex: 0 0 auto; margin-top: 1px; }
208
+ .notice-error { color: #912c3d; border-color: #f3bdc6; background: var(--red-light); }
209
+ .notice-success { color: #146b45; border-color: #b9e5ca; background: var(--green-light); }
210
+
211
+ .auth-layout { min-height: 100vh; display: grid; grid-template-columns: minmax(360px, .8fr) minmax(500px, 1.2fr); background: #fff; }
212
+ .auth-brand-panel { min-height: 100vh; padding: 34px 42px; position: relative; overflow: hidden; display: flex; flex-direction: column; color: #fff; background: var(--navy); border-right: 1px solid #286342; }
213
+ .auth-brand-copy { max-width: 470px; margin: auto 0; position: relative; z-index: 1; }
214
+ .auth-logo { width: 220px; height: 220px; margin: 0 0 2px -34px; display: block; }
215
+ .security-mark { margin-bottom: 18px; display: block; color: #7ce8aa; font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; font-weight: 800; }
216
+ .auth-brand-copy h1 { color: #fff; font-size: 38px; line-height: 1.15; }
217
+ .auth-brand-copy p { max-width: 390px; margin: 17px 0 0; color: #b4cbbb; font-size: 15px; }
218
+ .auth-signal { display: flex; align-items: center; gap: 8px; color: #93b29f; font-size: 11px; }
219
+ .signal-dot { width: 8px; height: 8px; border-radius: 50%; background: #7ce8aa; box-shadow: 0 0 0 4px rgba(124,232,170,.12); }
220
+ .auth-form-panel { min-width: 0; padding: 50px 7vw; display: grid; place-items: center; }
221
+ .auth-form-wrap { width: min(100%, 440px); }
222
+ .auth-form-wrap > header { margin-bottom: 27px; }
223
+ .auth-form-wrap > header h2 { margin-bottom: 6px; font-size: 26px; }
224
+ .auth-form-wrap > header p { margin: 0; font-size: 14px; }
225
+ .auth-form-wrap > footer { margin-top: 24px; color: #71788a; font-size: 13px; text-align: center; }
226
+ .auth-form-wrap > footer a { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; }
227
+ .auth-form-wrap .notice { margin-bottom: 17px; }
228
+ .divider { margin: 22px 0; display: flex; align-items: center; gap: 12px; color: #9197a5; font-size: 11px; }
229
+ .divider::before, .divider::after { content: ""; height: 1px; flex: 1; background: #e2e5eb; }
230
+ .oauth-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
231
+ .status-page { min-height: 100vh; padding: 32px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
232
+ .status-page p { max-width: 430px; margin-bottom: 20px; }
233
+ .sr-only { width: 1px; height: 1px; padding: 0; margin: -1px; position: absolute; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
234
+
235
+ @media (max-width: 1050px) {
236
+ .stat-grid { grid-template-columns: 1fr 1fr; }
237
+ .dashboard-grid { grid-template-columns: 1fr; }
238
+ .security-panel { max-width: none; }
239
+ .auth-layout { grid-template-columns: minmax(300px, .7fr) minmax(440px, 1fr); }
240
+ .auth-brand-copy h1 { font-size: 32px; }
241
+ }
242
+
243
+ @media (max-width: 760px) {
244
+ .app-layout { display: block; }
245
+ .sidebar { display: none; }
246
+ .mobile-scrim { display: block; position: fixed; inset: 0; z-index: 39; background: rgba(11,35,23,.58); }
247
+ .mobile-sidebar { width: min(84vw, 280px); padding: 20px 14px; position: fixed; inset: 0 auto 0 0; z-index: 40; display: flex; flex-direction: column; transform: translateX(-105%); transition: transform 180ms ease; }
248
+ .mobile-sidebar.is-open { transform: translateX(0); }
249
+ .topbar { height: 58px; padding: 0 16px; }
250
+ .menu-button { display: inline-flex; }
251
+ .page { padding: 22px 16px 36px; }
252
+ .page-header { min-height: 0; margin-bottom: 18px; display: grid; }
253
+ .page-header h1 { font-size: 24px; }
254
+ .page-actions { justify-content: flex-start; }
255
+ .stat-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
256
+ .stat-card { min-height: 86px; padding: 13px; }
257
+ .stat-card strong { font-size: 17px; }
258
+ .settings-layout { grid-template-columns: 1fr; gap: 16px; }
259
+ .settings-nav { grid-template-columns: 1fr 1fr; }
260
+ .settings-nav a { border-left: 0; border-bottom: 3px solid transparent; text-align: center; }
261
+ .detail-grid { grid-template-columns: 1fr; }
262
+ .detail-main { min-height: 190px; border-right: 0; border-bottom: 1px solid var(--line); }
263
+ .two-fields { grid-template-columns: 1fr; }
264
+ .auth-layout { display: block; background: #fff; }
265
+ .auth-brand-panel { min-height: 220px; padding: 22px; }
266
+ .auth-brand-copy { margin: auto 0 0; }
267
+ .auth-brand-copy h1 { max-width: 480px; margin-bottom: 7px; font-size: 26px; }
268
+ .auth-brand-copy p { display: none; }
269
+ .auth-logo { width: 104px; height: 104px; margin: -18px 0 -12px -18px; }
270
+ .auth-signal { display: none; }
271
+ .security-mark { margin-bottom: 10px; }
272
+ .auth-form-panel { min-height: calc(100vh - 220px); padding: 34px 20px; align-items: start; }
273
+ th, td { padding: 12px 14px; }
274
+ }
275
+
276
+ @media (max-width: 480px) {
277
+ .stat-grid { grid-template-columns: 1fr; }
278
+ .oauth-grid { grid-template-columns: 1fr; }
279
+ .form-panel, .settings-panel { padding: 18px; }
280
+ .form-actions, .action-row { justify-content: stretch; }
281
+ .form-actions .button, .action-row .button { flex: 1; }
282
+ .provider-row, .session-row { align-items: flex-start; flex-wrap: wrap; }
283
+ .provider-row > div, .session-row > div { width: calc(100% - 50px); }
284
+ .provider-row > .badge, .provider-row > .button, .session-row > .badge, .session-row > .button { margin-left: 48px; }
285
+ }
286
+
287
+ @media (prefers-reduced-motion: reduce) {
288
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
289
+ }
@@ -0,0 +1,12 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ describe("browser token storage rule", () => {
6
+ it("keeps the generated API client free of browser storage APIs", async () => {
7
+ const source = await readFile(path.resolve(process.cwd(), "../../packages/api-client/src/index.ts"), "utf8");
8
+ expect(source).not.toMatch(/localStorage|sessionStorage|indexedDB/);
9
+ expect(source).toContain("let accessToken: string | null = null");
10
+ expect(source).toContain('credentials: "include"');
11
+ });
12
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "allowImportingTsExtensions": false,
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "noEmit": true,
12
+ "jsx": "react-jsx",
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "types": ["vite/client"]
16
+ },
17
+ "include": ["src"],
18
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
19
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ const apiProxy = {
5
+ "/api": { target: "http://localhost:3000", changeOrigin: false },
6
+ };
7
+
8
+ export default defineConfig({
9
+ plugins: [react()],
10
+ server: {
11
+ port: 5173,
12
+ proxy: apiProxy,
13
+ },
14
+ preview: {
15
+ port: 4173,
16
+ proxy: apiProxy,
17
+ },
18
+ });
@@ -0,0 +1,35 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_DB: authenik8
6
+ POSTGRES_USER: postgres
7
+ POSTGRES_PASSWORD: postgres
8
+ ports:
9
+ - "5432:5432"
10
+ volumes:
11
+ - postgres-data:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U postgres -d authenik8"]
14
+ interval: 5s
15
+ timeout: 3s
16
+ retries: 10
17
+ restart: unless-stopped
18
+
19
+ redis:
20
+ image: redis:7-alpine
21
+ command: redis-server --appendonly yes
22
+ ports:
23
+ - "6379:6379"
24
+ volumes:
25
+ - redis-data:/data
26
+ healthcheck:
27
+ test: ["CMD", "redis-cli", "ping"]
28
+ interval: 5s
29
+ timeout: 3s
30
+ retries: 10
31
+ restart: unless-stopped
32
+
33
+ volumes:
34
+ postgres-data:
35
+ redis-data:
@@ -0,0 +1,30 @@
1
+ # Production checklist
2
+
3
+ ## Secrets and transport
4
+
5
+ - Store the generated ES256 signing key ring and high-entropy `REFRESH_SECRET` in a deployment secret manager. Never commit private JWK fields.
6
+ - Rotate access-token keys by appending a new private JWK, switching `AUTHENIK8_ACTIVE_KID`, and retaining old public keys until their tokens expire.
7
+ - Terminate TLS at a maintained reverse proxy or load balancer and redirect HTTP to HTTPS.
8
+ - Set `COOKIE_SECURE=true`, an exact HTTPS `WEB_ORIGIN`, and `TRUST_PROXY=true` only when requests pass through a trusted proxy that overwrites forwarding headers.
9
+
10
+ ## Data services
11
+
12
+ - Keep PostgreSQL and Redis on private networks with authentication and encryption where supported.
13
+ - Configure automated PostgreSQL backups and perform restore drills.
14
+ - Configure Redis persistence appropriate to the session availability requirement. Never expose port 6379 publicly.
15
+ - Keep `AUTHENIK8_AGENTS={}` unless machine identity is intentionally enabled. In production, prefer a database-backed registry and authenticate workloads with mTLS, cloud identity, or signed assertions before issuing tokens.
16
+ - Run Prisma migrations as a controlled release step, not concurrently from every application replica.
17
+
18
+ ## Identity operations
19
+
20
+ - Replace the development token output with a transactional email provider before accepting real users.
21
+ - Register exact OAuth callback URLs and rotate provider secrets through a secret manager.
22
+ - Change or remove the seed administrator password after the first deployment.
23
+
24
+ ## Runtime and observability
25
+
26
+ - Run `npm run build`, then start with `NODE_ENV=production npm start`.
27
+ - Send structured logs to centralized storage and preserve `x-request-id` across the proxy.
28
+ - Monitor `/api/health/live` and `/api/health/ready`, request latency, rate-limit responses, Redis availability, login failures, and refresh replay failures.
29
+ - Alert on administrator role changes and session revocation spikes.
30
+ - Review dependency advisories and apply security updates with the full test suite.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.log
5
+ coverage/
6
+ .DS_Store
7
+ apps/api/prisma/*.db
8
+ apps/api/prisma/*.db-journal
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "authenik8-fullstack-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "workspaces": [
7
+ "apps/*",
8
+ "packages/*"
9
+ ],
10
+ "engines": {
11
+ "node": "^20.19 || ^22.12 || >=24"
12
+ },
13
+ "scripts": {
14
+ "build:packages": "npm run build -w @authenik8/contracts && npm run build -w @authenik8/api-client && npm run build -w @authenik8/ui",
15
+ "dev": "npm run build:packages && concurrently -n contracts,client,ui,api,web -c blue,green,yellow,cyan,magenta \"npm run dev -w @authenik8/contracts\" \"npm run dev -w @authenik8/api-client\" \"npm run dev -w @authenik8/ui\" \"npm run dev -w @authenik8/api\" \"npm run dev -w @authenik8/web\"",
16
+ "build": "npm run build:packages && npm run build -w @authenik8/web && npm run build -w @authenik8/api",
17
+ "start": "npm run start -w @authenik8/api",
18
+ "test": "npm run build:packages && npm run test -w @authenik8/api && npm run test -w @authenik8/web",
19
+ "typecheck": "npm run typecheck --workspaces --if-present",
20
+ "db:generate": "npm run prisma:generate -w @authenik8/api",
21
+ "db:migrate": "npm run prisma:migrate -w @authenik8/api",
22
+ "db:seed": "npm run prisma:seed -w @authenik8/api",
23
+ "db:studio": "npm run prisma:studio -w @authenik8/api",
24
+ "docker:up": "docker compose up -d --wait",
25
+ "docker:down": "docker compose down"
26
+ },
27
+ "devDependencies": {
28
+ "concurrently": "^9.2.1",
29
+ "typescript": "^6.0.3"
30
+ },
31
+ "overrides": {
32
+ "@hono/node-server": "1.19.13"
33
+ },
34
+ "allowScripts": {
35
+ "prisma@7.8.0": true,
36
+ "@prisma/engines@7.8.0": true,
37
+ "esbuild": true
38
+ }
39
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@authenik8/api-client",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": ["dist"],
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
12
+ "typecheck": "tsc -p tsconfig.json --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@authenik8/contracts": "0.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^6.0.3"
19
+ }
20
+ }