@gntik-ai/templates 0.0.0-stage → 0.5.0

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 (300) hide show
  1. package/dist/index.d.ts +2420 -0
  2. package/dist/index.js +14520 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +64 -3
  5. package/src/accept-invitation/Page.test.tsx +40 -0
  6. package/src/accept-invitation/Page.tsx +153 -0
  7. package/src/accept-invitation/data.ts +22 -0
  8. package/src/accept-invitation/template.meta.ts +12 -0
  9. package/src/assistant-chat/Page.test.tsx +51 -0
  10. package/src/assistant-chat/Page.tsx +273 -0
  11. package/src/assistant-chat/data.ts +71 -0
  12. package/src/assistant-chat/template.meta.ts +12 -0
  13. package/src/audit-log/Page.test.tsx +58 -0
  14. package/src/audit-log/Page.tsx +284 -0
  15. package/src/audit-log/data.ts +133 -0
  16. package/src/audit-log/template.meta.ts +12 -0
  17. package/src/board/Lane.tsx +117 -0
  18. package/src/board/Page.test.tsx +68 -0
  19. package/src/board/Page.tsx +178 -0
  20. package/src/board/data.ts +127 -0
  21. package/src/board/template.meta.ts +12 -0
  22. package/src/board/useBoard.ts +146 -0
  23. package/src/changelog/Page.test.tsx +31 -0
  24. package/src/changelog/Page.tsx +174 -0
  25. package/src/changelog/data.ts +105 -0
  26. package/src/changelog/template.meta.ts +12 -0
  27. package/src/code-editor/Page.test.tsx +83 -0
  28. package/src/code-editor/Page.tsx +207 -0
  29. package/src/code-editor/data.ts +130 -0
  30. package/src/code-editor/template.meta.ts +12 -0
  31. package/src/contact/Page.test.tsx +33 -0
  32. package/src/contact/Page.tsx +272 -0
  33. package/src/contact/PublicFrame.tsx +84 -0
  34. package/src/contact/data.ts +129 -0
  35. package/src/contact/template.meta.ts +12 -0
  36. package/src/create-wizard/Overlay.test.tsx +68 -0
  37. package/src/create-wizard/Page.test.tsx +57 -0
  38. package/src/create-wizard/Page.tsx +246 -0
  39. package/src/create-wizard/data.ts +139 -0
  40. package/src/create-wizard/examples/Overlay.tsx +60 -0
  41. package/src/create-wizard/template.meta.ts +12 -0
  42. package/src/create-workspace/Page.test.tsx +41 -0
  43. package/src/create-workspace/Page.tsx +227 -0
  44. package/src/create-workspace/data.ts +61 -0
  45. package/src/create-workspace/template.meta.ts +12 -0
  46. package/src/data-explorer/Page.test.tsx +45 -0
  47. package/src/data-explorer/Page.tsx +247 -0
  48. package/src/data-explorer/data.ts +94 -0
  49. package/src/data-explorer/template.meta.ts +12 -0
  50. package/src/docs-article/ArticleFeedback.tsx +75 -0
  51. package/src/docs-article/Page.test.tsx +34 -0
  52. package/src/docs-article/Page.tsx +200 -0
  53. package/src/docs-article/data.ts +65 -0
  54. package/src/docs-article/template.meta.ts +12 -0
  55. package/src/evaluations/Page.test.tsx +40 -0
  56. package/src/evaluations/Page.tsx +238 -0
  57. package/src/evaluations/data.ts +194 -0
  58. package/src/evaluations/template.meta.ts +12 -0
  59. package/src/forgot-password/Page.test.tsx +199 -0
  60. package/src/forgot-password/Page.tsx +66 -0
  61. package/src/forgot-password/data.ts +5 -0
  62. package/src/forgot-password/examples/UsernameOrEmail.tsx +25 -0
  63. package/src/forgot-password/template.meta.ts +12 -0
  64. package/src/getting-started/Page.test.tsx +27 -0
  65. package/src/getting-started/Page.tsx +125 -0
  66. package/src/getting-started/data.ts +67 -0
  67. package/src/getting-started/template.meta.ts +12 -0
  68. package/src/help-home/Page.test.tsx +33 -0
  69. package/src/help-home/Page.tsx +159 -0
  70. package/src/help-home/data.ts +151 -0
  71. package/src/help-home/template.meta.ts +12 -0
  72. package/src/home-dashboard/Page.test.tsx +57 -0
  73. package/src/home-dashboard/Page.tsx +152 -0
  74. package/src/home-dashboard/data.ts +66 -0
  75. package/src/home-dashboard/template.meta.ts +12 -0
  76. package/src/incident-board/Page.test.tsx +77 -0
  77. package/src/incident-board/Page.tsx +263 -0
  78. package/src/incident-board/data.ts +220 -0
  79. package/src/incident-board/template.meta.ts +12 -0
  80. package/src/incident-console/Page.test.tsx +49 -0
  81. package/src/incident-console/Page.tsx +274 -0
  82. package/src/incident-console/data.ts +176 -0
  83. package/src/incident-console/template.meta.ts +12 -0
  84. package/src/index.ts +250 -0
  85. package/src/keyboard-shortcuts/Page.test.tsx +45 -0
  86. package/src/keyboard-shortcuts/Page.tsx +94 -0
  87. package/src/keyboard-shortcuts/ShortcutSheet.tsx +80 -0
  88. package/src/keyboard-shortcuts/data.ts +84 -0
  89. package/src/keyboard-shortcuts/template.meta.ts +12 -0
  90. package/src/landing/Page.test.tsx +27 -0
  91. package/src/landing/Page.tsx +110 -0
  92. package/src/landing/data.ts +17 -0
  93. package/src/landing/template.meta.ts +11 -0
  94. package/src/live-event-stream/Page.test.tsx +251 -0
  95. package/src/live-event-stream/Page.tsx +307 -0
  96. package/src/live-event-stream/data.ts +44 -0
  97. package/src/live-event-stream/examples/Connecting.tsx +5 -0
  98. package/src/live-event-stream/examples/Error.tsx +5 -0
  99. package/src/live-event-stream/examples/LiveEmpty.tsx +5 -0
  100. package/src/live-event-stream/examples/LiveWithEvents.tsx +6 -0
  101. package/src/live-event-stream/examples/Stopped.tsx +6 -0
  102. package/src/live-event-stream/template.meta.ts +74 -0
  103. package/src/messaging/Page.test.tsx +34 -0
  104. package/src/messaging/Page.tsx +236 -0
  105. package/src/messaging/data.ts +118 -0
  106. package/src/messaging/template.meta.ts +12 -0
  107. package/src/meta.ts +24 -0
  108. package/src/mfa/Page.test.tsx +22 -0
  109. package/src/mfa/Page.tsx +45 -0
  110. package/src/mfa/data.ts +6 -0
  111. package/src/mfa/template.meta.ts +12 -0
  112. package/src/model-providers/Page.test.tsx +58 -0
  113. package/src/model-providers/Page.tsx +198 -0
  114. package/src/model-providers/data.ts +101 -0
  115. package/src/model-providers/template.meta.ts +12 -0
  116. package/src/notification-inbox/Page.test.tsx +49 -0
  117. package/src/notification-inbox/Page.tsx +237 -0
  118. package/src/notification-inbox/data.ts +129 -0
  119. package/src/notification-inbox/template.meta.ts +12 -0
  120. package/src/pending-activation/Page.test.tsx +390 -0
  121. package/src/pending-activation/Page.tsx +145 -0
  122. package/src/pending-activation/data.ts +25 -0
  123. package/src/pending-activation/template.meta.ts +12 -0
  124. package/src/pricing/ComparisonTable.tsx +75 -0
  125. package/src/pricing/Page.test.tsx +29 -0
  126. package/src/pricing/Page.tsx +83 -0
  127. package/src/pricing/PublicFrame.tsx +84 -0
  128. package/src/pricing/data.ts +104 -0
  129. package/src/pricing/template.meta.ts +12 -0
  130. package/src/prompt-playground/Page.test.tsx +72 -0
  131. package/src/prompt-playground/Page.tsx +254 -0
  132. package/src/prompt-playground/data.ts +68 -0
  133. package/src/prompt-playground/template.meta.ts +12 -0
  134. package/src/public-hub/Page.test.tsx +176 -0
  135. package/src/public-hub/Page.tsx +125 -0
  136. package/src/public-hub/Source.test.ts +54 -0
  137. package/src/public-hub/data.ts +24 -0
  138. package/src/public-hub/examples/Dark.tsx +11 -0
  139. package/src/public-hub/examples/Full.tsx +6 -0
  140. package/src/public-hub/examples/HighContrast.tsx +11 -0
  141. package/src/public-hub/examples/Light.tsx +11 -0
  142. package/src/public-hub/examples/Loading.tsx +6 -0
  143. package/src/public-hub/examples/Minimal.tsx +5 -0
  144. package/src/public-hub/examples/MinimalLoading.tsx +5 -0
  145. package/src/public-hub/template.meta.ts +60 -0
  146. package/src/reset-password/Page.test.tsx +27 -0
  147. package/src/reset-password/Page.tsx +46 -0
  148. package/src/reset-password/data.ts +5 -0
  149. package/src/reset-password/template.meta.ts +12 -0
  150. package/src/resource-detail/Page.test.tsx +260 -0
  151. package/src/resource-detail/Page.tsx +262 -0
  152. package/src/resource-detail/data.ts +55 -0
  153. package/src/resource-detail/examples/postgres-table-detail.tsx +28 -0
  154. package/src/resource-detail/examples/realtime-configuration.tsx +26 -0
  155. package/src/resource-detail/examples/runtime-with-transition.tsx +30 -0
  156. package/src/resource-detail/examples/runtime-without-transition.tsx +33 -0
  157. package/src/resource-detail/examples/shell.ts +12 -0
  158. package/src/resource-detail/template.meta.ts +21 -0
  159. package/src/resource-form/Page.test.tsx +40 -0
  160. package/src/resource-form/Page.tsx +186 -0
  161. package/src/resource-form/data.ts +63 -0
  162. package/src/resource-form/template.meta.ts +12 -0
  163. package/src/resource-gallery/Page.test.tsx +42 -0
  164. package/src/resource-gallery/Page.tsx +125 -0
  165. package/src/resource-gallery/data.ts +44 -0
  166. package/src/resource-gallery/template.meta.ts +20 -0
  167. package/src/resource-index/Page.test.tsx +120 -0
  168. package/src/resource-index/Page.tsx +216 -0
  169. package/src/resource-index/data.ts +84 -0
  170. package/src/resource-index/template.meta.ts +21 -0
  171. package/src/resource-tree/Page.test.tsx +31 -0
  172. package/src/resource-tree/Page.tsx +232 -0
  173. package/src/resource-tree/data.ts +180 -0
  174. package/src/resource-tree/template.meta.ts +12 -0
  175. package/src/run-trace/Page.test.tsx +56 -0
  176. package/src/run-trace/Page.tsx +242 -0
  177. package/src/run-trace/data.ts +171 -0
  178. package/src/run-trace/template.meta.ts +12 -0
  179. package/src/scorecard/Page.test.tsx +38 -0
  180. package/src/scorecard/Page.tsx +160 -0
  181. package/src/scorecard/TargetsTable.tsx +92 -0
  182. package/src/scorecard/data.ts +118 -0
  183. package/src/scorecard/template.meta.ts +12 -0
  184. package/src/search-results/Page.test.tsx +43 -0
  185. package/src/search-results/Page.tsx +243 -0
  186. package/src/search-results/data.ts +130 -0
  187. package/src/search-results/template.meta.ts +12 -0
  188. package/src/settings-api-keys/Page.test.tsx +39 -0
  189. package/src/settings-api-keys/Page.tsx +171 -0
  190. package/src/settings-api-keys/data.ts +58 -0
  191. package/src/settings-api-keys/template.meta.ts +12 -0
  192. package/src/settings-appearance/Page.test.tsx +44 -0
  193. package/src/settings-appearance/Page.tsx +92 -0
  194. package/src/settings-appearance/data.ts +21 -0
  195. package/src/settings-appearance/template.meta.ts +11 -0
  196. package/src/settings-billing/Page.test.tsx +31 -0
  197. package/src/settings-billing/Page.tsx +71 -0
  198. package/src/settings-billing/data.ts +15 -0
  199. package/src/settings-billing/template.meta.ts +19 -0
  200. package/src/settings-integrations/Page.test.tsx +31 -0
  201. package/src/settings-integrations/Page.tsx +185 -0
  202. package/src/settings-integrations/data.ts +116 -0
  203. package/src/settings-integrations/template.meta.ts +11 -0
  204. package/src/settings-members/Page.test.tsx +32 -0
  205. package/src/settings-members/Page.tsx +140 -0
  206. package/src/settings-members/data.ts +28 -0
  207. package/src/settings-members/template.meta.ts +12 -0
  208. package/src/settings-notifications/Page.test.tsx +31 -0
  209. package/src/settings-notifications/Page.tsx +71 -0
  210. package/src/settings-notifications/data.ts +12 -0
  211. package/src/settings-notifications/template.meta.ts +11 -0
  212. package/src/settings-profile/Baseline.test.tsx +9 -0
  213. package/src/settings-profile/Editable.test.tsx +21 -0
  214. package/src/settings-profile/Page.test.tsx +41 -0
  215. package/src/settings-profile/Page.tsx +133 -0
  216. package/src/settings-profile/ReadOnly.test.tsx +143 -0
  217. package/src/settings-profile/ReadOnlyProfile.tsx +85 -0
  218. package/src/settings-profile/SettingsFrame.test.tsx +55 -0
  219. package/src/settings-profile/SettingsFrame.tsx +88 -0
  220. package/src/settings-profile/__tests__/dom.ts +8 -0
  221. package/src/settings-profile/data.ts +41 -0
  222. package/src/settings-profile/examples/ReadOnly.tsx +31 -0
  223. package/src/settings-profile/fixtures/editable.snap +1 -0
  224. package/src/settings-profile/settings-nav.ts +98 -0
  225. package/src/settings-profile/template.meta.ts +21 -0
  226. package/src/settings-security/MfaSetupDialog.tsx +96 -0
  227. package/src/settings-security/Page.test.tsx +44 -0
  228. package/src/settings-security/Page.tsx +158 -0
  229. package/src/settings-security/data.ts +11 -0
  230. package/src/settings-security/template.meta.ts +11 -0
  231. package/src/settings-workspace/Page.test.tsx +44 -0
  232. package/src/settings-workspace/Page.tsx +120 -0
  233. package/src/settings-workspace/data.ts +44 -0
  234. package/src/settings-workspace/template.meta.ts +19 -0
  235. package/src/shared/ConsoleShell.test.tsx +81 -0
  236. package/src/shared/ConsoleShell.tsx +98 -0
  237. package/src/shell-sidebar/Page.test.tsx +21 -0
  238. package/src/shell-sidebar/Page.tsx +85 -0
  239. package/src/shell-sidebar/data.ts +24 -0
  240. package/src/shell-sidebar/template.meta.ts +12 -0
  241. package/src/shell-topnav/Page.test.tsx +25 -0
  242. package/src/shell-topnav/Page.tsx +94 -0
  243. package/src/shell-topnav/data.ts +25 -0
  244. package/src/shell-topnav/template.meta.ts +12 -0
  245. package/src/sign-in/BrandPanel.tsx +34 -0
  246. package/src/sign-in/Page.test.tsx +41 -0
  247. package/src/sign-in/Page.tsx +73 -0
  248. package/src/sign-in/data.ts +11 -0
  249. package/src/sign-in/template.meta.ts +12 -0
  250. package/src/sign-in-card/Page.test.tsx +182 -0
  251. package/src/sign-in-card/Page.tsx +82 -0
  252. package/src/sign-in-card/data.ts +8 -0
  253. package/src/sign-in-card/examples/Username.tsx +28 -0
  254. package/src/sign-in-card/template.meta.ts +12 -0
  255. package/src/sign-up/Page.test.tsx +33 -0
  256. package/src/sign-up/Page.tsx +70 -0
  257. package/src/sign-up/data.ts +15 -0
  258. package/src/sign-up/template.meta.ts +12 -0
  259. package/src/sso-login/Page.test.tsx +41 -0
  260. package/src/sso-login/Page.tsx +151 -0
  261. package/src/sso-login/data.ts +10 -0
  262. package/src/sso-login/template.meta.ts +12 -0
  263. package/src/status-403/Page.test.tsx +23 -0
  264. package/src/status-403/Page.tsx +92 -0
  265. package/src/status-403/data.ts +13 -0
  266. package/src/status-403/template.meta.ts +12 -0
  267. package/src/status-404/Page.test.tsx +23 -0
  268. package/src/status-404/Page.tsx +60 -0
  269. package/src/status-404/PageHeading.test.tsx +52 -0
  270. package/src/status-404/data.ts +12 -0
  271. package/src/status-404/examples/PageHeader.tsx +28 -0
  272. package/src/status-404/template.meta.ts +12 -0
  273. package/src/status-500/Page.test.tsx +24 -0
  274. package/src/status-500/Page.tsx +70 -0
  275. package/src/status-500/data.ts +20 -0
  276. package/src/status-500/template.meta.ts +12 -0
  277. package/src/status-maintenance/Page.test.tsx +24 -0
  278. package/src/status-maintenance/Page.tsx +84 -0
  279. package/src/status-maintenance/data.ts +14 -0
  280. package/src/status-maintenance/template.meta.ts +12 -0
  281. package/src/styles.css +11 -0
  282. package/src/test/a11y.ts +18 -0
  283. package/src/test/setup.ts +36 -0
  284. package/src/upgrade-required/Page.test.tsx +28 -0
  285. package/src/upgrade-required/Page.tsx +79 -0
  286. package/src/upgrade-required/data.ts +19 -0
  287. package/src/upgrade-required/template.meta.ts +12 -0
  288. package/src/usage-analytics/Page.test.tsx +83 -0
  289. package/src/usage-analytics/Page.tsx +184 -0
  290. package/src/usage-analytics/data.ts +161 -0
  291. package/src/usage-analytics/template.meta.ts +21 -0
  292. package/src/verify-email/Page.test.tsx +32 -0
  293. package/src/verify-email/Page.tsx +118 -0
  294. package/src/verify-email/data.ts +7 -0
  295. package/src/verify-email/template.meta.ts +12 -0
  296. package/src/workflow-builder/Page.test.tsx +97 -0
  297. package/src/workflow-builder/Page.tsx +163 -0
  298. package/src/workflow-builder/data.ts +13 -0
  299. package/src/workflow-builder/template.meta.ts +12 -0
  300. package/README.md +0 -3
@@ -0,0 +1,98 @@
1
+ import { Bell, Building2, CreditCard, KeyRound, Palette, Plug, ShieldCheck, User, Users } from '@gntik-ai/icons';
2
+ import type { BreadcrumbItem, NavGroup } from '@gntik-ai/ui';
3
+
4
+ /** The nine settings pages, in nav order. */
5
+ export type SettingsPageId =
6
+ | 'profile'
7
+ | 'security'
8
+ | 'notifications'
9
+ | 'appearance'
10
+ | 'workspace'
11
+ | 'members'
12
+ | 'billing'
13
+ | 'api-keys'
14
+ | 'integrations';
15
+
16
+ export const SETTINGS_TITLES: Record<SettingsPageId, string> = {
17
+ profile: 'Profile',
18
+ security: 'Security',
19
+ notifications: 'Notifications',
20
+ appearance: 'Appearance',
21
+ workspace: 'Workspace',
22
+ members: 'Members',
23
+ billing: 'Billing',
24
+ 'api-keys': 'API keys',
25
+ integrations: 'Integrations',
26
+ };
27
+
28
+ /** A product's own settings page, added to the shared section nav. */
29
+ export interface SettingsNavExtraItem {
30
+ /** Page id (also the URL segment under `basePath`). */
31
+ id: string;
32
+ label: string;
33
+ icon?: NavGroup['items'][number]['icon'];
34
+ /** Explicit href; defaults to `${basePath}/${id}` when a basePath is set. */
35
+ href?: string;
36
+ /** Group label: "Account", "Workspace" or a new group (appended after them). Default "Workspace". */
37
+ group?: string;
38
+ }
39
+
40
+ /** Title of a built-in settings page; other ids fall back to the id itself. */
41
+ export function settingsTitle(page: string): string {
42
+ return (SETTINGS_TITLES as Record<string, string | undefined>)[page] ?? page;
43
+ }
44
+
45
+ /**
46
+ * Section nav shared by every settings template. Items are keyed by page id; pass a `basePath`
47
+ * (e.g. "/settings") to render them as links (`/settings/profile`), otherwise they are buttons
48
+ * and the template's `onNavigate` does the routing. `extraItems` adds a product's own pages,
49
+ * at the end of their group.
50
+ */
51
+ export function settingsNav(basePath?: string, extraItems: readonly SettingsNavExtraItem[] = []): NavGroup[] {
52
+ const href = (id: string) => (basePath === undefined ? undefined : `${basePath}/${id}`);
53
+ const item = (id: SettingsPageId, icon: NavGroup['items'][number]['icon']) => ({
54
+ id,
55
+ label: SETTINGS_TITLES[id],
56
+ icon,
57
+ href: href(id),
58
+ });
59
+ const groups: NavGroup[] = [
60
+ {
61
+ label: 'Account',
62
+ items: [
63
+ item('profile', User),
64
+ item('security', ShieldCheck),
65
+ item('notifications', Bell),
66
+ item('appearance', Palette),
67
+ ],
68
+ },
69
+ {
70
+ label: 'Workspace',
71
+ items: [
72
+ item('workspace', Building2),
73
+ item('members', Users),
74
+ item('billing', CreditCard),
75
+ item('api-keys', KeyRound),
76
+ item('integrations', Plug),
77
+ ],
78
+ },
79
+ ];
80
+ for (const extra of extraItems) {
81
+ const label = extra.group ?? 'Workspace';
82
+ let group = groups.find((g) => g.label === label);
83
+ if (!group) {
84
+ group = { label, items: [] };
85
+ groups.push(group);
86
+ }
87
+ group.items = [
88
+ ...group.items,
89
+ { id: extra.id, label: extra.label, icon: extra.icon, href: extra.href ?? href(extra.id) },
90
+ ];
91
+ }
92
+ return groups;
93
+ }
94
+
95
+ /** Topbar breadcrumb for a settings page. */
96
+ export function settingsBreadcrumbs(page: SettingsPageId | (string & {}), title?: string): BreadcrumbItem[] {
97
+ return [{ label: 'Settings', href: '/settings' }, { label: title ?? settingsTitle(page) }];
98
+ }
@@ -0,0 +1,21 @@
1
+ import type { TemplateMeta } from '../meta';
2
+
3
+ export const meta: TemplateMeta = {
4
+ name: 'Profile settings',
5
+ family: 'Settings',
6
+ priority: 'P1',
7
+ status: 'beta',
8
+ description:
9
+ "Personal profile defaults to mode='editable': avatar header, name and email, locale and time zone, with a sticky save bar. Opt into mode='read-only' for an initials avatar without upload or Edit actions, identity and optional context description lists, and optional ordered roles badges. identity/context accept { id, label, value, mono } rows; value may be a link. contextFallback supplies missing-value text; emptyRolesMessage supplies the empty roles state. Omitted roles/context hide their sections. Read-only mode passes saveBar='none' through SettingsFrame and has no profile form controls, copy buttons or save actions; the responsive section navigation remains available.",
10
+ layout: 'SettingsLayout',
11
+ blocks: [
12
+ 'app-sidebar',
13
+ 'app-topbar',
14
+ 'page-header',
15
+ 'profile-header',
16
+ 'form-section',
17
+ 'sticky-action-bar',
18
+ 'description-list-card',
19
+ 'section-header',
20
+ ],
21
+ };
@@ -0,0 +1,96 @@
1
+ import {
2
+ Button,
3
+ Dialog,
4
+ DialogBody,
5
+ DialogClose,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ Field,
12
+ FieldDescription,
13
+ FieldError,
14
+ FieldLabel,
15
+ OtpInput,
16
+ useI18n,
17
+ } from '@gntik-ai/ui';
18
+ import { useState } from 'react';
19
+
20
+ export interface MfaSetupDialogProps {
21
+ open: boolean;
22
+ onOpenChange: (open: boolean) => void;
23
+ setupKey: string;
24
+ /** Checks the 6-digit code; resolve `true` to finish enrolment. */
25
+ onVerify: (code: string) => boolean | Promise<boolean>;
26
+ }
27
+
28
+ /** Authenticator enrolment: manual setup key and a 6-digit OtpInput, verified on completion. */
29
+ export function MfaSetupDialog({ open, onOpenChange, setupKey, onVerify }: MfaSetupDialogProps) {
30
+ const { t } = useI18n();
31
+ const [code, setCode] = useState('');
32
+ const [invalid, setInvalid] = useState(false);
33
+ const [checking, setChecking] = useState(false);
34
+
35
+ const change = (next: boolean) => {
36
+ if (!next) {
37
+ setCode('');
38
+ setInvalid(false);
39
+ }
40
+ onOpenChange(next);
41
+ };
42
+
43
+ const verify = async (value: string) => {
44
+ if (value.length < 6 || checking) return;
45
+ setChecking(true);
46
+ const ok = await onVerify(value);
47
+ setChecking(false);
48
+ if (ok) change(false);
49
+ else setInvalid(true);
50
+ };
51
+
52
+ return (
53
+ <Dialog open={open} onOpenChange={change}>
54
+ <DialogContent>
55
+ <form
56
+ onSubmit={(e) => {
57
+ e.preventDefault();
58
+ void verify(code);
59
+ }}
60
+ >
61
+ <DialogHeader>
62
+ <DialogTitle>Set up two-factor authentication</DialogTitle>
63
+ <DialogDescription>
64
+ Add this account to your authenticator app, then enter the code it shows.
65
+ </DialogDescription>
66
+ </DialogHeader>
67
+ <DialogBody className="flex flex-col gap-5">
68
+ <p className="text-[12.5px] text-muted-foreground">
69
+ Setup key: <span className="font-mono text-foreground">{setupKey}</span>
70
+ </p>
71
+ <Field invalid={invalid}>
72
+ <FieldLabel>Verification code</FieldLabel>
73
+ <OtpInput
74
+ groupSize={3}
75
+ value={code}
76
+ onValueChange={(v) => {
77
+ setCode(v);
78
+ setInvalid(false);
79
+ }}
80
+ onComplete={(v) => void verify(v)}
81
+ />
82
+ <FieldDescription>Enter the 6-digit code from your authenticator app.</FieldDescription>
83
+ <FieldError match={invalid}>That code is not valid. Try again.</FieldError>
84
+ </Field>
85
+ </DialogBody>
86
+ <DialogFooter>
87
+ <DialogClose render={<Button variant="ghost" />}>{t('common.cancel')}</DialogClose>
88
+ <Button type="submit" loading={checking} disabled={code.length < 6}>
89
+ Verify and enable
90
+ </Button>
91
+ </DialogFooter>
92
+ </form>
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }
@@ -0,0 +1,44 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { expectNoAxeViolations } from '../test/a11y';
5
+ import SettingsSecurityPage from './Page';
6
+
7
+ describe('SettingsSecurityPage', () => {
8
+ it('renders the password, MFA and sessions sections', () => {
9
+ render(<SettingsSecurityPage />);
10
+ expect(screen.getByRole('heading', { level: 1, name: 'Security' })).toBeInTheDocument();
11
+ expect(screen.getByRole('button', { name: /^Security/ })).toHaveAttribute('aria-current', 'page');
12
+ expect(screen.getByRole('heading', { name: 'Password' })).toBeInTheDocument();
13
+ expect(screen.getByRole('heading', { name: 'Sessions and devices' })).toBeInTheDocument();
14
+ }, 15000);
15
+
16
+ it('validates the password change', async () => {
17
+ const onPasswordChange = vi.fn();
18
+ render(<SettingsSecurityPage onPasswordChange={onPasswordChange} />);
19
+ await userEvent.click(screen.getByRole('button', { name: 'Update password' }));
20
+ expect(onPasswordChange).not.toHaveBeenCalled();
21
+ expect(screen.getByText('Enter your current password.')).toBeInTheDocument();
22
+ await userEvent.type(screen.getByLabelText('Current password'), 'old-password');
23
+ await userEvent.type(screen.getByLabelText('New password'), 'a-much-longer-secret');
24
+ await userEvent.type(screen.getByLabelText('Confirm new password'), 'a-much-longer-secret');
25
+ await userEvent.click(screen.getByRole('button', { name: 'Update password' }));
26
+ expect(onPasswordChange).toHaveBeenCalledWith({ current: 'old-password', next: 'a-much-longer-secret' });
27
+ }, 15000);
28
+
29
+ it('enables MFA through the setup dialog', async () => {
30
+ const onMfaChange = vi.fn();
31
+ render(<SettingsSecurityPage onMfaChange={onMfaChange} />);
32
+ await userEvent.click(screen.getByRole('switch', { name: 'Authenticator app' }));
33
+ const dialog = await screen.findByRole('dialog', { name: 'Set up two-factor authentication' });
34
+ expect(dialog).toBeInTheDocument();
35
+ await userEvent.keyboard('123456');
36
+ expect(onMfaChange).toHaveBeenCalledWith(true);
37
+ expect(await screen.findByText('Enabled')).toBeInTheDocument();
38
+ }, 15000);
39
+
40
+ it('has no axe violations', async () => {
41
+ const { container } = render(<SettingsSecurityPage />);
42
+ await expectNoAxeViolations(container);
43
+ }, 15000);
44
+ });
@@ -0,0 +1,158 @@
1
+ import { FormSection, SessionsDevices, SettingsRow, type DeviceSession } from '@gntik-ai/blocks';
2
+ import {
3
+ Alert,
4
+ Badge,
5
+ Button,
6
+ Field,
7
+ FieldDescription,
8
+ FieldError,
9
+ FieldLabel,
10
+ PasswordInput,
11
+ Stack,
12
+ } from '@gntik-ai/ui';
13
+ import { useState, type FormEvent } from 'react';
14
+ import { SettingsFrame, type SettingsFrameOptions } from '../settings-profile/SettingsFrame';
15
+ import { DEMO_MFA_CODE, PASSWORD_MIN_LENGTH, mfaSetupKey, sessions as defaultSessions } from './data';
16
+ import { MfaSetupDialog } from './MfaSetupDialog';
17
+
18
+ export interface PasswordChange {
19
+ current: string;
20
+ next: string;
21
+ }
22
+
23
+ export interface SettingsSecurityProps extends SettingsFrameOptions {
24
+ sessions: DeviceSession[];
25
+ /** Whether two-factor authentication is on. */
26
+ mfaEnabled: boolean;
27
+ mfaSetupKey: string;
28
+ /** Checks an enrolment code; defaults to the demo code 123456. */
29
+ onVerifyMfa: (code: string) => boolean | Promise<boolean>;
30
+ onMfaChange: (enabled: boolean) => void;
31
+ /** Changes the password. A rejection shows its message. */
32
+ onPasswordChange: (change: PasswordChange) => void | Promise<void>;
33
+ onRevokeSession: (session: DeviceSession) => void;
34
+ onRevokeOtherSessions: (sessions: DeviceSession[]) => void;
35
+ }
36
+
37
+ /** Security settings: password FormSection, MFA SettingsRow with an OtpInput setup dialog, SessionsDevices. */
38
+ export default function SettingsSecurityPage({
39
+ sessions = defaultSessions,
40
+ mfaEnabled = false,
41
+ mfaSetupKey: setupKey = mfaSetupKey,
42
+ onVerifyMfa = (code) => code === DEMO_MFA_CODE,
43
+ onMfaChange,
44
+ onPasswordChange,
45
+ onRevokeSession,
46
+ onRevokeOtherSessions,
47
+ ...frame
48
+ }: Partial<SettingsSecurityProps>) {
49
+ const [current, setCurrent] = useState('');
50
+ const [next, setNext] = useState('');
51
+ const [confirm, setConfirm] = useState('');
52
+ const [submitted, setSubmitted] = useState(false);
53
+ const [saving, setSaving] = useState(false);
54
+ const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
55
+ const [mfa, setMfa] = useState(mfaEnabled);
56
+ const [setupOpen, setSetupOpen] = useState(false);
57
+
58
+ const tooShort = next.length < PASSWORD_MIN_LENGTH;
59
+ const mismatch = confirm !== next;
60
+ const missingCurrent = current.length === 0;
61
+
62
+ const submit = async (e: FormEvent) => {
63
+ e.preventDefault();
64
+ setSubmitted(true);
65
+ setResult(null);
66
+ if (tooShort || mismatch || missingCurrent) return;
67
+ setSaving(true);
68
+ try {
69
+ await onPasswordChange?.({ current, next });
70
+ setResult({ ok: true, message: 'Password updated. Other sessions stay signed in.' });
71
+ setCurrent('');
72
+ setNext('');
73
+ setConfirm('');
74
+ setSubmitted(false);
75
+ } catch (err) {
76
+ setResult({ ok: false, message: err instanceof Error ? err.message : 'Could not update the password.' });
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ };
81
+
82
+ const toggleMfa = (on: boolean) => {
83
+ if (on) {
84
+ setSetupOpen(true);
85
+ return;
86
+ }
87
+ setMfa(false);
88
+ onMfaChange?.(false);
89
+ };
90
+
91
+ const verify = async (code: string) => {
92
+ const ok = await onVerifyMfa(code);
93
+ if (ok) {
94
+ setMfa(true);
95
+ onMfaChange?.(true);
96
+ }
97
+ return ok;
98
+ };
99
+
100
+ return (
101
+ <SettingsFrame page="security" description="Sign-in methods and the devices signed in to your account." {...frame}>
102
+ <form noValidate onSubmit={(e) => void submit(e)}>
103
+ <FormSection
104
+ title="Password"
105
+ description={`Use at least ${PASSWORD_MIN_LENGTH} characters. Changing it keeps your other sessions signed in.`}
106
+ actions={
107
+ <Button type="submit" loading={saving}>
108
+ Update password
109
+ </Button>
110
+ }
111
+ >
112
+ <Stack gap={4}>
113
+ {result && <Alert tone={result.ok ? 'success' : 'destructive'} title={result.message} role="status" />}
114
+ <Field invalid={submitted && missingCurrent}>
115
+ <FieldLabel>Current password</FieldLabel>
116
+ <PasswordInput value={current} onValueChange={setCurrent} autoComplete="current-password" />
117
+ <FieldError match={submitted && missingCurrent}>Enter your current password.</FieldError>
118
+ </Field>
119
+ <Field invalid={submitted && tooShort}>
120
+ <FieldLabel>New password</FieldLabel>
121
+ <PasswordInput value={next} onValueChange={setNext} showStrength autoComplete="new-password" />
122
+ <FieldError match={submitted && tooShort}>Use at least {PASSWORD_MIN_LENGTH} characters.</FieldError>
123
+ </Field>
124
+ <Field invalid={submitted && mismatch}>
125
+ <FieldLabel>Confirm new password</FieldLabel>
126
+ <PasswordInput value={confirm} onValueChange={setConfirm} autoComplete="new-password" />
127
+ <FieldDescription>Type the new password again.</FieldDescription>
128
+ <FieldError match={submitted && mismatch}>The passwords do not match.</FieldError>
129
+ </Field>
130
+ </Stack>
131
+ </FormSection>
132
+ </form>
133
+ <FormSection
134
+ title="Two-factor authentication"
135
+ description="Ask for a one-time code from an authenticator app when you sign in."
136
+ divided
137
+ >
138
+ <SettingsRow
139
+ label="Authenticator app"
140
+ description={
141
+ mfa
142
+ ? 'Codes from your authenticator app are required at sign-in.'
143
+ : 'Off. Turning it on opens the setup steps.'
144
+ }
145
+ checked={mfa}
146
+ onCheckedChange={toggleMfa}
147
+ className="pt-0"
148
+ >
149
+ <Badge size="sm" tone={mfa ? 'success' : 'neutral'} dot className="mt-2">
150
+ {mfa ? 'Enabled' : 'Not set up'}
151
+ </Badge>
152
+ </SettingsRow>
153
+ </FormSection>
154
+ <SessionsDevices sessions={sessions} onRevoke={onRevokeSession} onRevokeOthers={onRevokeOtherSessions} />
155
+ <MfaSetupDialog open={setupOpen} onOpenChange={setSetupOpen} setupKey={setupKey} onVerify={verify} />
156
+ </SettingsFrame>
157
+ );
158
+ }
@@ -0,0 +1,11 @@
1
+ import { sampleDeviceSessions, type DeviceSession } from '@gntik-ai/blocks';
2
+
3
+ export const sessions: DeviceSession[] = sampleDeviceSessions;
4
+
5
+ /** Demo verification code accepted by the MFA setup dialog. */
6
+ export const DEMO_MFA_CODE = '123456';
7
+
8
+ /** Shown in the setup dialog for manual entry (the QR code comes from your auth provider). */
9
+ export const mfaSetupKey = 'JBSW Y3DP EHPK 3PXP';
10
+
11
+ export const PASSWORD_MIN_LENGTH = 12;
@@ -0,0 +1,11 @@
1
+ import type { TemplateMeta } from '../meta';
2
+
3
+ export const meta: TemplateMeta = {
4
+ name: 'Security settings',
5
+ family: 'Settings',
6
+ priority: 'P1',
7
+ status: 'beta',
8
+ description: 'Password change, two-factor setup with a one-time code dialog, and signed-in sessions and devices.',
9
+ layout: 'SettingsLayout',
10
+ blocks: ['app-sidebar', 'app-topbar', 'page-header', 'form-section', 'settings-row', 'sessions-devices'],
11
+ };
@@ -0,0 +1,44 @@
1
+ import { render, screen, within } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { expectNoAxeViolations } from '../test/a11y';
5
+ import SettingsWorkspacePage from './Page';
6
+
7
+ describe('SettingsWorkspacePage', () => {
8
+ it('renders the general, logo, region and danger zone sections', () => {
9
+ render(<SettingsWorkspacePage />);
10
+ expect(screen.getByRole('heading', { level: 1, name: 'Workspace' })).toBeInTheDocument();
11
+ expect(screen.getByRole('button', { name: /^Workspace/ })).toHaveAttribute('aria-current', 'page');
12
+ expect(screen.getByRole('group', { name: 'Workspace logo' })).toBeInTheDocument();
13
+ expect(screen.getByRole('region', { name: 'Danger zone' })).toBeInTheDocument();
14
+ }, 15000);
15
+
16
+ it('saves an edited name', async () => {
17
+ const onSave = vi.fn();
18
+ render(<SettingsWorkspacePage onSave={onSave} />);
19
+ const name = screen.getByLabelText('Workspace name');
20
+ await userEvent.clear(name);
21
+ await userEvent.type(name, 'Northwind');
22
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
23
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Northwind', slug: 'northwind-labs' }));
24
+ }, 15000);
25
+
26
+ it('asks to type the slug before deleting', async () => {
27
+ const onDangerAction = vi.fn();
28
+ render(<SettingsWorkspacePage onDangerAction={onDangerAction} />);
29
+ const zone = screen.getByRole('region', { name: 'Danger zone' });
30
+ await userEvent.click(within(zone).getByRole('button', { name: 'Delete workspace' }));
31
+ const dialog = await screen.findByRole('alertdialog');
32
+ const confirm = within(dialog).getByRole('button', { name: 'Delete workspace' });
33
+ expect(confirm).toBeDisabled();
34
+ await userEvent.type(within(dialog).getByRole('textbox'), 'northwind-labs');
35
+ expect(confirm).toBeEnabled();
36
+ await userEvent.click(confirm);
37
+ expect(onDangerAction).toHaveBeenCalledWith('delete');
38
+ }, 15000);
39
+
40
+ it('has no axe violations', async () => {
41
+ const { container } = render(<SettingsWorkspacePage />);
42
+ await expectNoAxeViolations(container);
43
+ }, 15000);
44
+ });
@@ -0,0 +1,120 @@
1
+ import { DangerZone, FileUploadPanel, FormSection, StickyActionBar, type DangerZoneAction } from '@gntik-ai/blocks';
2
+ import { Field, FieldDescription, FieldError, FieldLabel, Input, SimpleSelect, Stack } from '@gntik-ai/ui';
3
+ import { useState } from 'react';
4
+ import { SettingsFrame, type SettingsFrameOptions } from '../settings-profile/SettingsFrame';
5
+ import {
6
+ SLUG_RE,
7
+ regions as defaultRegions,
8
+ workspaceDangerActions,
9
+ workspaceValues,
10
+ type RegionOption,
11
+ type WorkspaceValues,
12
+ } from './data';
13
+
14
+ export interface SettingsWorkspaceProps extends SettingsFrameOptions {
15
+ values: WorkspaceValues;
16
+ regions: RegionOption[];
17
+ /** URL prefix shown before the slug. */
18
+ urlPrefix: string;
19
+ /** Danger zone rows; defaults to transfer and delete (typing the slug). */
20
+ dangerActions: DangerZoneAction[];
21
+ onSave: (values: WorkspaceValues) => void | Promise<void>;
22
+ /** Uploads the logo; defaults to a demo upload. */
23
+ onLogoUpload: (file: File, onProgress: (pct: number) => void) => Promise<void>;
24
+ onDangerAction: (actionId: string) => void | Promise<void>;
25
+ }
26
+
27
+ /** Workspace settings: general FormSection (name, slug, region), logo FileUploadPanel, DangerZone. */
28
+ export default function SettingsWorkspacePage({
29
+ values: saved = workspaceValues,
30
+ regions = defaultRegions,
31
+ urlPrefix = 'app.example.com/',
32
+ dangerActions,
33
+ onSave,
34
+ onLogoUpload,
35
+ onDangerAction,
36
+ ...frame
37
+ }: Partial<SettingsWorkspaceProps>) {
38
+ const [base, setBase] = useState(saved);
39
+ const [draft, setDraft] = useState(saved);
40
+ const [saving, setSaving] = useState(false);
41
+ const dirty = draft.name !== base.name || draft.slug !== base.slug || draft.region !== base.region;
42
+ const slugInvalid = !SLUG_RE.test(draft.slug);
43
+ const set = (key: keyof WorkspaceValues) => (value: string | null) => setDraft((d) => ({ ...d, [key]: value ?? '' }));
44
+
45
+ const save = async () => {
46
+ if (slugInvalid) return;
47
+ setSaving(true);
48
+ try {
49
+ await onSave?.(draft);
50
+ setBase(draft);
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <SettingsFrame page="workspace" description="Details every member sees, and where your data is stored." {...frame}>
58
+ <Stack gap={8}>
59
+ <FormSection title="General" description="The name and URL of this workspace.">
60
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
61
+ <Field>
62
+ <FieldLabel>Workspace name</FieldLabel>
63
+ <Input value={draft.name} onValueChange={set('name')} />
64
+ </Field>
65
+ <Field invalid={slugInvalid}>
66
+ <FieldLabel>URL slug</FieldLabel>
67
+ <Input
68
+ value={draft.slug}
69
+ onValueChange={set('slug')}
70
+ leadingAddon={urlPrefix}
71
+ inputClassName="font-mono"
72
+ />
73
+ <FieldDescription>Lowercase letters, numbers and dashes.</FieldDescription>
74
+ <FieldError match={slugInvalid}>Use lowercase letters, numbers and single dashes.</FieldError>
75
+ </Field>
76
+ </div>
77
+ </FormSection>
78
+ <FormSection
79
+ title="Logo"
80
+ description="Shown in the sidebar and in emails. Square PNG or SVG, at least 256 px."
81
+ divided
82
+ >
83
+ <FileUploadPanel
84
+ label="Workspace logo"
85
+ accept=".png,.svg,.jpg"
86
+ maxSize={2 * 1024 * 1024}
87
+ multiple={false}
88
+ defaultFiles={[]}
89
+ onUpload={onLogoUpload}
90
+ />
91
+ </FormSection>
92
+ <FormSection
93
+ title="Data region"
94
+ description="Where projects, logs and backups are stored. Moving regions is done by support."
95
+ divided
96
+ >
97
+ <SimpleSelect
98
+ label="Region"
99
+ items={regions}
100
+ value={draft.region}
101
+ onValueChange={set('region')}
102
+ className="w-full sm:w-80"
103
+ />
104
+ </FormSection>
105
+ </Stack>
106
+ <DangerZone
107
+ description="These actions affect the whole workspace and every member in it."
108
+ actions={dangerActions ?? workspaceDangerActions(base.slug)}
109
+ onConfirm={onDangerAction}
110
+ />
111
+ <StickyActionBar
112
+ dirty={dirty}
113
+ saving={saving}
114
+ onSave={() => void save()}
115
+ onCancel={() => setDraft(base)}
116
+ aria-label="Workspace changes"
117
+ />
118
+ </SettingsFrame>
119
+ );
120
+ }
@@ -0,0 +1,44 @@
1
+ import type { DangerZoneAction } from '@gntik-ai/blocks';
2
+
3
+ export interface WorkspaceValues {
4
+ name: string;
5
+ slug: string;
6
+ region: string;
7
+ }
8
+
9
+ export interface RegionOption {
10
+ value: string;
11
+ label: string;
12
+ }
13
+
14
+ export const workspaceValues: WorkspaceValues = {
15
+ name: 'Northwind Labs',
16
+ slug: 'northwind-labs',
17
+ region: 'eu-west',
18
+ };
19
+
20
+ export const regions: RegionOption[] = [
21
+ { value: 'eu-west', label: 'EU West · Frankfurt' },
22
+ { value: 'us-east', label: 'US East · Virginia' },
23
+ { value: 'us-west', label: 'US West · Oregon' },
24
+ { value: 'ap-south', label: 'AP South · Singapore' },
25
+ ];
26
+
27
+ export const workspaceDangerActions = (slug: string): DangerZoneAction[] => [
28
+ {
29
+ id: 'transfer',
30
+ title: 'Transfer ownership',
31
+ description: 'Make another admin the owner of this workspace. You stay on as an admin.',
32
+ actionLabel: 'Transfer',
33
+ destructive: false,
34
+ },
35
+ {
36
+ id: 'delete',
37
+ title: 'Delete workspace',
38
+ description: `Permanently deletes ${slug}, its projects, members and billing history. This cannot be undone.`,
39
+ actionLabel: 'Delete workspace',
40
+ confirmText: slug,
41
+ },
42
+ ];
43
+
44
+ export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -0,0 +1,19 @@
1
+ import type { TemplateMeta } from '../meta';
2
+
3
+ export const meta: TemplateMeta = {
4
+ name: 'Workspace settings',
5
+ family: 'Settings',
6
+ priority: 'P1',
7
+ status: 'beta',
8
+ description: 'Workspace name, URL slug, logo upload and data region, with a danger zone for transfer and deletion.',
9
+ layout: 'SettingsLayout',
10
+ blocks: [
11
+ 'app-sidebar',
12
+ 'app-topbar',
13
+ 'page-header',
14
+ 'form-section',
15
+ 'file-upload-panel',
16
+ 'sticky-action-bar',
17
+ 'danger-zone',
18
+ ],
19
+ };