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

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 (100) hide show
  1. package/README.en.md +19 -1
  2. package/README.ja.md +19 -1
  3. package/README.md +19 -1
  4. package/dist/{chunk-WUSFJFW5.js → chunk-WW2MIO6T.js} +437 -126
  5. package/dist/chunk-WW2MIO6T.js.map +1 -0
  6. package/dist/cli-config-templates-QRF6B5T4.js +5 -0
  7. package/dist/{cli-config-templates-6XA7V6CC.js.map → cli-config-templates-QRF6B5T4.js.map} +1 -1
  8. package/dist/index.js +28 -7
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/templates/README.md +14 -0
  12. package/templates/apps/api/.gitignore.hbs +1 -0
  13. package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +12 -0
  14. package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +4 -7
  15. package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +11 -1
  16. package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +2 -1
  17. package/templates/apps/api/e2e/api-service.spec.ts.hbs +72 -6
  18. package/templates/apps/api/package.json.hbs +4 -0
  19. package/templates/apps/fullstack/.gitignore.hbs +1 -0
  20. package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +10 -0
  21. package/templates/apps/fullstack/apps/{{projectName}}-app/project.inlang/settings.json.hbs +1 -1
  22. package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +10 -0
  23. package/templates/apps/fullstack/apps/{{projectName}}-desktop/project.inlang/settings.json.hbs +1 -1
  24. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/README.md.hbs +2 -0
  25. package/templates/apps/fullstack/apps/{{projectName}}-miniapp/project.inlang/settings.json.hbs +1 -1
  26. package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +10 -0
  27. package/templates/apps/fullstack/apps/{{projectName}}-web/project.inlang/settings.json.hbs +1 -1
  28. package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +63 -1
  29. package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +1 -1
  30. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +8 -1
  31. package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +1 -1
  32. package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +18 -3
  33. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_ai.yml.hbs +1 -0
  34. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +1 -3
  35. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +1 -2
  36. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +7 -0
  37. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +1 -3
  38. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_deploy.yml.hbs +1 -0
  39. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +1 -21
  40. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_payment.yml.hbs +1 -0
  41. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +52 -0
  42. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_scheduler.yml.hbs +1 -0
  43. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +8 -0
  44. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +1 -3
  45. package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_vecdb.yml.hbs +1 -0
  46. package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +1 -0
  47. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +1 -1
  48. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +2 -2
  49. package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +7 -3
  50. package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +11 -4
  51. package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +12 -0
  52. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +2 -2
  53. package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +2 -2
  54. package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +1 -0
  55. package/templates/apps/fullstack/packages/{{projectName}}-shared/project.inlang/settings.json.hbs +1 -1
  56. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +3 -0
  57. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +32 -23
  58. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +24 -24
  59. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +1 -1
  60. package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +20 -20
  61. package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +7 -8
  62. package/templates/apps/h5/src/routes/+layout.svelte.hbs +35 -23
  63. package/templates/apps/mobile-app/src/App.svelte.hbs +43 -0
  64. package/templates/base/.gitignore.hbs +1 -2
  65. package/templates/base/e2e/app.spec.ts.hbs +147 -0
  66. package/templates/base/package.json.hbs +1 -1
  67. package/templates/base/src/hooks.server.ts.hbs +2 -2
  68. package/templates/base/src/lib/server/init.ts.hbs +1 -1
  69. package/templates/base/src/routes/+layout.server.ts.hbs +6 -0
  70. package/templates/base/tests/scaffold.test.ts.hbs +3 -0
  71. package/templates/base/vite.config.ts.hbs +1 -0
  72. package/templates/features/iam/routes-admin/(auth)/+layout.svelte.hbs +2 -16
  73. package/templates/features/iam/routes-admin/(auth)/auth/forgot-password/+page.svelte.hbs +10 -0
  74. package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +6 -95
  75. package/templates/features/iam/routes-admin/(auth)/auth/register/+page.svelte.hbs +6 -117
  76. package/templates/features/iam/routes-admin/(auth)/auth/reset-password/+page.svelte.hbs +10 -0
  77. package/templates/features/iam/routes-h5/auth/forgot-password/+page.svelte.hbs +10 -0
  78. package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +6 -97
  79. package/templates/features/iam/routes-h5/auth/register/+page.svelte.hbs +6 -112
  80. package/templates/features/iam/routes-h5/auth/reset-password/+page.svelte.hbs +10 -0
  81. package/templates/features/iam/routes-shared/api/auth/config/+server.ts +4 -0
  82. package/templates/features/iam/routes-shared/api/auth/login/+server.ts +1 -33
  83. package/templates/features/iam/routes-shared/api/auth/login/ldap/+server.ts +4 -0
  84. package/templates/features/iam/routes-shared/api/auth/login/otp/+server.ts +4 -0
  85. package/templates/features/iam/routes-shared/api/auth/otp/send/+server.ts +4 -0
  86. package/templates/features/iam/routes-shared/api/auth/password-reset/confirm/+server.ts +4 -0
  87. package/templates/features/iam/routes-shared/api/auth/password-reset/request/+server.ts +4 -0
  88. package/templates/features/iam/routes-shared/api/auth/register/+server.ts +1 -23
  89. package/templates/features/iam/routes-website/auth/forgot-password/+page.svelte.hbs +10 -0
  90. package/templates/features/iam/routes-website/auth/login/+page.svelte.hbs +11 -0
  91. package/templates/features/iam/routes-website/auth/register/+page.svelte.hbs +11 -0
  92. package/templates/features/iam/routes-website/auth/reset-password/+page.svelte.hbs +10 -0
  93. package/templates/skills/hai-api-client/SKILL.md +5 -0
  94. package/templates/skills/hai-app-create/reference.md +10 -0
  95. package/templates/skills/hai-iam/SKILL.md +9 -2
  96. package/templates/skills/hai-iam/reference.md +76 -11
  97. package/templates/skills/hai-reach/SKILL.md +2 -0
  98. package/templates/skills/hai-ui/reference.md +47 -0
  99. package/dist/chunk-WUSFJFW5.js.map +0 -1
  100. package/dist/cli-config-templates-6XA7V6CC.js +0 -5
@@ -0,0 +1,52 @@
1
+ # 开发触达示例;生产环境改为 SMTP、短信或 API Provider。
2
+ providers:
3
+ - name: email
4
+ type: console
5
+ - name: sms
6
+ type: console
7
+ # SMTP 替换 email 控制台 Provider;同名 Provider 只保留一个。
8
+ # - name: email
9
+ # type: smtp
10
+ # host: ${HAI_REACH_SMTP_HOST:smtp.example.com}
11
+ # port: ${HAI_REACH_SMTP_PORT:465}
12
+ # secure: true
13
+ # user: ${HAI_REACH_SMTP_USER:}
14
+ # pass: ${HAI_REACH_SMTP_PASS:}
15
+ # from: ${HAI_REACH_SMTP_FROM:noreply@example.com}
16
+ # 阿里云短信替换 sms 控制台 Provider。
17
+ # - name: sms
18
+ # type: aliyun-sms
19
+ # accessKeyId: ${HAI_REACH_SMS_ACCESS_KEY}
20
+ # accessKeySecret: ${HAI_REACH_SMS_SECRET_KEY}
21
+ # signName: MyApp
22
+ # endpoint: dysmsapi.aliyuncs.com
23
+ # 自有发送服务:按需将 template.provider 改为 custom。
24
+ # - name: custom
25
+ # type: api
26
+ # url: https://notify.example.com/send
27
+ # method: POST # POST | PUT
28
+ # headers:
29
+ # Authorization: 'Bearer ${HAI_REACH_API_TOKEN}'
30
+ # timeout: 10000
31
+ templates:
32
+ - name: otp_email
33
+ provider: email
34
+ subject: Verification code
35
+ body: 'Your verification code is {code}.'
36
+ - name: otp_sms
37
+ provider: sms
38
+ body: 'Your verification code is {code}.'
39
+ - name: password_reset
40
+ provider: email
41
+ subject: Password reset
42
+ body: 'Reset your password: {resetUrl}. Expires at {expiresAt}.'
43
+ - name: password_reset_sms
44
+ provider: sms
45
+ body: 'Reset your password: {resetUrl}. Expires at {expiresAt}.'
46
+
47
+ # 免打扰可选;验证码应避免 delay,以免延迟到失效后投递。
48
+ # dnd:
49
+ # enabled: false
50
+ # strategy: discard # discard | delay
51
+ # start: '22:00'
52
+ # end: '08:00'
@@ -0,0 +1 @@
1
+ {{{moduleConfig 'scheduler' features.scheduler}}}
@@ -15,10 +15,17 @@ http:
15
15
  path: /openapi.json
16
16
  docs:
17
17
  path: /docs
18
+ # requireAuth: true
18
19
  health:
19
20
  path: /health
20
21
  readyPath: /ready
21
22
  rpc: false
23
+ # 内部 RPC 可选;启用时用对象替换 false,gateway-only 必须配置网关密钥。
24
+ # rpc:
25
+ # prefix: /api/rpc
26
+ # access: gateway-only # loopback | private-network | gateway-only
27
+ # gatewayHeader: X-Gateway-Key
28
+ # gatewaySecret: ${HAI_SERV_HTTP_RPC_GATEWAYSECRET}
22
29
 
23
30
  cors:
24
31
  origin: ${HAI_CORS_ORIGIN:*}
@@ -40,3 +47,4 @@ cors:
40
47
  transport:
41
48
  keyExchangePath: /_hai/key-exchange
42
49
  maxClients: 10000
50
+ # excludePaths: [/health, /ready]
@@ -1,3 +1 @@
1
- # HAI_STORAGE 可整体替换为 S3/MinIO/RustFS 配置。
2
- type: local
3
- root: ./data/uploads
1
+ {{{moduleConfig 'storage' true}}}
@@ -0,0 +1 @@
1
+ {{{moduleConfig 'vecdb' features.vecdb}}}
@@ -34,6 +34,7 @@
34
34
  "@h-ai/core": "{{haiPackageSpecifier}}",
35
35
  "@h-ai/crypto": "{{haiPackageSpecifier}}",
36
36
  "@h-ai/iam": "{{haiPackageSpecifier}}",
37
+ "@h-ai/reach": "{{haiPackageSpecifier}}",
37
38
  "@h-ai/reldb": "{{haiPackageSpecifier}}",
38
39
  "@h-ai/serv": "{{haiPackageSpecifier}}",
39
40
  "@h-ai/storage": "{{haiPackageSpecifier}}",
@@ -113,7 +113,7 @@ export async function createAdminUser(input: AdminCreateUser, context: ServConte
113
113
  return role
114
114
  if (!role.data)
115
115
  return err(HaiCommonError.NOT_FOUND, serverM('notFound', { locale: context.locale }))
116
- const registered = await iam.user.register(input)
116
+ const registered = await iam.user.createUser(input)
117
117
  if (!registered.success)
118
118
  return registered
119
119
  const userId = registered.data.user.id
@@ -20,7 +20,7 @@ import { serverM } from '../messages.js'
20
20
 
21
21
  /** 身份认证沿用框架公共契约与 refresh-cookie 传输。 */
22
22
  export function createAuthProcedures() {
23
- const { currentUser, refresh } = createIamProcedures({ iam }).auth
23
+ const { currentUser, refresh, publicConfig, loginWithOtp, loginWithLdap, register, sendOtp, requestPasswordReset, confirmPasswordReset } = createIamProcedures({ iam }).auth
24
24
  const { login, logout, changePassword } = authContract
25
25
  // 仅覆写需要业务审计的认证操作,其余直接复用框架;未选认证入口不挂载。
26
26
  const audited = serv.implement({ login, logout, changePassword }).context<ServContext>().route('login', async ({ input, context }) => {
@@ -42,7 +42,7 @@ export function createAuthProcedures() {
42
42
  await recordAudit(context, 'password-change', 'profile', context.session.userId)
43
43
  return result
44
44
  }).build()
45
- return { ...audited, currentUser, refresh }
45
+ return { ...audited, currentUser, refresh, publicConfig, loginWithOtp, loginWithLdap, register, sendOtp, requestPasswordReset, confirmPasswordReset }
46
46
  }
47
47
 
48
48
  export function createAdminProcedures() {
@@ -7,6 +7,7 @@ import { cache, CacheConfigSchema } from '@h-ai/cache'
7
7
  import { core } from '@h-ai/core'
8
8
  import { crypto } from '@h-ai/crypto'
9
9
  import { iam, IamConfigSchema } from '@h-ai/iam'
10
+ import { reach, ReachConfigSchema } from '@h-ai/reach'
10
11
  import { reldb, ReldbConfigSchema } from '@h-ai/reldb'
11
12
  import { ServConfigSchema } from '@h-ai/serv'
12
13
  import { storage, StorageConfigSchema } from '@h-ai/storage'
@@ -39,7 +40,10 @@ async function runInitialization(): Promise<void> {
39
40
  requireInitialized(await cache.init(cacheConfig), 'cache')
40
41
  requireInitialized(await crypto.init(), 'crypto')
41
42
  requireInitialized(await storage.init(storageConfig), 'storage')
42
- requireInitialized(await iam.init(iamConfig), 'iam')
43
+ const reachConfig = core.config.get('reach')
44
+ if (reachConfig)
45
+ requireInitialized(await reach.init(ReachConfigSchema.parse(reachConfig)), 'reach')
46
+ requireInitialized(await iam.init({ ...iamConfig, reach }), 'iam')
43
47
  requireInitialized(await audit.init(auditConfig), 'audit')
44
48
  await ensureRolesAndAdmin()
45
49
  initialized = true
@@ -71,7 +75,7 @@ async function ensureRolesAndAdmin(): Promise<void> {
71
75
  const password = process.env.HAI_ADMIN_DEFAULT_PASSWORD?.trim()
72
76
  if (!password)
73
77
  throw new Error('Set HAI_ADMIN_DEFAULT_PASSWORD before the first start to create the administrator account')
74
- const registered = requireInitialized(await iam.user.register({ username: 'admin', password, displayName: serverM('admin') }), 'iam-bootstrap')
78
+ const registered = requireInitialized(await iam.user.createUser({ username: 'admin', password, displayName: serverM('admin') }), 'iam-bootstrap')
75
79
  const adminRole = requireInitialized(await iam.authz.getRoleByCode('admin'), 'iam-role')
76
80
  if (!adminRole)
77
81
  throw new Error('Administrator role is missing')
@@ -87,7 +91,7 @@ async function ensureRolesAndAdmin(): Promise<void> {
87
91
 
88
92
  /** 关闭顺序与依赖初始化顺序相反,同时覆盖启动失败路径。 */
89
93
  export async function closeModules(): Promise<void> {
90
- for (const [name, module] of [['audit', audit], ['iam', iam], ['storage', storage], ['crypto', crypto], ['cache', cache], ['reldb', reldb]] as const) {
94
+ for (const [name, module] of [['audit', audit], ['iam', iam], ['reach', reach], ['storage', storage], ['crypto', crypto], ['cache', cache], ['reldb', reldb]] as const) {
91
95
  if (!module.isInitialized)
92
96
  continue
93
97
  const result = await module.close()
@@ -113,11 +113,18 @@ describe('{{fullstack.servPackageName}} mini administration', () => {
113
113
  }
114
114
  })
115
115
 
116
- it('does not mount unselected default IAM routes', async () => {
116
+ it('mounts public auth routes while keeping default IAM administration routes private', async () => {
117
+ const config = await transport.encryptedFetch(`http://localhost${PREFIX}/auth/config`)
118
+ expect(config.status).toBe(200)
119
+ for (const route of ['/auth/register', '/auth/login/ldap', '/auth/login/otp', '/auth/otp/send', '/auth/password-reset/request', '/auth/password-reset/confirm']) {
120
+ const response = await transport.encryptedFetch(`http://localhost${PREFIX}${route}`, {
121
+ method: 'POST',
122
+ headers: { 'content-type': 'application/json' },
123
+ body: '{}',
124
+ })
125
+ expect(response.status).toBe(400)
126
+ }
117
127
  for (const [route, method] of [
118
- ['/auth/register', 'POST'],
119
- ['/auth/login/otp', 'POST'],
120
- ['/auth/otp/send', 'POST'],
121
128
  ['/iam/users', 'GET'],
122
129
  ['/iam/roles', 'POST'],
123
130
  ] as const) {
@@ -25,8 +25,20 @@ Language and theme preferences are saved in IAM user metadata, restored after lo
25
25
 
26
26
  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.
27
27
 
28
+ All frontend and shared projects load the installed message-format plugin from the workspace root `node_modules`, matching the scaffold's hoisted dependency layout. Compilation uses the workspace dependency version and does not fetch a plugin from a CDN.
29
+
28
30
  The scaffold theme configuration determines the themes compiled into frontend CSS. Personal settings offer only those available themes. Customize the common layout in `src/lib/components/AppShell.svelte`, the management pages in adjacent components, and business behavior in `src/lib/stores/management-store.svelte.ts`.
29
31
 
30
32
  ## Validation
31
33
 
32
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.
35
+
36
+ ## IAM configuration
37
+
38
+ The service's `config/_iam.yml` controls login methods, registration, password reset, agreements and `ui` branding. Email and SMS use providers/templates in `_reach.yml` or custom callbacks. `AuthPanel` uses the public configuration and framework auth actions to manage tokens, resend countdowns and errors. All service nodes must share the same database and Redis; memory cache is for a single process.
39
+
40
+ LDAP and Reach are optional. Install ldapts in the IAM service workspace only when using its default LDAP client, then enable login.ldap and set connection settings. Custom onOtpSendEmail/onOtpSendSms/onPasswordResetRequest callbacks work without Reach and must reject on delivery failure. Agreements open in a Modal. LoginForm/RegisterForm support header/extraFields/footer snippets; AuthPanel forwards loginHeader/loginFields/loginFooter and registerHeader/registerFields/registerFooter. See the generated hai-iam/hai-ui skills for configuration and examples.
41
+
42
+ 协议支持配置中的 userAgreementMarkdown/privacyPolicyMarkdown 正文及 URL;同主题优先 Markdown,由安全渲染器在弹窗显示。
43
+
44
+ See `.agents/skills/hai-iam/reference.md` in the generated application for the complete contract.
@@ -39,7 +39,7 @@
39
39
  "preferences_title": "Preferences",
40
40
  "preferences_description": "Your language and appearance preferences follow your account across devices.",
41
41
  "password_title": "Change password",
42
- "password_rules": "Use at least 8 characters. Choose a different password. You will need to sign in again after changing it.",
42
+ "password_rules": "Choose a different password.",
43
43
  "password_changed": "Password updated. Please sign in with your new password.",
44
44
  "avatar_hint": "Upload a PNG, JPEG or WebP image, up to 512 KB.",
45
45
  "avatar_invalid": "Choose a PNG, JPEG or WebP image no larger than 512 KB.",
@@ -55,7 +55,7 @@
55
55
  "user_role": "Role",
56
56
  "user_status": "Status",
57
57
  "user_create": "Add user",
58
- "initial_password_rules": "Use at least 8 characters.",
58
+ "initial_password_rules": "Use at least {min} characters.",
59
59
  "account_enabled": "Allow sign-in",
60
60
  "users_empty": "No matching accounts. Try another search or create a user.",
61
61
  "user_edit": "Edit user",
@@ -39,7 +39,7 @@
39
39
  "preferences_title": "偏好设置",
40
40
  "preferences_description": "语言与界面风格保存到个人账户,在不同设备上保持一致。",
41
41
  "password_title": "修改密码",
42
- "password_rules": "密码至少 8 个字符,请使用与旧密码不同的新密码。修改后需要重新登录。",
42
+ "password_rules": "请使用与旧密码不同的新密码。",
43
43
  "password_changed": "密码已修改,请使用新密码重新登录。",
44
44
  "avatar_hint": "支持 PNG、JPEG 和 WebP 图片,大小不超过 512 KB。",
45
45
  "avatar_invalid": "请选择不超过 512 KB 的 PNG、JPEG 或 WebP 图片。",
@@ -55,7 +55,7 @@
55
55
  "user_role": "角色",
56
56
  "user_status": "状态",
57
57
  "user_create": "新增用户",
58
- "initial_password_rules": "密码至少 8 个字符。",
58
+ "initial_password_rules": "密码至少 {min} 个字符。",
59
59
  "account_enabled": "启用账户",
60
60
  "users_empty": "没有匹配的账户,请调整查询条件或新增用户。",
61
61
  "user_edit": "编辑用户",
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@h-ai/api-client": "{{haiPackageSpecifier}}",
34
+ "@h-ai/api-contract": "{{haiPackageSpecifier}}",
34
35
  "@h-ai/core": "{{haiPackageSpecifier}}",
35
36
  "@h-ai/crypto": "{{haiPackageSpecifier}}",
36
37
  "@h-ai/ui": "{{haiPackageSpecifier}}",
@@ -3,7 +3,7 @@
3
3
  "baseLocale": "{{fullstack.i18n.baseLocale}}",
4
4
  "locales": [{{#each fullstack.i18n.locales}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}],
5
5
  "modules": [
6
- "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"
6
+ "../../node_modules/@inlang/plugin-message-format/dist/index.js"
7
7
  ],
8
8
  "plugin.inlang.messageFormat": {
9
9
  "pathPattern": "./messages/{locale}.json"
@@ -86,3 +86,6 @@ function normalizeApiBaseUrl(value: string): string {
86
86
  url.pathname = '/api/v1'
87
87
  return url.toString().replace(/\/+$/, '')
88
88
  }
89
+
90
+ /** 标准 IAM 表单动作,平台安全 TokenStorage 由原有初始化配置决定。 */
91
+ export const managementAuthActions = apiClient.createAuthActions(managementApi)
@@ -1,9 +1,10 @@
1
- <script lang="ts">
1
+ <script lang='ts'>
2
2
  import type { ManagementApiOptions } from '../api/api-client.js'
3
3
  import { Alert, Skeleton } from '@h-ai/ui/components/compounds/index'
4
4
  import { Button } from '@h-ai/ui/components/primitives/index'
5
- import { LoginForm } from '@h-ai/ui/components/scenes/iam/index'
5
+ import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
6
6
  import { onMount } from 'svelte'
7
+ import { managementAuthActions } from '../api/api-client.js'
7
8
  import * as m from '../paraglide/messages.js'
8
9
  import { ManagementStore } from '../stores/management-store.svelte.js'
9
10
  import AppShell from './AppShell.svelte'
@@ -15,7 +16,16 @@
15
16
  }
16
17
  const { platform, apiOptions = {}, onLocaleChange }: Props = $props()
17
18
  const store = new ManagementStore()
18
- const loginErrors = $derived({ general: store.error })
19
+ let resetToken = $state('')
20
+ async function authenticated() {
21
+ await store.authenticated()
22
+ if (resetToken) {
23
+ const url = new URL(location.href)
24
+ url.searchParams.delete('token')
25
+ history.replaceState(history.state, '', url)
26
+ }
27
+ resetToken = ''
28
+ }
19
29
  const pageComponent = $derived.by(() => {
20
30
  if (store.isAdmin && store.page === 'users')
21
31
  return import('./UserManagement.svelte')
@@ -26,6 +36,7 @@
26
36
  return import('./ProfileSettings.svelte')
27
37
  })
28
38
  onMount(() => {
39
+ resetToken = new URL(location.href).searchParams.get('token') ?? ''
29
40
  void store.initialize(apiOptions, onLocaleChange)
30
41
  return () => {
31
42
  void store.close()
@@ -39,31 +50,29 @@
39
50
 
40
51
  {#key store.locale}
41
52
  {#if store.initializing}
42
- <div class="grid min-h-dvh place-items-center bg-base-200/50"><div class="w-72 space-y-4"><Skeleton variant="title" /><Skeleton count={3} /><p class="text-center text-sm text-base-content/50">{m.home_loading()}</p></div></div>
43
- {:else if !store.user}
44
- <main class="grid min-h-dvh place-items-center bg-base-200/50 px-4 py-10">
45
- <section class="w-full max-w-md rounded-2xl border border-base-content/8 bg-base-100 p-7 shadow-xl shadow-base-content/3 sm:p-9" data-testid="login-page">
46
- <div class="mb-7 flex items-center gap-3">
47
- <span class="grid size-11 place-items-center rounded-xl bg-primary text-primary-content"><span class="icon-[tabler--hexagon-letter-h] size-6"></span></span>
48
- <div><h1 class="text-lg font-semibold" data-testid="app-name">{store.info?.name || m.login_title()}</h1><p class="mt-1 text-xs text-base-content/45">{m.login_description()}</p></div>
53
+ <div class='grid min-h-dvh place-items-center bg-base-200/50'><div class='w-72 space-y-4'><Skeleton variant='title' /><Skeleton count={3} /><p class='text-center text-sm text-base-content/50'>{m.home_loading()}</p></div></div>
54
+ {:else if !store.user || resetToken}
55
+ <main data-testid='login-page'>
56
+ {#if !store.info || !store.authConfig}
57
+ <section class='mx-auto mt-20 max-w-md space-y-4 p-6'>
58
+ <Alert variant='error'>{store.error || m.home_error()}</Alert>
59
+ <Button variant='primary' onclick={() => void store.initialize(apiOptions, onLocaleChange)}>{m.home_refresh()}</Button>
60
+ </section>
61
+ {:else}
62
+ <div data-testid='login-form'>
63
+ {#if store.authConfig && store.error}<Alert variant='error' class='mx-auto max-w-lg'>{store.error}</Alert>{/if}
64
+ <AuthPanel config={store.authConfig} actions={managementAuthActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} error={store.authConfig ? '' : store.error} onretry={() => void store.initialize(apiOptions, onLocaleChange)} onauthenticated={authenticated} />
49
65
  </div>
50
- {#if store.notice}<Alert variant="success" class="mb-5" data-testid="notice">{store.notice}</Alert>{/if}
51
- {#if !store.info}
52
- <Alert variant="error">{store.error || m.home_error()}</Alert>
53
- <Button class="mt-5 w-full" variant="primary" onclick={() => void store.initialize(apiOptions, onLocaleChange)}>{m.home_refresh()}</Button>
54
- {:else}
55
- <LoginForm loading={store.saving} showRememberMe={false} showForgotPassword={false} errors={loginErrors} onsubmit={data => store.login(data)} data-testid="login-form" />
56
- <p class="mt-6 text-center text-xs text-base-content/40"><span data-testid="app-version">v{store.info.version}</span> · <span data-testid="app-environment">{store.info.env}</span></p>
57
- {/if}
58
- </section>
66
+ <p class='fixed bottom-3 left-0 right-0 text-center text-xs text-base-content/50'><span data-testid='app-name'>{store.info.name}</span> · <span data-testid='app-version'>v{store.info.version}</span> · <span data-testid='app-environment'>{store.info.env}</span></p>
67
+ {/if}
59
68
  </main>
60
69
  {:else}
61
70
  <AppShell {platform} {store}>
62
- <div class="space-y-5">
63
- {#if store.error}<Alert variant="error" data-testid="error">{store.error}</Alert>{/if}
64
- {#if store.notice}<Alert variant="success" data-testid="notice">{store.notice}</Alert>{/if}
71
+ <div class='space-y-5'>
72
+ {#if store.error}<Alert variant='error' data-testid='error'>{store.error}</Alert>{/if}
73
+ {#if store.notice}<Alert variant='success' data-testid='notice'>{store.notice}</Alert>{/if}
65
74
  {#await pageComponent}
66
- <div class="space-y-4"><Skeleton variant="title" /><Skeleton count={3} /></div>
75
+ <div class='space-y-4'><Skeleton variant='title' /><Skeleton count={3} /></div>
67
76
  {:then { default: Page }}
68
77
  <Page {store} />
69
78
  {/await}
@@ -1,4 +1,4 @@
1
- <script lang="ts">
1
+ <script lang='ts'>
2
2
  import type { ManagementStore } from '../stores/management-store.svelte.js'
3
3
  import { Card } from '@h-ai/ui/components/compounds/index'
4
4
  import { Avatar, Select } from '@h-ai/ui/components/primitives/index'
@@ -14,44 +14,44 @@
14
14
  const themeOptions = AVAILABLE_THEMES.map(theme => ({ value: theme, label: getThemeInfo(theme)?.name ?? theme }))
15
15
  </script>
16
16
 
17
- <section class="space-y-5" data-testid="settings-page">
17
+ <section class='space-y-5' data-testid='settings-page'>
18
18
  <div>
19
- <h1 class="text-xl font-semibold tracking-tight">{m.settings_title()}</h1>
20
- <p class="mt-1 text-sm text-base-content/50">{m.settings_description()}</p>
19
+ <h1 class='text-xl font-semibold tracking-tight'>{m.settings_title()}</h1>
20
+ <p class='mt-1 text-sm text-base-content/50'>{m.settings_description()}</p>
21
21
  </div>
22
22
  {#if store.user}
23
- <div class="grid gap-5 xl:grid-cols-[0.75fr_1.25fr]">
24
- <Card bordered shadow={false} class="h-fit">
25
- <div class="flex items-center gap-4">
26
- <Avatar name={store.user.displayName || store.user.username} src={store.user.avatarUrl} size="lg" />
27
- <div class="min-w-0">
28
- <h2 class="truncate text-lg font-semibold">{store.user.displayName || store.user.username}</h2>
29
- <p class="text-sm text-base-content/50">@{store.user.username}</p>
30
- <p class="mt-2 text-xs text-primary">{store.isAdmin ? m.role_admin() : m.role_user()}</p>
23
+ <div class='grid gap-5 xl:grid-cols-[0.75fr_1.25fr]'>
24
+ <Card bordered shadow={false} class='h-fit'>
25
+ <div class='flex items-center gap-4'>
26
+ <Avatar name={store.user.displayName || store.user.username} src={store.user.avatarUrl} size='lg' />
27
+ <div class='min-w-0'>
28
+ <h2 class='truncate text-lg font-semibold'>{store.user.displayName || store.user.username}</h2>
29
+ <p class='text-sm text-base-content/50'>@{store.user.username}</p>
30
+ <p class='mt-2 text-xs text-primary'>{store.isAdmin ? m.role_admin() : m.role_user()}</p>
31
31
  </div>
32
32
  </div>
33
- <p class="mt-4 text-xs leading-6 text-base-content/50">{m.avatar_hint()}</p>
33
+ <p class='mt-4 text-xs leading-6 text-base-content/50'>{m.avatar_hint()}</p>
34
34
  </Card>
35
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" />
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' />
37
37
  </Card>
38
38
  </div>
39
39
  <Card title={m.preferences_title()} bordered shadow={false}>
40
- <p class="mb-5 text-sm text-base-content/50">{m.preferences_description()}</p>
41
- <div class="grid gap-6 sm:grid-cols-2">
42
- <div class="space-y-2">
43
- <label class="text-sm font-medium" for="profile-language">{m.settings_language()}</label>
44
- <Select id="profile-language" value={store.locale} options={languageOptions} disabled={store.saving} onchange={locale => void store.savePreferences({ locale })} data-testid="profile-language" />
40
+ <p class='mb-5 text-sm text-base-content/50'>{m.preferences_description()}</p>
41
+ <div class='grid gap-6 sm:grid-cols-2'>
42
+ <div class='space-y-2'>
43
+ <label class='text-sm font-medium' for='profile-language'>{m.settings_language()}</label>
44
+ <Select id='profile-language' value={store.locale} options={languageOptions} disabled={store.saving} onchange={locale => void store.savePreferences({ locale })} data-testid='profile-language' />
45
45
  </div>
46
- <div class="space-y-2">
47
- <label class="text-sm font-medium" for="profile-theme">{m.settings_theme()}</label>
48
- <Select id="profile-theme" value={themeStore.current} options={themeOptions} disabled={store.saving} onchange={theme => void store.savePreferences({ theme })} data-testid="profile-theme" />
46
+ <div class='space-y-2'>
47
+ <label class='text-sm font-medium' for='profile-theme'>{m.settings_theme()}</label>
48
+ <Select id='profile-theme' value={themeStore.current} options={themeOptions} disabled={store.saving} onchange={theme => void store.savePreferences({ theme })} data-testid='profile-theme' />
49
49
  </div>
50
50
  </div>
51
51
  </Card>
52
52
  <Card title={m.password_title()} bordered shadow={false}>
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} minPasswordLength={8} onsubmit={data => store.changePassword(data)} data-testid="password-form" /></div>
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>
55
55
  </Card>
56
56
  {/if}
57
57
  </section>
@@ -141,7 +141,7 @@
141
141
  <FormField label={m.user_display_name()}><Input bind:value={displayName} maxlength={100} data-testid="user-display-name" /></FormField>
142
142
  <FormField label={m.user_email()}><Input type="email" bind:value={email} data-testid="user-email" /></FormField>
143
143
  {#if !editing}
144
- <FormField label={m.user_password()} hint={m.initial_password_rules()} required><PasswordInput bind:value={password} autocomplete="new-password" minLength={8} data-testid="user-password" /></FormField>
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
145
  {/if}
146
146
  <FormField label={m.user_role()}><Select bind:value={role} options={roleOptions} disabled={editing?.id === store.user?.id} data-testid="user-role" /></FormField>
147
147
  <Checkbox bind:checked={enabled} label={m.account_enabled()} disabled={editing?.id === store.user?.id} />
@@ -1,5 +1,6 @@
1
+ import type { IamPublicConfig } from '@h-ai/api-contract'
1
2
  import type { HaiResult } from '@h-ai/core'
2
- import type { ChangePasswordFormData, LoginFormData, UserProfileSubmitData } from '@h-ai/ui'
3
+ import type { ChangePasswordFormData, UserProfileSubmitData } from '@h-ai/ui'
3
4
  import type { AdminAuditLog, AdminCreateUser, AdminRole, AdminUpdateUser, AdminUser, AvatarUpload, ProfileUpdate } from '{{fullstack.contractPackageName}}'
4
5
  import type { AppInfo, ManagementApiOptions } from '../api/api-client.js'
5
6
  import { HaiApiClientError } from '@h-ai/api-client'
@@ -17,6 +18,7 @@ export type ManagementPage = 'users' | 'roles' | 'audit' | 'settings'
17
18
  /** 页面状态和业务操作在共享 store 中收敛,宿主端仅装配平台能力。 */
18
19
  export class ManagementStore {
19
20
  info = $state<AppInfo | null>(null)
21
+ authConfig = $state<IamPublicConfig | null>(null)
20
22
  user = $state<CurrentUser | null>(null)
21
23
  page = $state<ManagementPage>('settings')
22
24
  locale = $state(getLocale())
@@ -63,6 +65,12 @@ export class ManagementStore {
63
65
  return
64
66
  }
65
67
  this.info = info.data
68
+ const authConfig = await managementApi.iam.auth.publicConfig()
69
+ if (!authConfig.success) {
70
+ this.error = authConfig.error.message
71
+ return
72
+ }
73
+ this.authConfig = authConfig.data
66
74
  this.applyLocale(info.data.defaultLocale)
67
75
  // 默认 Cookie 存储通过 api-client 的加密刷新链路恢复会话。
68
76
  const me = await managementApi.iam.auth.currentUser()
@@ -89,20 +97,12 @@ export class ManagementStore {
89
97
  await managementApi.close()
90
98
  }
91
99
 
92
- async login(data: LoginFormData): Promise<void> {
93
- await this.mutate(async () => {
94
- const login = await managementApi.iam.auth.login({ identifier: data.username, password: data.password })
95
- if (!login.success) {
96
- this.error = login.error.httpStatus && login.error.httpStatus < 500 ? m.login_invalid() : m.network_error()
97
- return
98
- }
99
- await managementApi.auth.setTokens(login.data.tokens)
100
- const me = await this.request(() => managementApi.iam.auth.currentUser())
101
- if (!me?.success)
102
- return
103
- this.setUser(me.data)
104
- await this.navigate(this.isAdmin ? 'users' : 'settings')
105
- })
100
+ async authenticated(): Promise<void> {
101
+ const me = await this.request(() => managementApi.iam.auth.currentUser())
102
+ if (!me?.success)
103
+ return
104
+ this.setUser(me.data)
105
+ await this.navigate(this.isAdmin ? 'users' : 'settings')
106
106
  }
107
107
 
108
108
  async logout(): Promise<void> {
@@ -135,8 +135,8 @@ export class ManagementStore {
135
135
  await this.load(async (version) => {
136
136
  const result = await this.request(() => managementApi.admin.users.list({
137
137
  page,
138
- pageSize: this.pageSize,
139
- search: this.usersSearch.trim() || undefined,
138
+ pageSize: this.pageSize,
139
+ search: this.usersSearch.trim() || undefined,
140
140
  enabled: this.usersStatus === 'all' ? undefined : this.usersStatus === 'enabled',
141
141
  }), version)
142
142
  if (result?.success && version === this.requestVersion) {
@@ -163,8 +163,8 @@ search: this.usersSearch.trim() || undefined,
163
163
  await this.load(async (version) => {
164
164
  const result = await this.request(() => managementApi.admin.audit.list({
165
165
  page,
166
- pageSize: this.pageSize,
167
- action: this.auditAction.trim() || undefined,
166
+ pageSize: this.pageSize,
167
+ action: this.auditAction.trim() || undefined,
168
168
  resource: this.auditResource.trim() || undefined,
169
169
  startDate: this.auditStartDate ? new SvelteDate(`${this.auditStartDate}T00:00:00`).toISOString() : undefined,
170
170
  endDate: this.auditEndDate ? new SvelteDate(`${this.auditEndDate}T23:59:59.999`).toISOString() : undefined,
@@ -340,7 +340,7 @@ action: this.auditAction.trim() || undefined,
340
340
  if (!result.success) {
341
341
  if (result.error.httpStatus === 401 && this.user) {
342
342
  this.expireSession()
343
- }
343
+ }
344
344
  else if (result.error.httpStatus === 403 && this.user) {
345
345
  const current = await managementApi.iam.auth.currentUser()
346
346
  if (!isCurrent())
@@ -3,8 +3,8 @@ import * as m from '../src/lib/paraglide/messages.js'
3
3
  import { ManagementStore } from '../src/lib/stores/management-store.svelte.js'
4
4
 
5
5
  const api = vi.hoisted(() => ({
6
- auth: { clear: vi.fn(), setTokens: vi.fn() },
7
- iam: { auth: { login: vi.fn(), logout: vi.fn(), currentUser: vi.fn() } },
6
+ auth: { clear: vi.fn() },
7
+ iam: { auth: { logout: vi.fn(), currentUser: vi.fn() } },
8
8
  admin: { users: { list: vi.fn() } },
9
9
  }))
10
10
  vi.mock('../src/lib/api/api-client.js', () => ({
@@ -35,7 +35,6 @@ function deferred<T>() {
35
35
 
36
36
  beforeEach(() => {
37
37
  vi.resetAllMocks()
38
- api.iam.auth.login.mockResolvedValue({ success: true, data: { tokens: { accessToken: 'test-access', expiresIn: 3600, tokenType: 'Bearer' } } })
39
38
  api.iam.auth.logout.mockResolvedValue({ success: true })
40
39
  api.iam.auth.currentUser.mockResolvedValue({ success: true, data: member })
41
40
  })
@@ -48,7 +47,7 @@ describe('management session and request ownership', () => {
48
47
  store.user = administrator
49
48
  const loading = store.loadUsers()
50
49
  await store.logout()
51
- await store.login({ username: 'member', password: 'test-password', rememberMe: false })
50
+ await store.authenticated()
52
51
  oldRequest.resolve(forbidden)
53
52
  await loading
54
53
  expect(store.user?.id).toBe(member.id)
@@ -89,13 +88,13 @@ describe('management session and request ownership', () => {
89
88
  expect(api.iam.auth.currentUser).toHaveBeenCalledTimes(1)
90
89
  })
91
90
 
92
- it('reports a connection failure separately from invalid credentials', async () => {
93
- api.iam.auth.login.mockResolvedValueOnce({ success: false, error: { code: 'test:unavailable', message: 'Unavailable', httpStatus: 502 } })
91
+ it('reports a connection failure when loading the authenticated user', async () => {
92
+ api.iam.auth.currentUser.mockRejectedValueOnce(new Error('Connection unavailable'))
94
93
  const store = new ManagementStore()
95
- await store.login({ username: 'member', password: 'test-password', rememberMe: false })
94
+ await store.authenticated()
96
95
  expect(store.error).toBe(m.network_error())
97
96
  expect(store.error).not.toBe(m.login_invalid())
98
97
  expect(store.saving).toBe(false)
99
- expect(api.iam.auth.currentUser).not.toHaveBeenCalled()
98
+ expect(api.iam.auth.currentUser).toHaveBeenCalledTimes(1)
100
99
  })
101
100
  })