@ruzhiai/runid-react 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/src/index.tsx ADDED
@@ -0,0 +1,896 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ type CSSProperties,
8
+ type ReactNode,
9
+ } from 'react';
10
+
11
+ import {
12
+ Tabs,
13
+ Button,
14
+ Input,
15
+ Space,
16
+ Checkbox,
17
+ Modal,
18
+ Typography,
19
+ Spin,
20
+ message,
21
+ Form,
22
+ } from 'antd';
23
+ import {
24
+ PhoneOutlined,
25
+ MailOutlined,
26
+ MessageOutlined,
27
+ SafetyOutlined,
28
+ WechatOutlined,
29
+ UserOutlined,
30
+ SendOutlined,
31
+ } from '@ant-design/icons';
32
+
33
+ import {
34
+ RunIDAuth,
35
+ isConsentRequiredResponse,
36
+ parseAuthErrorMessage,
37
+ type RunIDAuthConfig,
38
+ type LoginConfig,
39
+ type LoginOrConsentResponse,
40
+ type LoginResponse,
41
+ type MeResponse,
42
+ type SessionInfo,
43
+ type AuditLogEntry,
44
+ type WecomAuthURLs,
45
+ type WechatAuthURLs,
46
+ type ConsentRequiredResponse,
47
+ type AuthMethod,
48
+ type AgreementVersion,
49
+ type RunIDAuthError,
50
+ } from '@ruzhiai/runid-sdk';
51
+
52
+ export { RunIDAuth };
53
+ export * from '@ruzhiai/runid-sdk';
54
+
55
+ const { Text, Title, Paragraph } = Typography;
56
+
57
+ interface RunIDAuthContextValue {
58
+ isAuthenticated: boolean;
59
+ isLoading: boolean;
60
+ me: MeResponse | null;
61
+ loginConfig: LoginConfig | null;
62
+ auth: RunIDAuth | null;
63
+
64
+ // Config
65
+ loadLoginConfig: (options?: { return_url?: string; invite_code?: string }) => Promise<void>;
66
+
67
+ // Phone
68
+ sendPhoneCode: (phone: string) => Promise<void>;
69
+ verifyPhone: (phone: string, code: string) => Promise<LoginOrConsentResponse>;
70
+
71
+ // Email
72
+ sendEmailCode: (email: string) => Promise<void>;
73
+ verifyEmail: (email: string, code: string) => Promise<LoginOrConsentResponse>;
74
+
75
+ // WhatsApp
76
+ sendWhatsAppCode: (phone: string) => Promise<void>;
77
+ verifyWhatsApp: (phone: string, code: string) => Promise<LoginOrConsentResponse>;
78
+
79
+ // OAuth
80
+ getWechatAuthURL: (returnUrl?: string) => Promise<WechatAuthURLs>;
81
+ getWecomAuthURL: (returnUrl?: string) => Promise<WecomAuthURLs>;
82
+
83
+ // Session
84
+ loadMe: () => Promise<void>;
85
+ logout: () => Promise<void>;
86
+ getMySessions: () => Promise<{ data: SessionInfo[] }>;
87
+ revokeMySession: (sessionId: string) => Promise<void>;
88
+ getMyAuditLogs: () => Promise<{ data: AuditLogEntry[] }>;
89
+ }
90
+
91
+ const RunIDAuthContext = createContext<RunIDAuthContextValue | null>(null);
92
+
93
+ // ============================================
94
+ // Auth Context
95
+ // ============================================
96
+
97
+ interface RunIDAuthProviderProps {
98
+ config: RunIDAuthConfig;
99
+ children: ReactNode;
100
+ autoLoadMe?: boolean;
101
+ }
102
+
103
+ export function RunIDAuthProvider({
104
+ config,
105
+ children,
106
+ autoLoadMe = true,
107
+ }: RunIDAuthProviderProps) {
108
+ const [auth] = useState(() => new RunIDAuth(config));
109
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
110
+ const [isLoading, setIsLoading] = useState(true);
111
+ const [me, setMe] = useState<MeResponse | null>(null);
112
+ const [loginConfig, setLoginConfig] = useState<LoginConfig | null>(null);
113
+
114
+ const loadMe = useCallback(async () => {
115
+ try {
116
+ const response = await auth.getMe();
117
+ setMe(response);
118
+ setIsAuthenticated(true);
119
+ } catch {
120
+ setMe(null);
121
+ setIsAuthenticated(false);
122
+ } finally {
123
+ setIsLoading(false);
124
+ }
125
+ }, [auth]);
126
+
127
+ useEffect(() => {
128
+ if (autoLoadMe) {
129
+ loadMe();
130
+ } else {
131
+ setIsLoading(false);
132
+ }
133
+ }, [loadMe, autoLoadMe]);
134
+
135
+ const loadLoginConfig = useCallback(
136
+ async (options?: { return_url?: string; invite_code?: string }) => {
137
+ const c = await auth.getLoginConfig(options);
138
+ setLoginConfig(c);
139
+ },
140
+ [auth]
141
+ );
142
+
143
+ const sendPhoneCode = useCallback(
144
+ async (phone: string) => auth.sendPhoneCode(phone),
145
+ [auth]
146
+ );
147
+
148
+ const verifyPhone = useCallback(
149
+ async (phone: string, code: string) => {
150
+ const response = await auth.verifyPhone(phone, code);
151
+ if (isConsentRequiredResponse(response)) {
152
+ return response;
153
+ }
154
+ setIsAuthenticated(true);
155
+ await loadMe();
156
+ return response;
157
+ },
158
+ [auth, loadMe]
159
+ );
160
+
161
+ const sendEmailCode = useCallback(
162
+ async (email: string) => auth.sendEmailCode(email),
163
+ [auth]
164
+ );
165
+
166
+ const verifyEmail = useCallback(
167
+ async (email: string, code: string) => {
168
+ const response = await auth.verifyEmail(email, code);
169
+ if (isConsentRequiredResponse(response)) {
170
+ return response;
171
+ }
172
+ setIsAuthenticated(true);
173
+ await loadMe();
174
+ return response;
175
+ },
176
+ [auth, loadMe]
177
+ );
178
+
179
+ const sendWhatsAppCode = useCallback(
180
+ async (phone: string) => auth.sendWhatsAppCode(phone),
181
+ [auth]
182
+ );
183
+
184
+ const verifyWhatsApp = useCallback(
185
+ async (phone: string, code: string) => {
186
+ const response = await auth.verifyWhatsApp(phone, code);
187
+ if (isConsentRequiredResponse(response)) {
188
+ return response;
189
+ }
190
+ setIsAuthenticated(true);
191
+ await loadMe();
192
+ return response;
193
+ },
194
+ [auth, loadMe]
195
+ );
196
+
197
+ const getWechatAuthURL = useCallback(
198
+ async (returnUrl?: string) => auth.getWechatAuthURL(returnUrl),
199
+ [auth]
200
+ );
201
+
202
+ const getWecomAuthURL = useCallback(
203
+ async (returnUrl?: string) => auth.getWecomAuthURL(returnUrl),
204
+ [auth]
205
+ );
206
+
207
+ const logout = useCallback(async () => {
208
+ await auth.logout();
209
+ setMe(null);
210
+ setIsAuthenticated(false);
211
+ }, [auth]);
212
+
213
+ const getMySessions = useCallback(
214
+ async () => auth.getMySessions(),
215
+ [auth]
216
+ );
217
+
218
+ const revokeMySession = useCallback(
219
+ async (sessionId: string) => auth.revokeMySession(sessionId),
220
+ [auth]
221
+ );
222
+
223
+ const getMyAuditLogs = useCallback(
224
+ async () => auth.getMyAuditLogs(),
225
+ [auth]
226
+ );
227
+
228
+ const value: RunIDAuthContextValue = {
229
+ isAuthenticated,
230
+ isLoading,
231
+ me,
232
+ loginConfig,
233
+ auth,
234
+ loadLoginConfig,
235
+ sendPhoneCode,
236
+ verifyPhone,
237
+ sendEmailCode,
238
+ verifyEmail,
239
+ sendWhatsAppCode,
240
+ verifyWhatsApp,
241
+ getWechatAuthURL,
242
+ getWecomAuthURL,
243
+ loadMe,
244
+ logout,
245
+ getMySessions,
246
+ revokeMySession,
247
+ getMyAuditLogs,
248
+ };
249
+
250
+ return (
251
+ <RunIDAuthContext.Provider value={value}>
252
+ {children}
253
+ </RunIDAuthContext.Provider>
254
+ );
255
+ }
256
+
257
+ export function useRunIDAuth(): RunIDAuthContextValue {
258
+ const context = useContext(RunIDAuthContext);
259
+ if (!context) {
260
+ throw new Error('useRunIDAuth must be used within a RunIDAuthProvider');
261
+ }
262
+ return context;
263
+ }
264
+
265
+ // ============================================
266
+ // Login Widget
267
+ // ============================================
268
+
269
+ export interface RunIDLoginWidgetProps {
270
+ config: RunIDAuthConfig;
271
+ returnUrl?: string;
272
+ defaultMethod?: AuthMethod;
273
+ locale?: 'zh-CN' | 'en-US';
274
+ onSuccess: (response: LoginResponse) => void | Promise<void>;
275
+ onError?: (error: RunIDAuthError) => void;
276
+ onOAuthRedirect?: (method: 'wechat' | 'wecom', url: string) => void;
277
+ className?: string;
278
+ style?: CSSProperties;
279
+ }
280
+
281
+ type CountdownState = {
282
+ [key in 'phone' | 'email' | 'whatsapp']: number;
283
+ };
284
+
285
+ const TEXT: Record<'zh-CN' | 'en-US', Record<string, string>> = {
286
+ 'zh-CN': {
287
+ wechat: '微信登录',
288
+ wecom: '企业微信',
289
+ phone: '手机号',
290
+ email: '邮箱',
291
+ whatsapp: 'WhatsApp',
292
+ code: '验证码',
293
+ sendCode: '获取验证码',
294
+ resendPrefix: '重发',
295
+ login: '登录',
296
+ verifying: '验证中',
297
+ sending: '发送中',
298
+ agreeTerms: '我已阅读并同意',
299
+ consentTitle: '同意协议后继续',
300
+ consentHintNewUser: '账号尚未完成协议授权,完成后将立即创建账户并登录。',
301
+ consentHintExistingUser: '此账号存在未读条款,请同意后继续登录。',
302
+ cancel: '取消',
303
+ continue: '同意并继续',
304
+ requiredMethodError: '请选择可用的登录方式',
305
+ countryCodePrefix: '区号',
306
+ phonePlaceholder: '请输入手机号',
307
+ emailPlaceholder: '请输入邮箱',
308
+ whatsappPlaceholder: '请输入 WhatsApp 号码',
309
+ codePlaceholder: '请输入验证码',
310
+ mfaHint: '该账号已开启二次验证,请完成下一步认证。',
311
+ },
312
+ 'en-US': {
313
+ wechat: 'WeChat',
314
+ wecom: 'WeCom',
315
+ phone: 'Phone',
316
+ email: 'Email',
317
+ whatsapp: 'WhatsApp',
318
+ code: 'Verification code',
319
+ sendCode: 'Send code',
320
+ resendPrefix: 'Resend',
321
+ login: 'Sign in',
322
+ verifying: 'Verifying',
323
+ sending: 'Sending',
324
+ agreeTerms: 'I have read and agree',
325
+ consentTitle: 'Agree to proceed',
326
+ consentHintNewUser: 'This account requires a first-time consent to proceed and will be created after confirmation.',
327
+ consentHintExistingUser: 'Please confirm the latest agreements before continuing.',
328
+ cancel: 'Cancel',
329
+ continue: 'Agree and continue',
330
+ requiredMethodError: 'No login method is available for this product',
331
+ countryCodePrefix: 'Code',
332
+ phonePlaceholder: 'Enter phone number',
333
+ emailPlaceholder: 'Enter email',
334
+ whatsappPlaceholder: 'Enter WhatsApp number',
335
+ codePlaceholder: 'Enter verification code',
336
+ mfaHint: 'MFA is required before login. Please complete second-factor verification.',
337
+ },
338
+ };
339
+
340
+ function isAllowedMethod(config: LoginConfig | null, method: AuthMethod): boolean {
341
+ return Boolean(config?.client?.allowed_methods.includes(method));
342
+ }
343
+
344
+ function resolveInitialMethod(config: LoginConfig, requested?: AuthMethod): AuthMethod {
345
+ if (requested && isAllowedMethod(config, requested)) {
346
+ return requested;
347
+ }
348
+ if (config.client.allowed_methods.includes('phone')) return 'phone';
349
+ if (config.client.allowed_methods.includes('email')) return 'email';
350
+ if (config.client.allowed_methods.includes('wecom')) return 'wecom';
351
+ if (config.client.allowed_methods.includes('wechat')) return 'wechat';
352
+ return 'whatsapp';
353
+ }
354
+
355
+ function isMfaResponse(response: unknown): response is { mfa_required: boolean; account_id: string } {
356
+ return !!(
357
+ response &&
358
+ typeof response === 'object' &&
359
+ (response as { mfa_required?: unknown }).mfa_required === true
360
+ );
361
+ }
362
+
363
+ const initialCountdown: CountdownState = {
364
+ phone: 0,
365
+ email: 0,
366
+ whatsapp: 0,
367
+ };
368
+
369
+ export function RunIDLoginWidget({
370
+ config,
371
+ returnUrl,
372
+ defaultMethod,
373
+ locale = 'zh-CN',
374
+ onSuccess,
375
+ onError,
376
+ onOAuthRedirect,
377
+ className,
378
+ style,
379
+ }: RunIDLoginWidgetProps) {
380
+ const [messageApi, contextHolder] = message.useMessage();
381
+ const [auth, setAuth] = useState(() => new RunIDAuth({
382
+ ...config,
383
+ defaultReturnURL: returnUrl,
384
+ onAuthSuccess: () => {},
385
+ onAuthError: (error: RunIDAuthError) => {
386
+ const msg = parseAuthErrorMessage(error.payload) || error.message;
387
+ if (msg) {
388
+ messageApi.error(msg);
389
+ }
390
+ onError?.(error);
391
+ },
392
+ }));
393
+
394
+ useEffect(() => {
395
+ setAuth(new RunIDAuth({
396
+ ...config,
397
+ defaultReturnURL: returnUrl,
398
+ onAuthSuccess: () => {},
399
+ onAuthError: (error: RunIDAuthError) => {
400
+ const msg = parseAuthErrorMessage(error.payload) || error.message;
401
+ if (msg) {
402
+ messageApi.error(msg);
403
+ }
404
+ onError?.(error);
405
+ },
406
+ }));
407
+ }, [config.baseURL, config.client_id, messageApi, onError, returnUrl]);
408
+
409
+ const texts = TEXT[locale];
410
+ const [loginConfig, setLoginConfig] = useState<LoginConfig | null>(null);
411
+ const [loadingConfig, setLoadingConfig] = useState(true);
412
+ const [activeMethod, setActiveMethod] = useState<AuthMethod>('phone');
413
+
414
+ const [countdown, setCountdown] = useState<CountdownState>(initialCountdown);
415
+ const [isSending, setIsSending] = useState<{ [key: string]: boolean }>({
416
+ phone: false,
417
+ email: false,
418
+ whatsapp: false,
419
+ });
420
+ const [isSubmitting, setIsSubmitting] = useState(false);
421
+
422
+ const [phone, setPhone] = useState('');
423
+ const [email, setEmail] = useState('');
424
+ const [phoneCode, setPhoneCode] = useState('');
425
+ const [emailCode, setEmailCode] = useState('');
426
+ const [whatsapp, setWhatsapp] = useState('');
427
+ const [whatsappCode, setWhatsappCode] = useState('');
428
+ const [countryCode, setCountryCode] = useState('+86');
429
+
430
+ const [consentData, setConsentData] = useState<ConsentRequiredResponse | null>(null);
431
+ const [agreed, setAgreed] = useState(false);
432
+ const [processingConsent, setProcessingConsent] = useState(false);
433
+
434
+ useEffect(() => {
435
+ let active = true;
436
+
437
+ const load = async () => {
438
+ try {
439
+ const loaded = await auth.getLoginConfig({
440
+ return_url: returnUrl,
441
+ });
442
+ if (!active) return;
443
+ setLoginConfig(loaded);
444
+ if (loaded.client.phone_country_code) {
445
+ setCountryCode(loaded.client.phone_country_code);
446
+ }
447
+ setActiveMethod(resolveInitialMethod(loaded, defaultMethod));
448
+ } catch (error) {
449
+ const runErr = error as RunIDAuthError;
450
+ const msg = parseAuthErrorMessage(runErr.payload) || runErr.message || texts.requiredMethodError;
451
+ messageApi.error(msg);
452
+ onError?.(runErr);
453
+ } finally {
454
+ if (active) {
455
+ setLoadingConfig(false);
456
+ }
457
+ }
458
+ };
459
+
460
+ load();
461
+ return () => {
462
+ active = false;
463
+ };
464
+ }, [auth, defaultMethod, onError, returnUrl, messageApi, texts.requiredMethodError]);
465
+
466
+ const methods = [
467
+ { key: 'phone' as const, visible: isAllowedMethod(loginConfig, 'phone'), icon: <PhoneOutlined />, label: texts.phone },
468
+ { key: 'email' as const, visible: isAllowedMethod(loginConfig, 'email'), icon: <MailOutlined />, label: texts.email },
469
+ { key: 'wechat' as const, visible: isAllowedMethod(loginConfig, 'wechat'), icon: <WechatOutlined />, label: texts.wechat },
470
+ { key: 'wecom' as const, visible: isAllowedMethod(loginConfig, 'wecom'), icon: <UserOutlined />, label: texts.wecom },
471
+ { key: 'whatsapp' as const, visible: isAllowedMethod(loginConfig, 'whatsapp'), icon: <MessageOutlined />, label: texts.whatsapp },
472
+ ].filter((item): item is { key: AuthMethod; visible: true; icon: JSX.Element; label: string } => item.visible);
473
+
474
+ const setCountDown = useCallback((method: keyof CountdownState, seconds: number) => {
475
+ setCountdown((prev) => ({ ...prev, [method]: seconds }));
476
+ const interval = setInterval(() => {
477
+ setCountdown((prev) => {
478
+ const next = Math.max(0, prev[method] - 1);
479
+ if (next === 0) {
480
+ clearInterval(interval);
481
+ }
482
+ return { ...prev, [method]: next };
483
+ });
484
+ }, 1000);
485
+ }, []);
486
+
487
+ const startTimer = useCallback((method: keyof CountdownState) => {
488
+ setCountDown(method, 60);
489
+ }, [setCountDown]);
490
+
491
+ const sendCode = useCallback(
492
+ async (method: 'phone' | 'email' | 'whatsapp') => {
493
+ if (countdown[method] > 0) {
494
+ return;
495
+ }
496
+
497
+ try {
498
+ const targetPhone = phone.trim();
499
+ const targetEmail = email.trim();
500
+ const targetWhatsapp = whatsapp.trim();
501
+ setIsSending((prev) => ({ ...prev, [method]: true }));
502
+ if (method === 'phone') {
503
+ const target = `${countryCode}${targetPhone}`;
504
+ if (!target) {
505
+ messageApi.error(texts.phonePlaceholder);
506
+ return;
507
+ }
508
+ await auth.sendPhoneCode(target);
509
+ } else if (method === 'email') {
510
+ if (!targetEmail.includes('@')) {
511
+ messageApi.error(texts.emailPlaceholder);
512
+ return;
513
+ }
514
+ await auth.sendEmailCode(targetEmail);
515
+ } else {
516
+ if (!targetWhatsapp) {
517
+ messageApi.error(texts.whatsappPlaceholder);
518
+ return;
519
+ }
520
+ await auth.sendWhatsAppCode(targetWhatsapp);
521
+ }
522
+ messageApi.success(texts.sendCode);
523
+ startTimer(method);
524
+ } catch (error) {
525
+ const runErr = error as RunIDAuthError;
526
+ const msg = parseAuthErrorMessage(runErr.payload) || runErr.message || `${texts.sendCode}失败`;
527
+ messageApi.error(msg);
528
+ onError?.(runErr);
529
+ } finally {
530
+ setIsSending((prev) => ({ ...prev, [method]: false }));
531
+ }
532
+ },
533
+ [auth, countryCode, countdown, email, messageApi, onError, phone, startTimer, texts, whatsapp]
534
+ );
535
+
536
+ const openOAuth = useCallback(
537
+ async (method: 'wechat' | 'wecom') => {
538
+ try {
539
+ const urls = method === 'wechat'
540
+ ? await auth.getWechatAuthURL(returnUrl)
541
+ : await auth.getWecomAuthURL(returnUrl);
542
+ const target = urls.auth_url;
543
+ if (onOAuthRedirect) {
544
+ onOAuthRedirect(method, target);
545
+ return;
546
+ }
547
+ window.location.assign(target);
548
+ } catch (error) {
549
+ const runErr = error as RunIDAuthError;
550
+ const msg = parseAuthErrorMessage(runErr.payload) || runErr.message || '获取 OAuth 登录地址失败';
551
+ messageApi.error(msg);
552
+ onError?.(runErr);
553
+ }
554
+ },
555
+ [auth, onError, onOAuthRedirect, returnUrl, messageApi]
556
+ );
557
+
558
+ const handleVerify = useCallback(async () => {
559
+ if (!loginConfig) {
560
+ messageApi.error(texts.requiredMethodError);
561
+ return;
562
+ }
563
+
564
+ setIsSubmitting(true);
565
+ try {
566
+ let response: LoginOrConsentResponse;
567
+
568
+ if (activeMethod === 'phone') {
569
+ if (!phone.trim() || !phoneCode.trim()) {
570
+ messageApi.error(texts.phonePlaceholder);
571
+ return;
572
+ }
573
+ response = await auth.verifyPhone(`${countryCode}${phone.trim()}`, phoneCode.trim());
574
+ } else if (activeMethod === 'email') {
575
+ if (!email.trim() || !emailCode.trim()) {
576
+ messageApi.error(texts.emailPlaceholder);
577
+ return;
578
+ }
579
+ response = await auth.verifyEmail(email.trim(), emailCode.trim());
580
+ } else if (activeMethod === 'whatsapp') {
581
+ if (!whatsapp.trim() || !whatsappCode.trim()) {
582
+ messageApi.error(texts.whatsappPlaceholder);
583
+ return;
584
+ }
585
+ response = await auth.verifyWhatsApp(whatsapp.trim(), whatsappCode.trim());
586
+ } else {
587
+ messageApi.error(texts.requiredMethodError);
588
+ return;
589
+ }
590
+
591
+ if (isConsentRequiredResponse(response)) {
592
+ setConsentData(response);
593
+ setAgreed(false);
594
+ return;
595
+ }
596
+
597
+ if (isMfaResponse(response)) {
598
+ messageApi.error(texts.mfaHint);
599
+ return;
600
+ }
601
+
602
+ await onSuccess(response as LoginResponse);
603
+ } catch (error) {
604
+ const runErr = error as RunIDAuthError;
605
+ const msg = parseAuthErrorMessage(runErr.payload) || runErr.message || '登录失败';
606
+ messageApi.error(msg);
607
+ onError?.(runErr);
608
+ } finally {
609
+ setIsSubmitting(false);
610
+ }
611
+ }, [activeMethod, auth, countryCode, email, emailCode, loginConfig, messageApi, onError, onSuccess, phone, phoneCode, texts.emailPlaceholder, texts.phonePlaceholder, texts.requiredMethodError, texts.whatsappPlaceholder, texts.mfaHint, whatsapp, whatsappCode]);
612
+
613
+ const submitConsent = useCallback(async () => {
614
+ if (!consentData || !agreed) {
615
+ return;
616
+ }
617
+
618
+ setProcessingConsent(true);
619
+ try {
620
+ const agreedIds = consentData.pending_agreements
621
+ .map((agreement: AgreementVersion) => agreement.id)
622
+ .filter(Boolean);
623
+
624
+ const response = await auth.consentAndLogin(
625
+ consentData.account_id,
626
+ agreedIds,
627
+ consentData.login_method || activeMethod,
628
+ consentData.client_id,
629
+ );
630
+ setConsentData(null);
631
+ setAgreed(false);
632
+ await onSuccess(response);
633
+ } catch (error) {
634
+ const runErr = error as RunIDAuthError;
635
+ const msg = parseAuthErrorMessage(runErr.payload) || runErr.message || '协议授权失败';
636
+ messageApi.error(msg);
637
+ onError?.(runErr);
638
+ } finally {
639
+ setProcessingConsent(false);
640
+ }
641
+ }, [activeMethod, agreed, auth, consentData, messageApi, onError, onSuccess]);
642
+
643
+ if (loadingConfig) {
644
+ return (
645
+ <div className={className} style={{ ...style, textAlign: 'center' }}>
646
+ {contextHolder}
647
+ <Spin />
648
+ </div>
649
+ );
650
+ }
651
+
652
+ if (!loginConfig) {
653
+ return (
654
+ <div className={className} style={style}>
655
+ {contextHolder}
656
+ <Text>{texts.requiredMethodError}</Text>
657
+ </div>
658
+ );
659
+ }
660
+
661
+ if (!methods.length) {
662
+ return (
663
+ <div className={className} style={style}>
664
+ {contextHolder}
665
+ <Text>{texts.requiredMethodError}</Text>
666
+ </div>
667
+ );
668
+ }
669
+
670
+ const tabItems = methods.map((method) => ({
671
+ key: method.key,
672
+ label: (
673
+ <span>
674
+ {method.icon}
675
+ <span style={{ marginLeft: 6 }}>{method.label}</span>
676
+ </span>
677
+ ),
678
+ }));
679
+
680
+ const canSubmit = !isSubmitting &&
681
+ [
682
+ activeMethod === 'phone' ? phone.trim() && phoneCode.trim() :
683
+ activeMethod === 'email' ? email.trim() && emailCode.trim() :
684
+ activeMethod === 'whatsapp' ? whatsapp.trim() && whatsappCode.trim() :
685
+ true,
686
+ ].every(Boolean);
687
+
688
+ const consentLink = (link: string) => {
689
+ const href = link.startsWith('http') || link.startsWith('/') ? link : `https://${link}`;
690
+ window.open(href, '_blank', 'noopener,noreferrer');
691
+ };
692
+
693
+ return (
694
+ <div className={className} style={style}>
695
+ {contextHolder}
696
+
697
+ <Tabs
698
+ activeKey={activeMethod}
699
+ items={tabItems}
700
+ onChange={(value) => setActiveMethod(value as AuthMethod)}
701
+ size="large"
702
+ />
703
+
704
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
705
+ {activeMethod === 'phone' && (
706
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
707
+ <Form.Item label={texts.countryCodePrefix} required>
708
+ <Space.Compact style={{ width: '100%' }}>
709
+ <Input
710
+ value={countryCode}
711
+ onChange={(e) => setCountryCode(e.target.value)}
712
+ style={{ width: 90 }}
713
+ />
714
+ <Input
715
+ value={phone}
716
+ onChange={(e) => setPhone(e.target.value)}
717
+ placeholder={texts.phonePlaceholder}
718
+ />
719
+ </Space.Compact>
720
+ </Form.Item>
721
+ <Form.Item label={texts.code} required>
722
+ <Space.Compact style={{ width: '100%' }}>
723
+ <Input
724
+ value={phoneCode}
725
+ onChange={(e) => setPhoneCode(e.target.value)}
726
+ placeholder={texts.codePlaceholder}
727
+ />
728
+ <Button
729
+ icon={<SendOutlined />}
730
+ onClick={() => sendCode('phone')}
731
+ loading={isSending.phone}
732
+ disabled={countdown.phone > 0}
733
+ >
734
+ {countdown.phone > 0 ? `${texts.resendPrefix}(${countdown.phone}s)` : texts.sendCode}
735
+ </Button>
736
+ </Space.Compact>
737
+ </Form.Item>
738
+ </Space>
739
+ )}
740
+
741
+ {activeMethod === 'email' && (
742
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
743
+ <Form.Item label={texts.email} required>
744
+ <Input
745
+ value={email}
746
+ onChange={(e) => setEmail(e.target.value)}
747
+ placeholder={texts.emailPlaceholder}
748
+ />
749
+ </Form.Item>
750
+ <Form.Item label={texts.code} required>
751
+ <Space.Compact style={{ width: '100%' }}>
752
+ <Input
753
+ value={emailCode}
754
+ onChange={(e) => setEmailCode(e.target.value)}
755
+ placeholder={texts.codePlaceholder}
756
+ />
757
+ <Button
758
+ onClick={() => sendCode('email')}
759
+ loading={isSending.email}
760
+ disabled={countdown.email > 0}
761
+ >
762
+ {countdown.email > 0 ? `${texts.resendPrefix}(${countdown.email}s)` : texts.sendCode}
763
+ </Button>
764
+ </Space.Compact>
765
+ </Form.Item>
766
+ </Space>
767
+ )}
768
+
769
+ {activeMethod === 'whatsapp' && (
770
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
771
+ <Form.Item label={texts.whatsapp} required>
772
+ <Input
773
+ value={whatsapp}
774
+ onChange={(e) => setWhatsapp(e.target.value)}
775
+ placeholder={texts.whatsappPlaceholder}
776
+ />
777
+ </Form.Item>
778
+ <Form.Item label={texts.code} required>
779
+ <Space.Compact style={{ width: '100%' }}>
780
+ <Input
781
+ value={whatsappCode}
782
+ onChange={(e) => setWhatsappCode(e.target.value)}
783
+ placeholder={texts.codePlaceholder}
784
+ />
785
+ <Button
786
+ onClick={() => sendCode('whatsapp')}
787
+ loading={isSending.whatsapp}
788
+ disabled={countdown.whatsapp > 0}
789
+ >
790
+ {countdown.whatsapp > 0 ? `${texts.resendPrefix}(${countdown.whatsapp}s)` : texts.sendCode}
791
+ </Button>
792
+ </Space.Compact>
793
+ </Form.Item>
794
+ </Space>
795
+ )}
796
+
797
+ {activeMethod === 'wechat' && (
798
+ <Button
799
+ block
800
+ size="large"
801
+ onClick={() => openOAuth('wechat')}
802
+ icon={<WechatOutlined />}
803
+ >
804
+ {texts.wechat}
805
+ </Button>
806
+ )}
807
+
808
+ {activeMethod === 'wecom' && (
809
+ <Button
810
+ block
811
+ size="large"
812
+ onClick={() => openOAuth('wecom')}
813
+ icon={<UserOutlined />}
814
+ >
815
+ {texts.wecom}
816
+ </Button>
817
+ )}
818
+
819
+ {(activeMethod === 'phone' || activeMethod === 'email' || activeMethod === 'whatsapp') && (
820
+ <Button
821
+ type="primary"
822
+ size="large"
823
+ loading={isSubmitting}
824
+ disabled={!canSubmit}
825
+ onClick={handleVerify}
826
+ >
827
+ {isSubmitting ? texts.verifying : texts.login}
828
+ </Button>
829
+ )}
830
+ </Space>
831
+
832
+ <Modal
833
+ open={Boolean(consentData)}
834
+ title={<Title level={4}>{texts.consentTitle}</Title>}
835
+ footer={null}
836
+ onCancel={() => {
837
+ setConsentData(null);
838
+ setAgreed(false);
839
+ }}
840
+ destroyOnClose
841
+ >
842
+ {consentData && (
843
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
844
+ <Paragraph>
845
+ {consentData.is_new_user
846
+ ? texts.consentHintNewUser
847
+ : texts.consentHintExistingUser}
848
+ </Paragraph>
849
+
850
+ <Space direction="vertical" style={{ width: '100%' }}>
851
+ {consentData.pending_agreements.map((agreement) => (
852
+ <Space key={agreement.id} align="center">
853
+ <a
854
+ href={agreement.url}
855
+ onClick={(event) => {
856
+ event.preventDefault();
857
+ consentLink(agreement.url);
858
+ }}
859
+ target="_blank"
860
+ rel="noreferrer"
861
+ >
862
+ {agreement.title}
863
+ </a>
864
+ </Space>
865
+ ))}
866
+ </Space>
867
+
868
+ <Checkbox checked={agreed} onChange={(e) => setAgreed(e.target.checked)}>
869
+ <Text>
870
+ <SafetyOutlined /> {texts.agreeTerms}
871
+ </Text>
872
+ </Checkbox>
873
+
874
+ <Space direction="vertical" size={12} style={{ width: '100%' }}>
875
+ <Button
876
+ type="primary"
877
+ block
878
+ loading={processingConsent}
879
+ disabled={!agreed}
880
+ onClick={submitConsent}
881
+ >
882
+ {texts.continue}
883
+ </Button>
884
+ <Button onClick={() => {
885
+ setConsentData(null);
886
+ setAgreed(false);
887
+ }} block>
888
+ {texts.cancel}
889
+ </Button>
890
+ </Space>
891
+ </Space>
892
+ )}
893
+ </Modal>
894
+ </div>
895
+ );
896
+ }