@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.
@@ -4,7 +4,7 @@
4
4
  -->
5
5
  <script lang='ts'>
6
6
  import type { DataAttributes } from '../../../types.js'
7
- import type { LoginFormData, LoginFormProps } from '../types.js'
7
+ import type { AuthLoginMethod, LoginFormData, LoginFormProps } from '../types.js'
8
8
  import { uiM } from '../../../messages.js'
9
9
  import { cn, getDataAttributes } from '../../../utils.js'
10
10
  import Alert from '../../compounds/Alert.svelte'
@@ -12,9 +12,15 @@
12
12
  import Button from '../../primitives/Button.svelte'
13
13
  import Checkbox from '../../primitives/Checkbox.svelte'
14
14
  import Input from '../../primitives/Input.svelte'
15
+ import AgreementNotice from './AgreementNotice.svelte'
16
+ import { authErrorMessage } from './auth-error-message.js'
15
17
  import PasswordInput from './PasswordInput.svelte'
16
18
 
17
19
  const {
20
+ methods = ['password'],
21
+ otpLength = 6,
22
+ resendInterval = 60,
23
+ onsendotp,
18
24
  loading = false,
19
25
  disabled = false,
20
26
  showTitle = false,
@@ -30,18 +36,65 @@
30
36
  onsubmit,
31
37
  onforgotpassword,
32
38
  header,
39
+ extraFields,
33
40
  footer,
34
41
  ...restProps
35
42
  }: LoginFormProps & DataAttributes = $props()
36
43
 
37
44
  const dataAttributes = $derived(getDataAttributes(restProps))
38
- const hasAgreements = $derived(
39
- !!(agreements?.userAgreementUrl || agreements?.privacyPolicyUrl),
40
- )
41
45
 
42
46
  let username = $state('')
43
47
  let password = $state('')
44
48
  let rememberMe = $state(false)
49
+ let selectedMethod = $state<AuthLoginMethod>('password')
50
+ const method = $derived(methods.includes(selectedMethod) ? selectedMethod : methods[0])
51
+ const isOtp = $derived(method === 'email' || method === 'sms')
52
+ let code = $state('')
53
+ let sending = $state(false)
54
+ let sendError = $state('')
55
+ let resendAt = $state(0)
56
+ let now = $state(Date.now())
57
+ const remaining = $derived(Math.max(0, Math.ceil((resendAt - now) / 1000)))
58
+ // 倒计时只负责 UI 提示;服务端独立校验 resendInterval,刷新页面不能绕过限制。
59
+ $effect(() => {
60
+ if (resendAt <= Date.now())
61
+ return
62
+ const timer = setInterval(() => {
63
+ now = Date.now()
64
+ }, 1000)
65
+ return () => clearInterval(timer)
66
+ })
67
+
68
+ function methodLabel(value: AuthLoginMethod): string {
69
+ if (value === 'ldap')
70
+ return uiM('login_method_ldap')
71
+ if (value === 'email')
72
+ return uiM('login_method_email')
73
+ if (value === 'sms')
74
+ return uiM('login_method_sms')
75
+ return uiM('login_method_password')
76
+ }
77
+
78
+ async function sendOtp() {
79
+ if (!onsendotp || sending || remaining || !username.trim())
80
+ return
81
+ sending = true
82
+ sendError = ''
83
+ try {
84
+ const result = await onsendotp(username.trim())
85
+ if (!result.success) {
86
+ sendError = authErrorMessage(result.error)
87
+ }
88
+ else {
89
+ now = Date.now()
90
+ resendAt = now + resendInterval * 1000
91
+ }
92
+ }
93
+ catch {
94
+ sendError = uiM('auth_network_error')
95
+ }
96
+ finally { sending = false }
97
+ }
45
98
 
46
99
  const formClass = $derived(
47
100
  cn(
@@ -52,13 +105,15 @@
52
105
 
53
106
  async function handleSubmit(e: SubmitEvent) {
54
107
  e.preventDefault()
55
- if (loading || disabled)
108
+ if (loading || disabled || !method)
56
109
  return
57
110
 
58
111
  const data: LoginFormData = {
59
112
  username,
60
113
  password,
61
114
  rememberMe,
115
+ method,
116
+ code: isOtp ? code : undefined,
62
117
  }
63
118
 
64
119
  await onsubmit?.(data)
@@ -83,41 +138,73 @@
83
138
  {/if}
84
139
 
85
140
  <!-- 用户名 -->
86
- <div class='space-y-1.5'>
87
- <label class='text-sm font-medium text-base-content/70' for='login-username'>
88
- {uiM('login_username')}
89
- </label>
90
- <Input
91
- id='login-username'
92
- name='username'
93
- type='text'
94
- placeholder={uiM('login_username_placeholder')}
95
- bind:value={username}
96
- autocomplete='username'
97
- {disabled}
98
- required
99
- error={errors.username}
100
- />
101
- </div>
102
-
103
- <!-- 密码 -->
104
- <div class='space-y-1.5'>
105
- <label class='text-sm font-medium text-base-content/70' for='login-password'>
106
- {uiM('login_password')}
107
- </label>
108
- <PasswordInput
109
- bind:value={password}
110
- placeholder={uiM('login_password_placeholder')}
111
- autocomplete='current-password'
112
- {disabled}
113
- required
114
- error={errors.password}
115
- showStrength={false}
116
- />
117
- </div>
141
+ {#if methods.length > 1}
142
+ <div class='grid grid-cols-2 gap-1 rounded-lg bg-base-200 p-1 sm:flex' aria-label={uiM('login_methods')}>
143
+ {#each methods as value (value)}
144
+ <button type='button' class={cn('flex-1 rounded-md px-2 py-2 text-sm transition-colors', method === value ? 'bg-base-100 font-medium shadow-sm' : 'text-base-content/60')} aria-pressed={method === value} disabled={loading || disabled} onclick={() => {
145
+ selectedMethod = value
146
+ password = ''
147
+ code = ''
148
+ sendError = ''
149
+ }}>
150
+ {methodLabel(value)}
151
+ </button>
152
+ {/each}
153
+ </div>
154
+ {/if}
155
+ {#if !method}
156
+ <Alert variant='warning'>{uiM('auth_unavailable')}</Alert>
157
+ {:else}
158
+ <div class='space-y-1.5'>
159
+ <label class='text-sm font-medium text-base-content/70' for='login-username'>
160
+ {method === 'email' ? uiM('register_email') : method === 'sms' ? uiM('register_phone') : uiM('login_username')}
161
+ </label>
162
+ <Input
163
+ id='login-username'
164
+ name='username'
165
+ type={method === 'email' ? 'email' : method === 'sms' ? 'tel' : 'text'}
166
+ placeholder={method === 'email' ? uiM('register_email_placeholder') : method === 'sms' ? uiM('register_phone_placeholder') : uiM('login_username_placeholder')}
167
+ bind:value={username}
168
+ autocomplete='username'
169
+ {disabled}
170
+ required
171
+ error={errors.username}
172
+ />
173
+ </div>
174
+
175
+ <!-- 密码 -->
176
+ {#if isOtp}
177
+ <div class='space-y-1.5'>
178
+ <label class='text-sm font-medium text-base-content/70' for='login-code'>{uiM('login_code')}</label>
179
+ <div class='flex items-start gap-2'>
180
+ <Input id='login-code' name='code' bind:value={code} autocomplete='one-time-code' inputmode='numeric' maxlength={otpLength} pattern={`[0-9]{${otpLength}}`} placeholder={uiM('login_code_placeholder', { length: otpLength })} required {disabled} />
181
+ <Button type='button' variant='outline' loading={sending} disabled={loading || disabled || !username.trim() || remaining > 0 || !onsendotp} onclick={sendOtp}>
182
+ {remaining ? uiM('login_resend_wait', { seconds: remaining }) : uiM('login_send_code')}
183
+ </Button>
184
+ </div>
185
+ {#if sendError}<p role='alert' class='text-sm text-error'>{sendError}</p>{/if}
186
+ </div>
187
+ {:else}
188
+ <div class='space-y-1.5'>
189
+ <label class='text-sm font-medium text-base-content/70' for='login-password'>
190
+ {uiM('login_password')}
191
+ </label>
192
+ <PasswordInput
193
+ id='login-password'
194
+ bind:value={password}
195
+ placeholder={uiM('login_password_placeholder')}
196
+ autocomplete='current-password'
197
+ {disabled}
198
+ required
199
+ error={errors.password}
200
+ showStrength={false}
201
+ />
202
+ </div>
203
+ {/if}
204
+ {/if}
118
205
 
119
206
  <!-- 记住我 & 忘记密码 -->
120
- {#if showRememberMe || showForgotPassword}
207
+ {#if method === 'password' && (showRememberMe || showForgotPassword)}
121
208
  <div class='flex items-center justify-between'>
122
209
  {#if showRememberMe}
123
210
  <Checkbox
@@ -149,6 +236,9 @@
149
236
  {/if}
150
237
 
151
238
  <!-- 通用错误 -->
239
+ {#if extraFields}
240
+ <div class='login-form-extra-fields'>{@render extraFields()}</div>
241
+ {/if}
152
242
  {#if errors.general}
153
243
  <Alert variant='error'>
154
244
  {errors.general}
@@ -160,27 +250,14 @@
160
250
  type='submit'
161
251
  variant='primary'
162
252
  {loading}
163
- disabled={loading || disabled}
253
+ disabled={loading || disabled || !method}
164
254
  class='w-full'
165
255
  >
166
256
  {submitText || uiM('login_submit')}
167
257
  </Button>
168
258
 
169
259
  <!-- 协议提示 -->
170
- {#if hasAgreements}
171
- <p class='text-xs text-base-content/50 text-center'>
172
- {uiM('agreement_prefix')}
173
- {#if agreements?.userAgreementUrl}
174
- <a href={agreements.userAgreementUrl} target='_blank' rel='noopener noreferrer' class='link link-primary'>{uiM('agreement_user_agreement')}</a>
175
- {/if}
176
- {#if agreements?.userAgreementUrl && agreements?.privacyPolicyUrl}
177
- {uiM('agreement_and')}
178
- {/if}
179
- {#if agreements?.privacyPolicyUrl}
180
- <a href={agreements.privacyPolicyUrl} target='_blank' rel='noopener noreferrer' class='link link-primary'>{uiM('agreement_privacy_policy')}</a>
181
- {/if}
182
- </p>
183
- {/if}
260
+ <AgreementNotice {agreements} />
184
261
 
185
262
  <!-- 自定义底部 -->
186
263
  {#if footer}
@@ -10,6 +10,8 @@
10
10
  import Alert from '../../compounds/Alert.svelte'
11
11
  import Button from '../../primitives/Button.svelte'
12
12
  import Input from '../../primitives/Input.svelte'
13
+ import AgreementNotice from './AgreementNotice.svelte'
14
+ import { passwordPolicyError, passwordPolicyHint } from './password-utils.js'
13
15
  import PasswordInput from './PasswordInput.svelte'
14
16
 
15
17
  const {
@@ -19,6 +21,7 @@
19
21
  showPasswordStrength = true,
20
22
  requireConfirmPassword = true,
21
23
  minPasswordLength = 8,
24
+ passwordPolicy,
22
25
  fields = ['username', 'email', 'password'],
23
26
  showLoginLink = false,
24
27
  loginUrl = '/login',
@@ -28,14 +31,12 @@
28
31
  errors = {},
29
32
  onsubmit,
30
33
  header,
34
+ extraFields,
31
35
  footer,
32
36
  ...restProps
33
37
  }: RegisterFormProps & DataAttributes = $props()
34
38
 
35
39
  const dataAttributes = $derived(getDataAttributes(restProps))
36
- const hasAgreements = $derived(
37
- !!(agreements?.userAgreementUrl || agreements?.privacyPolicyUrl),
38
- )
39
40
 
40
41
  // 表单数据
41
42
  let username = $state('')
@@ -45,6 +46,8 @@
45
46
  let confirmPassword = $state('')
46
47
  let nickname = $state('')
47
48
 
49
+ const policyError = $derived(passwordPolicy && password ? passwordPolicyError(password, passwordPolicy) : '')
50
+
48
51
  const formClass = $derived(
49
52
  cn(
50
53
  'register-form space-y-4',
@@ -112,7 +115,7 @@
112
115
 
113
116
  async function handleSubmit(e: SubmitEvent) {
114
117
  e.preventDefault()
115
- if (loading || disabled)
118
+ if (policyError || loading || disabled)
116
119
  return
117
120
  if (!passwordsMatch)
118
121
  return
@@ -174,6 +177,9 @@
174
177
  </div>
175
178
  {/if}
176
179
 
180
+ {#if passwordPolicy}<p class='text-xs text-base-content/60'>{passwordPolicyHint(passwordPolicy)}</p>{/if}
181
+ {#if policyError}<p role='alert' class='text-sm text-error'>{policyError}</p>{/if}
182
+
177
183
  <!-- 动态字段 -->
178
184
  {#each fields as field (field)}
179
185
  {#if field === 'password'}
@@ -235,6 +241,9 @@
235
241
  {/each}
236
242
 
237
243
  <!-- 通用错误 -->
244
+ {#if extraFields}
245
+ <div class='register-form-extra-fields'>{@render extraFields()}</div>
246
+ {/if}
238
247
  {#if errors.general}
239
248
  <Alert variant='error'>
240
249
  {errors.general}
@@ -246,27 +255,14 @@
246
255
  type='submit'
247
256
  variant='primary'
248
257
  class='w-full'
249
- disabled={loading || disabled || !passwordsMatch}
258
+ disabled={loading || disabled || !passwordsMatch || Boolean(policyError)}
250
259
  loading={loading}
251
260
  >
252
261
  {submitText || uiM('register_submit')}
253
262
  </Button>
254
263
 
255
264
  <!-- 协议提示 -->
256
- {#if hasAgreements}
257
- <p class='text-xs text-base-content/50 text-center'>
258
- {uiM('agreement_prefix')}
259
- {#if agreements?.userAgreementUrl}
260
- <a href={agreements.userAgreementUrl} target='_blank' rel='noopener noreferrer' class='link link-primary'>{uiM('agreement_user_agreement')}</a>
261
- {/if}
262
- {#if agreements?.userAgreementUrl && agreements?.privacyPolicyUrl}
263
- {uiM('agreement_and')}
264
- {/if}
265
- {#if agreements?.privacyPolicyUrl}
266
- <a href={agreements.privacyPolicyUrl} target='_blank' rel='noopener noreferrer' class='link link-primary'>{uiM('agreement_privacy_policy')}</a>
267
- {/if}
268
- </p>
269
- {/if}
265
+ <AgreementNotice {agreements} />
270
266
 
271
267
  <!-- 自定义底部 -->
272
268
  {#if footer}
@@ -10,7 +10,7 @@
10
10
  import Alert from '../../compounds/Alert.svelte'
11
11
  import Button from '../../primitives/Button.svelte'
12
12
  import Input from '../../primitives/Input.svelte'
13
- import { arePasswordsEqual } from './password-utils.js'
13
+ import { arePasswordsEqual, passwordPolicyError, passwordPolicyHint } from './password-utils.js'
14
14
  import PasswordInput from './PasswordInput.svelte'
15
15
 
16
16
  const {
@@ -21,6 +21,7 @@
21
21
  showCode = true,
22
22
  showPasswordStrength = true,
23
23
  minPasswordLength = 8,
24
+ passwordPolicy,
24
25
  showBackLink = false,
25
26
  loginUrl = '/login',
26
27
  submitText,
@@ -35,6 +36,8 @@
35
36
  let newPassword = $state('')
36
37
  let confirmPassword = $state('')
37
38
 
39
+ const policyError = $derived(passwordPolicy && newPassword ? passwordPolicyError(newPassword, passwordPolicy) : '')
40
+
38
41
  const formClass = $derived(
39
42
  cn(
40
43
  'reset-password-form space-y-4',
@@ -55,7 +58,7 @@
55
58
 
56
59
  async function handleSubmit(e: SubmitEvent) {
57
60
  e.preventDefault()
58
- if (loading || disabled || !canSubmit)
61
+ if (policyError || loading || disabled || !canSubmit)
59
62
  return
60
63
 
61
64
  const data: ResetPasswordFormData = {
@@ -136,12 +139,15 @@
136
139
  </Alert>
137
140
  {/if}
138
141
 
142
+ {#if passwordPolicy}<p class='text-xs text-base-content/60'>{passwordPolicyHint(passwordPolicy)}</p>{/if}
143
+ {#if policyError}<p role='alert' class='text-sm text-error'>{policyError}</p>{/if}
144
+
139
145
  <!-- 提交按钮 -->
140
146
  <Button
141
147
  type='submit'
142
148
  variant='primary'
143
149
  {loading}
144
- disabled={loading || disabled || !canSubmit}
150
+ disabled={loading || disabled || !canSubmit || Boolean(policyError)}
145
151
  class='w-full'
146
152
  >
147
153
  {submitText || uiM('reset_password_submit')}
@@ -0,0 +1,4 @@
1
+ export declare function authErrorMessage(error: {
2
+ code: string;
3
+ message: string;
4
+ }): string;
@@ -0,0 +1,33 @@
1
+ import { uiM } from '../../../messages.js';
2
+ const messages = {
3
+ 'hai:iam:001': 'auth_error_credentials',
4
+ 'hai:iam:002': 'auth_error_credentials',
5
+ 'hai:iam:003': 'auth_error_credentials',
6
+ 'hai:iam:004': 'auth_error_disabled',
7
+ 'hai:iam:005': 'auth_error_locked',
8
+ 'hai:iam:006': 'auth_error_duplicate',
9
+ 'hai:iam:007': 'auth_error_password_expired',
10
+ 'hai:iam:008': 'auth_error_password_policy',
11
+ 'hai:iam:009': 'auth_error_otp',
12
+ 'hai:iam:010': 'auth_error_service',
13
+ 'hai:iam:011': 'auth_error_otp_wait',
14
+ 'hai:iam:012': 'auth_unavailable',
15
+ 'hai:iam:013': 'auth_unavailable',
16
+ 'hai:iam:014': 'auth_unavailable',
17
+ 'hai:iam:019': 'auth_error_reset_token',
18
+ 'hai:iam:020': 'auth_error_reset_token',
19
+ 'hai:iam:021': 'auth_error_reset_token',
20
+ 'hai:iam:022': 'auth_error_otp',
21
+ 'hai:iam:023': 'auth_error_busy',
22
+ 'hai:iam:024': 'auth_unavailable',
23
+ 'hai:iam:301': 'auth_error_service',
24
+ 'hai:iam:302': 'auth_error_credentials',
25
+ 'hai:iam:303': 'auth_error_service',
26
+ 'hai:iam:401': 'auth_error_service',
27
+ 'hai:iam:901': 'auth_error_service',
28
+ 'hai:iam:999': 'auth_error_service',
29
+ };
30
+ export function authErrorMessage(error) {
31
+ const key = messages[error.code];
32
+ return key ? uiM(key) : error.message;
33
+ }
@@ -4,6 +4,7 @@
4
4
  * 身份认证相关的场景化组件
5
5
  * @module index
6
6
  */
7
+ export { default as AuthPanel } from './AuthPanel.svelte';
7
8
  export { default as AuthShell } from './AuthShell.svelte';
8
9
  export { default as ChangePasswordForm } from './ChangePasswordForm.svelte';
9
10
  export { default as ForgotPasswordForm } from './ForgotPasswordForm.svelte';
@@ -4,6 +4,7 @@
4
4
  * 身份认证相关的场景化组件
5
5
  * @module index
6
6
  */
7
+ export { default as AuthPanel } from './AuthPanel.svelte';
7
8
  // 认证表单
8
9
  export { default as AuthShell } from './AuthShell.svelte';
9
10
  export { default as ChangePasswordForm } from './ChangePasswordForm.svelte';
@@ -8,8 +8,12 @@
8
8
  * 规范化密码字符串
9
9
  * - 使用 NFKC 统一兼容字符
10
10
  */
11
+ import type { PasswordPolicy } from '../types.js';
11
12
  export declare function normalizePassword(value: string): string;
12
13
  /**
13
14
  * 比较两次密码是否一致
14
15
  */
15
16
  export declare function arePasswordsEqual(password: string, confirmPassword: string): boolean;
17
+ /** 与 IAM 密码策略一致的前端提示;服务端仍执行最终校验。 */
18
+ export declare function passwordPolicyError(password: string, policy: PasswordPolicy): string;
19
+ export declare function passwordPolicyHint(policy: PasswordPolicy): string;
@@ -4,10 +4,7 @@
4
4
  * 密码规范化与一致性比较工具
5
5
  * @module password-utils
6
6
  */
7
- /**
8
- * 规范化密码字符串
9
- * - 使用 NFKC 统一兼容字符
10
- */
7
+ import { uiM } from '../../../messages.js';
11
8
  export function normalizePassword(value) {
12
9
  return value.normalize('NFKC');
13
10
  }
@@ -17,3 +14,29 @@ export function normalizePassword(value) {
17
14
  export function arePasswordsEqual(password, confirmPassword) {
18
15
  return normalizePassword(password) === normalizePassword(confirmPassword);
19
16
  }
17
+ /** 与 IAM 密码策略一致的前端提示;服务端仍执行最终校验。 */
18
+ export function passwordPolicyError(password, policy) {
19
+ if (password.length < policy.minLength || password.length > policy.maxLength)
20
+ return uiM('password_policy_length', { min: policy.minLength, max: policy.maxLength });
21
+ if (policy.requireUppercase && !/[A-Z]/.test(password))
22
+ return uiM('password_policy_uppercase');
23
+ if (policy.requireLowercase && !/[a-z]/.test(password))
24
+ return uiM('password_policy_lowercase');
25
+ if (policy.requireNumber && !/\d/.test(password))
26
+ return uiM('password_policy_number');
27
+ if (policy.requireSpecialChar && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password))
28
+ return uiM('password_policy_special');
29
+ return '';
30
+ }
31
+ export function passwordPolicyHint(policy) {
32
+ const requirements = [uiM('password_policy_length', { min: policy.minLength, max: policy.maxLength })];
33
+ if (policy.requireUppercase)
34
+ requirements.push(uiM('password_policy_uppercase'));
35
+ if (policy.requireLowercase)
36
+ requirements.push(uiM('password_policy_lowercase'));
37
+ if (policy.requireNumber)
38
+ requirements.push(uiM('password_policy_number'));
39
+ if (policy.requireSpecialChar)
40
+ requirements.push(uiM('password_policy_special'));
41
+ return requirements.join(' · ');
42
+ }