@csntgao/uni-base 0.1.1 → 0.1.3

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.md CHANGED
@@ -218,8 +218,8 @@ function applyNotificationRealtimeEvent(rawEvent: unknown) {
218
218
  headerSubtitle: '手机号验证后进入企业服务平台',
219
219
  headerIconUrl: '/assets/login-shield.svg',
220
220
  securityHint: '登录凭证仅用于企业工作台认证,请勿向他人透露验证码',
221
- userAgreementUrl: '/agreements/user',
222
- privacyAgreementUrl: '/agreements/privacy',
221
+ userAgreementUrl: 'https://website.hrs100.com/agreement/user-service-agreement.html',
222
+ privacyAgreementUrl: 'https://website.hrs100.com/agreement/qqxb-user-privacy.html',
223
223
  forgotPasswordUrl: '/account/forgot-password',
224
224
  initialMethod: 'verification-code',
225
225
  })
@@ -139,8 +139,8 @@ userLogin.configure({
139
139
  headerSubtitle: '手机号验证后进入企业服务平台',
140
140
  headerIconUrl: 'https://static.example.com/login-shield.svg',
141
141
  securityHint: '登录凭证仅用于企业工作台认证,请勿向他人透露验证码',
142
- userAgreementUrl: '/agreements/user',
143
- privacyAgreementUrl: '/agreements/privacy',
142
+ userAgreementUrl: 'https://website.hrs100.com/agreement/user-service-agreement.html',
143
+ privacyAgreementUrl: 'https://website.hrs100.com/agreement/qqxb-user-privacy.html',
144
144
  forgotPasswordUrl: '/account/forgot-password',
145
145
  initialMethod: 'verification-code',
146
146
  })
@@ -151,7 +151,7 @@ userLogin.configure({
151
151
  | `transport` | 是 | 完成登录接口调用;专用 transport 的 `setAccessToken()` 必须原子保存 JWT。 |
152
152
  | `applicationName` | 是 | 目标应用稳定名称。 |
153
153
  | `headerTitle`、`headerSubtitle`、`headerIconUrl`、`securityHint` | 否 | 外观文案与图标;不得包含凭据。 |
154
- | `userAgreementUrl`、`privacyAgreementUrl`、`forgotPasswordUrl` | 否 | 调用方页面地址。 |
154
+ | `userAgreementUrl`、`privacyAgreementUrl`、`forgotPasswordUrl` | 否 | 调用方页面地址;两个协议地址未传时使用亲亲小保官网协议页。 |
155
155
  | `initialMethod` | 否 | `password`、`verification-code` 或 `qr-code`。 |
156
156
  | `countdownSeconds`、`qrPollIntervalMs`、`initiallyAgreed`、`initiallyRememberSession` | 否 | 固定行为的可选初始值。 |
157
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csntgao/uni-base",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@11.3.0",
6
6
  "publishConfig": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniplat/ai-employee-web-components",
3
- "version": "0.23.4",
3
+ "version": "0.23.6",
4
4
  "description": "Framework-independent Web Components for Uniplat account, login, application switching and AI Employee chat",
5
5
  "type": "module",
6
6
  "sideEffects": true,
@@ -84,6 +84,13 @@ interface UserLoginBranding {
84
84
 
85
85
  type QrViewState = 'idle' | 'loading' | UserLoginQrStatus | 'error'
86
86
 
87
+ export const USER_LOGIN_QR_LOGIN_ENABLED = false
88
+
89
+ const USER_LOGIN_DEFAULT_USER_AGREEMENT_URL =
90
+ 'https://website.hrs100.com/agreement/user-service-agreement.html'
91
+ const USER_LOGIN_DEFAULT_PRIVACY_AGREEMENT_URL =
92
+ 'https://website.hrs100.com/agreement/qqxb-user-privacy.html'
93
+
87
94
  const EMPTY_ERRORS: FormErrors = {
88
95
  mobile: '',
89
96
  smsCode: '',
@@ -921,8 +928,14 @@ export class UniplatUserLogin extends LitElement {
921
928
  this.config = {
922
929
  transport: config.transport,
923
930
  applicationName: normalizeApplicationName(config.applicationName),
924
- userAgreementUrl: safeNavigationUrl(config.userAgreementUrl, 'Agreement URL'),
925
- privacyAgreementUrl: safeNavigationUrl(config.privacyAgreementUrl, 'Agreement URL'),
931
+ userAgreementUrl: safeNavigationUrl(
932
+ config.userAgreementUrl ?? USER_LOGIN_DEFAULT_USER_AGREEMENT_URL,
933
+ 'Agreement URL',
934
+ ),
935
+ privacyAgreementUrl: safeNavigationUrl(
936
+ config.privacyAgreementUrl ?? USER_LOGIN_DEFAULT_PRIVACY_AGREEMENT_URL,
937
+ 'Agreement URL',
938
+ ),
926
939
  forgotPasswordUrl: safeNavigationUrl(config.forgotPasswordUrl, 'Forgot-password URL'),
927
940
  countdownSeconds,
928
941
  qrPollIntervalMs,
@@ -1063,9 +1076,12 @@ export class UniplatUserLogin extends LitElement {
1063
1076
  }
1064
1077
 
1065
1078
  private renderTabs() {
1079
+ const visibleMethods = LOGIN_METHODS.filter(
1080
+ ({ method }) => method !== 'qr-code' || USER_LOGIN_QR_LOGIN_ENABLED,
1081
+ )
1066
1082
  return html`
1067
1083
  <div class="tabs" part="title tabs" role="tablist" aria-label="登录方式">
1068
- ${LOGIN_METHODS.map(({ method, label }) => {
1084
+ ${visibleMethods.map(({ method, label }) => {
1069
1085
  const available = this.isMethodAvailable(method)
1070
1086
  return html`
1071
1087
  <button
@@ -1426,6 +1442,7 @@ export class UniplatUserLogin extends LitElement {
1426
1442
  const transport = this.config?.transport
1427
1443
  if (method === 'verification-code') return Boolean(transport)
1428
1444
  if (method === 'password') return typeof transport?.loginWithPassword === 'function'
1445
+ if (!USER_LOGIN_QR_LOGIN_ENABLED) return false
1429
1446
  return (
1430
1447
  typeof transport?.createQrChallenge === 'function' &&
1431
1448
  typeof transport.pollQrChallenge === 'function'
@@ -8,6 +8,7 @@ import {
8
8
  type UserLoginErrorDetail,
9
9
  type UserLoginTransport,
10
10
  } from '../src'
11
+ import { USER_LOGIN_QR_LOGIN_ENABLED } from '../src/user-login'
11
12
 
12
13
  async function settle(login: UniplatUserLogin): Promise<void> {
13
14
  await Promise.resolve()
@@ -47,6 +48,7 @@ describe('<uniplat-user-login>', () => {
47
48
  afterEach(() => {
48
49
  document.body.replaceChildren()
49
50
  vi.useRealTimers()
51
+ vi.restoreAllMocks()
50
52
  })
51
53
 
52
54
  it('registers, renders the verification login form and completes a safe login flow', async () => {
@@ -90,13 +92,15 @@ describe('<uniplat-user-login>', () => {
90
92
  /\.security-icon::before\s*\{[^}]*left:\s*50%;[^}]*box-sizing:\s*border-box;[^}]*transform:\s*translateX\(-50%\);/s,
91
93
  )
92
94
  expect(login.shadowRoot?.textContent).toContain('验证码登录')
93
- expect(login.shadowRoot?.querySelectorAll('[role="tab"]')).toHaveLength(3)
95
+ expect(USER_LOGIN_QR_LOGIN_ENABLED).toBe(false)
96
+ expect(login.shadowRoot?.querySelectorAll('[role="tab"]')).toHaveLength(2)
97
+ expect(login.shadowRoot?.textContent).not.toContain('扫码登录')
94
98
  expect(
95
99
  login.shadowRoot?.querySelector<HTMLButtonElement>('[role="tab"]:first-child')?.disabled,
96
100
  ).toBe(true)
97
101
  expect(
98
102
  login.shadowRoot?.querySelector<HTMLButtonElement>('[role="tab"]:last-child')?.disabled,
99
- ).toBe(true)
103
+ ).toBe(false)
100
104
  expect(login.shadowRoot?.querySelector('#uniplat-login-image-code')).toBeNull()
101
105
  expect(login.shadowRoot?.querySelector('.captcha-image')).toBeNull()
102
106
  expect(login.shadowRoot?.textContent).not.toContain('图形验证码')
@@ -275,6 +279,32 @@ describe('<uniplat-user-login>', () => {
275
279
  ).toThrow(TypeError)
276
280
  })
277
281
 
282
+ it('opens the official user and privacy agreements by default', async () => {
283
+ const openWindow = vi.spyOn(window, 'open').mockReturnValue(null)
284
+ const login = document.createElement(USER_LOGIN_TAG)
285
+ login.configure({ transport: createTransport(), applicationName: 'HrSaas' })
286
+ document.body.append(login)
287
+ await settle(login)
288
+
289
+ const links = login.shadowRoot?.querySelectorAll<HTMLButtonElement>('.agreement-link')
290
+ expect(links).toHaveLength(2)
291
+ links?.[0]?.click()
292
+ links?.[1]?.click()
293
+
294
+ expect(openWindow).toHaveBeenNthCalledWith(
295
+ 1,
296
+ 'https://website.hrs100.com/agreement/user-service-agreement.html',
297
+ '_blank',
298
+ 'noopener,noreferrer',
299
+ )
300
+ expect(openWindow).toHaveBeenNthCalledWith(
301
+ 2,
302
+ 'https://website.hrs100.com/agreement/qqxb-user-privacy.html',
303
+ '_blank',
304
+ 'noopener,noreferrer',
305
+ )
306
+ })
307
+
278
308
  it('maps transport failures to stable errors without exposing the original exception', async () => {
279
309
  const sensitiveText = 'backend stack with jwt=should-never-render'
280
310
  const transport = createTransport({
@@ -506,94 +536,125 @@ describe('<uniplat-user-login>', () => {
506
536
  expect(passwordInput?.value).toBe('')
507
537
  })
508
538
 
509
- it('keeps QR status in one panel and clears polling after confirmation', async () => {
510
- vi.useFakeTimers()
539
+ it('hides QR login and falls back to verification login while the feature is disabled', async () => {
511
540
  const createQrChallenge = vi.fn(async () => ({
512
- challengeId: 'obviously-fake-qr-challenge',
513
- imageUrl:
514
- 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120"%3E%3Crect width="120" height="120"/%3E%3C/svg%3E',
515
- pollIntervalMs: 750,
541
+ challengeId: 'obviously-fake-hidden-challenge',
542
+ imageUrl: 'data:image/png;base64,b2J2aW91c2x5LWZha2U=',
516
543
  }))
517
- const pollQrChallenge = vi
518
- .fn()
519
- .mockResolvedValueOnce({ status: 'scanned' })
520
- .mockResolvedValueOnce({
521
- status: 'confirmed',
522
- result: { applicationName: 'HrSaas' },
523
- })
524
- const transport = createTransport({ createQrChallenge, pollQrChallenge })
544
+ const pollQrChallenge = vi.fn(async () => ({ status: 'pending' as const }))
525
545
  const login = document.createElement(USER_LOGIN_TAG)
526
- const loginEvents: unknown[] = []
527
- login.addEventListener('login-success', (event) => {
528
- loginEvents.push((event as CustomEvent).detail)
529
- })
530
546
  login.configure({
531
- transport,
547
+ transport: createTransport({ createQrChallenge, pollQrChallenge }),
532
548
  applicationName: 'HrSaas',
533
549
  initialMethod: 'qr-code',
534
- qrPollIntervalMs: 500,
535
550
  })
536
551
  document.body.append(login)
537
552
  await settle(login)
538
553
 
539
- expect(createQrChallenge).toHaveBeenCalledWith({ applicationName: 'HrSaas' })
540
- expect(login.shadowRoot?.querySelector<HTMLImageElement>('.qr-image')?.src).toContain(
541
- 'data:image/svg+xml',
542
- )
543
- expect(login.shadowRoot?.textContent).toContain('请使用微信扫描小程序码登录')
544
- expect(login.shadowRoot?.textContent).toContain('未登录时先完成登录')
545
- expect(login.shadowRoot?.textContent).toContain('电脑端同步登录')
546
- expect(vi.getTimerCount()).toBe(1)
547
- expect(login.shadowRoot?.innerHTML).not.toContain('obviously-fake-qr-challenge')
548
-
549
- await vi.advanceTimersByTimeAsync(500)
554
+ expect(login.shadowRoot?.querySelectorAll('[role="tab"]')).toHaveLength(2)
555
+ expect(login.shadowRoot?.textContent).not.toContain('扫码登录')
556
+ expect(login.shadowRoot?.querySelector('.qr-panel')).toBeNull()
557
+ expect(
558
+ login.shadowRoot?.querySelector('[role="tab"][aria-selected="true"]')?.textContent,
559
+ ).toContain('验证码登录')
560
+ expect(createQrChallenge).not.toHaveBeenCalled()
550
561
  expect(pollQrChallenge).not.toHaveBeenCalled()
551
- await vi.advanceTimersByTimeAsync(250)
552
- await settle(login)
553
- expect(login.shadowRoot?.textContent).toContain('扫码成功,请在手机上确认')
554
- expect(vi.getTimerCount()).toBe(1)
555
- expect(pollQrChallenge).toHaveBeenCalledWith('obviously-fake-qr-challenge', 'HrSaas')
556
-
557
- await vi.advanceTimersByTimeAsync(750)
558
- await settle(login)
559
- expect(loginEvents).toEqual([{ applicationName: 'HrSaas' }])
560
- expect(JSON.stringify(loginEvents)).not.toContain('obviously-fake-qr-challenge')
561
- expect(vi.getTimerCount()).toBe(0)
562
562
  })
563
563
 
564
- it('stops polling and offers a refresh after QR login is rejected', async () => {
565
- vi.useFakeTimers()
566
- const transport = createTransport({
567
- createQrChallenge: vi.fn(async () => ({
568
- challengeId: 'obviously-fake-rejected-challenge',
569
- imageUrl: 'data:image/png;base64,b2J2aW91c2x5LWZha2UtcG5n',
570
- pollIntervalMs: 500,
571
- })),
572
- pollQrChallenge: vi.fn(async () => ({ status: 'rejected' as const })),
573
- })
574
- const errors: UserLoginErrorDetail[] = []
575
- const login = document.createElement(USER_LOGIN_TAG)
576
- login.addEventListener('error', (event) => {
577
- errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
578
- })
579
- login.configure({ transport, applicationName: 'HrSaas', initialMethod: 'qr-code' })
580
- document.body.append(login)
581
- await settle(login)
582
-
583
- await vi.advanceTimersByTimeAsync(500)
584
- await settle(login)
585
-
586
- expect(login.shadowRoot?.textContent).toContain('登录已取消')
587
- expect(login.shadowRoot?.textContent).toContain('已在手机上取消登录,请点击刷新。')
588
- expect(vi.getTimerCount()).toBe(0)
589
- expect(errors).toEqual([
590
- {
591
- code: 'QR_LOGIN_REJECTED',
592
- message: '已在手机上取消登录,请点击刷新。',
593
- recoverable: true,
594
- },
595
- ])
596
- })
564
+ it.skipIf(!USER_LOGIN_QR_LOGIN_ENABLED)(
565
+ 'keeps QR status in one panel and clears polling after confirmation',
566
+ async () => {
567
+ vi.useFakeTimers()
568
+ const createQrChallenge = vi.fn(async () => ({
569
+ challengeId: 'obviously-fake-qr-challenge',
570
+ imageUrl:
571
+ 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120"%3E%3Crect width="120" height="120"/%3E%3C/svg%3E',
572
+ pollIntervalMs: 750,
573
+ }))
574
+ const pollQrChallenge = vi
575
+ .fn()
576
+ .mockResolvedValueOnce({ status: 'scanned' })
577
+ .mockResolvedValueOnce({
578
+ status: 'confirmed',
579
+ result: { applicationName: 'HrSaas' },
580
+ })
581
+ const transport = createTransport({ createQrChallenge, pollQrChallenge })
582
+ const login = document.createElement(USER_LOGIN_TAG)
583
+ const loginEvents: unknown[] = []
584
+ login.addEventListener('login-success', (event) => {
585
+ loginEvents.push((event as CustomEvent).detail)
586
+ })
587
+ login.configure({
588
+ transport,
589
+ applicationName: 'HrSaas',
590
+ initialMethod: 'qr-code',
591
+ qrPollIntervalMs: 500,
592
+ })
593
+ document.body.append(login)
594
+ await settle(login)
595
+
596
+ expect(createQrChallenge).toHaveBeenCalledWith({ applicationName: 'HrSaas' })
597
+ expect(login.shadowRoot?.querySelector<HTMLImageElement>('.qr-image')?.src).toContain(
598
+ 'data:image/svg+xml',
599
+ )
600
+ expect(login.shadowRoot?.textContent).toContain('请使用微信扫描小程序码登录')
601
+ expect(login.shadowRoot?.textContent).toContain('未登录时先完成登录')
602
+ expect(login.shadowRoot?.textContent).toContain('电脑端同步登录')
603
+ expect(vi.getTimerCount()).toBe(1)
604
+ expect(login.shadowRoot?.innerHTML).not.toContain('obviously-fake-qr-challenge')
605
+
606
+ await vi.advanceTimersByTimeAsync(500)
607
+ expect(pollQrChallenge).not.toHaveBeenCalled()
608
+ await vi.advanceTimersByTimeAsync(250)
609
+ await settle(login)
610
+ expect(login.shadowRoot?.textContent).toContain('扫码成功,请在手机上确认')
611
+ expect(vi.getTimerCount()).toBe(1)
612
+ expect(pollQrChallenge).toHaveBeenCalledWith('obviously-fake-qr-challenge', 'HrSaas')
613
+
614
+ await vi.advanceTimersByTimeAsync(750)
615
+ await settle(login)
616
+ expect(loginEvents).toEqual([{ applicationName: 'HrSaas' }])
617
+ expect(JSON.stringify(loginEvents)).not.toContain('obviously-fake-qr-challenge')
618
+ expect(vi.getTimerCount()).toBe(0)
619
+ },
620
+ )
621
+
622
+ it.skipIf(!USER_LOGIN_QR_LOGIN_ENABLED)(
623
+ 'stops polling and offers a refresh after QR login is rejected',
624
+ async () => {
625
+ vi.useFakeTimers()
626
+ const transport = createTransport({
627
+ createQrChallenge: vi.fn(async () => ({
628
+ challengeId: 'obviously-fake-rejected-challenge',
629
+ imageUrl: 'data:image/png;base64,b2J2aW91c2x5LWZha2UtcG5n',
630
+ pollIntervalMs: 500,
631
+ })),
632
+ pollQrChallenge: vi.fn(async () => ({ status: 'rejected' as const })),
633
+ })
634
+ const errors: UserLoginErrorDetail[] = []
635
+ const login = document.createElement(USER_LOGIN_TAG)
636
+ login.addEventListener('error', (event) => {
637
+ errors.push((event as unknown as CustomEvent<UserLoginErrorDetail>).detail)
638
+ })
639
+ login.configure({ transport, applicationName: 'HrSaas', initialMethod: 'qr-code' })
640
+ document.body.append(login)
641
+ await settle(login)
642
+
643
+ await vi.advanceTimersByTimeAsync(500)
644
+ await settle(login)
645
+
646
+ expect(login.shadowRoot?.textContent).toContain('登录已取消')
647
+ expect(login.shadowRoot?.textContent).toContain('已在手机上取消登录,请点击刷新。')
648
+ expect(vi.getTimerCount()).toBe(0)
649
+ expect(errors).toEqual([
650
+ {
651
+ code: 'QR_LOGIN_REJECTED',
652
+ message: '已在手机上取消登录,请点击刷新。',
653
+ recoverable: true,
654
+ },
655
+ ])
656
+ },
657
+ )
597
658
 
598
659
  it('maps a locked password account to a stable safe component error', async () => {
599
660
  const transport = createTransport({
package/restart ADDED
@@ -0,0 +1,4 @@
1
+ #! /bin/bash
2
+ git pull
3
+ pnpm i
4
+ pnpm build