@csntgao/uni-base 0.1.0
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/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,2027 @@
|
|
|
1
|
+
import { LitElement, css, html, nothing } from 'lit'
|
|
2
|
+
import {
|
|
3
|
+
UserLoginTransportError,
|
|
4
|
+
type UserLoginQrStatus,
|
|
5
|
+
type UserLoginSuccess,
|
|
6
|
+
type UserLoginTransport,
|
|
7
|
+
} from './user-login-transport'
|
|
8
|
+
|
|
9
|
+
export type UserLoginMethod = 'password' | 'verification-code' | 'qr-code'
|
|
10
|
+
|
|
11
|
+
export type UserLoginErrorCode =
|
|
12
|
+
| 'CAPTCHA_LOAD_FAILED'
|
|
13
|
+
| 'SMS_SEND_FAILED'
|
|
14
|
+
| 'SMS_CODE_INVALID_OR_EXPIRED'
|
|
15
|
+
| 'LOGIN_FAILED'
|
|
16
|
+
| 'QR_LOAD_FAILED'
|
|
17
|
+
| 'QR_POLL_FAILED'
|
|
18
|
+
| 'PASSWORD_INVALID'
|
|
19
|
+
| 'ACCOUNT_LOCKED'
|
|
20
|
+
| 'ACCOUNT_DISABLED'
|
|
21
|
+
| 'ORG_ACCESS_DENIED'
|
|
22
|
+
| 'QR_LOGIN_REJECTED'
|
|
23
|
+
| 'QR_LOGIN_UNAVAILABLE'
|
|
24
|
+
|
|
25
|
+
export interface UserLoginErrorDetail {
|
|
26
|
+
code: UserLoginErrorCode
|
|
27
|
+
message: string
|
|
28
|
+
recoverable: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UserLoginEventMap {
|
|
32
|
+
'sms-sent': CustomEvent<Record<string, never>>
|
|
33
|
+
'login-success': CustomEvent<UserLoginSuccess>
|
|
34
|
+
'onboarding-requested': CustomEvent<{
|
|
35
|
+
applicationName: string
|
|
36
|
+
trigger: 'automatic' | 'manual'
|
|
37
|
+
}>
|
|
38
|
+
'agreement-requested': CustomEvent<{ type: 'user' | 'privacy' }>
|
|
39
|
+
'forgot-password-requested': CustomEvent<Record<string, never>>
|
|
40
|
+
error: CustomEvent<UserLoginErrorDetail>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UserLoginConfig {
|
|
44
|
+
transport: UserLoginTransport
|
|
45
|
+
applicationName: string
|
|
46
|
+
headerTitle?: string
|
|
47
|
+
headerSubtitle?: string
|
|
48
|
+
headerIconUrl?: string
|
|
49
|
+
securityHint?: string
|
|
50
|
+
userAgreementUrl?: string
|
|
51
|
+
privacyAgreementUrl?: string
|
|
52
|
+
forgotPasswordUrl?: string
|
|
53
|
+
countdownSeconds?: number
|
|
54
|
+
qrPollIntervalMs?: number
|
|
55
|
+
initialMethod?: UserLoginMethod
|
|
56
|
+
initiallyAgreed?: boolean
|
|
57
|
+
initiallyRememberSession?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ResolvedUserLoginConfig {
|
|
61
|
+
transport: UserLoginTransport
|
|
62
|
+
applicationName: string
|
|
63
|
+
userAgreementUrl: string
|
|
64
|
+
privacyAgreementUrl: string
|
|
65
|
+
forgotPasswordUrl: string
|
|
66
|
+
countdownSeconds: number
|
|
67
|
+
qrPollIntervalMs: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FormErrors {
|
|
71
|
+
mobile: string
|
|
72
|
+
smsCode: string
|
|
73
|
+
password: string
|
|
74
|
+
agreement: string
|
|
75
|
+
global: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface UserLoginBranding {
|
|
79
|
+
headerTitle: string
|
|
80
|
+
headerSubtitle: string
|
|
81
|
+
headerIconUrl: string
|
|
82
|
+
securityHint: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type QrViewState = 'idle' | 'loading' | UserLoginQrStatus | 'error'
|
|
86
|
+
|
|
87
|
+
const EMPTY_ERRORS: FormErrors = {
|
|
88
|
+
mobile: '',
|
|
89
|
+
smsCode: '',
|
|
90
|
+
password: '',
|
|
91
|
+
agreement: '',
|
|
92
|
+
global: '',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const LOGIN_METHODS: ReadonlyArray<{ method: UserLoginMethod; label: string }> = [
|
|
96
|
+
{ method: 'password', label: '密码登录' },
|
|
97
|
+
{ method: 'verification-code', label: '验证码登录' },
|
|
98
|
+
{ method: 'qr-code', label: '扫码登录' },
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
export class UniplatUserLogin extends LitElement {
|
|
102
|
+
static override properties = {
|
|
103
|
+
activeMethod: { state: true },
|
|
104
|
+
mobile: { state: true },
|
|
105
|
+
smsCode: { state: true },
|
|
106
|
+
password: { state: true },
|
|
107
|
+
passwordVisible: { state: true },
|
|
108
|
+
agreed: { state: true },
|
|
109
|
+
rememberSession: { state: true },
|
|
110
|
+
sendingSms: { state: true },
|
|
111
|
+
loggingIn: { state: true },
|
|
112
|
+
countdown: { state: true },
|
|
113
|
+
errors: { state: true },
|
|
114
|
+
statusMessage: { state: true },
|
|
115
|
+
branding: { state: true },
|
|
116
|
+
qrState: { state: true },
|
|
117
|
+
qrImageUrl: { state: true },
|
|
118
|
+
registrationSucceeded: { state: true },
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static override styles = css`
|
|
122
|
+
:host {
|
|
123
|
+
--uniplat-user-login-width: 400px;
|
|
124
|
+
--uniplat-user-login-primary: #2e6bf5;
|
|
125
|
+
--uniplat-user-login-text: #182634;
|
|
126
|
+
--uniplat-user-login-muted: #7a8794;
|
|
127
|
+
--uniplat-user-login-placeholder: #a9b3bf;
|
|
128
|
+
--uniplat-user-login-background: #ffffff;
|
|
129
|
+
--uniplat-user-login-input-background: #ffffff;
|
|
130
|
+
--uniplat-user-login-error: #e5484d;
|
|
131
|
+
--uniplat-user-login-font-family:
|
|
132
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
133
|
+
display: block;
|
|
134
|
+
width: min(var(--uniplat-user-login-width), 100%);
|
|
135
|
+
color: var(--uniplat-user-login-text);
|
|
136
|
+
font-family: var(--uniplat-user-login-font-family);
|
|
137
|
+
line-height: 1.4;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
* {
|
|
141
|
+
box-sizing: border-box;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
button,
|
|
145
|
+
input {
|
|
146
|
+
font: inherit;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.shell {
|
|
150
|
+
display: flex;
|
|
151
|
+
width: 100%;
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
border: 1px solid #e4ebf3;
|
|
154
|
+
border-radius: 16px;
|
|
155
|
+
background: var(--uniplat-user-login-background);
|
|
156
|
+
box-shadow: 0 8px 24px rgb(20 40 80 / 6%);
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.header {
|
|
161
|
+
display: grid;
|
|
162
|
+
min-height: 72px;
|
|
163
|
+
grid-template-columns: 42px minmax(0, 1fr);
|
|
164
|
+
gap: 12px;
|
|
165
|
+
align-items: center;
|
|
166
|
+
padding: 14px 18px;
|
|
167
|
+
border-bottom: 1px solid #edf1f6;
|
|
168
|
+
background: #fafcfe;
|
|
169
|
+
order: 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.header.no-icon {
|
|
173
|
+
grid-template-columns: minmax(0, 1fr);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.header-icon {
|
|
177
|
+
width: 42px;
|
|
178
|
+
height: 42px;
|
|
179
|
+
border-radius: 12px;
|
|
180
|
+
object-fit: contain;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.header-copy {
|
|
184
|
+
min-width: 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.header-title,
|
|
188
|
+
.header-subtitle {
|
|
189
|
+
overflow: hidden;
|
|
190
|
+
text-overflow: ellipsis;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.header-title {
|
|
195
|
+
color: var(--uniplat-user-login-text);
|
|
196
|
+
font-size: 16px;
|
|
197
|
+
font-weight: 800;
|
|
198
|
+
line-height: 22px;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.header-subtitle {
|
|
202
|
+
margin-top: 2px;
|
|
203
|
+
color: var(--uniplat-user-login-muted);
|
|
204
|
+
font-size: 12.5px;
|
|
205
|
+
line-height: 18px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.form {
|
|
209
|
+
display: flex;
|
|
210
|
+
width: 100%;
|
|
211
|
+
padding: 22px 24px 24px;
|
|
212
|
+
flex-direction: column;
|
|
213
|
+
order: 2;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.tabs {
|
|
217
|
+
display: flex;
|
|
218
|
+
gap: 18px;
|
|
219
|
+
border-bottom: 1px solid #edf1f6;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.tab {
|
|
223
|
+
position: relative;
|
|
224
|
+
min-width: 0;
|
|
225
|
+
padding: 0 0 10px;
|
|
226
|
+
border: 0;
|
|
227
|
+
background: transparent;
|
|
228
|
+
color: #98a4b2;
|
|
229
|
+
cursor: pointer;
|
|
230
|
+
font-size: 14.5px;
|
|
231
|
+
line-height: 20px;
|
|
232
|
+
white-space: nowrap;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.tab.selected {
|
|
236
|
+
color: var(--uniplat-user-login-text);
|
|
237
|
+
font-weight: 800;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.tab.selected::after {
|
|
241
|
+
position: absolute;
|
|
242
|
+
right: 0;
|
|
243
|
+
bottom: -1px;
|
|
244
|
+
left: 0;
|
|
245
|
+
height: 2px;
|
|
246
|
+
border-radius: 2px;
|
|
247
|
+
background: var(--uniplat-user-login-primary);
|
|
248
|
+
content: '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.tab:disabled {
|
|
252
|
+
color: #98a4b2;
|
|
253
|
+
cursor: not-allowed;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.method-panel {
|
|
257
|
+
display: flex;
|
|
258
|
+
gap: 12px;
|
|
259
|
+
padding-top: 18px;
|
|
260
|
+
flex-direction: column;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.field {
|
|
264
|
+
min-height: 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.input-row {
|
|
268
|
+
display: flex;
|
|
269
|
+
width: 100%;
|
|
270
|
+
height: 44px;
|
|
271
|
+
overflow: hidden;
|
|
272
|
+
align-items: center;
|
|
273
|
+
border: 1px solid #e2e9f3;
|
|
274
|
+
border-radius: 9px;
|
|
275
|
+
background: var(--uniplat-user-login-input-background);
|
|
276
|
+
transition:
|
|
277
|
+
border-color 0.16s ease,
|
|
278
|
+
box-shadow 0.16s ease;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.input-row:hover,
|
|
282
|
+
.input-row:focus-within {
|
|
283
|
+
border-color: #8eaff9;
|
|
284
|
+
box-shadow: 0 0 0 3px rgb(46 107 245 / 10%);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.input-row.error {
|
|
288
|
+
border-color: var(--uniplat-user-login-error);
|
|
289
|
+
box-shadow: none;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.country-code {
|
|
293
|
+
padding-left: 12px;
|
|
294
|
+
color: #5a6673;
|
|
295
|
+
font-size: 13.5px;
|
|
296
|
+
font-weight: 600;
|
|
297
|
+
white-space: nowrap;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.country-divider {
|
|
301
|
+
width: 1px;
|
|
302
|
+
height: 16px;
|
|
303
|
+
margin: 0 8px;
|
|
304
|
+
flex: none;
|
|
305
|
+
background: #e2e9f3;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.input {
|
|
309
|
+
width: 100%;
|
|
310
|
+
min-width: 0;
|
|
311
|
+
height: 100%;
|
|
312
|
+
padding: 0 12px;
|
|
313
|
+
border: 0;
|
|
314
|
+
outline: 0;
|
|
315
|
+
background: transparent;
|
|
316
|
+
color: #25303f;
|
|
317
|
+
font-size: 13.5px;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.phone-input {
|
|
321
|
+
padding-left: 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.input::placeholder {
|
|
325
|
+
color: var(--uniplat-user-login-placeholder);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.error-text {
|
|
329
|
+
display: flex;
|
|
330
|
+
gap: 5px;
|
|
331
|
+
align-items: center;
|
|
332
|
+
margin-top: 6px;
|
|
333
|
+
color: var(--uniplat-user-login-error);
|
|
334
|
+
font-size: 12px;
|
|
335
|
+
line-height: 16px;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.error-dot {
|
|
339
|
+
display: grid;
|
|
340
|
+
width: 13px;
|
|
341
|
+
height: 13px;
|
|
342
|
+
flex: none;
|
|
343
|
+
border-radius: 50%;
|
|
344
|
+
background: currentcolor;
|
|
345
|
+
color: #fff;
|
|
346
|
+
font-size: 9px;
|
|
347
|
+
font-weight: 800;
|
|
348
|
+
place-items: center;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.send-button,
|
|
352
|
+
.countdown {
|
|
353
|
+
display: grid;
|
|
354
|
+
width: 100%;
|
|
355
|
+
height: 44px;
|
|
356
|
+
padding: 0 8px;
|
|
357
|
+
flex: none;
|
|
358
|
+
border: 0;
|
|
359
|
+
border-radius: 9px;
|
|
360
|
+
font-size: 13px;
|
|
361
|
+
font-weight: 700;
|
|
362
|
+
text-align: center;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
place-items: center;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.sms-row {
|
|
368
|
+
display: flex;
|
|
369
|
+
gap: 10px;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.sms-row .input-row {
|
|
373
|
+
min-width: 0;
|
|
374
|
+
flex: 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.sms-action {
|
|
378
|
+
width: 96px;
|
|
379
|
+
height: 44px;
|
|
380
|
+
flex: none;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.send-button {
|
|
384
|
+
background: #f4f7fc;
|
|
385
|
+
color: var(--uniplat-user-login-primary);
|
|
386
|
+
cursor: pointer;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.send-button:disabled,
|
|
390
|
+
.countdown {
|
|
391
|
+
background: #f4f7fc;
|
|
392
|
+
color: #98a4b2;
|
|
393
|
+
cursor: not-allowed;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.password-toggle {
|
|
397
|
+
display: grid;
|
|
398
|
+
width: 42px;
|
|
399
|
+
height: 42px;
|
|
400
|
+
padding: 0;
|
|
401
|
+
flex: none;
|
|
402
|
+
border: 0;
|
|
403
|
+
background: transparent;
|
|
404
|
+
color: #98a4b2;
|
|
405
|
+
cursor: pointer;
|
|
406
|
+
font-size: 16px;
|
|
407
|
+
place-items: center;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.options,
|
|
411
|
+
.agreement {
|
|
412
|
+
display: flex;
|
|
413
|
+
color: var(--uniplat-user-login-muted);
|
|
414
|
+
font-size: 12.5px;
|
|
415
|
+
line-height: 20px;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.options {
|
|
419
|
+
align-items: center;
|
|
420
|
+
justify-content: space-between;
|
|
421
|
+
margin-top: 1px;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.check-label {
|
|
425
|
+
display: inline-flex;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
align-items: center;
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.checkbox {
|
|
432
|
+
position: relative;
|
|
433
|
+
width: 15px;
|
|
434
|
+
height: 15px;
|
|
435
|
+
margin: 0;
|
|
436
|
+
appearance: none;
|
|
437
|
+
border: 1.5px solid #c4ceda;
|
|
438
|
+
border-radius: 4px;
|
|
439
|
+
background: #fff;
|
|
440
|
+
cursor: pointer;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.checkbox:checked {
|
|
444
|
+
border-color: var(--uniplat-user-login-primary);
|
|
445
|
+
background: var(--uniplat-user-login-primary);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.checkbox.error {
|
|
449
|
+
border-color: var(--uniplat-user-login-error);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.checkbox:checked::after {
|
|
453
|
+
position: absolute;
|
|
454
|
+
top: 1px;
|
|
455
|
+
left: 4px;
|
|
456
|
+
width: 4px;
|
|
457
|
+
height: 8px;
|
|
458
|
+
border: solid #fff;
|
|
459
|
+
border-width: 0 1.5px 1.5px 0;
|
|
460
|
+
content: '';
|
|
461
|
+
transform: rotate(45deg);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.options .checkbox {
|
|
465
|
+
width: 14px;
|
|
466
|
+
height: 14px;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.text-button,
|
|
470
|
+
.agreement-link {
|
|
471
|
+
padding: 0;
|
|
472
|
+
border: 0;
|
|
473
|
+
background: transparent;
|
|
474
|
+
color: var(--uniplat-user-login-primary);
|
|
475
|
+
cursor: pointer;
|
|
476
|
+
text-decoration: none;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.agreement {
|
|
480
|
+
gap: 8px;
|
|
481
|
+
align-items: flex-start;
|
|
482
|
+
margin-top: 2px;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.agreement .checkbox {
|
|
486
|
+
margin-top: 3px;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.agreement-copy {
|
|
490
|
+
display: inline;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.agreement-link {
|
|
494
|
+
margin: 0 2px;
|
|
495
|
+
white-space: nowrap;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.submit {
|
|
499
|
+
display: flex;
|
|
500
|
+
width: 100%;
|
|
501
|
+
height: 46px;
|
|
502
|
+
align-items: center;
|
|
503
|
+
justify-content: center;
|
|
504
|
+
margin-top: 4px;
|
|
505
|
+
border: 0;
|
|
506
|
+
border-radius: 10px;
|
|
507
|
+
background: var(--uniplat-user-login-primary);
|
|
508
|
+
color: #fff;
|
|
509
|
+
cursor: pointer;
|
|
510
|
+
font-size: 15px;
|
|
511
|
+
font-weight: 700;
|
|
512
|
+
transition: opacity 0.16s ease;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.submit:hover:not(:disabled) {
|
|
516
|
+
opacity: 0.92;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.submit:disabled {
|
|
520
|
+
background: #a9c2f6;
|
|
521
|
+
cursor: not-allowed;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.helper {
|
|
525
|
+
margin-top: 0;
|
|
526
|
+
color: #98a4b2;
|
|
527
|
+
font-size: 12.5px;
|
|
528
|
+
line-height: 18px;
|
|
529
|
+
text-align: center;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.verification-panel .submit {
|
|
533
|
+
margin-top: 6px;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.verification-panel.has-errors {
|
|
537
|
+
gap: 16px;
|
|
538
|
+
padding-top: 16px;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.verification-panel.has-errors .submit {
|
|
542
|
+
margin-top: 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.global-message {
|
|
546
|
+
min-height: 18px;
|
|
547
|
+
margin-top: 10px;
|
|
548
|
+
color: var(--uniplat-user-login-muted);
|
|
549
|
+
font-size: 12px;
|
|
550
|
+
line-height: 18px;
|
|
551
|
+
text-align: center;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.global-message.error {
|
|
555
|
+
color: var(--uniplat-user-login-error);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.qr-panel {
|
|
559
|
+
gap: 0;
|
|
560
|
+
padding-top: 16px;
|
|
561
|
+
align-items: center;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.qr-frame {
|
|
565
|
+
position: relative;
|
|
566
|
+
display: grid;
|
|
567
|
+
width: 172px;
|
|
568
|
+
height: 172px;
|
|
569
|
+
overflow: hidden;
|
|
570
|
+
border: 1px solid #e4ebf3;
|
|
571
|
+
border-radius: 12px;
|
|
572
|
+
background: #fafcfe;
|
|
573
|
+
place-items: center;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.qr-image {
|
|
577
|
+
width: 150px;
|
|
578
|
+
height: 150px;
|
|
579
|
+
object-fit: contain;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
.qr-placeholder {
|
|
583
|
+
color: #98a4b2;
|
|
584
|
+
font-size: 12px;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.qr-overlay {
|
|
588
|
+
position: absolute;
|
|
589
|
+
display: flex;
|
|
590
|
+
inset: 0;
|
|
591
|
+
align-items: center;
|
|
592
|
+
justify-content: center;
|
|
593
|
+
padding: 18px;
|
|
594
|
+
border: 0;
|
|
595
|
+
background: rgb(255 255 255 / 92%);
|
|
596
|
+
color: var(--uniplat-user-login-text);
|
|
597
|
+
cursor: default;
|
|
598
|
+
font-size: 13px;
|
|
599
|
+
font-weight: 700;
|
|
600
|
+
line-height: 20px;
|
|
601
|
+
text-align: center;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
button.qr-overlay.retry {
|
|
605
|
+
color: var(--uniplat-user-login-primary);
|
|
606
|
+
cursor: pointer;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.qr-heading {
|
|
610
|
+
margin-top: 14px;
|
|
611
|
+
color: var(--uniplat-user-login-text);
|
|
612
|
+
font-size: 13.5px;
|
|
613
|
+
font-weight: 700;
|
|
614
|
+
line-height: 20px;
|
|
615
|
+
text-align: center;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.qr-steps {
|
|
619
|
+
display: flex;
|
|
620
|
+
width: 100%;
|
|
621
|
+
gap: 7px;
|
|
622
|
+
margin: 10px 0 0;
|
|
623
|
+
padding: 0;
|
|
624
|
+
color: var(--uniplat-user-login-muted);
|
|
625
|
+
flex-direction: column;
|
|
626
|
+
font-size: 12.5px;
|
|
627
|
+
line-height: 1.6;
|
|
628
|
+
list-style: none;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.qr-step {
|
|
632
|
+
display: flex;
|
|
633
|
+
gap: 8px;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.step-number {
|
|
637
|
+
display: grid;
|
|
638
|
+
width: 17px;
|
|
639
|
+
height: 17px;
|
|
640
|
+
margin-top: 2px;
|
|
641
|
+
flex: none;
|
|
642
|
+
border-radius: 50%;
|
|
643
|
+
background: #edf1f6;
|
|
644
|
+
color: #5a6673;
|
|
645
|
+
font-size: 11px;
|
|
646
|
+
font-weight: 700;
|
|
647
|
+
place-items: center;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.qr-tip {
|
|
651
|
+
display: flex;
|
|
652
|
+
width: 100%;
|
|
653
|
+
gap: 6px;
|
|
654
|
+
align-items: center;
|
|
655
|
+
margin-top: 12px;
|
|
656
|
+
padding: 8px 12px;
|
|
657
|
+
border-radius: 8px;
|
|
658
|
+
background: #eaf1fe;
|
|
659
|
+
color: var(--uniplat-user-login-primary);
|
|
660
|
+
font-size: 12.5px;
|
|
661
|
+
line-height: 20px;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.qr-tip-icon {
|
|
665
|
+
position: relative;
|
|
666
|
+
width: 10px;
|
|
667
|
+
height: 15px;
|
|
668
|
+
flex: none;
|
|
669
|
+
border: 1.4px solid currentcolor;
|
|
670
|
+
border-radius: 2px;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.qr-tip-icon::after {
|
|
674
|
+
position: absolute;
|
|
675
|
+
bottom: 1px;
|
|
676
|
+
left: 3px;
|
|
677
|
+
width: 2px;
|
|
678
|
+
height: 1px;
|
|
679
|
+
border-radius: 1px;
|
|
680
|
+
background: currentcolor;
|
|
681
|
+
content: '';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.form.success-form {
|
|
685
|
+
padding: 34px 24px;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.success-panel {
|
|
689
|
+
display: flex;
|
|
690
|
+
width: 100%;
|
|
691
|
+
align-items: center;
|
|
692
|
+
flex-direction: column;
|
|
693
|
+
text-align: center;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.success-icon {
|
|
697
|
+
display: grid;
|
|
698
|
+
width: 62px;
|
|
699
|
+
height: 62px;
|
|
700
|
+
border-radius: 50%;
|
|
701
|
+
background: #e3f7ef;
|
|
702
|
+
color: #0ca678;
|
|
703
|
+
place-items: center;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.success-title {
|
|
707
|
+
margin-top: 16px;
|
|
708
|
+
color: var(--uniplat-user-login-text);
|
|
709
|
+
font-size: 18px;
|
|
710
|
+
font-weight: 800;
|
|
711
|
+
line-height: 25px;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.success-description {
|
|
715
|
+
margin-top: 8px;
|
|
716
|
+
color: #61708a;
|
|
717
|
+
font-size: 13.5px;
|
|
718
|
+
line-height: 1.75;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.success-countdown {
|
|
722
|
+
display: flex;
|
|
723
|
+
gap: 7px;
|
|
724
|
+
align-items: center;
|
|
725
|
+
margin-top: 18px;
|
|
726
|
+
color: #98a4b2;
|
|
727
|
+
font-size: 12.5px;
|
|
728
|
+
line-height: 18px;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.success-spinner {
|
|
732
|
+
width: 15px;
|
|
733
|
+
height: 15px;
|
|
734
|
+
border: 1.5px solid #d4dce6;
|
|
735
|
+
border-top-color: #98a4b2;
|
|
736
|
+
border-radius: 50%;
|
|
737
|
+
animation: onboarding-spin 0.8s linear infinite;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.onboarding-button {
|
|
741
|
+
display: flex;
|
|
742
|
+
width: 100%;
|
|
743
|
+
height: 44px;
|
|
744
|
+
align-items: center;
|
|
745
|
+
justify-content: center;
|
|
746
|
+
margin-top: 18px;
|
|
747
|
+
border: 0;
|
|
748
|
+
border-radius: 10px;
|
|
749
|
+
background: var(--uniplat-user-login-primary);
|
|
750
|
+
color: #fff;
|
|
751
|
+
cursor: pointer;
|
|
752
|
+
font-size: 14.5px;
|
|
753
|
+
font-weight: 700;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
@keyframes onboarding-spin {
|
|
757
|
+
to {
|
|
758
|
+
transform: rotate(360deg);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.security-footer {
|
|
763
|
+
display: flex;
|
|
764
|
+
min-height: 38px;
|
|
765
|
+
gap: 8px;
|
|
766
|
+
align-items: center;
|
|
767
|
+
justify-content: center;
|
|
768
|
+
padding: 10px 18px;
|
|
769
|
+
border-top: 1px solid #edf1f6;
|
|
770
|
+
color: var(--uniplat-user-login-placeholder);
|
|
771
|
+
font-size: 12px;
|
|
772
|
+
line-height: 18px;
|
|
773
|
+
order: 3;
|
|
774
|
+
text-align: center;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.security-icon {
|
|
778
|
+
position: relative;
|
|
779
|
+
width: 14px;
|
|
780
|
+
height: 10px;
|
|
781
|
+
margin-top: 4px;
|
|
782
|
+
flex: none;
|
|
783
|
+
border: 1.3px solid currentcolor;
|
|
784
|
+
border-radius: 2px;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.security-icon::before {
|
|
788
|
+
position: absolute;
|
|
789
|
+
bottom: 8px;
|
|
790
|
+
left: 2px;
|
|
791
|
+
width: 8px;
|
|
792
|
+
height: 6px;
|
|
793
|
+
border: 1.3px solid currentcolor;
|
|
794
|
+
border-bottom: 0;
|
|
795
|
+
border-radius: 6px 6px 0 0;
|
|
796
|
+
content: '';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.tab:focus-visible,
|
|
800
|
+
.send-button:focus-visible,
|
|
801
|
+
.password-toggle:focus-visible,
|
|
802
|
+
.text-button:focus-visible,
|
|
803
|
+
.agreement-link:focus-visible,
|
|
804
|
+
.submit:focus-visible,
|
|
805
|
+
.qr-overlay:focus-visible,
|
|
806
|
+
.onboarding-button:focus-visible {
|
|
807
|
+
outline: 2px solid var(--uniplat-user-login-primary);
|
|
808
|
+
outline-offset: 2px;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
@media (max-width: 420px) {
|
|
812
|
+
.form {
|
|
813
|
+
padding-right: 18px;
|
|
814
|
+
padding-left: 18px;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
.tabs {
|
|
818
|
+
gap: 10px;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.tab {
|
|
822
|
+
font-size: 13.5px;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
@media (prefers-reduced-motion: reduce) {
|
|
827
|
+
.input-row,
|
|
828
|
+
.submit {
|
|
829
|
+
transition: none;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
.success-spinner {
|
|
833
|
+
animation: none;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
`
|
|
837
|
+
|
|
838
|
+
declare private activeMethod: UserLoginMethod
|
|
839
|
+
declare private mobile: string
|
|
840
|
+
declare private smsCode: string
|
|
841
|
+
declare private password: string
|
|
842
|
+
declare private passwordVisible: boolean
|
|
843
|
+
declare private agreed: boolean
|
|
844
|
+
declare private rememberSession: boolean
|
|
845
|
+
declare private sendingSms: boolean
|
|
846
|
+
declare private loggingIn: boolean
|
|
847
|
+
declare private countdown: number
|
|
848
|
+
declare private errors: FormErrors
|
|
849
|
+
declare private statusMessage: string
|
|
850
|
+
declare private branding: UserLoginBranding
|
|
851
|
+
declare private qrState: QrViewState
|
|
852
|
+
declare private qrImageUrl: string
|
|
853
|
+
declare private registrationSucceeded: boolean
|
|
854
|
+
|
|
855
|
+
private config: ResolvedUserLoginConfig | null = null
|
|
856
|
+
private countdownTimer: number | undefined
|
|
857
|
+
private qrChallengeId = ''
|
|
858
|
+
private qrPollIntervalMs = 0
|
|
859
|
+
private qrGeneration = 0
|
|
860
|
+
private qrPollTimer: number | undefined
|
|
861
|
+
private onboardingTimer: number | undefined
|
|
862
|
+
private onboardingRequested = false
|
|
863
|
+
|
|
864
|
+
constructor() {
|
|
865
|
+
super()
|
|
866
|
+
this.activeMethod = 'verification-code'
|
|
867
|
+
this.mobile = ''
|
|
868
|
+
this.smsCode = ''
|
|
869
|
+
this.password = ''
|
|
870
|
+
this.passwordVisible = false
|
|
871
|
+
this.agreed = false
|
|
872
|
+
this.rememberSession = true
|
|
873
|
+
this.sendingSms = false
|
|
874
|
+
this.loggingIn = false
|
|
875
|
+
this.countdown = 0
|
|
876
|
+
this.errors = { ...EMPTY_ERRORS }
|
|
877
|
+
this.statusMessage = ''
|
|
878
|
+
this.branding = {
|
|
879
|
+
headerTitle: '',
|
|
880
|
+
headerSubtitle: '',
|
|
881
|
+
headerIconUrl: '',
|
|
882
|
+
securityHint: '',
|
|
883
|
+
}
|
|
884
|
+
this.qrState = 'idle'
|
|
885
|
+
this.qrImageUrl = ''
|
|
886
|
+
this.registrationSucceeded = false
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
configure(config: UserLoginConfig): void {
|
|
890
|
+
if (
|
|
891
|
+
!config ||
|
|
892
|
+
!config.transport ||
|
|
893
|
+
typeof config.transport.sendSmsCode !== 'function' ||
|
|
894
|
+
typeof config.transport.login !== 'function'
|
|
895
|
+
) {
|
|
896
|
+
throw new TypeError('user-login requires a complete verification-code transport')
|
|
897
|
+
}
|
|
898
|
+
const countdownSeconds = config.countdownSeconds ?? 60
|
|
899
|
+
if (!Number.isInteger(countdownSeconds) || countdownSeconds < 1 || countdownSeconds > 300) {
|
|
900
|
+
throw new TypeError('user-login countdownSeconds must be an integer from 1 to 300')
|
|
901
|
+
}
|
|
902
|
+
const qrPollIntervalMs = config.qrPollIntervalMs ?? 2_000
|
|
903
|
+
if (
|
|
904
|
+
!Number.isInteger(qrPollIntervalMs) ||
|
|
905
|
+
qrPollIntervalMs < 500 ||
|
|
906
|
+
qrPollIntervalMs > 10_000
|
|
907
|
+
) {
|
|
908
|
+
throw new TypeError('user-login qrPollIntervalMs must be an integer from 500 to 10000')
|
|
909
|
+
}
|
|
910
|
+
const nextBranding: UserLoginBranding = {
|
|
911
|
+
headerTitle: normalizeDisplayText(config.headerTitle, 80),
|
|
912
|
+
headerSubtitle: normalizeDisplayText(config.headerSubtitle, 160),
|
|
913
|
+
headerIconUrl: safeConfiguredImageUrl(config.headerIconUrl),
|
|
914
|
+
securityHint: normalizeDisplayText(config.securityHint, 200),
|
|
915
|
+
}
|
|
916
|
+
this.stopQrFlow()
|
|
917
|
+
this.clearCountdown()
|
|
918
|
+
this.clearOnboardingTimer()
|
|
919
|
+
this.config = {
|
|
920
|
+
transport: config.transport,
|
|
921
|
+
applicationName: normalizeApplicationName(config.applicationName),
|
|
922
|
+
userAgreementUrl: safeNavigationUrl(config.userAgreementUrl, 'Agreement URL'),
|
|
923
|
+
privacyAgreementUrl: safeNavigationUrl(config.privacyAgreementUrl, 'Agreement URL'),
|
|
924
|
+
forgotPasswordUrl: safeNavigationUrl(config.forgotPasswordUrl, 'Forgot-password URL'),
|
|
925
|
+
countdownSeconds,
|
|
926
|
+
qrPollIntervalMs,
|
|
927
|
+
}
|
|
928
|
+
this.branding = nextBranding
|
|
929
|
+
this.activeMethod = this.resolveInitialMethod(normalizeLoginMethod(config.initialMethod))
|
|
930
|
+
this.mobile = ''
|
|
931
|
+
this.smsCode = ''
|
|
932
|
+
this.password = ''
|
|
933
|
+
this.passwordVisible = false
|
|
934
|
+
this.agreed = config.initiallyAgreed ?? false
|
|
935
|
+
this.rememberSession = config.initiallyRememberSession ?? true
|
|
936
|
+
this.qrState = 'idle'
|
|
937
|
+
this.qrImageUrl = ''
|
|
938
|
+
this.registrationSucceeded = false
|
|
939
|
+
this.onboardingRequested = false
|
|
940
|
+
this.errors = { ...EMPTY_ERRORS }
|
|
941
|
+
this.statusMessage = ''
|
|
942
|
+
if (this.isConnected) this.startActiveMethod()
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
override connectedCallback(): void {
|
|
946
|
+
super.connectedCallback()
|
|
947
|
+
if (this.registrationSucceeded) this.startOnboardingTimer()
|
|
948
|
+
else if (this.config) this.startActiveMethod()
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
override disconnectedCallback(): void {
|
|
952
|
+
this.clearCountdown()
|
|
953
|
+
this.clearOnboardingTimer()
|
|
954
|
+
this.stopQrFlow()
|
|
955
|
+
const passwordInput =
|
|
956
|
+
this.shadowRoot?.querySelector<HTMLInputElement>('#uniplat-login-password')
|
|
957
|
+
if (passwordInput) passwordInput.value = ''
|
|
958
|
+
this.password = ''
|
|
959
|
+
this.passwordVisible = false
|
|
960
|
+
super.disconnectedCallback()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
override render() {
|
|
964
|
+
const hasHeader = Boolean(
|
|
965
|
+
this.branding.headerTitle || this.branding.headerSubtitle || this.branding.headerIconUrl,
|
|
966
|
+
)
|
|
967
|
+
return html`
|
|
968
|
+
<section class="shell" part="shell">
|
|
969
|
+
${hasHeader ? this.renderHeader() : nothing}
|
|
970
|
+
${
|
|
971
|
+
this.branding.securityHint
|
|
972
|
+
? html`<div class="security-footer" part="security-footer">
|
|
973
|
+
<span class="security-icon" part="security-icon" aria-hidden="true"></span>
|
|
974
|
+
<span>${this.branding.securityHint}</span>
|
|
975
|
+
</div>`
|
|
976
|
+
: nothing
|
|
977
|
+
}
|
|
978
|
+
<form
|
|
979
|
+
class=${`form${this.registrationSucceeded ? ' success-form' : ''}`}
|
|
980
|
+
part="form"
|
|
981
|
+
novalidate
|
|
982
|
+
@submit=${this.submitLogin}
|
|
983
|
+
>
|
|
984
|
+
${
|
|
985
|
+
this.registrationSucceeded
|
|
986
|
+
? this.renderRegistrationSuccess()
|
|
987
|
+
: html`${this.renderTabs()} ${this.renderActivePanel()}`
|
|
988
|
+
}
|
|
989
|
+
</form>
|
|
990
|
+
</section>
|
|
991
|
+
`
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
private renderHeader() {
|
|
995
|
+
return html`
|
|
996
|
+
<header class=${`header${this.branding.headerIconUrl ? '' : ' no-icon'}`} part="header">
|
|
997
|
+
${
|
|
998
|
+
this.branding.headerIconUrl
|
|
999
|
+
? html`<img
|
|
1000
|
+
class="header-icon"
|
|
1001
|
+
part="header-icon"
|
|
1002
|
+
src=${this.branding.headerIconUrl}
|
|
1003
|
+
alt=""
|
|
1004
|
+
/>`
|
|
1005
|
+
: nothing
|
|
1006
|
+
}
|
|
1007
|
+
<div class="header-copy">
|
|
1008
|
+
${
|
|
1009
|
+
this.branding.headerTitle
|
|
1010
|
+
? html`<div class="header-title" part="header-title">
|
|
1011
|
+
${this.branding.headerTitle}
|
|
1012
|
+
</div>`
|
|
1013
|
+
: nothing
|
|
1014
|
+
}
|
|
1015
|
+
${
|
|
1016
|
+
this.branding.headerSubtitle
|
|
1017
|
+
? html`<div class="header-subtitle" part="header-subtitle">
|
|
1018
|
+
${this.branding.headerSubtitle}
|
|
1019
|
+
</div>`
|
|
1020
|
+
: nothing
|
|
1021
|
+
}
|
|
1022
|
+
</div>
|
|
1023
|
+
</header>
|
|
1024
|
+
`
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private renderRegistrationSuccess() {
|
|
1028
|
+
return html`
|
|
1029
|
+
<div class="success-panel" part="registration-success" role="status" aria-live="polite">
|
|
1030
|
+
<span class="success-icon" part="registration-success-icon" aria-hidden="true">
|
|
1031
|
+
<svg
|
|
1032
|
+
width="32"
|
|
1033
|
+
height="32"
|
|
1034
|
+
viewBox="0 0 24 24"
|
|
1035
|
+
fill="none"
|
|
1036
|
+
stroke="currentColor"
|
|
1037
|
+
stroke-width="1.8"
|
|
1038
|
+
stroke-linecap="round"
|
|
1039
|
+
stroke-linejoin="round"
|
|
1040
|
+
>
|
|
1041
|
+
<circle cx="12" cy="12" r="9"></circle>
|
|
1042
|
+
<path d="m8 12 2.6 2.6L16.5 9"></path>
|
|
1043
|
+
</svg>
|
|
1044
|
+
</span>
|
|
1045
|
+
<div class="success-title">注册成功,欢迎加入亲亲企服</div>
|
|
1046
|
+
<div class="success-description">接下来创建你的团队 / 企业,即可开始办理业务</div>
|
|
1047
|
+
<div class="success-countdown">
|
|
1048
|
+
<span class="success-spinner" aria-hidden="true"></span>
|
|
1049
|
+
<span>2 秒后自动进入入驻引导</span>
|
|
1050
|
+
</div>
|
|
1051
|
+
<button
|
|
1052
|
+
class="onboarding-button"
|
|
1053
|
+
part="onboarding-button"
|
|
1054
|
+
type="button"
|
|
1055
|
+
@click=${this.requestOnboardingManually}
|
|
1056
|
+
>
|
|
1057
|
+
立即开始
|
|
1058
|
+
</button>
|
|
1059
|
+
</div>
|
|
1060
|
+
`
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private renderTabs() {
|
|
1064
|
+
return html`
|
|
1065
|
+
<div class="tabs" part="title tabs" role="tablist" aria-label="登录方式">
|
|
1066
|
+
${LOGIN_METHODS.map(({ method, label }) => {
|
|
1067
|
+
const available = this.isMethodAvailable(method)
|
|
1068
|
+
return html`
|
|
1069
|
+
<button
|
|
1070
|
+
class=${`tab${this.activeMethod === method ? ' selected' : ''}`}
|
|
1071
|
+
part="tab"
|
|
1072
|
+
type="button"
|
|
1073
|
+
role="tab"
|
|
1074
|
+
aria-selected=${String(this.activeMethod === method)}
|
|
1075
|
+
title=${available ? '' : '当前 Host 未配置此登录方式'}
|
|
1076
|
+
?disabled=${!available}
|
|
1077
|
+
@click=${() => this.selectMethod(method)}
|
|
1078
|
+
>
|
|
1079
|
+
${label}
|
|
1080
|
+
</button>
|
|
1081
|
+
`
|
|
1082
|
+
})}
|
|
1083
|
+
</div>
|
|
1084
|
+
`
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private renderActivePanel() {
|
|
1088
|
+
if (this.activeMethod === 'password') return this.renderPasswordPanel()
|
|
1089
|
+
if (this.activeMethod === 'qr-code') return this.renderQrPanel()
|
|
1090
|
+
return this.renderVerificationPanel()
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private renderPasswordPanel() {
|
|
1094
|
+
const busy = this.loggingIn || this.sendingSms
|
|
1095
|
+
const canSubmit = Boolean(this.isMobileValid() && this.password.length >= 6 && !busy)
|
|
1096
|
+
return html`
|
|
1097
|
+
<div class="method-panel" part="method-panel" role="tabpanel">
|
|
1098
|
+
${this.renderMobileField()}
|
|
1099
|
+
<div class="field" part="field">
|
|
1100
|
+
<div class=${`input-row${this.errors.password ? ' error' : ''}`}>
|
|
1101
|
+
<input
|
|
1102
|
+
id="uniplat-login-password"
|
|
1103
|
+
class="input"
|
|
1104
|
+
part="input"
|
|
1105
|
+
type=${this.passwordVisible ? 'text' : 'password'}
|
|
1106
|
+
autocomplete="current-password"
|
|
1107
|
+
maxlength="128"
|
|
1108
|
+
placeholder="请输入密码"
|
|
1109
|
+
aria-label="密码"
|
|
1110
|
+
aria-invalid=${String(Boolean(this.errors.password))}
|
|
1111
|
+
.value=${this.password}
|
|
1112
|
+
@input=${this.updatePassword}
|
|
1113
|
+
@blur=${this.validatePasswordField}
|
|
1114
|
+
/>
|
|
1115
|
+
<button
|
|
1116
|
+
class="password-toggle"
|
|
1117
|
+
part="password-toggle"
|
|
1118
|
+
type="button"
|
|
1119
|
+
aria-label=${this.passwordVisible ? '隐藏密码' : '显示密码'}
|
|
1120
|
+
@click=${this.togglePasswordVisibility}
|
|
1121
|
+
>
|
|
1122
|
+
${this.renderPasswordVisibilityIcon()}
|
|
1123
|
+
</button>
|
|
1124
|
+
</div>
|
|
1125
|
+
${this.renderFieldError(this.errors.password)}
|
|
1126
|
+
</div>
|
|
1127
|
+
<div class="options" part="login-options">
|
|
1128
|
+
<label class="check-label">
|
|
1129
|
+
<input
|
|
1130
|
+
class="checkbox"
|
|
1131
|
+
type="checkbox"
|
|
1132
|
+
.checked=${this.rememberSession}
|
|
1133
|
+
@change=${this.updateRememberSession}
|
|
1134
|
+
/>
|
|
1135
|
+
<span>7 天内免登录</span>
|
|
1136
|
+
</label>
|
|
1137
|
+
<button
|
|
1138
|
+
class="text-button"
|
|
1139
|
+
part="forgot-password"
|
|
1140
|
+
type="button"
|
|
1141
|
+
@click=${this.openForgotPassword}
|
|
1142
|
+
>
|
|
1143
|
+
忘记密码
|
|
1144
|
+
</button>
|
|
1145
|
+
</div>
|
|
1146
|
+
<button class="submit" part="submit-button" type="submit" ?disabled=${!canSubmit}>
|
|
1147
|
+
${this.loggingIn ? '登录中…' : '登录'}
|
|
1148
|
+
</button>
|
|
1149
|
+
${this.renderGlobalMessage()}
|
|
1150
|
+
</div>
|
|
1151
|
+
`
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
private renderVerificationPanel() {
|
|
1155
|
+
const busy = this.sendingSms || this.loggingIn
|
|
1156
|
+
const hasValidationErrors = Boolean(
|
|
1157
|
+
this.errors.mobile || this.errors.smsCode || this.errors.agreement,
|
|
1158
|
+
)
|
|
1159
|
+
const canSubmit = Boolean(this.agreed && this.isMobileValid() && this.isSmsCodeValid() && !busy)
|
|
1160
|
+
return html`
|
|
1161
|
+
<div
|
|
1162
|
+
class=${`method-panel verification-panel${hasValidationErrors ? ' has-errors' : ''}`}
|
|
1163
|
+
part="method-panel"
|
|
1164
|
+
role="tabpanel"
|
|
1165
|
+
>
|
|
1166
|
+
${this.renderMobileField()}
|
|
1167
|
+
<div class="field" part="field">
|
|
1168
|
+
<div class="sms-row" part="sms-row">
|
|
1169
|
+
<div class=${`input-row${this.errors.smsCode ? ' error' : ''}`}>
|
|
1170
|
+
<input
|
|
1171
|
+
id="uniplat-login-sms-code"
|
|
1172
|
+
class="input"
|
|
1173
|
+
part="input"
|
|
1174
|
+
type="text"
|
|
1175
|
+
inputmode="numeric"
|
|
1176
|
+
autocomplete="one-time-code"
|
|
1177
|
+
maxlength="6"
|
|
1178
|
+
placeholder="请输入 6 位验证码"
|
|
1179
|
+
aria-label="手机验证码"
|
|
1180
|
+
aria-invalid=${String(Boolean(this.errors.smsCode))}
|
|
1181
|
+
.value=${this.smsCode}
|
|
1182
|
+
@input=${this.updateSmsCode}
|
|
1183
|
+
@blur=${this.validateSmsCodeField}
|
|
1184
|
+
/>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div class="sms-action">
|
|
1187
|
+
<button
|
|
1188
|
+
class="send-button"
|
|
1189
|
+
part="send-button"
|
|
1190
|
+
type="button"
|
|
1191
|
+
?disabled=${busy || this.countdown > 0 || !this.config || !this.isMobileValid()}
|
|
1192
|
+
@click=${this.sendSmsCode}
|
|
1193
|
+
>
|
|
1194
|
+
${
|
|
1195
|
+
this.sendingSms
|
|
1196
|
+
? '发送中…'
|
|
1197
|
+
: this.countdown > 0
|
|
1198
|
+
? `${this.countdown}s 后重发`
|
|
1199
|
+
: '获取验证码'
|
|
1200
|
+
}
|
|
1201
|
+
</button>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
${this.renderFieldError(this.errors.smsCode)}
|
|
1205
|
+
</div>
|
|
1206
|
+
<div class="agreement-field">
|
|
1207
|
+
<div class="agreement" part="agreement">
|
|
1208
|
+
<input
|
|
1209
|
+
class=${`checkbox${this.errors.agreement ? ' error' : ''}`}
|
|
1210
|
+
type="checkbox"
|
|
1211
|
+
aria-label="同意用户服务协议和隐私政策"
|
|
1212
|
+
aria-invalid=${String(Boolean(this.errors.agreement))}
|
|
1213
|
+
.checked=${this.agreed}
|
|
1214
|
+
@change=${this.updateAgreement}
|
|
1215
|
+
@blur=${this.validateAgreementField}
|
|
1216
|
+
/>
|
|
1217
|
+
<span class="agreement-copy">
|
|
1218
|
+
我已阅读并同意 ${this.renderAgreementLink('user', '《用户服务协议》')} 与
|
|
1219
|
+
${this.renderAgreementLink('privacy', '《隐私政策》')}
|
|
1220
|
+
</span>
|
|
1221
|
+
</div>
|
|
1222
|
+
${this.renderFieldError(this.errors.agreement)}
|
|
1223
|
+
</div>
|
|
1224
|
+
<button class="submit" part="submit-button" type="submit" ?disabled=${!canSubmit}>
|
|
1225
|
+
${this.loggingIn ? '登录中…' : '注册 / 登录'}
|
|
1226
|
+
</button>
|
|
1227
|
+
${hasValidationErrors ? nothing : html`<div class="helper">未注册的手机号将自动创建账号</div>`}
|
|
1228
|
+
${this.renderGlobalMessage()}
|
|
1229
|
+
</div>
|
|
1230
|
+
`
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private renderMobileField() {
|
|
1234
|
+
return html`
|
|
1235
|
+
<div class="field" part="field">
|
|
1236
|
+
<div class=${`input-row${this.errors.mobile ? ' error' : ''}`}>
|
|
1237
|
+
<span class="country-code">+86</span>
|
|
1238
|
+
<span class="country-divider" aria-hidden="true"></span>
|
|
1239
|
+
<input
|
|
1240
|
+
id="uniplat-login-mobile"
|
|
1241
|
+
class="input phone-input"
|
|
1242
|
+
part="input"
|
|
1243
|
+
type="tel"
|
|
1244
|
+
inputmode="numeric"
|
|
1245
|
+
autocomplete="tel"
|
|
1246
|
+
maxlength="11"
|
|
1247
|
+
placeholder="请输入手机号"
|
|
1248
|
+
aria-label="手机号"
|
|
1249
|
+
aria-invalid=${String(Boolean(this.errors.mobile))}
|
|
1250
|
+
.value=${this.mobile}
|
|
1251
|
+
@input=${this.updateMobile}
|
|
1252
|
+
@blur=${this.validateMobileField}
|
|
1253
|
+
/>
|
|
1254
|
+
</div>
|
|
1255
|
+
${this.renderFieldError(this.errors.mobile)}
|
|
1256
|
+
</div>
|
|
1257
|
+
`
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private renderQrPanel() {
|
|
1261
|
+
const overlay = this.renderQrOverlay()
|
|
1262
|
+
return html`
|
|
1263
|
+
<div class="method-panel qr-panel" part="method-panel qr-panel" role="tabpanel">
|
|
1264
|
+
<div class="qr-frame" part="qr-code">
|
|
1265
|
+
${
|
|
1266
|
+
this.qrImageUrl
|
|
1267
|
+
? html`<img class="qr-image" src=${this.qrImageUrl} alt="登录二维码" />`
|
|
1268
|
+
: html`<span class="qr-placeholder"
|
|
1269
|
+
>${this.qrState === 'loading' ? '加载中…' : ''}</span
|
|
1270
|
+
>`
|
|
1271
|
+
}
|
|
1272
|
+
${overlay}
|
|
1273
|
+
</div>
|
|
1274
|
+
<div class="qr-heading">
|
|
1275
|
+
${this.qrState === 'scanned' ? '扫码成功,请在手机上确认' : '请使用微信扫描小程序码登录'}
|
|
1276
|
+
</div>
|
|
1277
|
+
<ol class="qr-steps">
|
|
1278
|
+
<li class="qr-step">
|
|
1279
|
+
<span class="step-number">1</span><span>打开微信右上角「扫一扫」</span>
|
|
1280
|
+
</li>
|
|
1281
|
+
<li class="qr-step">
|
|
1282
|
+
<span class="step-number">2</span><span>扫描上方小程序码,未登录时先完成登录</span>
|
|
1283
|
+
</li>
|
|
1284
|
+
<li class="qr-step">
|
|
1285
|
+
<span class="step-number">3</span><span>在小程序确认后,电脑端同步登录</span>
|
|
1286
|
+
</li>
|
|
1287
|
+
</ol>
|
|
1288
|
+
<div class="qr-tip" part="qr-tip">
|
|
1289
|
+
<span class="qr-tip-icon" aria-hidden="true"></span>
|
|
1290
|
+
<span>扫码将直接打开「亲亲企服」小程序</span>
|
|
1291
|
+
</div>
|
|
1292
|
+
${this.renderGlobalMessage()}
|
|
1293
|
+
</div>
|
|
1294
|
+
`
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
private renderQrOverlay() {
|
|
1298
|
+
if (this.qrState === 'expired') {
|
|
1299
|
+
return html`<button
|
|
1300
|
+
class="qr-overlay retry"
|
|
1301
|
+
part="qr-overlay"
|
|
1302
|
+
type="button"
|
|
1303
|
+
@click=${this.startQrChallenge}
|
|
1304
|
+
>
|
|
1305
|
+
二维码已失效<br />点击刷新
|
|
1306
|
+
</button>`
|
|
1307
|
+
}
|
|
1308
|
+
if (this.qrState === 'error') {
|
|
1309
|
+
return html`<button
|
|
1310
|
+
class="qr-overlay retry"
|
|
1311
|
+
part="qr-overlay"
|
|
1312
|
+
type="button"
|
|
1313
|
+
@click=${this.startQrChallenge}
|
|
1314
|
+
>
|
|
1315
|
+
加载失败<br />点击重试
|
|
1316
|
+
</button>`
|
|
1317
|
+
}
|
|
1318
|
+
if (this.qrState === 'rejected') {
|
|
1319
|
+
return html`<button
|
|
1320
|
+
class="qr-overlay retry"
|
|
1321
|
+
part="qr-overlay"
|
|
1322
|
+
type="button"
|
|
1323
|
+
@click=${this.startQrChallenge}
|
|
1324
|
+
>
|
|
1325
|
+
登录已取消<br />点击刷新
|
|
1326
|
+
</button>`
|
|
1327
|
+
}
|
|
1328
|
+
if (this.qrState === 'scanned') {
|
|
1329
|
+
return html`<div class="qr-overlay" part="qr-overlay">✓<br />等待手机确认</div>`
|
|
1330
|
+
}
|
|
1331
|
+
if (this.qrState === 'confirmed') {
|
|
1332
|
+
return html`<div class="qr-overlay" part="qr-overlay">✓<br />登录成功</div>`
|
|
1333
|
+
}
|
|
1334
|
+
return nothing
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
private renderPasswordVisibilityIcon() {
|
|
1338
|
+
return this.passwordVisible
|
|
1339
|
+
? html`<svg
|
|
1340
|
+
aria-hidden="true"
|
|
1341
|
+
width="16"
|
|
1342
|
+
height="16"
|
|
1343
|
+
viewBox="0 0 24 24"
|
|
1344
|
+
fill="none"
|
|
1345
|
+
stroke="currentColor"
|
|
1346
|
+
stroke-width="2"
|
|
1347
|
+
stroke-linecap="round"
|
|
1348
|
+
stroke-linejoin="round"
|
|
1349
|
+
>
|
|
1350
|
+
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"></path>
|
|
1351
|
+
<circle cx="12" cy="12" r="3"></circle>
|
|
1352
|
+
</svg>`
|
|
1353
|
+
: html`<svg
|
|
1354
|
+
aria-hidden="true"
|
|
1355
|
+
width="16"
|
|
1356
|
+
height="16"
|
|
1357
|
+
viewBox="0 0 24 24"
|
|
1358
|
+
fill="none"
|
|
1359
|
+
stroke="currentColor"
|
|
1360
|
+
stroke-width="2"
|
|
1361
|
+
stroke-linecap="round"
|
|
1362
|
+
stroke-linejoin="round"
|
|
1363
|
+
>
|
|
1364
|
+
<path d="m3 3 18 18"></path>
|
|
1365
|
+
<path d="M10.6 10.7a2 2 0 0 0 2.7 2.7"></path>
|
|
1366
|
+
<path d="M9.9 4.2A10.5 10.5 0 0 1 12 4c6.5 0 10 8 10 8a18.8 18.8 0 0 1-2.1 3.2"></path>
|
|
1367
|
+
<path d="M6.6 6.6C3.6 8.6 2 12 2 12s3.5 8 10 8a9.8 9.8 0 0 0 4.1-.9"></path>
|
|
1368
|
+
</svg>`
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
private renderFieldError(message: string) {
|
|
1372
|
+
return message
|
|
1373
|
+
? html`<span class="error-text" part="field-error" role="alert">
|
|
1374
|
+
<span class="error-dot" aria-hidden="true">!</span>${message}
|
|
1375
|
+
</span>`
|
|
1376
|
+
: nothing
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
private renderGlobalMessage() {
|
|
1380
|
+
if (!this.errors.global && !this.statusMessage) return nothing
|
|
1381
|
+
return html`<div
|
|
1382
|
+
class=${`global-message${this.errors.global ? ' error' : ''}`}
|
|
1383
|
+
part=${this.errors.global ? 'error' : 'status'}
|
|
1384
|
+
role=${this.errors.global ? 'alert' : 'status'}
|
|
1385
|
+
aria-live="polite"
|
|
1386
|
+
>
|
|
1387
|
+
${this.errors.global || this.statusMessage}
|
|
1388
|
+
</div>`
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
private renderAgreementLink(type: 'user' | 'privacy', label: string) {
|
|
1392
|
+
return html`
|
|
1393
|
+
<button
|
|
1394
|
+
class="agreement-link"
|
|
1395
|
+
part="agreement-link"
|
|
1396
|
+
type="button"
|
|
1397
|
+
@click=${() => this.openAgreement(type)}
|
|
1398
|
+
>
|
|
1399
|
+
${label}
|
|
1400
|
+
</button>
|
|
1401
|
+
`
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private readonly selectMethod = (method: UserLoginMethod): void => {
|
|
1405
|
+
if (method === this.activeMethod || !this.isMethodAvailable(method)) return
|
|
1406
|
+
if (this.activeMethod === 'qr-code') this.stopQrFlow()
|
|
1407
|
+
if (this.activeMethod === 'password') {
|
|
1408
|
+
this.password = ''
|
|
1409
|
+
this.passwordVisible = false
|
|
1410
|
+
}
|
|
1411
|
+
this.activeMethod = method
|
|
1412
|
+
this.errors = { ...EMPTY_ERRORS }
|
|
1413
|
+
this.statusMessage = ''
|
|
1414
|
+
this.startActiveMethod()
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private startActiveMethod(): void {
|
|
1418
|
+
if (this.activeMethod === 'qr-code') {
|
|
1419
|
+
void this.startQrChallenge()
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
private isMethodAvailable(method: UserLoginMethod): boolean {
|
|
1424
|
+
const transport = this.config?.transport
|
|
1425
|
+
if (method === 'verification-code') return Boolean(transport)
|
|
1426
|
+
if (method === 'password') return typeof transport?.loginWithPassword === 'function'
|
|
1427
|
+
return (
|
|
1428
|
+
typeof transport?.createQrChallenge === 'function' &&
|
|
1429
|
+
typeof transport.pollQrChallenge === 'function'
|
|
1430
|
+
)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private resolveInitialMethod(method: UserLoginMethod | undefined): UserLoginMethod {
|
|
1434
|
+
return method && this.isMethodAvailable(method) ? method : 'verification-code'
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
private readonly updateMobile = (event: InputEvent): void => {
|
|
1438
|
+
const input = event.currentTarget as HTMLInputElement
|
|
1439
|
+
this.mobile = input.value.replace(/\D/g, '').slice(0, 11)
|
|
1440
|
+
input.value = this.mobile
|
|
1441
|
+
this.errors = { ...this.errors, mobile: '', global: '' }
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private readonly updateSmsCode = (event: InputEvent): void => {
|
|
1445
|
+
const input = event.currentTarget as HTMLInputElement
|
|
1446
|
+
this.smsCode = input.value.replace(/\D/g, '').slice(0, 6)
|
|
1447
|
+
input.value = this.smsCode
|
|
1448
|
+
this.errors = { ...this.errors, smsCode: '', global: '' }
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private readonly updatePassword = (event: InputEvent): void => {
|
|
1452
|
+
this.password = (event.currentTarget as HTMLInputElement).value.slice(0, 128)
|
|
1453
|
+
this.errors = { ...this.errors, password: '', global: '' }
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private readonly updateAgreement = (event: Event): void => {
|
|
1457
|
+
this.agreed = (event.currentTarget as HTMLInputElement).checked
|
|
1458
|
+
this.errors = { ...this.errors, agreement: '', global: '' }
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
private readonly updateRememberSession = (event: Event): void => {
|
|
1462
|
+
this.rememberSession = (event.currentTarget as HTMLInputElement).checked
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
private readonly togglePasswordVisibility = (): void => {
|
|
1466
|
+
this.passwordVisible = !this.passwordVisible
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private readonly validateMobileField = (): void => {
|
|
1470
|
+
const mobile = this.mobileError()
|
|
1471
|
+
this.errors = { ...this.errors, mobile }
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
private readonly validateSmsCodeField = (): void => {
|
|
1475
|
+
const smsCode = this.smsCodeError()
|
|
1476
|
+
this.errors = { ...this.errors, smsCode }
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
private readonly validatePasswordField = (): void => {
|
|
1480
|
+
const password = this.passwordError()
|
|
1481
|
+
this.errors = { ...this.errors, password }
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
private readonly validateAgreementField = (): void => {
|
|
1485
|
+
const agreement = this.agreed ? '' : '请先阅读并同意服务协议'
|
|
1486
|
+
this.errors = { ...this.errors, agreement }
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private readonly sendSmsCode = async (): Promise<void> => {
|
|
1490
|
+
const config = this.config
|
|
1491
|
+
if (!config || this.sendingSms || this.loggingIn || this.countdown > 0) return
|
|
1492
|
+
if (!this.validateMobile()) return
|
|
1493
|
+
|
|
1494
|
+
this.sendingSms = true
|
|
1495
|
+
this.statusMessage = ''
|
|
1496
|
+
try {
|
|
1497
|
+
await config.transport.sendSmsCode({ mobile: this.mobile })
|
|
1498
|
+
this.startCountdown(config.countdownSeconds)
|
|
1499
|
+
this.statusMessage = '验证码已发送'
|
|
1500
|
+
this.dispatchPublicEvent('sms-sent', {})
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
const message =
|
|
1503
|
+
error instanceof UserLoginTransportError && error.code === 'SMS_SEND_TOO_FREQUENT'
|
|
1504
|
+
? '验证码发送频繁,请稍后重试。'
|
|
1505
|
+
: '验证码发送失败,请检查后重试。'
|
|
1506
|
+
this.handleError('SMS_SEND_FAILED', message, true)
|
|
1507
|
+
} finally {
|
|
1508
|
+
this.sendingSms = false
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
private readonly submitLogin = async (event: SubmitEvent): Promise<void> => {
|
|
1513
|
+
event.preventDefault()
|
|
1514
|
+
if (this.activeMethod === 'password') await this.submitPasswordLogin()
|
|
1515
|
+
if (this.activeMethod === 'verification-code') await this.submitVerificationLogin()
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
private async submitPasswordLogin(): Promise<void> {
|
|
1519
|
+
const config = this.config
|
|
1520
|
+
if (
|
|
1521
|
+
!config ||
|
|
1522
|
+
typeof config.transport.loginWithPassword !== 'function' ||
|
|
1523
|
+
this.loggingIn ||
|
|
1524
|
+
this.sendingSms
|
|
1525
|
+
) {
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
if (!this.validatePasswordForm()) return
|
|
1529
|
+
|
|
1530
|
+
this.loggingIn = true
|
|
1531
|
+
this.statusMessage = ''
|
|
1532
|
+
try {
|
|
1533
|
+
const result = await config.transport.loginWithPassword({
|
|
1534
|
+
mobile: this.mobile,
|
|
1535
|
+
password: this.password,
|
|
1536
|
+
applicationName: config.applicationName,
|
|
1537
|
+
rememberSession: this.rememberSession,
|
|
1538
|
+
})
|
|
1539
|
+
this.finishLogin(result)
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
const failure = passwordLoginFailure(error, config.applicationName)
|
|
1542
|
+
this.handleError(failure.code, failure.message, failure.recoverable)
|
|
1543
|
+
} finally {
|
|
1544
|
+
this.loggingIn = false
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
private async submitVerificationLogin(): Promise<void> {
|
|
1549
|
+
const config = this.config
|
|
1550
|
+
if (!config || this.loggingIn || this.sendingSms) return
|
|
1551
|
+
if (!this.validateVerificationForm()) return
|
|
1552
|
+
|
|
1553
|
+
this.loggingIn = true
|
|
1554
|
+
this.statusMessage = ''
|
|
1555
|
+
try {
|
|
1556
|
+
const result = await config.transport.login({
|
|
1557
|
+
mobile: this.mobile,
|
|
1558
|
+
smsCode: this.smsCode,
|
|
1559
|
+
applicationName: config.applicationName,
|
|
1560
|
+
})
|
|
1561
|
+
this.finishLogin(result)
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
if (
|
|
1564
|
+
error instanceof UserLoginTransportError &&
|
|
1565
|
+
error.code === 'SMS_CODE_INVALID_OR_EXPIRED'
|
|
1566
|
+
) {
|
|
1567
|
+
this.handleSmsCodeError('验证码错误或已失效,请重新获取')
|
|
1568
|
+
} else if (error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED') {
|
|
1569
|
+
this.handleError(
|
|
1570
|
+
'ORG_ACCESS_DENIED',
|
|
1571
|
+
applicationAccessDeniedMessage(error, config.applicationName),
|
|
1572
|
+
false,
|
|
1573
|
+
)
|
|
1574
|
+
} else {
|
|
1575
|
+
this.handleError('LOGIN_FAILED', '登录失败,请检查手机验证码后重试。', true)
|
|
1576
|
+
}
|
|
1577
|
+
} finally {
|
|
1578
|
+
this.loggingIn = false
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
private finishLogin(result: UserLoginSuccess): void {
|
|
1583
|
+
const applicationName =
|
|
1584
|
+
typeof result.applicationName === 'string' ? result.applicationName.trim() : ''
|
|
1585
|
+
if (!applicationName || applicationName !== this.config?.applicationName) {
|
|
1586
|
+
throw new Error('Invalid login result')
|
|
1587
|
+
}
|
|
1588
|
+
const isNewUser = result.isNewUser === true
|
|
1589
|
+
this.clearCountdown()
|
|
1590
|
+
this.clearOnboardingTimer()
|
|
1591
|
+
this.password = ''
|
|
1592
|
+
this.passwordVisible = false
|
|
1593
|
+
this.errors = { ...EMPTY_ERRORS }
|
|
1594
|
+
this.statusMessage = isNewUser ? '' : '登录成功'
|
|
1595
|
+
this.registrationSucceeded = isNewUser
|
|
1596
|
+
this.onboardingRequested = false
|
|
1597
|
+
const detail: UserLoginSuccess = {
|
|
1598
|
+
applicationName,
|
|
1599
|
+
...(typeof result.isNewUser === 'boolean' ? { isNewUser } : {}),
|
|
1600
|
+
}
|
|
1601
|
+
this.dispatchPublicEvent('login-success', detail)
|
|
1602
|
+
if (isNewUser && this.isConnected) this.startOnboardingTimer()
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
private validateMobile(): boolean {
|
|
1606
|
+
const mobile = this.mobileError()
|
|
1607
|
+
this.errors = { ...this.errors, mobile, global: '' }
|
|
1608
|
+
return !mobile
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
private validateVerificationForm(): boolean {
|
|
1612
|
+
const validMobile = this.validateMobile()
|
|
1613
|
+
const smsCode = this.smsCodeError()
|
|
1614
|
+
const agreement = this.agreed ? '' : '请先阅读并同意服务协议'
|
|
1615
|
+
this.errors = { ...this.errors, smsCode, agreement, global: '' }
|
|
1616
|
+
return validMobile && !smsCode && !agreement
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private validatePasswordForm(): boolean {
|
|
1620
|
+
const mobile = this.mobileError()
|
|
1621
|
+
const password = this.passwordError()
|
|
1622
|
+
this.errors = { ...this.errors, mobile, password, global: '' }
|
|
1623
|
+
return !mobile && !password
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
private mobileError(): string {
|
|
1627
|
+
return this.isMobileValid() ? '' : '请输入正确的 11 位手机号'
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
private smsCodeError(): string {
|
|
1631
|
+
return this.isSmsCodeValid() ? '' : '请输入正确的手机验证码'
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
private isMobileValid(): boolean {
|
|
1635
|
+
return /^1[3-9]\d{9}$/.test(this.mobile)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
private isSmsCodeValid(): boolean {
|
|
1639
|
+
return /^\d{6}$/.test(this.smsCode)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
private passwordError(): string {
|
|
1643
|
+
return this.password.length >= 6 ? '' : '请输入至少 6 位密码'
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private openAgreement(type: 'user' | 'privacy'): void {
|
|
1647
|
+
const config = this.config
|
|
1648
|
+
const proceed = this.dispatchPublicEvent('agreement-requested', { type }, true)
|
|
1649
|
+
if (!proceed || !config) return
|
|
1650
|
+
const url = type === 'user' ? config.userAgreementUrl : config.privacyAgreementUrl
|
|
1651
|
+
if (url && typeof window !== 'undefined') window.open(url, '_blank', 'noopener,noreferrer')
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
private readonly openForgotPassword = (): void => {
|
|
1655
|
+
const proceed = this.dispatchPublicEvent('forgot-password-requested', {}, true)
|
|
1656
|
+
const url = this.config?.forgotPasswordUrl
|
|
1657
|
+
if (proceed && url && typeof window !== 'undefined') {
|
|
1658
|
+
window.open(url, '_blank', 'noopener,noreferrer')
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
private readonly requestOnboardingManually = (): void => {
|
|
1663
|
+
this.requestOnboarding('manual')
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
private startOnboardingTimer(): void {
|
|
1667
|
+
if (!this.registrationSucceeded || this.onboardingRequested) return
|
|
1668
|
+
this.clearOnboardingTimer()
|
|
1669
|
+
this.onboardingTimer = window.setTimeout(() => {
|
|
1670
|
+
this.onboardingTimer = undefined
|
|
1671
|
+
this.requestOnboarding('automatic')
|
|
1672
|
+
}, 2_000)
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
private requestOnboarding(trigger: 'automatic' | 'manual'): void {
|
|
1676
|
+
const applicationName = this.config?.applicationName
|
|
1677
|
+
if (!this.registrationSucceeded || this.onboardingRequested || !applicationName) return
|
|
1678
|
+
this.clearOnboardingTimer()
|
|
1679
|
+
this.onboardingRequested = true
|
|
1680
|
+
this.dispatchPublicEvent('onboarding-requested', { applicationName, trigger })
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
private readonly startQrChallenge = async (): Promise<void> => {
|
|
1684
|
+
const config = this.config
|
|
1685
|
+
if (
|
|
1686
|
+
!config ||
|
|
1687
|
+
typeof config.transport.createQrChallenge !== 'function' ||
|
|
1688
|
+
typeof config.transport.pollQrChallenge !== 'function' ||
|
|
1689
|
+
this.activeMethod !== 'qr-code'
|
|
1690
|
+
) {
|
|
1691
|
+
return
|
|
1692
|
+
}
|
|
1693
|
+
this.stopQrFlow()
|
|
1694
|
+
const generation = this.qrGeneration
|
|
1695
|
+
this.qrState = 'loading'
|
|
1696
|
+
this.qrImageUrl = ''
|
|
1697
|
+
this.errors = { ...this.errors, global: '' }
|
|
1698
|
+
this.statusMessage = ''
|
|
1699
|
+
try {
|
|
1700
|
+
const challenge = await config.transport.createQrChallenge({
|
|
1701
|
+
applicationName: config.applicationName,
|
|
1702
|
+
})
|
|
1703
|
+
if (!this.isCurrentQr(generation)) return
|
|
1704
|
+
const challengeId = normalizeQrChallengeId(challenge.challengeId)
|
|
1705
|
+
const imageUrl = safeQrImageUrl(challenge.imageUrl)
|
|
1706
|
+
const pollIntervalMs = normalizeQrPollInterval(
|
|
1707
|
+
challenge.pollIntervalMs,
|
|
1708
|
+
config.qrPollIntervalMs,
|
|
1709
|
+
)
|
|
1710
|
+
if (!challengeId || !imageUrl || imageUrl.includes(challengeId)) {
|
|
1711
|
+
throw new Error('Invalid QR challenge')
|
|
1712
|
+
}
|
|
1713
|
+
this.qrChallengeId = challengeId
|
|
1714
|
+
this.qrPollIntervalMs = pollIntervalMs
|
|
1715
|
+
this.qrImageUrl = imageUrl
|
|
1716
|
+
this.qrState = 'pending'
|
|
1717
|
+
this.scheduleQrPoll(generation)
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
if (this.isCurrentQr(generation)) {
|
|
1720
|
+
this.qrState = 'error'
|
|
1721
|
+
const unavailable =
|
|
1722
|
+
error instanceof UserLoginTransportError && error.code === 'QR_LOGIN_UNAVAILABLE'
|
|
1723
|
+
const accessDenied =
|
|
1724
|
+
error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED'
|
|
1725
|
+
this.handleError(
|
|
1726
|
+
unavailable
|
|
1727
|
+
? 'QR_LOGIN_UNAVAILABLE'
|
|
1728
|
+
: accessDenied
|
|
1729
|
+
? 'ORG_ACCESS_DENIED'
|
|
1730
|
+
: 'QR_LOAD_FAILED',
|
|
1731
|
+
unavailable
|
|
1732
|
+
? '扫码登录服务暂不可用,请稍后重试。'
|
|
1733
|
+
: accessDenied
|
|
1734
|
+
? applicationAccessDeniedMessage(error, config.applicationName)
|
|
1735
|
+
: '登录二维码加载失败,请点击重试。',
|
|
1736
|
+
!accessDenied,
|
|
1737
|
+
)
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
private scheduleQrPoll(generation: number): void {
|
|
1743
|
+
const interval = this.qrPollIntervalMs
|
|
1744
|
+
if (!interval || !this.qrChallengeId || !this.isCurrentQr(generation)) return
|
|
1745
|
+
this.qrPollTimer = window.setTimeout(() => void this.pollQrChallenge(generation), interval)
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
private async pollQrChallenge(generation: number): Promise<void> {
|
|
1749
|
+
const config = this.config
|
|
1750
|
+
const challengeId = this.qrChallengeId
|
|
1751
|
+
if (
|
|
1752
|
+
!config ||
|
|
1753
|
+
typeof config.transport.pollQrChallenge !== 'function' ||
|
|
1754
|
+
!challengeId ||
|
|
1755
|
+
!this.isCurrentQr(generation)
|
|
1756
|
+
) {
|
|
1757
|
+
return
|
|
1758
|
+
}
|
|
1759
|
+
this.qrPollTimer = undefined
|
|
1760
|
+
try {
|
|
1761
|
+
const result = await config.transport.pollQrChallenge(challengeId, config.applicationName)
|
|
1762
|
+
if (!this.isCurrentQr(generation)) return
|
|
1763
|
+
if (!['pending', 'scanned', 'confirmed', 'expired', 'rejected'].includes(result.status)) {
|
|
1764
|
+
throw new Error('Invalid QR status')
|
|
1765
|
+
}
|
|
1766
|
+
this.qrState = result.status
|
|
1767
|
+
if (result.status === 'confirmed') {
|
|
1768
|
+
if (!result.result) throw new Error('Missing QR login result')
|
|
1769
|
+
this.finishLogin(result.result)
|
|
1770
|
+
this.finishQrFlow()
|
|
1771
|
+
return
|
|
1772
|
+
}
|
|
1773
|
+
if (result.status === 'expired') {
|
|
1774
|
+
this.finishQrFlow()
|
|
1775
|
+
return
|
|
1776
|
+
}
|
|
1777
|
+
if (result.status === 'rejected') {
|
|
1778
|
+
this.finishQrFlow()
|
|
1779
|
+
this.handleError('QR_LOGIN_REJECTED', '已在手机上取消登录,请点击刷新。', true)
|
|
1780
|
+
return
|
|
1781
|
+
}
|
|
1782
|
+
this.scheduleQrPoll(generation)
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (this.isCurrentQr(generation)) {
|
|
1785
|
+
this.qrState = 'error'
|
|
1786
|
+
this.finishQrFlow()
|
|
1787
|
+
const unavailable =
|
|
1788
|
+
error instanceof UserLoginTransportError && error.code === 'QR_LOGIN_UNAVAILABLE'
|
|
1789
|
+
const accessDenied =
|
|
1790
|
+
error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED'
|
|
1791
|
+
this.handleError(
|
|
1792
|
+
unavailable
|
|
1793
|
+
? 'QR_LOGIN_UNAVAILABLE'
|
|
1794
|
+
: accessDenied
|
|
1795
|
+
? 'ORG_ACCESS_DENIED'
|
|
1796
|
+
: 'QR_POLL_FAILED',
|
|
1797
|
+
unavailable
|
|
1798
|
+
? '扫码登录服务暂不可用,请稍后重试。'
|
|
1799
|
+
: accessDenied
|
|
1800
|
+
? applicationAccessDeniedMessage(error, config.applicationName)
|
|
1801
|
+
: '扫码登录状态确认失败,请点击重试。',
|
|
1802
|
+
!accessDenied,
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
private startCountdown(seconds: number): void {
|
|
1809
|
+
this.clearCountdown()
|
|
1810
|
+
this.countdown = seconds
|
|
1811
|
+
this.countdownTimer = window.setInterval(() => {
|
|
1812
|
+
this.countdown -= 1
|
|
1813
|
+
if (this.countdown <= 0) this.clearCountdown()
|
|
1814
|
+
}, 1_000)
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
private clearCountdown(): void {
|
|
1818
|
+
if (this.countdownTimer !== undefined) {
|
|
1819
|
+
window.clearInterval(this.countdownTimer)
|
|
1820
|
+
this.countdownTimer = undefined
|
|
1821
|
+
}
|
|
1822
|
+
this.countdown = 0
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
private clearOnboardingTimer(): void {
|
|
1826
|
+
if (this.onboardingTimer !== undefined) {
|
|
1827
|
+
window.clearTimeout(this.onboardingTimer)
|
|
1828
|
+
this.onboardingTimer = undefined
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private stopQrFlow(): void {
|
|
1833
|
+
this.qrGeneration += 1
|
|
1834
|
+
this.finishQrFlow()
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
private finishQrFlow(): void {
|
|
1838
|
+
if (this.qrPollTimer !== undefined) {
|
|
1839
|
+
window.clearTimeout(this.qrPollTimer)
|
|
1840
|
+
this.qrPollTimer = undefined
|
|
1841
|
+
}
|
|
1842
|
+
this.qrChallengeId = ''
|
|
1843
|
+
this.qrPollIntervalMs = 0
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
private handleError(code: UserLoginErrorCode, message: string, recoverable: boolean): void {
|
|
1847
|
+
this.errors = { ...this.errors, global: message }
|
|
1848
|
+
this.statusMessage = ''
|
|
1849
|
+
this.dispatchPublicEvent('error', { code, message, recoverable })
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
private handleSmsCodeError(message: string): void {
|
|
1853
|
+
this.errors = { ...this.errors, smsCode: message, global: '' }
|
|
1854
|
+
this.statusMessage = ''
|
|
1855
|
+
this.dispatchPublicEvent('error', {
|
|
1856
|
+
code: 'SMS_CODE_INVALID_OR_EXPIRED',
|
|
1857
|
+
message,
|
|
1858
|
+
recoverable: true,
|
|
1859
|
+
})
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
private dispatchPublicEvent<K extends keyof UserLoginEventMap>(
|
|
1863
|
+
type: K,
|
|
1864
|
+
detail: UserLoginEventMap[K]['detail'],
|
|
1865
|
+
cancelable = false,
|
|
1866
|
+
): boolean {
|
|
1867
|
+
return this.dispatchEvent(
|
|
1868
|
+
new CustomEvent(type, { detail, bubbles: true, composed: true, cancelable }),
|
|
1869
|
+
)
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private isCurrentQr(generation: number): boolean {
|
|
1873
|
+
return this.isConnected && this.activeMethod === 'qr-code' && generation === this.qrGeneration
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function safeNavigationUrl(rawValue: string | undefined, label: string): string {
|
|
1878
|
+
if (rawValue === undefined) return ''
|
|
1879
|
+
const value = rawValue.trim()
|
|
1880
|
+
if (!value) return ''
|
|
1881
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
1882
|
+
let url: URL
|
|
1883
|
+
try {
|
|
1884
|
+
url = new URL(value, documentBase)
|
|
1885
|
+
} catch {
|
|
1886
|
+
throw new TypeError(`${label} is invalid`)
|
|
1887
|
+
}
|
|
1888
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
1889
|
+
throw new TypeError(`${label} must use HTTP(S) without user credentials`)
|
|
1890
|
+
}
|
|
1891
|
+
if (hasSensitiveUrlParameter(url)) {
|
|
1892
|
+
throw new TypeError(`${label} must not contain authentication parameters`)
|
|
1893
|
+
}
|
|
1894
|
+
return url.href
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function normalizeDisplayText(rawValue: string | undefined, maxLength: number): string {
|
|
1898
|
+
return typeof rawValue === 'string' ? rawValue.trim().slice(0, maxLength) : ''
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function normalizeLoginMethod(rawValue: UserLoginMethod | undefined): UserLoginMethod | undefined {
|
|
1902
|
+
if (rawValue === undefined) return undefined
|
|
1903
|
+
if (LOGIN_METHODS.some(({ method }) => method === rawValue)) return rawValue
|
|
1904
|
+
throw new TypeError('user-login initialMethod is invalid')
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function normalizeApplicationName(rawValue: string): string {
|
|
1908
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
1909
|
+
if (!value || value.length > 128) {
|
|
1910
|
+
throw new TypeError('user-login applicationName must contain 1 to 128 characters')
|
|
1911
|
+
}
|
|
1912
|
+
return value
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function normalizeQrChallengeId(rawValue: unknown): string {
|
|
1916
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
1917
|
+
return /^[A-Za-z0-9_-]{16,512}$/.test(value) ? value : ''
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function normalizeQrPollInterval(rawValue: unknown, fallback: number): number {
|
|
1921
|
+
return typeof rawValue === 'number' &&
|
|
1922
|
+
Number.isInteger(rawValue) &&
|
|
1923
|
+
rawValue >= 500 &&
|
|
1924
|
+
rawValue <= 10_000
|
|
1925
|
+
? rawValue
|
|
1926
|
+
: fallback
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function passwordLoginFailure(
|
|
1930
|
+
error: unknown,
|
|
1931
|
+
applicationName: string,
|
|
1932
|
+
): {
|
|
1933
|
+
code: UserLoginErrorCode
|
|
1934
|
+
message: string
|
|
1935
|
+
recoverable: boolean
|
|
1936
|
+
} {
|
|
1937
|
+
if (error instanceof UserLoginTransportError) {
|
|
1938
|
+
if (error.code === 'ACCOUNT_LOCKED') {
|
|
1939
|
+
return {
|
|
1940
|
+
code: 'ACCOUNT_LOCKED',
|
|
1941
|
+
message: '账号暂时锁定,请稍后重试。',
|
|
1942
|
+
recoverable: false,
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
if (error.code === 'ACCOUNT_DISABLED') {
|
|
1946
|
+
return {
|
|
1947
|
+
code: 'ACCOUNT_DISABLED',
|
|
1948
|
+
message: '账号已停用,请联系管理员。',
|
|
1949
|
+
recoverable: false,
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (error.code === 'ORG_ACCESS_DENIED') {
|
|
1953
|
+
return {
|
|
1954
|
+
code: 'ORG_ACCESS_DENIED',
|
|
1955
|
+
message: applicationAccessDeniedMessage(error, applicationName),
|
|
1956
|
+
recoverable: false,
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (error.code === 'PASSWORD_INVALID') {
|
|
1960
|
+
return {
|
|
1961
|
+
code: 'PASSWORD_INVALID',
|
|
1962
|
+
message: '手机号或密码错误,请检查后重试。',
|
|
1963
|
+
recoverable: true,
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return {
|
|
1968
|
+
code: 'LOGIN_FAILED',
|
|
1969
|
+
message: '登录失败,请稍后重试。',
|
|
1970
|
+
recoverable: true,
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function applicationAccessDeniedMessage(
|
|
1975
|
+
error: UserLoginTransportError,
|
|
1976
|
+
applicationName: string,
|
|
1977
|
+
): string {
|
|
1978
|
+
return error.displayMessage ?? `当前用户没有可用的${applicationName}应用实例`
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function safeConfiguredImageUrl(rawValue: string | undefined): string {
|
|
1982
|
+
if (rawValue === undefined || rawValue.trim() === '') return ''
|
|
1983
|
+
const url = safeImageUrl(rawValue)
|
|
1984
|
+
if (!url) throw new TypeError('Header icon URL is invalid')
|
|
1985
|
+
return url
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function safeQrImageUrl(rawValue: string): string {
|
|
1989
|
+
const value = safeImageUrl(rawValue)
|
|
1990
|
+
if (!value || value.startsWith('data:')) return value
|
|
1991
|
+
const url = new URL(value)
|
|
1992
|
+
return url.search || url.hash ? '' : value
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function safeImageUrl(rawValue: string): string {
|
|
1996
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
1997
|
+
if (!value) return ''
|
|
1998
|
+
if (/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)[;,]/i.test(value)) return value
|
|
1999
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
2000
|
+
try {
|
|
2001
|
+
const url = new URL(value, documentBase)
|
|
2002
|
+
return ['http:', 'https:'].includes(url.protocol) &&
|
|
2003
|
+
!url.username &&
|
|
2004
|
+
!url.password &&
|
|
2005
|
+
!hasSensitiveUrlParameter(url)
|
|
2006
|
+
? url.href
|
|
2007
|
+
: ''
|
|
2008
|
+
} catch {
|
|
2009
|
+
return ''
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function hasSensitiveUrlParameter(url: URL): boolean {
|
|
2014
|
+
const sensitiveNames = new Set([
|
|
2015
|
+
'token',
|
|
2016
|
+
'jwt',
|
|
2017
|
+
'access_token',
|
|
2018
|
+
'authorization',
|
|
2019
|
+
'auth_code',
|
|
2020
|
+
'ticket',
|
|
2021
|
+
'challenge',
|
|
2022
|
+
'challenge_id',
|
|
2023
|
+
'login_token',
|
|
2024
|
+
'qr_token',
|
|
2025
|
+
])
|
|
2026
|
+
return Array.from(url.searchParams.keys()).some((name) => sensitiveNames.has(name.toLowerCase()))
|
|
2027
|
+
}
|