@h-ai/ui 0.1.0-alpha.59 → 0.1.0-alpha.60

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 CHANGED
@@ -262,10 +262,11 @@ For `BareInput` with `type="file"`, read `event.currentTarget.files` in `onchang
262
262
  | `ThemeSelector` | Full theme selector | No props required |
263
263
  | `ThemeToggle` | Light/dark toggle | No props required |
264
264
 
265
- #### IAM authentication (9)
265
+ #### IAM authentication (10)
266
266
 
267
267
  | Component | Description | Key props |
268
268
  | -------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
269
+ | `AuthPanel` | Configuration driven authentication | `config`, `actions`, `loginHeader/loginFields/loginFooter`, `registerHeader/registerFields/registerFooter` |
269
270
  | `AuthShell` | Auth page layout (centered card / split columns) | `variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `illustration`, `description`, `highlights` |
270
271
  | `LoginForm` | Login form | `loading`, `errors`, `showRememberMe`, `onsubmit` |
271
272
  | `RegisterForm` | Registration form | `loading`, `errors`, `fields`, `onsubmit` |
@@ -724,6 +725,35 @@ import {
724
725
  } from '@h-ai/ui'
725
726
  ```
726
727
 
728
+ ## Configuration driven authentication
729
+
730
+ Agreement content can be configured as `agreements.userAgreementMarkdown` and `privacyPolicyMarkdown` using YAML `|`. The modal safely renders configured Markdown and prefers it over the URL of the same agreement. URL-only content stays in a sandboxed iframe.
731
+
732
+ ```yaml
733
+ agreements:
734
+ showOnRegister: true
735
+ showOnLogin: false
736
+ userAgreementMarkdown: |
737
+ # 用户协议
738
+
739
+ **账户使用条款**
740
+
741
+ - 请妥善保管账户凭据。
742
+ privacyPolicyMarkdown: |
743
+ # 隐私政策
744
+
745
+ 注册资料用于账户管理,会话信息用于身份认证。
746
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
747
+ # userAgreementUrl: https://example.com/terms
748
+ # privacyPolicyUrl: https://example.com/privacy
749
+ ```
750
+
751
+
752
+
753
+ AuthPanel accepts public IAM config and actions for password/LDAP/email/SMS login, registration and recovery. config.ui supplies layout, branding and images. LoginForm supports methods, onsendotp, otpLength and resendInterval; password forms accept passwordPolicy. Network and token storage remain in actions. Known IAM authentication errors use the current UI language; custom application messages are preserved.
754
+
755
+ [完整 IAM 配置 / IAM configuration](../iam/README.en.md)
756
+
727
757
  ## Other exports
728
758
 
729
759
  ```ts
@@ -740,3 +770,17 @@ import { cn, generateId, getSizeClass, getVariantClass } from '@h-ai/ui'
740
770
  // Toast 单例
741
771
  import { toast } from '@h-ai/ui'
742
772
  ```
773
+
774
+ Agreements open in a Modal and preserve form input. Pages must allow iframe embedding; scripts are disabled and a new-window fallback is provided. LoginForm/RegisterForm expose `header/extraFields/footer`. AuthPanel forwards `loginHeader/loginFields/loginFooter` and `registerHeader/registerFields/registerFooter` while preserving navigation. Bind additional fields to application state and handle them in custom actions; they are not automatically sent to IAM. Use `type="button"` for extension buttons.
775
+
776
+ ```svelte
777
+ <!-- config 来自 IAM 公开配置,actions 来自 Kit/API client;m 为应用翻译。 -->
778
+ <AuthPanel {config} {actions}>
779
+ {#snippet loginHeader()}
780
+ <p>{m.loginHelp()}</p>
781
+ {/snippet}
782
+ {#snippet registerFields()}
783
+ <label><input type='checkbox' bind:checked={subscribe} />{m.subscribe()}</label>
784
+ {/snippet}
785
+ </AuthPanel>
786
+ ```
package/README.ja.md CHANGED
@@ -262,10 +262,11 @@ components/
262
262
  | `ThemeSelector` | テーマ選択パネル | プロパティ不要 |
263
263
  | `ThemeToggle` | ライト・ダーク切り替え | プロパティ不要 |
264
264
 
265
- #### IAM:認証(9)
265
+ #### IAM:認証(10)
266
266
 
267
267
  | コンポーネント | 説明 | 主なプロパティ |
268
268
  | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
269
+ | `AuthPanel` | 設定による認証画面 | `config`, `actions`, `loginHeader/loginFields/loginFooter`, `registerHeader/registerFields/registerFooter` |
269
270
  | `AuthShell` | 認証ページ配置(中央カード / 左右分割) | `variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `illustration`, `description`, `highlights` |
270
271
  | `LoginForm` | ログインフォーム | `loading`, `errors`, `showRememberMe`, `onsubmit` |
271
272
  | `RegisterForm` | 登録フォーム | `loading`, `errors`, `fields`, `onsubmit` |
@@ -724,6 +725,35 @@ import {
724
725
  } from '@h-ai/ui'
725
726
  ```
726
727
 
728
+ ## 設定による認証画面
729
+
730
+ YAML の `|` で `agreements.userAgreementMarkdown` と `privacyPolicyMarkdown` を設定できます。Modal は本文を安全に描画し、同じ規約の URL より本文を優先します。URL のみの場合は隔離 iframe を使用します。
731
+
732
+ ```yaml
733
+ agreements:
734
+ showOnRegister: true
735
+ showOnLogin: false
736
+ userAgreementMarkdown: |
737
+ # 用户协议
738
+
739
+ **账户使用条款**
740
+
741
+ - 请妥善保管账户凭据。
742
+ privacyPolicyMarkdown: |
743
+ # 隐私政策
744
+
745
+ 注册资料用于账户管理,会话信息用于身份认证。
746
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
747
+ # userAgreementUrl: https://example.com/terms
748
+ # privacyPolicyUrl: https://example.com/privacy
749
+ ```
750
+
751
+
752
+
753
+ AuthPanel に IAM 公開設定と actions を渡すと、複数方式のログイン、登録、復旧、規約を利用できます。config.ui でレイアウトとブランド画像を指定し、パスワードフォームは passwordPolicy を受け取ります。通信と Token 保存は actions の責務です。既知の IAM 認証エラーは現在の UI 言語で表示し、アプリ独自のメッセージはそのまま保持します。
754
+
755
+ [完整 IAM 配置 / IAM configuration](../iam/README.ja.md)
756
+
727
757
  ## その他のエクスポート
728
758
 
729
759
  ```ts
@@ -740,3 +770,17 @@ import { cn, generateId, getSizeClass, getVariantClass } from '@h-ai/ui'
740
770
  // Toast 单例
741
771
  import { toast } from '@h-ai/ui'
742
772
  ```
773
+
774
+ 規約は入力を保持したまま Modal で表示します。ページは iframe 埋め込みを許可し、スクリプトを無効化して別ウィンドウ表示も用意します。LoginForm/RegisterForm は `header/extraFields/footer`、AuthPanel は `loginHeader/loginFields/loginFooter` と `registerHeader/registerFields/registerFooter` を提供し、既定ナビゲーションを保持します。追加フィールドはアプリ状態と独自 actions で処理し、IAM に自動送信しません。拡張ボタンは `type="button"` を指定します。
775
+
776
+ ```svelte
777
+ <!-- config 来自 IAM 公开配置,actions 来自 Kit/API client;m 为应用翻译。 -->
778
+ <AuthPanel {config} {actions}>
779
+ {#snippet loginHeader()}
780
+ <p>{m.loginHelp()}</p>
781
+ {/snippet}
782
+ {#snippet registerFields()}
783
+ <label><input type='checkbox' bind:checked={subscribe} />{m.subscribe()}</label>
784
+ {/snippet}
785
+ </AuthPanel>
786
+ ```
package/README.md CHANGED
@@ -262,10 +262,11 @@ components/
262
262
  | `ThemeSelector` | 完整主题选择面板 | 无需 Props |
263
263
  | `ThemeToggle` | 明/暗主题切换 | 无需 Props |
264
264
 
265
- #### IAM 身份认证(9 个)
265
+ #### IAM 身份认证(10 个)
266
266
 
267
267
  | 组件 | 描述 | 主要属性 |
268
268
  | -------------------- | ---------- | ----------------------------------------------------- |
269
+ | `AuthPanel` | 配置驱动认证页 | `config`, `actions`, `loginHeader/loginFields/loginFooter`, `registerHeader/registerFields/registerFooter` |
269
270
  | `AuthShell` | 认证页布局(居中卡片 / 左右分栏) | `variant`, `title`, `subtitle`, `brandTitle`, `brandText`, `illustration`, `description`, `highlights` |
270
271
  | `LoginForm` | 登录表单 | `loading`, `errors`, `showRememberMe`, `onsubmit` |
271
272
  | `RegisterForm` | 注册表单 | `loading`, `errors`, `fields`, `onsubmit` |
@@ -683,6 +684,35 @@ transformPageChunk: ({ html }) => html.replace('%theme_bootstrap%', themeBootstr
683
684
  - 应用层只需处理**页面级文本**,组件内部文本由 @h-ai/ui 统一管理
684
685
  - 如需覆盖特定文本,通过 `submitText`、`labels` 等 props 传入
685
686
 
687
+ ## 配置驱动认证页面
688
+
689
+ 协议支持 `agreements.userAgreementMarkdown` 与 `privacyPolicyMarkdown` 直接从配置加载;YAML 的 `|` 保留 Markdown 段落和换行。弹窗复用安全 MarkdownRenderer,关闭原始 HTML、脚本和代码复制工具。同主题同时提供正文与 URL 时优先正文,URL 可作为额外查看入口;只有 URL 时使用隔离 iframe。
690
+
691
+ ```yaml
692
+ agreements:
693
+ showOnRegister: true
694
+ showOnLogin: false
695
+ userAgreementMarkdown: |
696
+ # 用户协议
697
+
698
+ **账户使用条款**
699
+
700
+ - 请妥善保管账户凭据。
701
+ privacyPolicyMarkdown: |
702
+ # 隐私政策
703
+
704
+ 注册资料用于账户管理,会话信息用于身份认证。
705
+ # 可选 URL;同主题同时配置 Markdown 和 URL 时,弹窗优先显示 Markdown。
706
+ # userAgreementUrl: https://example.com/terms
707
+ # privacyPolicyUrl: https://example.com/privacy
708
+ ```
709
+
710
+
711
+
712
+ AuthPanel 直接接收 IAM 白名单配置和 actions,内置登录(密码/LDAP/邮件/短信)、注册、找回、重置及协议展示。使用 `config.ui` 配置卡片/分栏、标题、品牌、Logo 和配图。`LoginForm` 提供 methods、onsendotp、otpLength、resendInterval;密码表单接受 passwordPolicy。组件只渲染与交互,网络和 Token 存储通过 actions 注入。认证场景按公开 IAM 错误码显示当前 UI 语言的提示,应用自定义错误保留原文。
713
+
714
+ [完整 IAM 配置 / IAM configuration](../iam/README.md)
715
+
686
716
  ### createLocaleStore
687
717
 
688
718
  用于客户端 locale 状态管理,自动同步到 `@h-ai/core` 全局 locale 管理器:
@@ -723,6 +753,24 @@ import {
723
753
  } from '@h-ai/ui'
724
754
  ```
725
755
 
756
+
757
+ 协议在 Modal 内显示并保留表单输入;使用 URL 时,协议页面须允许 iframe 嵌入,组件禁用其脚本,另提供新窗口查看入口。登录/注册表单使用 `header/extraFields/footer` snippet,AuthPanel 透传 `loginHeader/loginFields/loginFooter` 和 `registerHeader/registerFields/registerFooter`,底部保留内置导航。自定义字段由应用绑定状态和提交,不会隐式写入 IAM 用户模型。
758
+
759
+ ```svelte
760
+ <!-- config 来自 IAM 公开配置,actions 来自 Kit/API client;m 为应用翻译。 -->
761
+ <AuthPanel {config} {actions}>
762
+ {#snippet loginHeader()}
763
+ <p>{m.loginHelp()}</p>
764
+ {/snippet}
765
+ {#snippet registerFields()}
766
+ <label><input type='checkbox' bind:checked={subscribe} />{m.subscribe()}</label>
767
+ {/snippet}
768
+ </AuthPanel>
769
+ ```
770
+
771
+ 上例的 subscribe 为应用自身的 $state,应用包装 register 动作校验/提交扩展业务数据;如需单独组合表单,使用 `<RegisterForm> {#snippet extraFields()}...{/snippet} </RegisterForm>`。扩展里的按钮须为 `type="button"`,避免误提交。
772
+
773
+
726
774
  ## 其他导出
727
775
 
728
776
  ```ts
package/auto-import.d.ts CHANGED
@@ -88,6 +88,7 @@ declare global {
88
88
 
89
89
  // scenes - iam
90
90
  const AuthShell: typeof import('@h-ai/ui')['AuthShell']
91
+ const AuthPanel: typeof import('@h-ai/ui')['AuthPanel']
91
92
  const ChangePasswordForm: typeof import('@h-ai/ui')['ChangePasswordForm']
92
93
  const ForgotPasswordForm: typeof import('@h-ai/ui')['ForgotPasswordForm']
93
94
  const LoginForm: typeof import('@h-ai/ui')['LoginForm']
package/auto-import.js CHANGED
@@ -79,6 +79,7 @@ const UI_COMPONENTS = new Set([
79
79
  'ThemeToggle',
80
80
  // scenes - iam
81
81
  'AuthShell',
82
+ 'AuthPanel',
82
83
  'ChangePasswordForm',
83
84
  'ForgotPasswordForm',
84
85
  'LoginForm',
@@ -0,0 +1,48 @@
1
+ <!-- 登录和注册共用协议提示;弹窗不会清空表单或触发提交。 -->
2
+ <script lang='ts'>
3
+ import type { AgreementDisplay } from '../types.js'
4
+ import { uiM } from '../../../messages.js'
5
+ import Modal from '../../compounds/Modal.svelte'
6
+ import BareButton from '../../primitives/BareButton.svelte'
7
+ import MarkdownRenderer from '../ai/MarkdownRenderer.svelte'
8
+
9
+ const { agreements }: { agreements?: AgreementDisplay } = $props()
10
+ let selected = $state<'user' | 'privacy' | null>(null)
11
+ let open = $state(false)
12
+ const url = $derived(selected === 'user' ? agreements?.userAgreementUrl : agreements?.privacyPolicyUrl)
13
+ const markdown = $derived(selected === 'user' ? agreements?.userAgreementMarkdown : agreements?.privacyPolicyMarkdown)
14
+ const hasUserAgreement = $derived(Boolean(agreements?.userAgreementMarkdown || agreements?.userAgreementUrl))
15
+ const hasPrivacyPolicy = $derived(Boolean(agreements?.privacyPolicyMarkdown || agreements?.privacyPolicyUrl))
16
+ const title = $derived(selected === 'user' ? uiM('agreement_user_agreement') : uiM('agreement_privacy_policy'))
17
+
18
+ function show(kind: 'user' | 'privacy') {
19
+ selected = kind
20
+ open = true
21
+ }
22
+ </script>
23
+
24
+ {#snippet openPage()}
25
+ <a href={url} target='_blank' rel='noopener noreferrer' class='link link-primary text-sm'>{uiM('agreement_open_page')}</a>
26
+ {/snippet}
27
+
28
+ {#if hasUserAgreement || hasPrivacyPolicy}
29
+ <p class='text-xs text-base-content/50 text-center'>
30
+ {uiM('agreement_prefix')}
31
+ {#if hasUserAgreement}
32
+ <BareButton type='button' class='link link-primary' onclick={() => show('user')}>{uiM('agreement_user_agreement')}</BareButton>
33
+ {/if}
34
+ {#if hasUserAgreement && hasPrivacyPolicy}{uiM('agreement_and')}{/if}
35
+ {#if hasPrivacyPolicy}
36
+ <BareButton type='button' class='link link-primary' onclick={() => show('privacy')}>{uiM('agreement_privacy_policy')}</BareButton>
37
+ {/if}
38
+ </p>
39
+ <Modal bind:open {title} size='lg' height='min(80dvh, 48rem)' bodyClass='p-0' footer={url ? openPage : undefined}>
40
+ {#if open && markdown}
41
+ <!-- 正文优先;复用安全 Markdown 渲染,不开启原始 HTML 或协议页不需要的代码工具。 -->
42
+ <MarkdownRenderer content={markdown} class='p-6' enableHighlight={false} showCopyButton={false} breaks={false} />
43
+ {:else if open && url}
44
+ <!-- 配置只允许 HTTP(S);协议隔离显示,不允许执行脚本或顶层导航。 -->
45
+ <iframe src={url} {title} sandbox='' referrerpolicy='no-referrer' class='h-full min-h-80 w-full border-0 bg-white'></iframe>
46
+ {/if}
47
+ </Modal>
48
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { AgreementDisplay } from '../types.js';
2
+ type $$ComponentProps = {
3
+ agreements?: AgreementDisplay;
4
+ };
5
+ declare const AgreementNotice: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type AgreementNotice = ReturnType<typeof AgreementNotice>;
7
+ export default AgreementNotice;
@@ -0,0 +1,116 @@
1
+ <!-- 配置驱动认证场景;网络、通知及 Token 存储由 actions 注入。 -->
2
+ <script lang='ts'>
3
+ import type { HaiResult } from '@h-ai/core'
4
+ import type { AuthLoginMethod, AuthPanelMode, AuthPanelProps } from '../types.js'
5
+ import { untrack } from 'svelte'
6
+ import { uiM } from '../../../messages.js'
7
+ import { getDataAttributes } from '../../../utils.js'
8
+ import Alert from '../../compounds/Alert.svelte'
9
+ import BareButton from '../../primitives/BareButton.svelte'
10
+ import Button from '../../primitives/Button.svelte'
11
+ import Spinner from '../../primitives/Spinner.svelte'
12
+ import { authErrorMessage } from './auth-error-message.js'
13
+ import AuthShell from './AuthShell.svelte'
14
+ import ForgotPasswordForm from './ForgotPasswordForm.svelte'
15
+ import LoginForm from './LoginForm.svelte'
16
+ import RegisterForm from './RegisterForm.svelte'
17
+ import ResetPasswordForm from './ResetPasswordForm.svelte'
18
+
19
+ const { config, actions, initialMode = 'login', resetToken = '', loginUrl, registerUrl, forgotPasswordUrl, onauthenticated, error = '', onretry, loginHeader, loginFields, loginFooter, registerHeader, registerFields, registerFooter, ...restProps }: AuthPanelProps = $props()
20
+ const dataAttributes = $derived(getDataAttributes(restProps))
21
+ let mode = $state<AuthPanelMode>(untrack(() => initialMode))
22
+ let loading = $state(false)
23
+ let errors = $state<Record<string, string>>({})
24
+ let notice = $state('')
25
+ const methods = $derived(config ? (['password', 'ldap', 'email', 'sms'] as AuthLoginMethod[]).filter(method => config.login[method]) : [])
26
+ const title = $derived(mode === 'register' ? uiM('register_title') : mode === 'forgot' ? uiM('forgot_password_title') : mode === 'reset' ? uiM('reset_password_title') : uiM('login_title'))
27
+ const available = $derived(config && (mode === 'login' || (mode === 'register' ? config.register.enabled : config.passwordReset.enabled)))
28
+
29
+ function switchMode(next: AuthPanelMode) {
30
+ mode = next
31
+ errors = {}
32
+ notice = ''
33
+ }
34
+ async function run(operation: () => Promise<HaiResult<unknown>>, authenticate = false, successNotice = '') {
35
+ if (loading)
36
+ return
37
+ errors = {}
38
+ notice = ''
39
+ loading = true
40
+ try {
41
+ const result = await operation()
42
+ if (!result.success) {
43
+ if (mode === 'register' && result.error.code === 'hai:iam:004') {
44
+ // 默认禁用的新账户已完成注册,提示等待启用,避免用户重复注册。
45
+ switchMode('login')
46
+ notice = uiM('auth_registration_pending')
47
+ }
48
+ else {
49
+ errors = { general: authErrorMessage(result.error) }
50
+ }
51
+ }
52
+ else if (authenticate) {
53
+ await onauthenticated?.()
54
+ }
55
+ else {
56
+ notice = successNotice
57
+ }
58
+ }
59
+ catch { errors = { general: uiM('auth_network_error') } }
60
+ finally { loading = false }
61
+ }
62
+ </script>
63
+
64
+ {#snippet link(next: AuthPanelMode, label: string, href?: string)}
65
+ {#if href}<a {href} class='link link-primary text-sm' aria-disabled={loading} onclick={(event) => {
66
+ if (loading)
67
+ event.preventDefault()
68
+ // 跨路径或哈希路由由路由创建新表单;提前切换会让输入被随后导航清空。
69
+ // 当前路由只更新场景,支持登录页内的找回密码和返回登录。
70
+ else if (event.currentTarget.origin === window.location.origin && event.currentTarget.pathname === window.location.pathname && event.currentTarget.hash === window.location.hash)
71
+ switchMode(next)
72
+ }}>{label}</a>
73
+ {:else}<BareButton type='button' class='link link-primary text-sm' disabled={loading} onclick={() => switchMode(next)}>{label}</BareButton>{/if}
74
+ {/snippet}
75
+
76
+ <AuthShell {...dataAttributes} {...config?.ui} title={config?.ui.title || title} brandTitle={config?.ui.brandTitle || uiM('auth_brand')}>
77
+ {#if error}
78
+ <Alert variant='error'>{error}</Alert>
79
+ {#if onretry}<Button class='mt-4 w-full' onclick={onretry}>{uiM('crud_retry')}</Button>{/if}
80
+ {:else if !config}
81
+ <div class='flex justify-center' role='status'><Spinner /></div>
82
+ {:else if !available}
83
+ <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
84
+ <div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
85
+ {:else}
86
+ {#if notice}<Alert variant='success' class='mb-4'>{notice}</Alert>{/if}
87
+ {#if mode === 'login'}
88
+ <LoginForm {methods} {loading} {errors} header={loginHeader} extraFields={loginFields} showRememberMe={false} showForgotPassword={config.passwordReset.enabled} {forgotPasswordUrl}
89
+ onforgotpassword={forgotPasswordUrl ? undefined : () => switchMode('forgot')}
90
+ otpLength={config.otp.length} resendInterval={config.otp.resendInterval} onsendotp={actions.sendOtp}
91
+ agreements={config.agreements.showOnLogin ? config.agreements : undefined}
92
+ onsubmit={data => run(() => actions.login({ identifier: data.username, password: data.password, method: data.method, code: data.code }), true)}>
93
+ {#snippet footer()}
94
+ {#if loginFooter}{@render loginFooter()}{/if}
95
+ {#if config?.register.enabled}<div class='text-center text-sm'>{uiM('login_no_account')} {@render link('register', uiM('login_register_now'), registerUrl)}</div>{/if}
96
+ {/snippet}
97
+ </LoginForm>
98
+ {:else if mode === 'register'}
99
+ <RegisterForm {loading} {errors} header={registerHeader} extraFields={registerFields} fields={config.passwordReset.mode === 'phone' ? ['username', 'phone', 'password'] : ['username', 'email', 'password']} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
100
+ agreements={config.agreements.showOnRegister ? config.agreements : undefined} onsubmit={data => run(() => actions.register({ username: data.username || '', password: data.password, email: data.email, phone: data.phone, displayName: data.nickname }), true)}>
101
+ {#snippet footer()}
102
+ {#if registerFooter}{@render registerFooter()}{/if}
103
+ <div class='text-center'>{@render link('login', uiM('register_login_now'), loginUrl)}</div>
104
+ {/snippet}
105
+ </RegisterForm>
106
+ {:else if mode === 'forgot'}
107
+ <ForgotPasswordForm {loading} {errors} mode={config.passwordReset.mode ?? 'email'} onsubmit={data => run(() => actions.requestPasswordReset(data.email || data.phone || ''), false, uiM('auth_reset_sent'))}>
108
+ {#snippet footer()}<div class='flex justify-between gap-3'>{@render link('login', uiM('login_submit'), loginUrl)}{@render link('reset', uiM('auth_have_token'))}</div>{/snippet}
109
+ </ForgotPasswordForm>
110
+ {:else}
111
+ <ResetPasswordForm {loading} {errors} showCode={!resetToken} passwordPolicy={config.password} minPasswordLength={config.password.minLength}
112
+ onsubmit={data => run(() => actions.confirmPasswordReset(resetToken || data.code || '', data.newPassword), false, uiM('auth_reset_done'))} />
113
+ <div class='mt-4 text-center'>{@render link('login', uiM('login_submit'), loginUrl)}</div>
114
+ {/if}
115
+ {/if}
116
+ </AuthShell>
@@ -0,0 +1,4 @@
1
+ import type { AuthPanelProps } from '../types.js';
2
+ declare const AuthPanel: import("svelte").Component<AuthPanelProps, {}, "">;
3
+ type AuthPanel = ReturnType<typeof AuthPanel>;
4
+ export default AuthPanel;
@@ -23,6 +23,8 @@
23
23
  subtitle = '',
24
24
  brandTitle = '',
25
25
  brandText = '',
26
+ logoUrl,
27
+ illustrationUrl,
26
28
  logo,
27
29
  illustration,
28
30
  description,
@@ -43,6 +45,9 @@
43
45
  brandTitle?: string
44
46
  /** split 模式品牌区描述文案 */
45
47
  brandText?: string
48
+ /** 配置形式的 Logo 与配图;插槽优先。 */
49
+ logoUrl?: string
50
+ illustrationUrl?: string
46
51
  /** 顶部 Logo 插槽 */
47
52
  logo?: Snippet
48
53
  /** split 模式品牌区插图(如大图/海报) */
@@ -68,8 +73,8 @@
68
73
 
69
74
  {#snippet formPanel()}
70
75
  <div {...dataAttributes} class='flex w-full max-w-sm flex-col'>
71
- {#if logo}
72
- <div class={logoWrapClass}>{@render logo()}</div>
76
+ {#if logo || logoUrl}
77
+ <div class={logoWrapClass}>{#if logo}{@render logo()}{:else}<img src={logoUrl} alt={brandTitle || title} class='h-12 max-w-full object-contain' />{/if}</div>
73
78
  {/if}
74
79
  {#if title}
75
80
  <h1 class='text-center text-2xl font-semibold tracking-tight text-base-content'>{title}</h1>
@@ -98,11 +103,15 @@
98
103
  <div class='space-y-6'>
99
104
  {#if logo}
100
105
  <div>{@render logo()}</div>
106
+ {:else if logoUrl}
107
+ <img src={logoUrl} alt={brandTitle || title} class='h-12 max-w-full self-start object-contain' />
101
108
  {/if}
102
109
  {#if illustration}
103
110
  <div class='overflow-hidden rounded-2xl border border-primary-content/15 bg-primary-content/5 p-2'>
104
111
  {@render illustration()}
105
112
  </div>
113
+ {:else if illustrationUrl}
114
+ <img src={illustrationUrl} alt='' class='max-h-96 w-full rounded-2xl object-contain' />
106
115
  {/if}
107
116
  </div>
108
117
 
@@ -11,6 +11,9 @@ type $$ComponentProps = {
11
11
  brandTitle?: string;
12
12
  /** split 模式品牌区描述文案 */
13
13
  brandText?: string;
14
+ /** 配置形式的 Logo 与配图;插槽优先。 */
15
+ logoUrl?: string;
16
+ illustrationUrl?: string;
14
17
  /** 顶部 Logo 插槽 */
15
18
  logo?: Snippet;
16
19
  /** split 模式品牌区插图(如大图/海报) */
@@ -9,7 +9,7 @@
9
9
  import { cn, getDataAttributes } from '../../../utils.js'
10
10
  import Alert from '../../compounds/Alert.svelte'
11
11
  import Button from '../../primitives/Button.svelte'
12
- import { arePasswordsEqual } from './password-utils.js'
12
+ import { arePasswordsEqual, passwordPolicyError, passwordPolicyHint } from './password-utils.js'
13
13
  import PasswordInput from './PasswordInput.svelte'
14
14
 
15
15
  const {
@@ -18,6 +18,7 @@
18
18
  requireOldPassword = true,
19
19
  showPasswordStrength = true,
20
20
  minPasswordLength = 8,
21
+ passwordPolicy,
21
22
  submitText,
22
23
  class: className = '',
23
24
  errors = {},
@@ -30,6 +31,8 @@
30
31
  let newPassword = $state('')
31
32
  let confirmPassword = $state('')
32
33
 
34
+ const policyError = $derived(passwordPolicy && newPassword ? passwordPolicyError(newPassword, passwordPolicy) : '')
35
+
33
36
  const formClass = $derived(
34
37
  cn(
35
38
  'change-password-form space-y-5',
@@ -56,7 +59,7 @@
56
59
  */
57
60
  async function handleSubmit(e: SubmitEvent) {
58
61
  e.preventDefault()
59
- if (loading || disabled || !canSubmit)
62
+ if (policyError || loading || disabled || !canSubmit)
60
63
  return
61
64
 
62
65
  const data: ChangePasswordFormData = {
@@ -125,11 +128,14 @@
125
128
  </Alert>
126
129
  {/if}
127
130
 
131
+ {#if passwordPolicy}<p class='text-xs text-base-content/60'>{passwordPolicyHint(passwordPolicy)}</p>{/if}
132
+ {#if policyError}<p role='alert' class='text-sm text-error'>{policyError}</p>{/if}
133
+
128
134
  <Button
129
135
  type='submit'
130
136
  variant='primary'
131
137
  class='w-full sm:w-auto sm:min-w-40'
132
- disabled={loading || disabled || !canSubmit}
138
+ disabled={loading || disabled || !canSubmit || Boolean(policyError)}
133
139
  {loading}
134
140
  >
135
141
  {submitText || uiM('change_password_submit')}