@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.
- package/README.en.md +19 -1
- package/README.ja.md +19 -1
- package/README.md +19 -1
- package/dist/{chunk-WUSFJFW5.js → chunk-WW2MIO6T.js} +437 -126
- package/dist/chunk-WW2MIO6T.js.map +1 -0
- package/dist/cli-config-templates-QRF6B5T4.js +5 -0
- package/dist/{cli-config-templates-6XA7V6CC.js.map → cli-config-templates-QRF6B5T4.js.map} +1 -1
- package/dist/index.js +28 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/templates/README.md +14 -0
- package/templates/apps/api/.gitignore.hbs +1 -0
- package/templates/apps/api/apps/{{projectName}}-service/README.md.hbs +12 -0
- package/templates/apps/api/apps/{{projectName}}-service/src/app.ts.hbs +4 -7
- package/templates/apps/api/apps/{{projectName}}-service/src/lib/server/init.ts.hbs +11 -1
- package/templates/apps/api/apps/{{projectName}}-service/tests/service.test.ts.hbs +2 -1
- package/templates/apps/api/e2e/api-service.spec.ts.hbs +72 -6
- package/templates/apps/api/package.json.hbs +4 -0
- package/templates/apps/fullstack/.gitignore.hbs +1 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/README.md.hbs +10 -0
- package/templates/apps/fullstack/apps/{{projectName}}-app/project.inlang/settings.json.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/README.md.hbs +10 -0
- package/templates/apps/fullstack/apps/{{projectName}}-desktop/project.inlang/settings.json.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-miniapp/README.md.hbs +2 -0
- package/templates/apps/fullstack/apps/{{projectName}}-miniapp/project.inlang/settings.json.hbs +1 -1
- package/templates/apps/fullstack/apps/{{projectName}}-web/README.md.hbs +10 -0
- package/templates/apps/fullstack/apps/{{projectName}}-web/project.inlang/settings.json.hbs +1 -1
- package/templates/apps/fullstack/e2e/fullstack.spec.ts.hbs +63 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/README.md.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-contract.ts.hbs +8 -1
- package/templates/apps/fullstack/packages/{{projectName}}-contract/src/admin-schemas.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/README.md.hbs +18 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_ai.yml.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_audit.yml.hbs +1 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_cache.yml.hbs +1 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_core.yml.hbs +7 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_db.yml.hbs +1 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_deploy.yml.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_iam.yml.hbs +1 -21
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_payment.yml.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_reach.yml.hbs +52 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_scheduler.yml.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_serv.yml.hbs +8 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_storage.yml.hbs +1 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/config/_vecdb.yml.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/admin-service.ts.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/procedures/admin-procedures.ts.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-serv/src/server/runtime.ts.hbs +7 -3
- package/templates/apps/fullstack/packages/{{projectName}}-serv/tests/app-server.test.ts.hbs +11 -4
- package/templates/apps/fullstack/packages/{{projectName}}-shared/README.md.hbs +12 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/en-US.json.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-shared/messages/zh-CN.json.hbs +2 -2
- package/templates/apps/fullstack/packages/{{projectName}}-shared/package.json.hbs +1 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/project.inlang/settings.json.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/api/api-client.ts.hbs +3 -0
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/AdminWorkspace.svelte.hbs +32 -23
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/ProfileSettings.svelte.hbs +24 -24
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/components/UserManagement.svelte.hbs +1 -1
- package/templates/apps/fullstack/packages/{{projectName}}-shared/src/lib/stores/management-store.svelte.ts.hbs +20 -20
- package/templates/apps/fullstack/packages/{{projectName}}-shared/tests/management-store.test.ts.hbs +7 -8
- package/templates/apps/h5/src/routes/+layout.svelte.hbs +35 -23
- package/templates/apps/mobile-app/src/App.svelte.hbs +43 -0
- package/templates/base/.gitignore.hbs +1 -2
- package/templates/base/e2e/app.spec.ts.hbs +147 -0
- package/templates/base/package.json.hbs +1 -1
- package/templates/base/src/hooks.server.ts.hbs +2 -2
- package/templates/base/src/lib/server/init.ts.hbs +1 -1
- package/templates/base/src/routes/+layout.server.ts.hbs +6 -0
- package/templates/base/tests/scaffold.test.ts.hbs +3 -0
- package/templates/base/vite.config.ts.hbs +1 -0
- package/templates/features/iam/routes-admin/(auth)/+layout.svelte.hbs +2 -16
- package/templates/features/iam/routes-admin/(auth)/auth/forgot-password/+page.svelte.hbs +10 -0
- package/templates/features/iam/routes-admin/(auth)/auth/login/+page.svelte.hbs +6 -95
- package/templates/features/iam/routes-admin/(auth)/auth/register/+page.svelte.hbs +6 -117
- package/templates/features/iam/routes-admin/(auth)/auth/reset-password/+page.svelte.hbs +10 -0
- package/templates/features/iam/routes-h5/auth/forgot-password/+page.svelte.hbs +10 -0
- package/templates/features/iam/routes-h5/auth/login/+page.svelte.hbs +6 -97
- package/templates/features/iam/routes-h5/auth/register/+page.svelte.hbs +6 -112
- package/templates/features/iam/routes-h5/auth/reset-password/+page.svelte.hbs +10 -0
- package/templates/features/iam/routes-shared/api/auth/config/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/login/+server.ts +1 -33
- package/templates/features/iam/routes-shared/api/auth/login/ldap/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/login/otp/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/otp/send/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/password-reset/confirm/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/password-reset/request/+server.ts +4 -0
- package/templates/features/iam/routes-shared/api/auth/register/+server.ts +1 -23
- package/templates/features/iam/routes-website/auth/forgot-password/+page.svelte.hbs +10 -0
- package/templates/features/iam/routes-website/auth/login/+page.svelte.hbs +11 -0
- package/templates/features/iam/routes-website/auth/register/+page.svelte.hbs +11 -0
- package/templates/features/iam/routes-website/auth/reset-password/+page.svelte.hbs +10 -0
- package/templates/skills/hai-api-client/SKILL.md +5 -0
- package/templates/skills/hai-app-create/reference.md +10 -0
- package/templates/skills/hai-iam/SKILL.md +9 -2
- package/templates/skills/hai-iam/reference.md +76 -11
- package/templates/skills/hai-reach/SKILL.md +2 -0
- package/templates/skills/hai-ui/reference.md +47 -0
- package/dist/chunk-WUSFJFW5.js.map +0 -1
- package/dist/cli-config-templates-6XA7V6CC.js +0 -5
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* H5 应用根布局
|
|
5
5
|
*/
|
|
6
|
+
import type { BottomNavItem } from '@h-ai/ui'
|
|
6
7
|
import type { Snippet } from 'svelte'
|
|
7
8
|
{{#if hasI18n}}
|
|
8
9
|
import { browser } from '$app/environment'
|
|
9
10
|
{{/if}}
|
|
11
|
+
import { goto } from '$app/navigation'
|
|
10
12
|
import { resolve } from '$app/paths'
|
|
11
13
|
import { page } from '$app/state'
|
|
12
14
|
{{#if hasI18n}}
|
|
13
15
|
import * as m from '$lib/paraglide/messages'
|
|
14
16
|
import { getLocale } from '$lib/paraglide/runtime.js'
|
|
15
|
-
import { setGlobalLocale } from '@h-ai/ui'
|
|
17
|
+
import { BottomNav, setGlobalLocale } from '@h-ai/ui'
|
|
18
|
+
{{else}}
|
|
19
|
+
import { BottomNav } from '@h-ai/ui'
|
|
16
20
|
{{/if}}
|
|
17
21
|
import '../app.css'
|
|
18
22
|
|
|
@@ -22,17 +26,27 @@
|
|
|
22
26
|
|
|
23
27
|
const { children }: Props = $props()
|
|
24
28
|
|
|
25
|
-
const navItems = [
|
|
29
|
+
const navItems: (BottomNavItem & { path: '/' | '/discover' | '/profile' })[] = [
|
|
26
30
|
{{#if hasI18n}}
|
|
27
|
-
{ path: '/', label: m.nav_home(),
|
|
28
|
-
{ path: '/discover', label: m.nav_discover(),
|
|
29
|
-
{ path: '/profile', label: m.nav_profile(),
|
|
31
|
+
{ id: 'home', path: '/', label: m.nav_home(), iconClass: 'icon-[tabler--home]' },
|
|
32
|
+
{ id: 'discover', path: '/discover', label: m.nav_discover(), iconClass: 'icon-[tabler--compass]' },
|
|
33
|
+
{ id: 'profile', path: '/profile', label: m.nav_profile(), iconClass: 'icon-[tabler--user]' },
|
|
30
34
|
{{else}}
|
|
31
|
-
{ path: '/', label: '首页',
|
|
32
|
-
{ path: '/discover', label: '发现',
|
|
33
|
-
{ path: '/profile', label: '我的',
|
|
35
|
+
{ id: 'home', path: '/', label: '首页', iconClass: 'icon-[tabler--home]' },
|
|
36
|
+
{ id: 'discover', path: '/discover', label: '发现', iconClass: 'icon-[tabler--compass]' },
|
|
37
|
+
{ id: 'profile', path: '/profile', label: '我的', iconClass: 'icon-[tabler--user]' },
|
|
34
38
|
{{/if}}
|
|
35
|
-
]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const activeNav = $derived(navItems.find(item => item.path === page.url.pathname)?.id)
|
|
42
|
+
// 认证页面独立显示;业务页面复用框架导航和移动端安全区域样式。
|
|
43
|
+
const isAuthPage = $derived(page.url.pathname.startsWith('/auth'))
|
|
44
|
+
|
|
45
|
+
function handleNavigation(id: string) {
|
|
46
|
+
const item = navItems.find(item => item.id === id)
|
|
47
|
+
if (item)
|
|
48
|
+
void goto(resolve(item.path, {}))
|
|
49
|
+
}
|
|
36
50
|
{{#if hasI18n}}
|
|
37
51
|
|
|
38
52
|
if (browser) {
|
|
@@ -46,17 +60,15 @@
|
|
|
46
60
|
<title>{page.data.appConfig.name}</title>
|
|
47
61
|
</svelte:head>
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
</nav>
|
|
62
|
-
</div>
|
|
63
|
+
{#if isAuthPage}
|
|
64
|
+
{@render children()}
|
|
65
|
+
{:else}
|
|
66
|
+
<div class='hai-mobile-viewport'>
|
|
67
|
+
<div class='hai-mobile-shell max-w-lg'>
|
|
68
|
+
<main class='hai-mobile-main'>
|
|
69
|
+
{@render children()}
|
|
70
|
+
</main>
|
|
71
|
+
<BottomNav items={navItems} active={activeNav} onchange={handleNavigation} centered maxWidth='lg' safeArea />
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<script lang='ts'>
|
|
2
|
+
{{#if features.iam}}
|
|
3
|
+
import type { IamPublicConfig } from '@h-ai/api-contract'
|
|
4
|
+
import { apiClient } from '@h-ai/api-client'
|
|
5
|
+
{{/if}}
|
|
2
6
|
import { applyTheme, getSavedTheme, setGlobalLocale } from '@h-ai/ui'
|
|
7
|
+
{{#if features.iam}}
|
|
8
|
+
import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
|
|
9
|
+
{{/if}}
|
|
3
10
|
import { onMount } from 'svelte'
|
|
4
11
|
import { closeApi, initApi, resolveApiBase, usesNativeTokenStorage } from './lib/api.js'
|
|
5
12
|
import { appConfig, initClientCore } from './lib/app-config.js'
|
|
@@ -7,6 +14,23 @@
|
|
|
7
14
|
import * as m from './lib/paraglide/messages.js'
|
|
8
15
|
import { getLocale } from './lib/paraglide/runtime.js'
|
|
9
16
|
|
|
17
|
+
{{#if features.iam}}
|
|
18
|
+
const authActions = apiClient.createAuthActions(apiClient)
|
|
19
|
+
let authConfig = $state<IamPublicConfig | null>(null)
|
|
20
|
+
let authenticated = $state(false)
|
|
21
|
+
let resetToken = $state('')
|
|
22
|
+
|
|
23
|
+
function handleAuthenticated() {
|
|
24
|
+
if (resetToken) {
|
|
25
|
+
const url = new URL(location.href)
|
|
26
|
+
url.searchParams.delete('token')
|
|
27
|
+
history.replaceState(history.state, '', url)
|
|
28
|
+
}
|
|
29
|
+
resetToken = ''
|
|
30
|
+
authenticated = true
|
|
31
|
+
}
|
|
32
|
+
{{/if}}
|
|
33
|
+
|
|
10
34
|
type TabId = 'home' | 'tasks' | 'profile'
|
|
11
35
|
|
|
12
36
|
let booting = $state(true)
|
|
@@ -29,6 +53,16 @@
|
|
|
29
53
|
setGlobalLocale(getLocale())
|
|
30
54
|
await initCapacitor()
|
|
31
55
|
await initApi()
|
|
56
|
+
{{#if features.iam}}
|
|
57
|
+
const config = await authActions.publicConfig()
|
|
58
|
+
if (!config.success)
|
|
59
|
+
throw new Error(config.error.message)
|
|
60
|
+
authConfig = config.data
|
|
61
|
+
if (!authConfig)
|
|
62
|
+
throw new Error(m.boot_failed_title())
|
|
63
|
+
const me = await apiClient.iam.auth.currentUser()
|
|
64
|
+
authenticated = !resetToken && me.success
|
|
65
|
+
{{/if}}
|
|
32
66
|
nativeRuntime = await isNativeApp()
|
|
33
67
|
nativeTokenStorage = await usesNativeTokenStorage()
|
|
34
68
|
}
|
|
@@ -46,6 +80,9 @@
|
|
|
46
80
|
}
|
|
47
81
|
|
|
48
82
|
onMount(() => {
|
|
83
|
+
{{#if features.iam}}
|
|
84
|
+
resetToken = new URL(location.href).searchParams.get('token') ?? ''
|
|
85
|
+
{{/if}}
|
|
49
86
|
void bootstrap()
|
|
50
87
|
|
|
51
88
|
return () => {
|
|
@@ -73,6 +110,12 @@
|
|
|
73
110
|
</div>
|
|
74
111
|
</div>
|
|
75
112
|
</div>
|
|
113
|
+
{{#if features.iam}}
|
|
114
|
+
{:else if !authenticated}
|
|
115
|
+
<main>
|
|
116
|
+
<AuthPanel config={authConfig} actions={authActions} {resetToken} initialMode={resetToken ? 'reset' : 'login'} onauthenticated={handleAuthenticated} />
|
|
117
|
+
</main>
|
|
118
|
+
{{/if}}
|
|
76
119
|
{:else}
|
|
77
120
|
<div class='hai-mobile-viewport'>
|
|
78
121
|
<div class='hai-mobile-shell max-w-lg'>
|
|
@@ -1,11 +1,158 @@
|
|
|
1
|
+
{{#if features.iam}}
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
{{/if}}
|
|
5
|
+
import process from 'node:process'
|
|
1
6
|
import { expect, test } from '@playwright/test'
|
|
2
7
|
|
|
3
8
|
/** 首页从真实浏览器页面发起,覆盖基础渲染链路。 */
|
|
4
9
|
test('home page renders from browser', async ({ page }) => {
|
|
10
|
+
{{#if_eq appType 'h5'}}
|
|
11
|
+
await page.setViewportSize({ width: 390, height: 844 })
|
|
12
|
+
{{/if_eq}}
|
|
5
13
|
const pageErrors: string[] = []
|
|
6
14
|
page.on('pageerror', error => pageErrors.push(error.message))
|
|
7
15
|
await page.goto('/')
|
|
8
16
|
await expect(page).toHaveTitle(process.env.HAI_CORE_NAME ?? '{{projectName}}')
|
|
9
17
|
await expect(pageErrors).toEqual([])
|
|
10
18
|
await expect(page.locator('main, section').first()).toBeVisible()
|
|
19
|
+
{{#if_eq appType 'h5'}}
|
|
20
|
+
// 在手机宽度验证导航真正切换路由,避免样式失效但首页渲染测试仍通过。
|
|
21
|
+
const nav = page.locator('nav')
|
|
22
|
+
await nav.getByRole('button', { name: /发现|Discover/i, exact: true }).click()
|
|
23
|
+
await expect(page).toHaveURL(/\/discover$/)
|
|
24
|
+
await nav.getByRole('button', { name: /首页|Home/i, exact: true }).click()
|
|
25
|
+
await expect(page).toHaveURL(/\/$/)
|
|
26
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
|
27
|
+
{{#if features.iam}}
|
|
28
|
+
const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
|
|
29
|
+
await mkdir(evidence, { recursive: true })
|
|
30
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-home.png'), fullPage: true })
|
|
31
|
+
{{/if}}
|
|
32
|
+
{{/if_eq}}
|
|
11
33
|
})
|
|
34
|
+
{{#if features.iam}}
|
|
35
|
+
|
|
36
|
+
/** 实际注册和密码登录;截图只保存空白表单。 */
|
|
37
|
+
test('configured IAM registration and password login', async ({ page, context }) => {
|
|
38
|
+
test.setTimeout(90_000)
|
|
39
|
+
{{#if_eq appType 'h5'}}
|
|
40
|
+
await page.setViewportSize({ width: 390, height: 844 })
|
|
41
|
+
{{/if_eq}}
|
|
42
|
+
const evidence = process.env.HAI_AUTH_EVIDENCE_DIR || path.resolve('test-results/auth-screenshots')
|
|
43
|
+
await mkdir(evidence, { recursive: true })
|
|
44
|
+
const username = `scaf${Date.now()}`
|
|
45
|
+
const password = 'ScaffoldPassword123'
|
|
46
|
+
const authUrl = '{{#if isCapacitorApp}}/{{else}}/auth/login{{/if}}'
|
|
47
|
+
await page.goto(authUrl)
|
|
48
|
+
await expect(page.locator('#login-username')).toBeVisible()
|
|
49
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-login.png'), fullPage: true })
|
|
50
|
+
await page.getByRole('{{#if isCapacitorApp}}button{{else}}link{{/if}}', { name: /立即注册|Register now/i }).click()
|
|
51
|
+
await expect(page.locator('input[name="username"]')).toBeVisible()
|
|
52
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-register.png'), fullPage: true })
|
|
53
|
+
if (process.env.IAM_NOTIFICATION_URL) {
|
|
54
|
+
// 协议弹窗留在认证页,关闭后继续填写注册表单。
|
|
55
|
+
await page.getByRole('button', { name: /用户协议|User Agreement/i, exact: true }).click()
|
|
56
|
+
await expect(page.getByRole('dialog')).toBeVisible()
|
|
57
|
+
await expect(page.getByRole('dialog').getByRole('heading', { name: 'Configured registration agreement' })).toBeVisible()
|
|
58
|
+
await expect(page.locator('iframe')).toHaveCount(0)
|
|
59
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-agreement.png'), fullPage: true })
|
|
60
|
+
await page.keyboard.press('Escape')
|
|
61
|
+
await expect(page.getByRole('dialog')).not.toBeVisible()
|
|
62
|
+
}
|
|
63
|
+
await page.locator('input[name="username"]').fill(username)
|
|
64
|
+
await page.locator('input[name="email"]').fill(`${username}@example.com`)
|
|
65
|
+
await page.locator('input[type="password"]').nth(0).fill(password)
|
|
66
|
+
await page.locator('input[type="password"]').nth(1).fill(password)
|
|
67
|
+
await page.locator('button[type="submit"]').click()
|
|
68
|
+
await expect(page.locator('.register-form')).not.toBeVisible()
|
|
69
|
+
{{#unless isCapacitorApp}}
|
|
70
|
+
const me = await page.request.get('/api/auth/me')
|
|
71
|
+
expect(me.ok()).toBe(true)
|
|
72
|
+
await context.clearCookies()
|
|
73
|
+
{{/unless}}
|
|
74
|
+
await page.reload()
|
|
75
|
+
await page.goto(authUrl)
|
|
76
|
+
await expect(page.locator('#login-username')).toBeVisible()
|
|
77
|
+
await page.locator('#login-username').fill(username)
|
|
78
|
+
await page.locator('input[type="password"]').fill(password)
|
|
79
|
+
await page.locator('button[type="submit"]').click()
|
|
80
|
+
await expect(page.locator('.login-form')).not.toBeVisible()
|
|
81
|
+
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
|
82
|
+
|
|
83
|
+
if (!process.env.IAM_NOTIFICATION_URL)
|
|
84
|
+
return
|
|
85
|
+
await context.clearCookies()
|
|
86
|
+
await page.reload()
|
|
87
|
+
await page.goto(authUrl)
|
|
88
|
+
await page.getByRole('button', { name: /邮件验证码|Email code/i, exact: true }).click()
|
|
89
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-email.png'), fullPage: true })
|
|
90
|
+
const email = `${username}@example.com`
|
|
91
|
+
await page.locator('#login-username').fill(email)
|
|
92
|
+
await page.getByRole('button', { name: /发送验证码|Send code/i, exact: true }).click()
|
|
93
|
+
await expect(page.getByRole('button', { name: /秒后重发|Resend in/ })).toBeDisabled()
|
|
94
|
+
async function received(template: string) {
|
|
95
|
+
const response = await page.request.get(`${process.env.IAM_NOTIFICATION_URL}/messages?to=${encodeURIComponent(email)}`)
|
|
96
|
+
const messages = await response.json() as { template: string, vars: Record<string, string> }[]
|
|
97
|
+
return messages.findLast(item => item.template === template)?.vars
|
|
98
|
+
}
|
|
99
|
+
const otp = await received('otp_email')
|
|
100
|
+
expect(Boolean(otp?.code)).toBe(true)
|
|
101
|
+
await page.locator('#login-code').fill(otp?.code ?? '')
|
|
102
|
+
await page.locator('button[type="submit"]').click()
|
|
103
|
+
await expect(page.locator('.login-form')).not.toBeVisible()
|
|
104
|
+
await context.clearCookies()
|
|
105
|
+
await page.reload()
|
|
106
|
+
await page.goto(authUrl)
|
|
107
|
+
await page.getByRole('button', { name: /忘记密码|Forgot password/i }).click()
|
|
108
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-forgot.png'), fullPage: true })
|
|
109
|
+
await page.locator('input[type="email"]').fill(email)
|
|
110
|
+
await page.locator('button[type="submit"]').click()
|
|
111
|
+
await expect(page.locator('.alert-success')).toBeVisible()
|
|
112
|
+
const reset = await received('password_reset')
|
|
113
|
+
expect(Boolean(reset?.token)).toBe(true)
|
|
114
|
+
await page.getByRole('button', { name: /已有重置令牌|I have a reset token/i }).click()
|
|
115
|
+
await page.screenshot({ path: path.join(evidence, '{{projectName}}-reset.png'), fullPage: true })
|
|
116
|
+
const resetUrl = `{{#if isCapacitorApp}}/{{else}}/auth/reset-password{{/if}}?token=${encodeURIComponent(reset?.token ?? '')}`
|
|
117
|
+
{{#if_eq appType 'h5'}}
|
|
118
|
+
// 延迟客户端脚本,确定性覆盖 SSR 页面已显示、事件绑定尚未完成的窗口。
|
|
119
|
+
let resumeClient = () => {}
|
|
120
|
+
const clientBoot = new Promise<void>((resolve) => {
|
|
121
|
+
resumeClient = resolve
|
|
122
|
+
})
|
|
123
|
+
await page.route('**/*.js', async (route) => {
|
|
124
|
+
await clientBoot
|
|
125
|
+
await route.continue()
|
|
126
|
+
})
|
|
127
|
+
try {
|
|
128
|
+
await page.goto(resetUrl, { waitUntil: 'commit' })
|
|
129
|
+
await expect(page.locator('input[type="password"]').nth(0)).toBeDisabled()
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
resumeClient()
|
|
133
|
+
// 等待正在恢复的路由完成,避免提前移除拦截导致 continue 被重复处理。
|
|
134
|
+
await page.unrouteAll({ behavior: 'wait' })
|
|
135
|
+
}
|
|
136
|
+
{{else}}
|
|
137
|
+
await page.goto(resetUrl)
|
|
138
|
+
{{/if_eq}}
|
|
139
|
+
await expect(page.locator('input[type="password"]').nth(0)).toBeVisible()
|
|
140
|
+
await page.locator('input[type="password"]').nth(0).fill('ScaffoldReset456')
|
|
141
|
+
await page.locator('input[type="password"]').nth(1).fill('ScaffoldReset456')
|
|
142
|
+
await page.locator('button[type="submit"]').click()
|
|
143
|
+
await expect(page.locator('.alert-success')).toBeVisible()
|
|
144
|
+
await page.getByRole('{{#if isCapacitorApp}}button{{else}}link{{/if}}', { name: /^(登录|Sign in|Login)$/i }).click()
|
|
145
|
+
await page.locator('#login-username').fill(username)
|
|
146
|
+
await page.locator('input[type="password"]').fill('ScaffoldReset456')
|
|
147
|
+
await page.locator('button[type="submit"]').click()
|
|
148
|
+
await expect(page.locator('.login-form')).not.toBeVisible()
|
|
149
|
+
expect(new URL(page.url()).searchParams.has('token')).toBe(false)
|
|
150
|
+
await page.reload()
|
|
151
|
+
{{#if isCapacitorApp}}
|
|
152
|
+
// Web 预览采用内存 TokenStore,刷新后应回到登录,而不是已消费令牌的重置页面。
|
|
153
|
+
await expect(page.locator('#login-username')).toBeVisible()
|
|
154
|
+
{{else}}
|
|
155
|
+
await expect(await page.request.get('/api/auth/me')).toBeOK()
|
|
156
|
+
{{/if}}
|
|
157
|
+
})
|
|
158
|
+
{{/if}}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" },
|
|
3
3
|
"name": "{{projectName}}",
|
|
4
|
-
"version": "0.1.0-alpha.
|
|
4
|
+
"version": "0.1.0-alpha.61",
|
|
5
5
|
"private": true,
|
|
6
6
|
"packageManager": "pnpm@12.11.2",
|
|
7
7
|
"description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
|
|
@@ -89,10 +89,10 @@ const haiHandle = kit.createHandle({
|
|
|
89
89
|
data: session.data,
|
|
90
90
|
}
|
|
91
91
|
},
|
|
92
|
-
operations: iam.auth,
|
|
93
92
|
loginUrl: '/auth/login',
|
|
94
93
|
protectedPaths: ['/admin/*', '/api/*'],
|
|
95
|
-
publicPaths: ['/api/auth
|
|
94
|
+
publicPaths: [...kit.auth.publicPaths, '/api/auth/logout', '/api/public/*', '/api/health'],
|
|
95
|
+
operations: () => iam.auth,
|
|
96
96
|
},
|
|
97
97
|
{{/if}}
|
|
98
98
|
rateLimit: { windowMs: 60000, maxRequests: 200 },
|
|
@@ -190,7 +190,7 @@ export async function initApp(): Promise<void> {
|
|
|
190
190
|
|
|
191
191
|
{{#if features.iam}}
|
|
192
192
|
// ─── 初始化 IAM ───
|
|
193
|
-
const iamResult = await iam.init(iamConfig)
|
|
193
|
+
const iamResult = await iam.init({ ...iamConfig{{#if features.reach}}, reach{{/if}} })
|
|
194
194
|
if (!iamResult.success) {
|
|
195
195
|
throw new Error(`IAM initialization failed: ${iamResult.error.message}`)
|
|
196
196
|
}
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
{{#unless isSvelteOnlyApp}}
|
|
3
3
|
import type { CoreConfig } from '@h-ai/core'
|
|
4
4
|
import { core } from '@h-ai/core'
|
|
5
|
+
{{#if features.iam}}
|
|
6
|
+
import { iam } from '@h-ai/iam'
|
|
7
|
+
{{/if}}
|
|
5
8
|
|
|
6
9
|
/** 只向浏览器提供公开配置,避免暴露日志上下文、凭证或其它模块配置。 */
|
|
7
10
|
export function load() {
|
|
8
11
|
const config = core.config.getOrThrow<CoreConfig>('core')
|
|
9
12
|
return {
|
|
13
|
+
{{#if features.iam}}
|
|
14
|
+
iamPublicConfig: iam.publicConfig,
|
|
15
|
+
{{/if}}
|
|
10
16
|
appConfig: {
|
|
11
17
|
name: config.name,
|
|
12
18
|
version: config.version,
|
|
@@ -55,6 +55,9 @@ describe('generated scaffold', () => {
|
|
|
55
55
|
const config = core.config.getOrThrow<CoreConfig>('core')
|
|
56
56
|
expect(core.logger.getLevel()).toBe(config.logging?.level ?? 'info')
|
|
57
57
|
expect(load()).toEqual({
|
|
58
|
+
{{#if features.iam}}
|
|
59
|
+
iamPublicConfig: null,
|
|
60
|
+
{{/if}}
|
|
58
61
|
appConfig: { name: config.name, version: config.version, env: config.env, defaultLocale: config.defaultLocale },
|
|
59
62
|
})
|
|
60
63
|
})
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
{{!-- 认证布局(登录/注册共用) --}}
|
|
2
1
|
<script lang='ts'>
|
|
3
|
-
/**
|
|
4
|
-
* 认证布局(登录/注册共用)
|
|
5
|
-
*/
|
|
6
2
|
import type { Snippet } from 'svelte'
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
children: Snippet
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const { children }: Props = $props()
|
|
4
|
+
const { children }: { children: Snippet } = $props()
|
|
13
5
|
</script>
|
|
14
6
|
|
|
15
|
-
|
|
16
|
-
<div class='card w-full max-w-md bg-base-100 shadow-xl'>
|
|
17
|
-
<div class='card-body'>
|
|
18
|
-
{@render children()}
|
|
19
|
-
</div>
|
|
20
|
-
</div>
|
|
21
|
-
</div>
|
|
7
|
+
{@render children()}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang='ts'>
|
|
2
|
+
import { resolve } from '$app/paths'
|
|
3
|
+
import { page } from '$app/state'
|
|
4
|
+
import { kit } from '@h-ai/kit'
|
|
5
|
+
import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
|
|
6
|
+
|
|
7
|
+
const actions = kit.client.createAuthActions()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<AuthPanel config={page.data.iamPublicConfig} {actions} initialMode='forgot' resetToken={page.url.searchParams.get('token') ?? ''} loginUrl={resolve('/auth/login', {})} />
|
|
@@ -1,100 +1,11 @@
|
|
|
1
|
-
{{!-- Admin 登录页面 --}}
|
|
2
1
|
<script lang='ts'>
|
|
3
|
-
|
|
4
|
-
* 登录页面
|
|
5
|
-
*/
|
|
2
|
+
import { goto } from '$app/navigation'
|
|
6
3
|
import { resolve } from '$app/paths'
|
|
7
|
-
{
|
|
8
|
-
import
|
|
9
|
-
{
|
|
10
|
-
import { Alert, Button, FormField, Input } from '@h-ai/ui'
|
|
4
|
+
import { page } from '$app/state'
|
|
5
|
+
import { kit } from '@h-ai/kit'
|
|
6
|
+
import { AuthPanel } from '@h-ai/ui/components/scenes/iam/index'
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
let password = $state('')
|
|
14
|
-
let error = $state('')
|
|
15
|
-
let loading = $state(false)
|
|
16
|
-
|
|
17
|
-
async function handleLogin(event: SubmitEvent) {
|
|
18
|
-
event.preventDefault()
|
|
19
|
-
error = ''
|
|
20
|
-
loading = true
|
|
21
|
-
try {
|
|
22
|
-
const res = await fetch('/api/auth/login', {
|
|
23
|
-
method: 'POST',
|
|
24
|
-
headers: { 'Content-Type': 'application/json' },
|
|
25
|
-
body: JSON.stringify({ identifier: username, password }),
|
|
26
|
-
})
|
|
27
|
-
const data = await res.json()
|
|
28
|
-
if (data.success) {
|
|
29
|
-
window.location.href = '/admin'
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
{{#if hasI18n}}
|
|
33
|
-
error = data.error?.message || m.auth_login_failed()
|
|
34
|
-
{{else}}
|
|
35
|
-
error = data.error?.message || '登录失败'
|
|
36
|
-
{{/if}}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
{{#if hasI18n}}
|
|
41
|
-
error = m.auth_error_network()
|
|
42
|
-
{{else}}
|
|
43
|
-
error = '网络错误'
|
|
44
|
-
{{/if}}
|
|
45
|
-
}
|
|
46
|
-
finally {
|
|
47
|
-
loading = false
|
|
48
|
-
}
|
|
49
|
-
}
|
|
8
|
+
const actions = kit.client.createAuthActions()
|
|
50
9
|
</script>
|
|
51
10
|
|
|
52
|
-
<
|
|
53
|
-
{{#if hasI18n}}
|
|
54
|
-
<title>{m.auth_login_title()}</title>
|
|
55
|
-
{{else}}
|
|
56
|
-
<title>登录</title>
|
|
57
|
-
{{/if}}
|
|
58
|
-
</svelte:head>
|
|
59
|
-
|
|
60
|
-
{{#if hasI18n}}
|
|
61
|
-
<h2 class='text-2xl font-bold text-center mb-6'>{m.auth_login_title()}</h2>
|
|
62
|
-
{{else}}
|
|
63
|
-
<h2 class='text-2xl font-bold text-center mb-6'>登录</h2>
|
|
64
|
-
{{/if}}
|
|
65
|
-
|
|
66
|
-
{#if error}
|
|
67
|
-
<Alert variant='error' class='mb-4'>{error}</Alert>
|
|
68
|
-
{/if}
|
|
69
|
-
|
|
70
|
-
<form onsubmit={handleLogin} class='space-y-4'>
|
|
71
|
-
{{#if hasI18n}}
|
|
72
|
-
<FormField label={m.auth_label_username()} required>
|
|
73
|
-
<Input id='username' type='text' bind:value={username} required />
|
|
74
|
-
</FormField>
|
|
75
|
-
<FormField label={m.auth_label_password()} required>
|
|
76
|
-
<Input id='password' type='password' bind:value={password} required />
|
|
77
|
-
</FormField>
|
|
78
|
-
<Button type='submit' variant='primary' loading={loading} class='w-full'>
|
|
79
|
-
{loading ? m.auth_login_loading() : m.auth_login_submit()}
|
|
80
|
-
</Button>
|
|
81
|
-
{{else}}
|
|
82
|
-
<FormField label='用户名' required>
|
|
83
|
-
<Input id='username' type='text' bind:value={username} required />
|
|
84
|
-
</FormField>
|
|
85
|
-
<FormField label='密码' required>
|
|
86
|
-
<Input id='password' type='password' bind:value={password} required />
|
|
87
|
-
</FormField>
|
|
88
|
-
<Button type='submit' variant='primary' loading={loading} class='w-full'>
|
|
89
|
-
{loading ? '登录中...' : '登录'}
|
|
90
|
-
</Button>
|
|
91
|
-
{{/if}}
|
|
92
|
-
</form>
|
|
93
|
-
|
|
94
|
-
<div class='text-center mt-4'>
|
|
95
|
-
{{#if hasI18n}}
|
|
96
|
-
<a href={resolve('/auth/register', {})} class='link link-primary text-sm'>{m.auth_login_to_register()}</a>
|
|
97
|
-
{{else}}
|
|
98
|
-
<a href={resolve('/auth/register', {})} class='link link-primary text-sm'>没有账号?立即注册</a>
|
|
99
|
-
{{/if}}
|
|
100
|
-
</div>
|
|
11
|
+
<AuthPanel config={page.data.iamPublicConfig} {actions} initialMode='login' loginUrl={resolve('/auth/login', {})} registerUrl={resolve('/auth/register', {})} onauthenticated={() => goto(resolve('/admin', {}))} />
|