@h-ai/cli 0.1.0-alpha.61 → 0.1.0-alpha.63

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 (71) hide show
  1. package/README.en.md +21 -3
  2. package/README.ja.md +21 -3
  3. package/README.md +21 -3
  4. package/dist/{chunk-WWG3U5ZD.js → chunk-X3RSYNBG.js} +8 -3
  5. package/dist/chunk-X3RSYNBG.js.map +1 -0
  6. package/dist/{cli-skill-templates-2KDUACT6.js → cli-skill-templates-EZFW7D43.js} +3 -3
  7. package/dist/{cli-skill-templates-2KDUACT6.js.map → cli-skill-templates-EZFW7D43.js.map} +1 -1
  8. package/dist/index.js +7 -5
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/templates/README.md +9 -4
  12. package/templates/apps/api/e2e/capture-evidence.ts.hbs +23 -0
  13. package/templates/apps/api/playwright.config.ts.hbs +1 -0
  14. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +123 -4
  15. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +2 -0
  16. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/app-server.ts.hbs +1 -21
  17. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +1 -1
  18. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +11 -1
  19. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +19 -1
  20. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +19 -1
  21. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +7 -2
  22. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AppShell.svelte.hbs +5 -3
  23. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +5 -5
  24. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +20 -18
  25. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/guide-store.ts.hbs +55 -0
  26. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +2 -2
  27. package/templates/apps/fullstack/playwright.config.ts.hbs +21 -10
  28. package/templates/apps/website/src/routes/contact/+page.svelte.hbs +24 -22
  29. package/templates/base/e2e/app.spec.ts.hbs +10 -6
  30. package/templates/base/package.json.hbs +1 -1
  31. package/templates/skills/AGENTS.md +6 -0
  32. package/templates/skills/bridges/admin/AGENTS.md +6 -0
  33. package/templates/skills/bridges/api/AGENTS.md +6 -0
  34. package/templates/skills/bridges/fullstack/AGENTS.md.hbs +6 -0
  35. package/templates/skills/bridges/h5/AGENTS.md +6 -0
  36. package/templates/skills/bridges/mobile-app/AGENTS.md +6 -0
  37. package/templates/skills/bridges/website/AGENTS.md +6 -0
  38. package/templates/skills/copilot-instructions.md +3 -9
  39. package/templates/skills/hai-app-create/SKILL.md +6 -0
  40. package/templates/skills/hai-app-create/development.md +10 -6
  41. package/templates/skills/hai-audit/SKILL.md +1 -1
  42. package/templates/skills/hai-build/SKILL.md +1 -0
  43. package/templates/skills/hai-build/reference.md +1 -1
  44. package/templates/skills/hai-ci/SKILL.md +1 -1
  45. package/templates/skills/hai-framework-sync/SKILL.md +67 -70
  46. package/templates/skills/hai-framework-sync/sync.md +49 -0
  47. package/templates/skills/hai-iam/SKILL.md +5 -0
  48. package/templates/skills/hai-iam/reference.md +88 -7
  49. package/templates/skills/hai-kit/SKILL.md +4 -0
  50. package/templates/skills/hai-pr-review/SKILL.md +1 -1
  51. package/templates/skills/hai-reach/SKILL.md +1 -1
  52. package/templates/skills/hai-reach/reference.md +4 -5
  53. package/templates/skills/hai-serv/SKILL.md +1 -1
  54. package/templates/skills/hai-serv/reference.md +9 -6
  55. package/templates/skills/hai-spec-implement/SKILL.md +41 -0
  56. package/templates/skills/hai-spec-implement/templates.md +90 -0
  57. package/templates/skills/hai-spec-implement/workflow.md +53 -0
  58. package/templates/skills/hai-spec-review/SKILL.md +44 -0
  59. package/templates/skills/hai-spec-review/checklist.md +70 -0
  60. package/templates/skills/hai-storage/SKILL.md +9 -2
  61. package/templates/skills/hai-ui/SKILL.md +13 -1
  62. package/templates/skills/hai-ui/guide.md +79 -0
  63. package/templates/skills/hai-ui/reference.md +36 -2
  64. package/templates/skills/opencode.json +1 -1
  65. package/dist/chunk-WWG3U5ZD.js.map +0 -1
  66. package/templates/skills/bridges/admin/copilot-instructions.md +0 -11
  67. package/templates/skills/bridges/api/copilot-instructions.md +0 -13
  68. package/templates/skills/bridges/fullstack/copilot-instructions.md.hbs +0 -19
  69. package/templates/skills/bridges/h5/copilot-instructions.md +0 -11
  70. package/templates/skills/bridges/mobile-app/copilot-instructions.md +0 -11
  71. package/templates/skills/bridges/website/copilot-instructions.md +0 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.61",
4
+ "version": "0.1.0-alpha.63",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
31
31
  },
32
32
  "dependencies": {
33
- "@h-ai/core": "0.1.0-alpha.61",
33
+ "@h-ai/core": "0.1.0-alpha.63",
34
34
  "cac": "^7.0.1",
35
35
  "chalk": "^6.0.1",
36
36
  "fast-wrap-ansi": "^0.2.2",
@@ -41,11 +41,11 @@
41
41
  "yaml": "^2.9.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@h-ai/cache": "0.1.0-alpha.61",
45
- "@h-ai/deploy": "0.1.0-alpha.61",
46
- "@h-ai/reach": "0.1.0-alpha.61",
47
- "@h-ai/reldb": "0.1.0-alpha.61",
48
- "@h-ai/storage": "0.1.0-alpha.61",
44
+ "@h-ai/cache": "0.1.0-alpha.63",
45
+ "@h-ai/deploy": "0.1.0-alpha.63",
46
+ "@h-ai/reach": "0.1.0-alpha.63",
47
+ "@h-ai/reldb": "0.1.0-alpha.63",
48
+ "@h-ai/storage": "0.1.0-alpha.63",
49
49
  "@types/fs-extra": "^11.0.4",
50
50
  "@types/node": "^26.6.5",
51
51
  "@types/prompts": "^2.4.9",
@@ -57,7 +57,7 @@ templates/
57
57
  │ └── settings.json # Paraglide 项目配置
58
58
  │
59
59
  └── skills/ # AI Skill 与各 appType 桥接指引
60
- └── bridges/ # admin / api / website / h5 / mobile-app / fullstack 专属 AGENTS/Copilot
60
+ └── bridges/ # admin / api / website / h5 / mobile-app / fullstack 专属 AGENTS
61
61
  ```
62
62
 
63
63
  ## AI Skill 模板(Single Source of Truth)
@@ -65,11 +65,12 @@ templates/
65
65
  `templates/skills/` 是 CLI 生成共享 AI 上下文时的单一来源,同时用于 `hai create` 与 `hai add`。
66
66
 
67
67
  - `hai create` 会把 `templates/skills/` 下所有 `hai-*` Skill 目录复制到 `.agents/skills/<skill>/`,再按 appType 排除互斥项(例如 fullstack 不复制 `hai-kit`;admin / website / h5 不复制 `hai-serv`、`hai-api-contract`、`hai-api-client`、`hai-capacitor`;mobile-app 不复制 `hai-core`、`hai-kit`、`hai-serv`、`hai-api-contract`)
68
- - `bridges/<appType>/copilot-instructions.md` 会复制到 `.github/copilot-instructions.md`;没有专属文件时回退到通用桥接文件
68
+ - 通用 `copilot-instructions.md` 复制到 `.github/copilot-instructions.md`,只引用项目根 `AGENTS.md`,不另维护 appType 专属规则
69
69
  - `bridges/<appType>/AGENTS.md` 会复制到项目根目录,确保不同样板工程使用自己的 AI 指引;通用 `CLAUDE.md` 仅包含 `@AGENTS.md`
70
70
  - `opencode.json` 会复制到项目根目录
71
- - `templates/skills/AGENTS.md`、`CLAUDE.md`、`copilot-instructions.md` 与 `opencode.json` 不是多余副本:它们是 generic fallback,主要服务于缺少 appType 上下文时的桥接复制与 `hai add` 回填 AI 支持
72
- - `opencode.json` 使用 OpenCode 的 `instructions` 配置复用 `.github/copilot-instructions.md`;`.agents/skills/` 由 OpenCode 原生发现,不再写入过期的 `skills.paths`
71
+ - `templates/skills/AGENTS.md` 是缺少 appType 上下文时的通用规则;`CLAUDE.md`、`copilot-instructions.md` 与 `opencode.json` 是各工具的轻量入口,用于创建项目和回填 AI 支持
72
+ - Codex 原生读取根 `AGENTS.md` 并发现 `.agents/skills/`,支持 `$hai-app-create` 等显式调用和按任务自动选择;无需 `.codex/skills` 或工具专属配置。依据:[Codex skills](https://learn.chatgpt.com/docs/build-skills)、[AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
73
+ - `opencode.json` 使用 OpenCode 的 `instructions` 配置直接引用 `AGENTS.md`;`.agents/skills/` 由 OpenCode 原生发现,不再写入过期的 `skills.paths`
73
74
  - `CLAUDE.md` 通过 `@AGENTS.md` 复用共享规范,Claude Code 按其中的任务导航读取 `.agents/skills/` 正文与参考。CLI 不生成 `.claude/` 技能副本;此模式不提供 Claude 原生技能自动发现或 `/skill` 命令。依据:[Claude Code 项目指引](https://code.claude.com/docs/en/memory)、[原生 skills](https://code.claude.com/docs/en/skills)
74
75
  - `hai add` / `hai init` 只补齐缺失的 AI 支持文件,不覆盖用户已自定义的桥接文件,也不会自动删除遗留的 `.github/skills/` 或 `.claude/`
75
76
 
@@ -156,3 +157,7 @@ import { iam } from '@h-ai/iam'
156
157
  选用 IAM 的 admin/h5/website 模板提供 AuthPanel 和标准认证路由;mobile-app 与 fullstack Web/App/Desktop 使用 API 客户端共享动作。修改服务端 config/_iam.yml 即可控制登录方式、注册、重置、协议和品牌,无需改表单。配置 Reach 模板和真实发送 Provider 后启用 OTP/重置。分布式部署使用共享数据库与 Redis。
157
158
 
158
159
  LDAP/Reach 是可选 peer;IAM 不强制启用 Reach。默认 LDAP 客户端由应用选择安装 ldapts。自定义通知回调、协议弹窗、表单 snippet 用法随 hai-iam/hai-ui skills 完整复制到生成项目,不能要求应用读取本仓库私有路径。
160
+
161
+ ## 表单布局
162
+
163
+ 新增/编辑复用 `@h-ai/ui` 的 Form/FormLayout/FormField:可选一列或双列、标签独立成行或与控件同行,多行文本与多选自动跨列,窄容器自动收为一列。CRUD 使用 `form` 配置,字段可用 `colSpan` 覆盖。错误显示在字段附近并随 UI 语言更新。website 联系页采用 FormLayout + FormField;fullstack 共享用户编辑页采用双列同行标签,个人资料与密码页复用 UI 场景组件。没有数据表单的模板保留现有骨架,新增表单按 hai-ui skill 接入。
@@ -0,0 +1,23 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import { chromium, expect } from '@playwright/test'
5
+
6
+ /** 仅在指定证据目录时采集健康端点截图,常规 API 测试通过 HTTP 请求完成。 */
7
+ export default async function captureEvidence() {
8
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
9
+ if (!evidence)
10
+ return
11
+ const browser = await chromium.launch({ channel: 'chrome' })
12
+ try {
13
+ const page = await browser.newPage()
14
+ const response = await page.goto(new URL('/health', process.env.BASE_URL || 'http://127.0.0.1:3000').href)
15
+ expect(response?.status()).toBe(200)
16
+ await expect(page.locator('body')).toContainText('ok')
17
+ await mkdir(evidence, { recursive: true })
18
+ await page.screenshot({ path: path.join(evidence, '{{projectName}}-health.png'), fullPage: true })
19
+ }
20
+ finally {
21
+ await browser.close()
22
+ }
23
+ }
@@ -32,6 +32,7 @@ function resolvePortFromBaseUrl(urlString: string, fallbackPort: string): string
32
32
 
33
33
  export default defineConfig({
34
34
  testDir: './e2e',
35
+ globalSetup: './e2e/capture-evidence.ts',
35
36
  fullyParallel: false,
36
37
  forbidOnly: !!process.env.CI,
37
38
  retries: process.env.CI ? 2 : 0,
@@ -18,13 +18,29 @@ const configuredEnv = process.env.HAI_CORE_ENV || 'test'
18
18
  // 测试专用的一像素 PNG;上传仍使用完整前后端加密链路。
19
19
  const avatar = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=', 'base64')
20
20
 
21
- async function login(page: Page, username: string, password: string) {
21
+ async function capture(page: Page, name: string, fallback: string) {
22
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
23
+ if (evidence)
24
+ await mkdir(evidence, { recursive: true })
25
+ await expect(page.getByTestId('guide-loading')).toHaveCount(0)
26
+ await page.evaluate(() => document.fonts.ready)
27
+ await page.screenshot({ path: evidence ? path.join(evidence, name) : fallback, fullPage: true, animations: 'disabled' })
28
+ }
29
+
30
+ async function submitLogin(page: Page, username: string, password: string) {
22
31
  await expect(page.getByTestId('login-form')).toBeVisible()
23
32
  await page.locator('#login-username').fill(username)
24
33
  await page.getByTestId('login-form').locator('input[type="password"]').fill(password)
25
34
  await page.getByTestId('login-form').getByRole('button', { name: /登录|Sign in|Login|Log in/i }).click()
26
35
  }
27
36
 
37
+ async function login(page: Page, username: string, password: string, keepGuide = false) {
38
+ await submitLogin(page, username, password)
39
+ await expect(page.getByTestId('logout')).toBeVisible()
40
+ if (!keepGuide && await page.getByTestId('guide-later').isVisible())
41
+ await page.getByTestId('guide-later').click()
42
+ }
43
+
28
44
  async function userEditor(page: Page, username: string) {
29
45
  await page.getByTestId('users-search').locator('input').fill(username)
30
46
  await page.getByTestId('users-page').getByRole('button', { name: /查询|Search/ }).click()
@@ -45,6 +61,95 @@ test('service rejects plaintext business requests', async ({ request }) => {
45
61
  })
46
62
 
47
63
  {{#if fullstack.e2eFrontend}}
64
+ test('first-use tour, help search and settings navigation work on desktop and mobile', async ({ page }, testInfo) => {
65
+ await page.goto('/')
66
+ await login(page, 'admin', adminPassword, true)
67
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
68
+ await page.getByTestId('guide-start').click()
69
+ const popover = page.locator('.hai-guide-popover')
70
+ await expect(popover).toBeVisible()
71
+ await capture(page, '{{projectName}}-guide-desktop.png', testInfo.outputPath('guide-desktop.png'))
72
+ for (let step = 1; step < 3; step++) {
73
+ await popover.locator('.driver-popover-next-btn').click()
74
+ await expect(popover.locator('.driver-popover-progress-text')).toContainText(String(step + 1))
75
+ }
76
+ await popover.locator('.driver-popover-next-btn').click()
77
+ await expect(popover).toHaveCount(0)
78
+ await page.reload()
79
+ await expect(page.getByTestId('admin-help')).toBeVisible()
80
+ await expect(page.getByTestId('guide-offer')).toHaveCount(0)
81
+ await page.getByTestId('admin-help').click()
82
+ const profileTitle = (await page.getByTestId('guide-article-profile').textContent())!.trim()
83
+ await page.getByTestId('guide-search').locator('input').fill(profileTitle)
84
+ await expect(page.getByTestId('guide-article-profile')).toBeVisible()
85
+ await expect(page.getByTestId('guide-article-overview')).toHaveCount(0)
86
+ await page.getByTestId('guide-article-start').click()
87
+ await expect(page.getByTestId('settings-page')).toBeVisible()
88
+ await expect(popover).toBeVisible()
89
+ await page.keyboard.press('Escape')
90
+ await expect(popover).toHaveCount(0)
91
+ await page.setViewportSize({ width: 390, height: 844 })
92
+ await page.getByTestId('admin-help').click()
93
+ await expect(page.getByTestId('help-center')).toBeVisible()
94
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
95
+ await capture(page, '{{projectName}}-guide-help-mobile.png', testInfo.outputPath('guide-help-mobile.png'))
96
+ await page.getByTestId('guide-tour-overview').click()
97
+ await expect(popover.locator('.driver-popover-next-btn')).toBeEnabled()
98
+ // 下一步未渲染时仍能通过宿主提示取消,不能被引擎遮罩阻止。
99
+ await page.locator('[data-guide="admin-profile"]').evaluate(element => element.removeAttribute('data-guide'))
100
+ await popover.locator('.driver-popover-next-btn').click()
101
+ await expect(page.getByTestId('guide-loading')).toBeVisible()
102
+ await page.getByTestId('guide-cancel').click()
103
+ await expect(popover).toHaveCount(0)
104
+ await expect(page.getByTestId('guide-error')).toHaveCount(0)
105
+ })
106
+
107
+ // 所有已生成的浏览器前端都验证共享引导,不能用 Web 的成功代替 App/Desktop 预览。
108
+ const additionalFrontends = [
109
+ {{#each fullstack.frontendApps}}
110
+ {
111
+ target: '{{target}}',
112
+ url: process.env.HAI_E2E_{{target}}_URL || 'http://127.0.0.1:{{port}}',
113
+ },
114
+ {{/each}}
115
+ ].filter(frontend => frontend.target !== '{{fullstack.e2eFrontend.target}}')
116
+
117
+ for (const frontend of additionalFrontends) {
118
+ test(`${frontend.target} preview supports login, tour, help and profile navigation`, async ({ browser }, testInfo) => {
119
+ const page = await browser.newPage({ viewport: frontend.target === 'app' ? { width: 390, height: 844 } : { width: 1440, height: 960 } })
120
+ const pageErrors: string[] = []
121
+ page.on('pageerror', error => pageErrors.push(error.message))
122
+ try {
123
+ await page.goto(frontend.url)
124
+ await expect(page.getByTestId('login-form')).toBeVisible()
125
+ await capture(page, `{{projectName}}-${frontend.target}-login.png`, testInfo.outputPath(`${frontend.target}-login.png`))
126
+ await login(page, 'admin', adminPassword, true)
127
+ await expect(page.getByTestId('guide-offer')).toBeVisible()
128
+ await page.getByTestId('guide-start').click()
129
+ const popover = page.locator('.hai-guide-popover')
130
+ await expect(popover).toBeVisible()
131
+ await capture(page, `{{projectName}}-${frontend.target}-guide.png`, testInfo.outputPath(`${frontend.target}-guide.png`))
132
+ await page.keyboard.press('Escape')
133
+ await expect(popover).toHaveCount(0)
134
+ await page.getByTestId('admin-help').click()
135
+ await expect(page.getByTestId('help-center')).toBeVisible()
136
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
137
+ await capture(page, `{{projectName}}-${frontend.target}-help.png`, testInfo.outputPath(`${frontend.target}-help.png`))
138
+ await page.getByTestId('guide-article-profile').click()
139
+ await page.getByTestId('guide-article-start').click()
140
+ await expect(page.getByTestId('settings-page')).toBeVisible()
141
+ await expect(popover).toBeVisible()
142
+ await page.keyboard.press('Escape')
143
+ await expect(popover).toHaveCount(0)
144
+ await capture(page, `{{projectName}}-${frontend.target}-profile.png`, testInfo.outputPath(`${frontend.target}-profile.png`))
145
+ expect(pageErrors).toEqual([])
146
+ }
147
+ finally {
148
+ await page.close()
149
+ }
150
+ })
151
+ }
152
+
48
153
  test('shared authentication supports public registration with configuration', async ({ page }) => {
49
154
  test.setTimeout(90_000)
50
155
  const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
@@ -106,7 +211,7 @@ test('shared authentication supports public registration with configuration', as
106
211
  await expect(page.getByTestId('settings-page')).toBeVisible()
107
212
  })
108
213
 
109
- test('frontend displays configured core metadata and restores the administrator session', async ({ page }) => {
214
+ test('frontend displays configured core metadata and restores the administrator session', async ({ page }, testInfo) => {
110
215
  await page.goto('/')
111
216
  await expect(page.getByTestId('app-name')).toHaveText(configuredName)
112
217
  await expect(page).toHaveTitle(configuredName)
@@ -118,17 +223,24 @@ test('frontend displays configured core metadata and restores the administrator
118
223
  await expect(page.getByTestId('users-page')).toBeVisible()
119
224
  await expect(page.getByTestId('nav-roles')).toBeVisible()
120
225
  await expect(page.getByTestId('nav-audit')).toBeVisible()
226
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
227
+ if (evidence)
228
+ await mkdir(evidence, { recursive: true })
229
+ await page.screenshot({ path: evidence ? path.join(evidence, '{{projectName}}-users.png') : testInfo.outputPath('users.png'), fullPage: true })
121
230
  await page.getByTestId('logout').click()
122
231
  await expect(page.getByTestId('login-form')).toBeVisible()
123
232
  })
124
233
 
125
- test('audit filters align and fit desktop, tablet and narrow mobile screens', async ({ page }) => {
234
+ test('audit filters align and fit desktop, tablet and narrow mobile screens', async ({ page }, testInfo) => {
126
235
  await page.goto('/')
127
236
  await login(page, 'admin', adminPassword)
128
237
  await page.getByTestId('nav-audit').click()
129
238
  await expect(page.getByTestId('audit-page')).toBeVisible()
130
239
  for (const width of [1440, 960, 390, 320]) {
131
240
  await page.setViewportSize({ width, height: 900 })
241
+ // 等待移动侧栏收起,避免过渡帧遮挡内容并污染截图。
242
+ if (width < 1024)
243
+ await expect.poll(() => page.locator('aside').evaluate(sidebar => sidebar.getBoundingClientRect().right)).toBeLessThanOrEqual(1)
132
244
  const controls = await page.getByTestId('audit-page').locator('form').evaluate(form => [
133
245
  form.querySelector('#audit-action')?.parentElement,
134
246
  form.querySelector('#audit-resource')?.parentElement,
@@ -152,6 +264,13 @@ test('audit filters align and fit desktop, tablet and narrow mobile screens', as
152
264
  expect(controls[2]!.top).toBeCloseTo(controls[3]!.top, 0)
153
265
  }
154
266
  expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
267
+ if (width === 1440 || width === 390) {
268
+ const evidence = process.env.HAI_AUTH_EVIDENCE_DIR
269
+ if (evidence)
270
+ await mkdir(evidence, { recursive: true })
271
+ const file = `{{projectName}}-audit-${width}.png`
272
+ await page.screenshot({ path: evidence ? path.join(evidence, file) : testInfo.outputPath(file), fullPage: true, animations: 'disabled' })
273
+ }
155
274
  }
156
275
  })
157
276
 
@@ -252,7 +371,7 @@ test('administrator and user complete account, avatar, preference, password and
252
371
  await passwordInputs.nth(2).fill(newPassword)
253
372
  await userPage.getByTestId('password-form').getByRole('button', { name: /Change password/i }).click()
254
373
  await expect(userPage.getByTestId('login-form')).toBeVisible()
255
- await login(userPage, username, password)
374
+ await submitLogin(userPage, username, password)
256
375
  await expect(userPage.getByTestId('login-form')).toContainText(/不正确|incorrect/i)
257
376
  await login(userPage, username, newPassword)
258
377
  await expect(userPage.getByTestId('settings-page')).toBeVisible()
@@ -10,6 +10,8 @@ server:
10
10
  port: 3000
11
11
 
12
12
  http:
13
+ # 仅列出会清洗/追加转发头的代理 IP/CIDR;空数组忽略 X-Forwarded-For/X-Real-IP。
14
+ trustedProxies: []
13
15
  apiPrefix: /api/v1
14
16
  openapi:
15
17
  path: /openapi.json
@@ -1,6 +1,6 @@
1
1
  import type { CoreConfig } from '@h-ai/core'
2
2
  import type { ServHttpApp } from '@h-ai/serv'
3
- import { core, err, HaiCommonError, ok } from '@h-ai/core'
3
+ import { core } from '@h-ai/core'
4
4
  import { createReldbTransportKeyStore, crypto } from '@h-ai/crypto'
5
5
  import { iam } from '@h-ai/iam'
6
6
  import { reldb } from '@h-ai/reldb'
@@ -40,26 +40,6 @@ export function createServerApp(): ServHttpApp {
40
40
  },
41
41
  }),
42
42
  iam,
43
- verifyToken: async (token) => {
44
- const session = await iam.session.verifyToken(token)
45
- if (!session.success)
46
- return session
47
- const user = await iam.user.getUser(session.data.userId)
48
- if (!user.success)
49
- return user
50
- if (!user.data?.enabled)
51
- return err(HaiCommonError.UNAUTHORIZED, core.i18n.coreM('core_errorUnauthorized'))
52
- // 会话中的授权同步属于最大努力,业务守卫始终读取最新持久化角色与权限。
53
- const [roles, permissions] = await Promise.all([
54
- iam.authz.getUserRoles(user.data.id),
55
- iam.authz.getUserPermissions(user.data.id),
56
- ])
57
- if (!roles.success)
58
- return roles
59
- if (!permissions.success)
60
- return permissions
61
- return ok({ ...session.data, roles: roles.data.map(role => role.code), permissions: permissions.data.map(permission => permission.code) })
62
- },
63
43
  refreshCookie: {
64
44
  secure: security.secureRefreshCookie,
65
45
  nativeTokenTransport: { isRequest: request => request.headers.get('x-hai-token-transport') === 'body'
@@ -85,7 +85,7 @@ export function createAdminProcedures() {
85
85
  const result = await audit.list({ ...input, startDate: input.startDate ? new Date(input.startDate) : undefined, endDate: input.endDate ? new Date(input.endDate) : undefined })
86
86
  if (!result.success)
87
87
  return result
88
- return ok({ ...result.data, page: input.page, pageSize: input.pageSize, items: result.data.items.map(item => ({ ...item, createdAt: new Date(item.createdAt).toISOString() })) })
88
+ return ok({ ...result.data, items: result.data.items.map(item => ({ ...item, createdAt: new Date(item.createdAt).toISOString() })) })
89
89
  }).build()
90
90
  }
91
91
 
@@ -21,6 +21,12 @@ Capacitor native uses `createCapacitorTokenStorage()` and the trusted native tok
21
21
 
22
22
  Language and theme preferences are saved in IAM user metadata, restored after login and synchronized with the shared/host Paraglide runtimes and `core.i18n`. The small reactive theme store delegates DOM updates and safe appearance caching to `@h-ai/ui/theme` (`applyTheme` / `getSavedTheme`); the framework uses the `theme` localStorage key. Appearance selectors live in personal settings and save through the authenticated profile contract.
23
23
 
24
+ ## Onboarding and help
25
+
26
+ AdminWorkspace mounts the shared `@h-ai/ui/guide` controller and GuideHost after authentication. AppShell includes a help button; first-use invitations offer start, later and dismiss. Web stores preferences in Cookies, while App/Desktop use localStorage, scoped by the current user and tour version. Help articles link to the overview and profile tours.
27
+
28
+ Extend `src/lib/stores/guide-store.ts` and the shared Chinese/English messages, then add stable data-guide attributes to existing components. Navigation uses the same ManagementStore. Keep business text as i18n callbacks and preserve the host's locale/scope binding. Browser tests cover completion, reload, replay, settings navigation and mobile help.
29
+
24
30
  ## i18n and themes
25
31
 
26
32
  Shared page copy belongs in `messages/{zh-CN,en-US}.json`. Frontend-specific copy belongs in each app's own messages directory. Keep `baseLocale` and `locales` aligned across all enabled projects, then run root `pnpm i18n:compile`; do not edit generated Paraglide files.
@@ -31,7 +37,7 @@ The scaffold theme configuration determines the themes compiled into frontend CS
31
37
 
32
38
  ## Validation
33
39
 
34
- Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Shared API tests exercise real transport key exchange, encrypted request/response handling, configured core metadata and credential forwarding. Root browser tests cover administrator and ordinary-user workflows, avatar rendering, persisted preferences, password/session invalidation and narrow-screen layout.
40
+ Run root `pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` and `pnpm test:e2e`. Shared API tests exercise real transport key exchange, encrypted request/response handling, configured core metadata and credential forwarding. Root browser tests cover administrator and ordinary-user workflows, avatar rendering, persisted preferences, password/session invalidation and narrow-screen layout. All selected Web/App/Desktop previews also exercise login, tours, help and profile navigation. Set `HAI_AUTH_EVIDENCE_DIR` to keep screenshots outside the test output directory. These browser checks do not replace native-shell or miniapp device acceptance.
35
41
 
36
42
  ## IAM configuration
37
43
 
@@ -42,3 +48,7 @@ LDAP and Reach are optional. Install ldapts in the IAM service workspace only wh
42
48
  协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
43
49
 
44
50
  See `.agents/skills/hai-iam/reference.md` in the generated application for the complete contract.
51
+
52
+ ## Form layouts
53
+
54
+ The user editor uses `FormLayout columns={2} labelPosition="left"`; profile and password forms reuse the same UI options. Narrow containers automatically stack fields and labels. Field validation errors follow the current UI language. The management store syncs locale through the lightweight `@h-ai/ui/i18n` entry, without loading components or tour CSS. Configure `colSpan` on FormField to override automatic spans for textareas, uploads and multiple choices.
@@ -83,5 +83,23 @@
83
83
  "audit_system": "System",
84
84
  "audit_details": "Audit log details",
85
85
  "audit_date_invalid": "The end date must be on or after the start date.",
86
- "api_not_configured": "The service connection is not configured. Please contact your administrator."
86
+ "api_not_configured": "The service connection is not configured. Please contact your administrator.",
87
+ "guide_overview_title": "Explore your admin workspace",
88
+ "guide_overview_description": "A few short steps introduce the workspace, personal settings and help. Leave at any time and replay tours from the help center.",
89
+ "guide_content_title": "Workspace",
90
+ "guide_content_description": "The current page appears here. Use navigation to switch features. Available actions depend on your permissions.",
91
+ "guide_theme_title": "Appearance",
92
+ "guide_theme_description": "Switch between light and dark appearances here. More themes are available in settings.",
93
+ "guide_profile_title": "Your profile",
94
+ "guide_profile_description": "Use your avatar to access your profile and settings, including personal information and passwords.",
95
+ "guide_help_title": "Help center",
96
+ "guide_help_description": "Search instructions here whenever you need help, or replay a product tour.",
97
+ "guide_profile_tour_title": "Set up your profile",
98
+ "guide_profile_tour_description": "Find your personal information, preferences and password settings. This tour explains actions without submitting changes.",
99
+ "guide_profile_form_description": "View or edit your profile, choose an avatar and save your changes. Uploaded files must meet the format and size requirements shown on the page.",
100
+ "guide_preferences_description": "Save your preferred language and theme. The interface and help content follow your selection.",
101
+ "guide_password_title": "Change password",
102
+ "guide_password_description": "Enter your current password, new password and confirmation. Check the password rules before submitting.",
103
+ "guide_overview_article": "## Getting started\n\n- Open features through navigation. Menus and actions depend on your current permissions.\n- The workspace contains lists, details or settings. Follow the page instructions and check information before submitting.\n- Use your avatar to access your profile and settings.\n- Search instructions or replay tours through the help button.\n\n## First-use tour\n\n“Remind me later” pauses reminders for one day. “Do not remind me” saves your preference. Completed tours no longer prompt automatically and remain available for manual replay.",
104
+ "guide_profile_article": "## Profile and preferences\n\nOpen your profile to view or update personal information and your avatar. Check the file requirements, save changes and read the result message. Appearance and language can be adjusted in settings.\n\n## Password\n\nChanging a password requires the current password, new password and confirmation. Follow the displayed password rules and check the result after submitting.\n\n“Start tour” opens the relevant page. Tours never save forms or change your account automatically."
87
105
  }
@@ -83,5 +83,23 @@
83
83
  "audit_system": "系统",
84
84
  "audit_details": "审计日志详情",
85
85
  "audit_date_invalid": "结束日期不能早于开始日期。",
86
- "api_not_configured": "尚未配置服务连接,请联系应用管理员。"
86
+ "api_not_configured": "尚未配置服务连接,请联系应用管理员。",
87
+ "guide_overview_title": "快速认识管理后台",
88
+ "guide_overview_description": "用几个简短步骤了解工作区、个人设置和帮助入口。你可以随时退出,也可以从帮助中心重新查看。",
89
+ "guide_content_title": "工作区",
90
+ "guide_content_description": "这里展示当前页面的内容。通过导航菜单切换功能,页面会根据你的权限提供可用操作。",
91
+ "guide_theme_title": "外观切换",
92
+ "guide_theme_description": "点击这里快速切换亮色与暗色外观。更多主题可以在设置页面中选择。",
93
+ "guide_profile_title": "个人资料",
94
+ "guide_profile_description": "点击头像打开个人资料和设置入口,管理头像、个人信息与密码。",
95
+ "guide_help_title": "帮助中心",
96
+ "guide_help_description": "遇到问题时从这里搜索使用说明,或重新开始任意操作导览。",
97
+ "guide_profile_tour_title": "完善个人资料",
98
+ "guide_profile_tour_description": "了解个人信息、偏好和密码设置的位置。引导只说明操作,不会自动提交修改。",
99
+ "guide_profile_form_description": "在这里查看或编辑个人资料,选择头像并保存修改。上传文件需符合页面显示的格式与大小要求。",
100
+ "guide_preferences_description": "选择语言和主题后保存,界面与帮助内容会使用所选语言和外观。",
101
+ "guide_password_title": "修改密码",
102
+ "guide_password_description": "输入当前密码、新密码和确认密码,按页面提示提交。修改前请确认新密码符合密码规则。",
103
+ "guide_overview_article": "## 从哪里开始\n\n- 使用导航菜单打开需要的功能。可见菜单和操作由当前权限决定。\n- 工作区显示列表、详情或设置内容。按页面提示完成操作,提交前确认信息。\n- 点击头像访问个人资料与设置。\n- 点击帮助入口搜索说明或重新查看导览。\n\n## 首次引导\n\n选择“稍后提醒”会暂缓一天;选择“不再提醒”会保留你的选择。完成导览后不会重复提示,仍可手动重看。",
104
+ "guide_profile_article": "## 个人资料与偏好\n\n打开个人资料页面,查看或修改个人信息与头像。头像需符合页面显示的文件要求,保存后查看结果提示。外观和语言可在设置中调整。\n\n## 密码\n\n修改密码需要当前密码、新密码和确认密码。按页面显示的密码规则填写,提交后查看结果。\n\n点击“开始引导”可以直接进入对应页面。导览不会自动保存表单或修改账户。"
87
105
  }
@@ -3,10 +3,12 @@
3
3
  import { Alert, Skeleton } from '@h-ai/ui/components/compounds/index'
4
4
  import { Button } from '@h-ai/ui/components/primitives/index'
5
5
  import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
6
- import { onMount } from 'svelte'
6
+ import { GuideHost } from '@h-ai/ui/guide'
7
+ import { onMount, untrack } from 'svelte'
7
8
  import { managementAuthActions } from '../api/api-client.js'
8
9
  import * as m from '../paraglide/messages.js'
9
10
  import { ManagementStore } from '../stores/management-store.svelte.js'
11
+ import { createManagementGuide } from '../stores/guide-store.js'
10
12
  import AppShell from './AppShell.svelte'
11
13
 
12
14
  interface Props {
@@ -16,6 +18,8 @@
16
18
  }
17
19
  const { platform, apiOptions = {}, onLocaleChange }: Props = $props()
18
20
  const store = new ManagementStore()
21
+ // 宿主平台在组件生命周期中固定,引导控制器只创建一次。
22
+ const guide = untrack(() => createManagementGuide(store, platform))
19
23
  let resetToken = $state('')
20
24
  async function authenticated() {
21
25
  await store.authenticated()
@@ -67,7 +71,7 @@
67
71
  {/if}
68
72
  </main>
69
73
  {:else}
70
- <AppShell {platform} {store}>
74
+ <AppShell {platform} {store} onhelp={() => guide.openHelp()}>
71
75
  <div class='space-y-5'>
72
76
  {#if store.error}<Alert variant='error' data-testid='error'>{store.error}</Alert>{/if}
73
77
  {#if store.notice}<Alert variant='success' data-testid='notice'>{store.notice}</Alert>{/if}
@@ -78,5 +82,6 @@
78
82
  {/await}
79
83
  </div>
80
84
  </AppShell>
85
+ <GuideHost {guide} locale={store.locale} scope={store.user.id} autoStart='overview' />
81
86
  {/if}
82
87
  {/key}
@@ -8,8 +8,9 @@
8
8
  platform: 'web' | 'app' | 'desktop' | 'miniapp'
9
9
  store: ManagementStore
10
10
  children: Snippet
11
+ onhelp: () => void
11
12
  }
12
- const { platform, store, children }: Props = $props()
13
+ const { platform, store, children, onhelp }: Props = $props()
13
14
  let menuOpen = $state(false)
14
15
  const items = $derived([
15
16
  ...(store.isAdmin
@@ -54,9 +55,10 @@
54
55
  <span class="truncate text-sm font-medium">{title}</span>
55
56
  </div>
56
57
  <div class="flex shrink-0 items-center gap-1.5 sm:gap-3">
58
+ <Button variant="ghost" size="sm" onclick={onhelp} data-guide="admin-help" data-testid="admin-help" ariaLabel={m.guide_help_title()}><span class="icon-[tabler--help-circle] size-4"></span><span class="hidden sm:inline">{m.guide_help_title()}</span></Button>
57
59
  <Badge variant="ghost" size="sm" data-testid="app-environment">{store.info?.env}</Badge>
58
60
  {#if store.user}
59
- <BareButton class="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-base-content/5 sm:px-2" ariaLabel={m.settings_title()} onclick={() => navigate('settings')}>
61
+ <BareButton class="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-base-content/5 sm:px-2" ariaLabel={m.settings_title()} onclick={() => navigate('settings')} data-guide="admin-profile">
60
62
  <Avatar name={store.user.displayName || store.user.username} src={store.user.avatarUrl} size="sm" />
61
63
  <span class="hidden max-w-36 truncate text-sm sm:block">{store.user.displayName || store.user.username}</span>
62
64
  </BareButton>
@@ -64,7 +66,7 @@
64
66
  <Button variant="ghost" size="sm" loading={store.saving} onclick={() => void store.logout()} data-testid="logout">{m.logout()}</Button>
65
67
  </div>
66
68
  </header>
67
- <main class="mx-auto w-full max-w-7xl flex-1 p-4 pb-8 sm:p-6 lg:p-8">{@render children()}</main>
69
+ <main class="mx-auto w-full max-w-7xl flex-1 p-4 pb-8 sm:p-6 lg:p-8" data-guide="admin-content">{@render children()}</main>
68
70
  <footer class="border-t border-base-content/8 px-6 py-3 text-center text-xs text-base-content/35">{store.info?.name} · v{store.info?.version}</footer>
69
71
  </div>
70
72
  </div>
@@ -32,11 +32,11 @@
32
32
  </div>
33
33
  <p class='mt-4 text-xs leading-6 text-base-content/50'>{m.avatar_hint()}</p>
34
34
  </Card>
35
- <Card title={m.profile_title()} bordered shadow={false}>
36
- <UserProfile user={store.user} editable alwaysEditable fields={['avatar', 'displayName', 'email']} avatarAccept='image/png,image/jpeg,image/webp' avatarHint={m.avatar_hint()} loading={store.saving} onsubmit={data => store.saveProfile(data)} onavatarchange={file => void store.uploadAvatar(file)} data-testid='profile-form' />
35
+ <Card title={m.profile_title()} bordered shadow={false} data-guide='admin-profile-form'>
36
+ <UserProfile columns={2} labelPosition='left' user={store.user} editable alwaysEditable fields={['avatar', 'displayName', 'email']} avatarAccept='image/png,image/jpeg,image/webp' avatarHint={m.avatar_hint()} loading={store.saving} onsubmit={data => store.saveProfile(data)} onavatarchange={file => void store.uploadAvatar(file)} data-testid='profile-form' />
37
37
  </Card>
38
38
  </div>
39
- <Card title={m.preferences_title()} bordered shadow={false}>
39
+ <Card title={m.preferences_title()} bordered shadow={false} data-guide='admin-preferences'>
40
40
  <p class='mb-5 text-sm text-base-content/50'>{m.preferences_description()}</p>
41
41
  <div class='grid gap-6 sm:grid-cols-2'>
42
42
  <div class='space-y-2'>
@@ -49,9 +49,9 @@
49
49
  </div>
50
50
  </div>
51
51
  </Card>
52
- <Card title={m.password_title()} bordered shadow={false}>
52
+ <Card title={m.password_title()} bordered shadow={false} data-guide='admin-password'>
53
53
  <p class='mb-5 text-sm text-base-content/50'>{m.password_rules()}</p>
54
- <div class='max-w-xl'><ChangePasswordForm loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' /></div>
54
+ <div class='max-w-xl'><ChangePasswordForm labelPosition='left' loading={store.saving} passwordPolicy={store.authConfig?.password} minPasswordLength={store.authConfig?.password.minLength ?? 8} onsubmit={data => store.changePassword(data)} data-testid='password-form' /></div>
55
55
  </Card>
56
56
  {/if}
57
57
  </section>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { AdminRoleCode, AdminUser } from '{{fullstack.contractPackageName}}'
3
3
  import type { ManagementStore } from '../stores/management-store.svelte.js'
4
- import { Card, DataTable, FormField, Modal, Pagination, Skeleton } from '@h-ai/ui/components/compounds/index'
4
+ import { Card, DataTable, FormField, FormLayout, Modal, Pagination, Skeleton } from '@h-ai/ui/components/compounds/index'
5
5
  import { Avatar, Badge, BareInput, Button, Checkbox, Input, Select } from '@h-ai/ui/components/primitives/index'
6
6
  import { PasswordInput } from '@h-ai/ui/components/scenes/iam/index'
7
7
  import * as m from '../paraglide/messages.js'
@@ -133,23 +133,25 @@
133
133
  </div>
134
134
  </section>
135
135
 
136
- <Modal bind:open={editorOpen} title={editing ? m.user_edit() : m.user_create()} closeOnBackdrop={!store.saving} closeOnEscape={!store.saving} data-testid="user-editor">
137
- <form class="space-y-4" onsubmit={submit}>
138
- <FormField label={m.user_username()} required>
139
- <Input bind:value={username} name="username" readonly={!!editing} required minlength={3} maxlength={64} pattern="[\w\-]+" autocomplete="off" data-testid="user-username" />
140
- </FormField>
141
- <FormField label={m.user_display_name()}><Input bind:value={displayName} maxlength={100} data-testid="user-display-name" /></FormField>
142
- <FormField label={m.user_email()}><Input type="email" bind:value={email} data-testid="user-email" /></FormField>
143
- {#if !editing}
144
- <FormField label={m.user_password()} hint={m.initial_password_rules({ min: store.authConfig?.password.minLength ?? 8 })} required><PasswordInput bind:value={password} autocomplete="new-password" minLength={store.authConfig?.password.minLength ?? 8} data-testid="user-password" /></FormField>
145
- {/if}
146
- <FormField label={m.user_role()}><Select bind:value={role} options={roleOptions} disabled={editing?.id === store.user?.id} data-testid="user-role" /></FormField>
147
- <Checkbox bind:checked={enabled} label={m.account_enabled()} disabled={editing?.id === store.user?.id} />
148
- {#if store.error}<p class="text-sm text-error" role="alert">{store.error}</p>{/if}
149
- <div class="flex justify-end gap-2 border-t border-base-content/8 pt-4">
150
- <Button variant="ghost" disabled={store.saving} onclick={() => { editorOpen = false }}>{m.cancel()}</Button>
151
- <Button type="submit" variant="primary" loading={store.saving} disabled={!editing && password.length < 8} data-testid="user-save">{m.save()}</Button>
152
- </div>
136
+ <Modal bind:open={editorOpen} title={editing ? m.user_edit() : m.user_create()} closeOnBackdrop={!store.saving} closeOnEscape={!store.saving} size="3xl" data-testid="user-editor">
137
+ <form onsubmit={submit}>
138
+ <FormLayout columns={2} labelPosition="left" labelWidth="6rem">
139
+ <FormField label={m.user_username()} required>
140
+ <Input bind:value={username} name="username" readonly={!!editing} required minlength={3} maxlength={64} pattern="[\w\-]+" autocomplete="off" data-testid="user-username" />
141
+ </FormField>
142
+ <FormField label={m.user_display_name()}><Input bind:value={displayName} maxlength={100} data-testid="user-display-name" /></FormField>
143
+ <FormField label={m.user_email()}><Input type="email" bind:value={email} data-testid="user-email" /></FormField>
144
+ {#if !editing}
145
+ <FormField label={m.user_password()} hint={m.initial_password_rules({ min: store.authConfig?.password.minLength ?? 8 })} required><PasswordInput required bind:value={password} autocomplete="new-password" minLength={store.authConfig?.password.minLength ?? 8} data-testid="user-password" /></FormField>
146
+ {/if}
147
+ <FormField label={m.user_role()}><Select bind:value={role} options={roleOptions} disabled={editing?.id === store.user?.id} data-testid="user-role" /></FormField>
148
+ <Checkbox bind:checked={enabled} label={m.account_enabled()} disabled={editing?.id === store.user?.id} />
149
+ {#if store.error}<p class="text-sm text-error" role="alert">{store.error}</p>{/if}
150
+ <div class="flex justify-end gap-2 border-t border-base-content/8 pt-4">
151
+ <Button variant="ghost" disabled={store.saving} onclick={() => { editorOpen = false }}>{m.cancel()}</Button>
152
+ <Button type="submit" variant="primary" loading={store.saving} disabled={!editing && password.length < 8} data-testid="user-save">{m.save()}</Button>
153
+ </div>
154
+ </FormLayout>
153
155
  </form>
154
156
  </Modal>
155
157