central-auth-js-sdk 1.0.10
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/INTEGRATION_GUIDE.md +473 -0
- package/README.md +386 -0
- package/auth.js +159 -0
- package/core/api.js +226 -0
- package/core/env-config.js +25 -0
- package/core/events.js +28 -0
- package/core/flow.js +131 -0
- package/core/login-config.js +73 -0
- package/core/login-flow.js +148 -0
- package/core/login-state.js +50 -0
- package/core/progress-storage.js +17 -0
- package/core/state.js +55 -0
- package/index.js +20 -0
- package/login.js +318 -0
- package/package.json +35 -0
- package/signup.js +315 -0
- package/styles.css +947 -0
- package/theme.js +277 -0
- package/ui/components.js +94 -0
- package/ui/layout.js +100 -0
- package/ui/screens/email-entry.js +136 -0
- package/ui/screens/email-verify.js +96 -0
- package/ui/screens/face-registration.js +125 -0
- package/ui/screens/index.js +67 -0
- package/ui/screens/login/entry.js +63 -0
- package/ui/screens/login/frs.js +134 -0
- package/ui/screens/login/identifier-email.js +57 -0
- package/ui/screens/login/identifier-mobile.js +85 -0
- package/ui/screens/login/index.js +103 -0
- package/ui/screens/login/otp.js +79 -0
- package/ui/screens/login/password.js +65 -0
- package/ui/screens/login/success.js +32 -0
- package/ui/screens/mobile-entry.js +79 -0
- package/ui/screens/mobile-verify.js +79 -0
- package/ui/screens/password-setup.js +137 -0
- package/ui/screens/success.js +34 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Verification Screen — OTP + First/Last name
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MAIL_CHECK = 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z';
|
|
7
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
8
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
9
|
+
|
|
10
|
+
export function createEmailVerifyScreen(email, onNext, onBack, prefill = {}) {
|
|
11
|
+
const wrap = document.createElement('div');
|
|
12
|
+
wrap.className = 'wf-screen';
|
|
13
|
+
|
|
14
|
+
const otpInputs = Array(6).fill(0).map((_, i) => `
|
|
15
|
+
<input type="text" inputmode="numeric" maxlength="1" class="wf-otp-input" id="wf-email-otp-${i}" data-idx="${i}" autocomplete="one-time-code" />
|
|
16
|
+
`).join('');
|
|
17
|
+
|
|
18
|
+
wrap.innerHTML = `
|
|
19
|
+
<div class="wf-screen-header">
|
|
20
|
+
<div class="wf-icon-box">
|
|
21
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${MAIL_CHECK}"/></svg>
|
|
22
|
+
</div>
|
|
23
|
+
<h2 class="wf-screen-title">Verify your email</h2>
|
|
24
|
+
<p class="wf-screen-desc">We sent a 6-digit code to <span class="wf-highlight">${email}</span></p>
|
|
25
|
+
</div>
|
|
26
|
+
<form class="wf-form" id="wf-email-verify-form">
|
|
27
|
+
<div class="wf-field">
|
|
28
|
+
<label class="wf-label">Verification code</label>
|
|
29
|
+
<div class="wf-otp-row">${otpInputs}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="wf-row-2">
|
|
32
|
+
<div class="wf-field">
|
|
33
|
+
<label class="wf-label" for="wf-first-name">First name</label>
|
|
34
|
+
<input type="text" id="wf-first-name" placeholder="John" class="wf-input" required autocomplete="given-name" />
|
|
35
|
+
</div>
|
|
36
|
+
<div class="wf-field">
|
|
37
|
+
<label class="wf-label" for="wf-last-name">Last name</label>
|
|
38
|
+
<input type="text" id="wf-last-name" placeholder="Doe" class="wf-input" required autocomplete="family-name" />
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="wf-btn-row">
|
|
42
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" data-action="back" aria-label="Go back">
|
|
43
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg>
|
|
44
|
+
</button>
|
|
45
|
+
<button type="submit" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-flex">
|
|
46
|
+
Verify & Continue
|
|
47
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_RIGHT}"/></svg>
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</form>
|
|
51
|
+
<p class="wf-footer-link">
|
|
52
|
+
Didn't receive the code? <button type="button" class="wf-link">Resend</button>
|
|
53
|
+
</p>
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const form = wrap.querySelector('#wf-email-verify-form');
|
|
57
|
+
const otpEls = wrap.querySelectorAll('.wf-otp-input');
|
|
58
|
+
const firstName = wrap.querySelector('#wf-first-name');
|
|
59
|
+
const lastName = wrap.querySelector('#wf-last-name');
|
|
60
|
+
|
|
61
|
+
if (prefill.firstName) firstName.value = prefill.firstName;
|
|
62
|
+
if (prefill.lastName) lastName.value = prefill.lastName;
|
|
63
|
+
|
|
64
|
+
function handleOtpPaste(e) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
const pasted = (e.clipboardData || window.clipboardData).getData('text');
|
|
67
|
+
const digits = pasted.replace(/\D/g, '').slice(0, 6).split('');
|
|
68
|
+
digits.forEach((d, i) => { if (otpEls[i]) otpEls[i].value = d; });
|
|
69
|
+
const nextIdx = Math.min(digits.length, 5);
|
|
70
|
+
if (otpEls[nextIdx]) otpEls[nextIdx].focus();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
otpEls.forEach((el, i) => {
|
|
74
|
+
el.addEventListener('paste', handleOtpPaste);
|
|
75
|
+
el.addEventListener('input', (e) => {
|
|
76
|
+
const v = e.target.value.replace(/\D/g, '').slice(0, 1);
|
|
77
|
+
e.target.value = v;
|
|
78
|
+
if (v && i < 5) otpEls[i + 1].focus();
|
|
79
|
+
});
|
|
80
|
+
el.addEventListener('keydown', (e) => {
|
|
81
|
+
if (e.key === 'Backspace' && !e.target.value && i > 0) otpEls[i - 1].focus();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
form.addEventListener('submit', (e) => {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
const code = [...otpEls].map((o) => o.value).join('');
|
|
88
|
+
if (code.length === 6 && firstName.value.trim() && lastName.value.trim()) {
|
|
89
|
+
onNext({ otp: code, firstName: firstName.value.trim(), lastName: lastName.value.trim() });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
94
|
+
|
|
95
|
+
return wrap;
|
|
96
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Face Registration Screen — Camera capture for biometric verification
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SCAN_FACE = 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z';
|
|
7
|
+
const CAMERA_ICON = 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z M19 10v1a1 1 0 01-1 1H6a1 1 0 01-1-1v-1';
|
|
8
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
9
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
10
|
+
const ROTATE_ICON = 'M1 4v6h6M23 20v-6h-6';
|
|
11
|
+
|
|
12
|
+
export function createFaceRegistrationScreen(onNext, onBack) {
|
|
13
|
+
const wrap = document.createElement('div');
|
|
14
|
+
wrap.className = 'wf-screen';
|
|
15
|
+
|
|
16
|
+
wrap.innerHTML = `
|
|
17
|
+
<div class="wf-screen-header">
|
|
18
|
+
<div class="wf-icon-box">
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${SCAN_FACE}"/></svg>
|
|
20
|
+
</div>
|
|
21
|
+
<h2 class="wf-screen-title">Face registration</h2>
|
|
22
|
+
<p class="wf-screen-desc">Capture your face for biometric verification</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="wf-face-area">
|
|
25
|
+
<div class="wf-camera-view" id="wf-camera-view">
|
|
26
|
+
<div class="wf-camera-placeholder" id="wf-camera-placeholder">
|
|
27
|
+
<div class="wf-camera-icon">
|
|
28
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${CAMERA_ICON}"/></svg>
|
|
29
|
+
</div>
|
|
30
|
+
<p>Position your face within the frame</p>
|
|
31
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg" id="wf-enable-camera">Enable Camera</button>
|
|
32
|
+
</div>
|
|
33
|
+
<video id="wf-video" class="wf-video hidden" autoplay playsinline muted></video>
|
|
34
|
+
<img id="wf-captured" class="wf-captured hidden" alt="Captured face" />
|
|
35
|
+
<div class="wf-face-overlay hidden" id="wf-face-overlay"></div>
|
|
36
|
+
</div>
|
|
37
|
+
<canvas id="wf-canvas" class="hidden"></canvas>
|
|
38
|
+
<p class="wf-error hidden" id="wf-camera-error"></p>
|
|
39
|
+
<div class="wf-face-actions">
|
|
40
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-full hidden" id="wf-capture-btn">
|
|
41
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${CAMERA_ICON}"/></svg>
|
|
42
|
+
Capture Photo
|
|
43
|
+
</button>
|
|
44
|
+
<div class="wf-btn-row hidden" id="wf-captured-actions">
|
|
45
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" id="wf-retake-btn">
|
|
46
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ROTATE_ICON}"/></svg>
|
|
47
|
+
Retake
|
|
48
|
+
</button>
|
|
49
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-flex" id="wf-face-continue">
|
|
50
|
+
Continue
|
|
51
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_RIGHT}"/></svg>
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="wf-btn-row" id="wf-back-only">
|
|
55
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" data-action="back" aria-label="Go back">
|
|
56
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const placeholder = wrap.querySelector('#wf-camera-placeholder');
|
|
64
|
+
const video = wrap.querySelector('#wf-video');
|
|
65
|
+
const captured = wrap.querySelector('#wf-captured');
|
|
66
|
+
const overlay = wrap.querySelector('#wf-face-overlay');
|
|
67
|
+
const canvas = wrap.querySelector('#wf-canvas');
|
|
68
|
+
const errorEl = wrap.querySelector('#wf-camera-error');
|
|
69
|
+
const enableBtn = wrap.querySelector('#wf-enable-camera');
|
|
70
|
+
const captureBtn = wrap.querySelector('#wf-capture-btn');
|
|
71
|
+
const capturedActions = wrap.querySelector('#wf-captured-actions');
|
|
72
|
+
const backOnly = wrap.querySelector('#wf-back-only');
|
|
73
|
+
|
|
74
|
+
let stream = null;
|
|
75
|
+
|
|
76
|
+
async function startCamera() {
|
|
77
|
+
try {
|
|
78
|
+
errorEl.classList.add('hidden');
|
|
79
|
+
errorEl.textContent = '';
|
|
80
|
+
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
|
81
|
+
video.srcObject = stream;
|
|
82
|
+
video.classList.remove('hidden');
|
|
83
|
+
placeholder.classList.add('hidden');
|
|
84
|
+
overlay.classList.remove('hidden');
|
|
85
|
+
captureBtn.classList.remove('hidden');
|
|
86
|
+
backOnly.classList.add('hidden');
|
|
87
|
+
} catch {
|
|
88
|
+
errorEl.textContent = 'Camera access denied. Please enable camera permissions.';
|
|
89
|
+
errorEl.classList.remove('hidden');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function captureImage() {
|
|
94
|
+
const ctx = canvas.getContext('2d');
|
|
95
|
+
canvas.width = video.videoWidth;
|
|
96
|
+
canvas.height = video.videoHeight;
|
|
97
|
+
ctx.drawImage(video, 0, 0);
|
|
98
|
+
const dataUrl = canvas.toDataURL('image/jpeg');
|
|
99
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
100
|
+
stream = null;
|
|
101
|
+
video.classList.add('hidden');
|
|
102
|
+
overlay.classList.add('hidden');
|
|
103
|
+
captureBtn.classList.add('hidden');
|
|
104
|
+
captured.src = dataUrl;
|
|
105
|
+
captured.classList.remove('hidden');
|
|
106
|
+
capturedActions.classList.remove('hidden');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function retake() {
|
|
110
|
+
captured.classList.add('hidden');
|
|
111
|
+
capturedActions.classList.add('hidden');
|
|
112
|
+
captured.src = '';
|
|
113
|
+
startCamera();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
enableBtn.addEventListener('click', startCamera);
|
|
117
|
+
captureBtn.addEventListener('click', captureImage);
|
|
118
|
+
wrap.querySelector('#wf-retake-btn').addEventListener('click', retake);
|
|
119
|
+
wrap.querySelector('#wf-face-continue').addEventListener('click', () => {
|
|
120
|
+
onNext(captured.src || null);
|
|
121
|
+
});
|
|
122
|
+
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
123
|
+
|
|
124
|
+
return wrap;
|
|
125
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen factory - Renders the appropriate screen for current step
|
|
3
|
+
* Callbacks are async API handlers (called from index.js)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { STEPS } from '../../core/flow.js';
|
|
7
|
+
import { createEmailEntryScreen } from './email-entry.js';
|
|
8
|
+
import { createEmailVerifyScreen } from './email-verify.js';
|
|
9
|
+
import { createMobileEntryScreen } from './mobile-entry.js';
|
|
10
|
+
import { createMobileVerifyScreen } from './mobile-verify.js';
|
|
11
|
+
import { createPasswordSetupScreen } from './password-setup.js';
|
|
12
|
+
import { createFaceRegistrationScreen } from './face-registration.js';
|
|
13
|
+
import { createSuccessScreen } from './success.js';
|
|
14
|
+
|
|
15
|
+
export function createScreen(step, state, flow, callbacks) {
|
|
16
|
+
const {
|
|
17
|
+
onEmailSubmit,
|
|
18
|
+
onEmailVerify,
|
|
19
|
+
onMobileSubmit,
|
|
20
|
+
onMobileVerify,
|
|
21
|
+
onPasswordSubmit,
|
|
22
|
+
onFaceSubmit,
|
|
23
|
+
onBack,
|
|
24
|
+
onSocialSignUp,
|
|
25
|
+
onContinue,
|
|
26
|
+
onSwitchToLogin,
|
|
27
|
+
onBackToApp,
|
|
28
|
+
} = callbacks;
|
|
29
|
+
|
|
30
|
+
switch (step) {
|
|
31
|
+
case STEPS.EMAIL:
|
|
32
|
+
return createEmailEntryScreen(onEmailSubmit, onSocialSignUp, onSwitchToLogin, onBackToApp);
|
|
33
|
+
|
|
34
|
+
case STEPS.EMAIL_VERIFY:
|
|
35
|
+
return createEmailVerifyScreen(
|
|
36
|
+
state.get('email'),
|
|
37
|
+
(data) => onEmailVerify(data),
|
|
38
|
+
onBack,
|
|
39
|
+
{ firstName: state.get('firstName') || '', lastName: state.get('lastName') || '' }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
case STEPS.MOBILE:
|
|
43
|
+
return createMobileEntryScreen(onMobileSubmit, onBack);
|
|
44
|
+
|
|
45
|
+
case STEPS.MOBILE_VERIFY:
|
|
46
|
+
return createMobileVerifyScreen(
|
|
47
|
+
state.get('mobile'),
|
|
48
|
+
(otp) => onMobileVerify(otp),
|
|
49
|
+
onBack
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
case STEPS.PASSWORD:
|
|
53
|
+
return createPasswordSetupScreen(onPasswordSubmit, onBack);
|
|
54
|
+
|
|
55
|
+
case STEPS.FACE:
|
|
56
|
+
return createFaceRegistrationScreen(
|
|
57
|
+
(capture) => onFaceSubmit(capture),
|
|
58
|
+
onBack
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
case STEPS.SUCCESS:
|
|
62
|
+
return createSuccessScreen(typeof onContinue === 'function' ? onContinue : () => {});
|
|
63
|
+
|
|
64
|
+
default:
|
|
65
|
+
return document.createElement('div');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login entry — method picker (only enabled methods from config)
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
7
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
8
|
+
const MAIL_ICON = 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z';
|
|
9
|
+
const PHONE_ICON = 'M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 010 2.78 12.84 12.84 0 00.7 2.81 2 2 0 010 2.78 12.84 12.84 0 006 6 2 2 0 002.78 0 12.84 12.84 0 002.81-.7A2 2 0 0022 16.92z';
|
|
10
|
+
const FACE_ICON = 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z';
|
|
11
|
+
|
|
12
|
+
function methodIcon(type) {
|
|
13
|
+
const attrs = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
|
14
|
+
if (type === 'mail') return `<svg class="wf-login-method-icon" ${attrs}><path d="${MAIL_ICON}"/></svg>`;
|
|
15
|
+
if (type === 'phone') return `<svg class="wf-login-method-icon" ${attrs}><path d="${PHONE_ICON}"/></svg>`;
|
|
16
|
+
if (type === 'face') return `<svg class="wf-login-method-icon" ${attrs}><path d="${FACE_ICON}"/></svg>`;
|
|
17
|
+
if (type === 'google') return `<svg class="wf-login-method-icon" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>`;
|
|
18
|
+
if (type === 'microsoft') return `<svg class="wf-login-method-icon" viewBox="0 0 23 23"><rect x="1" y="1" width="10" height="10" fill="#F25022"/><rect x="12" y="1" width="10" height="10" fill="#7FBA00"/><rect x="1" y="12" width="10" height="10" fill="#00A4EF"/><rect x="12" y="12" width="10" height="10" fill="#FFB900"/></svg>`;
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createLoginEntryScreen(methods, onSelectMethod, onSignUp, onBackToApp) {
|
|
23
|
+
const wrap = document.createElement('div');
|
|
24
|
+
wrap.className = 'wf-screen';
|
|
25
|
+
|
|
26
|
+
const methodButtons = methods.map((m) => `
|
|
27
|
+
<button type="button" class="wf-btn wf-btn-social wf-btn-lg wf-btn-full wf-login-method" data-method="${m.id}">
|
|
28
|
+
${methodIcon(m.icon)}
|
|
29
|
+
<span>${m.label}</span>
|
|
30
|
+
<svg class="wf-icon-sm" style="margin-left:auto;opacity:0.4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
|
31
|
+
</button>
|
|
32
|
+
`).join('');
|
|
33
|
+
|
|
34
|
+
wrap.innerHTML = `
|
|
35
|
+
<div class="wf-screen-back"><button type="button" class="wf-btn wf-btn-ghost wf-btn-lg" id="wf-login-entry-back" aria-label="Back"><svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg> Back</button></div>
|
|
36
|
+
<div class="wf-screen-header">
|
|
37
|
+
<h2 class="wf-screen-title">Welcome back</h2>
|
|
38
|
+
<p class="wf-screen-desc">Choose how you'd like to sign in</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="wf-login-methods">
|
|
41
|
+
${methodButtons}
|
|
42
|
+
</div>
|
|
43
|
+
<p class="wf-footer-link">
|
|
44
|
+
Don't have an account? <button type="button" class="wf-link" data-action="signup">Sign up</button>
|
|
45
|
+
</p>
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
wrap.querySelectorAll('[data-method]').forEach((btn) => {
|
|
49
|
+
btn.addEventListener('click', () => onSelectMethod(btn.dataset.method));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const signUpBtn = wrap.querySelector('[data-action="signup"]');
|
|
53
|
+
if (signUpBtn && typeof onSignUp === 'function') {
|
|
54
|
+
signUpBtn.addEventListener('click', () => onSignUp());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const backBtn = wrap.querySelector('#wf-login-entry-back');
|
|
58
|
+
if (backBtn && typeof onBackToApp === 'function') {
|
|
59
|
+
backBtn.addEventListener('click', () => onBackToApp());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return wrap;
|
|
63
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login — Face verification (FRS) as second factor or email+face only
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SCAN_FACE = 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z';
|
|
7
|
+
const CAMERA_ICON = 'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z M19 10v1a1 1 0 01-1 1H6a1 1 0 01-1-1v-1';
|
|
8
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
9
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
10
|
+
const ROTATE_ICON = 'M1 4v6h6M23 20v-6h-6';
|
|
11
|
+
|
|
12
|
+
export function createLoginFrsScreen(onNext, onBack, opts = {}) {
|
|
13
|
+
const { onSwitchToOtp, showOtpFallback } = opts;
|
|
14
|
+
const wrap = document.createElement('div');
|
|
15
|
+
wrap.className = 'wf-screen';
|
|
16
|
+
|
|
17
|
+
const otpFallbackHtml = showOtpFallback && typeof onSwitchToOtp === 'function'
|
|
18
|
+
? `<p class="wf-footer-link" style="margin-top:0.5rem;">
|
|
19
|
+
No webcam? <button type="button" class="wf-link" data-action="switch-to-otp">Sign in with OTP instead</button>
|
|
20
|
+
</p>`
|
|
21
|
+
: '';
|
|
22
|
+
|
|
23
|
+
wrap.innerHTML = `
|
|
24
|
+
<div class="wf-screen-header">
|
|
25
|
+
<div class="wf-icon-box">
|
|
26
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${SCAN_FACE}"/></svg>
|
|
27
|
+
</div>
|
|
28
|
+
<h2 class="wf-screen-title">Verify your face</h2>
|
|
29
|
+
<p class="wf-screen-desc">Capture your face to complete sign in</p>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="wf-face-area">
|
|
32
|
+
<div class="wf-camera-view" id="wf-login-camera-view">
|
|
33
|
+
<div class="wf-camera-placeholder" id="wf-login-camera-placeholder">
|
|
34
|
+
<div class="wf-camera-icon">
|
|
35
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${CAMERA_ICON}"/></svg>
|
|
36
|
+
</div>
|
|
37
|
+
<p>Position your face within the frame</p>
|
|
38
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg" id="wf-login-enable-camera">Enable Camera</button>
|
|
39
|
+
</div>
|
|
40
|
+
<video id="wf-login-video" class="wf-video hidden" autoplay playsinline muted></video>
|
|
41
|
+
<img id="wf-login-captured" class="wf-captured hidden" alt="Captured face" />
|
|
42
|
+
<div class="wf-face-overlay hidden" id="wf-login-face-overlay"></div>
|
|
43
|
+
</div>
|
|
44
|
+
<canvas id="wf-login-canvas" class="hidden"></canvas>
|
|
45
|
+
<p class="wf-error hidden" id="wf-login-camera-error"></p>
|
|
46
|
+
<div class="wf-face-actions">
|
|
47
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-full hidden" id="wf-login-capture-btn">
|
|
48
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${CAMERA_ICON}"/></svg>
|
|
49
|
+
Capture Photo
|
|
50
|
+
</button>
|
|
51
|
+
<div class="wf-btn-row hidden" id="wf-login-captured-actions">
|
|
52
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" id="wf-login-retake-btn">
|
|
53
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ROTATE_ICON}"/></svg>
|
|
54
|
+
Retake
|
|
55
|
+
</button>
|
|
56
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-flex" id="wf-login-face-continue">
|
|
57
|
+
Continue
|
|
58
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_RIGHT}"/></svg>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="wf-btn-row" id="wf-login-back-only">
|
|
62
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" data-action="back" aria-label="Go back">
|
|
63
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg>
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
${otpFallbackHtml}
|
|
68
|
+
</div>
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const placeholder = wrap.querySelector('#wf-login-camera-placeholder');
|
|
72
|
+
const video = wrap.querySelector('#wf-login-video');
|
|
73
|
+
const captured = wrap.querySelector('#wf-login-captured');
|
|
74
|
+
const overlay = wrap.querySelector('#wf-login-face-overlay');
|
|
75
|
+
const canvas = wrap.querySelector('#wf-login-canvas');
|
|
76
|
+
const errorEl = wrap.querySelector('#wf-login-camera-error');
|
|
77
|
+
|
|
78
|
+
let stream = null;
|
|
79
|
+
|
|
80
|
+
function startCamera() {
|
|
81
|
+
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } })
|
|
82
|
+
.then((s) => {
|
|
83
|
+
stream = s;
|
|
84
|
+
video.srcObject = s;
|
|
85
|
+
video.classList.remove('hidden');
|
|
86
|
+
placeholder.classList.add('hidden');
|
|
87
|
+
overlay.classList.remove('hidden');
|
|
88
|
+
wrap.querySelector('#wf-login-capture-btn').classList.remove('hidden');
|
|
89
|
+
errorEl.classList.add('hidden');
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
errorEl.textContent = 'Camera access denied. Please enable camera permissions.';
|
|
93
|
+
errorEl.classList.remove('hidden');
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function captureImage() {
|
|
98
|
+
const ctx = canvas.getContext('2d');
|
|
99
|
+
canvas.width = video.videoWidth;
|
|
100
|
+
canvas.height = video.videoHeight;
|
|
101
|
+
ctx.drawImage(video, 0, 0);
|
|
102
|
+
const dataUrl = canvas.toDataURL('image/jpeg');
|
|
103
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
104
|
+
stream = null;
|
|
105
|
+
video.classList.add('hidden');
|
|
106
|
+
overlay.classList.add('hidden');
|
|
107
|
+
wrap.querySelector('#wf-login-capture-btn').classList.add('hidden');
|
|
108
|
+
captured.src = dataUrl;
|
|
109
|
+
captured.classList.remove('hidden');
|
|
110
|
+
wrap.querySelector('#wf-login-captured-actions').classList.remove('hidden');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function retake() {
|
|
114
|
+
captured.classList.add('hidden');
|
|
115
|
+
captured.src = '';
|
|
116
|
+
wrap.querySelector('#wf-login-captured-actions').classList.add('hidden');
|
|
117
|
+
startCamera();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
wrap.querySelector('#wf-login-enable-camera').addEventListener('click', startCamera);
|
|
121
|
+
wrap.querySelector('#wf-login-capture-btn').addEventListener('click', captureImage);
|
|
122
|
+
wrap.querySelector('#wf-login-retake-btn').addEventListener('click', retake);
|
|
123
|
+
wrap.querySelector('#wf-login-face-continue').addEventListener('click', () => {
|
|
124
|
+
onNext(captured.src || null);
|
|
125
|
+
});
|
|
126
|
+
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
127
|
+
|
|
128
|
+
const switchToOtpBtn = wrap.querySelector('[data-action="switch-to-otp"]');
|
|
129
|
+
if (switchToOtpBtn && typeof onSwitchToOtp === 'function') {
|
|
130
|
+
switchToOtpBtn.addEventListener('click', () => onSwitchToOtp());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return wrap;
|
|
134
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login — Email identifier input (for email+password, email+otp, email+frs)
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MAIL_ICON = 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z';
|
|
7
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
8
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
9
|
+
|
|
10
|
+
export function createLoginIdentifierEmailScreen(onNext, onBack, onSignUp) {
|
|
11
|
+
const wrap = document.createElement('div');
|
|
12
|
+
wrap.className = 'wf-screen';
|
|
13
|
+
|
|
14
|
+
wrap.innerHTML = `
|
|
15
|
+
<div class="wf-screen-header">
|
|
16
|
+
<div class="wf-icon-box">
|
|
17
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${MAIL_ICON}"/></svg>
|
|
18
|
+
</div>
|
|
19
|
+
<h2 class="wf-screen-title">Enter your email</h2>
|
|
20
|
+
<p class="wf-screen-desc">We'll use this to sign you in</p>
|
|
21
|
+
</div>
|
|
22
|
+
<form class="wf-form" id="wf-login-email-form">
|
|
23
|
+
<div class="wf-field">
|
|
24
|
+
<label class="wf-label" for="wf-login-email">Email address</label>
|
|
25
|
+
<div class="wf-input-wrap">
|
|
26
|
+
<svg class="wf-input-icon-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${MAIL_ICON}"/></svg>
|
|
27
|
+
<input type="email" id="wf-login-email" placeholder="you@example.com" class="wf-input" required autocomplete="email" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="wf-btn-row">
|
|
31
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" data-action="back" aria-label="Go back">
|
|
32
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg>
|
|
33
|
+
</button>
|
|
34
|
+
<button type="submit" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-flex">
|
|
35
|
+
Continue
|
|
36
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_RIGHT}"/></svg>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
40
|
+
<p class="wf-footer-link">
|
|
41
|
+
Don't have an account? <button type="button" class="wf-link" data-action="signup">Sign up</button>
|
|
42
|
+
</p>
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
wrap.querySelector('#wf-login-email-form').addEventListener('submit', (e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
const val = wrap.querySelector('#wf-login-email').value.trim();
|
|
48
|
+
if (val && val.includes('@')) onNext(val);
|
|
49
|
+
});
|
|
50
|
+
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
51
|
+
const signUpBtn = wrap.querySelector('[data-action="signup"]');
|
|
52
|
+
if (signUpBtn && typeof onSignUp === 'function') {
|
|
53
|
+
signUpBtn.addEventListener('click', () => onSignUp());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return wrap;
|
|
57
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login — Mobile identifier input (country code + number)
|
|
3
|
+
* v2.0 — warm, modern design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const PHONE_ICON = 'M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 010 2.78 12.84 12.84 0 006 6 2 2 0 002.78 0 12.84 12.84 0 002.81-.7A2 2 0 0022 16.92z';
|
|
7
|
+
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
8
|
+
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
9
|
+
|
|
10
|
+
const COUNTRY_CODES = [
|
|
11
|
+
{ code: '91', label: 'India +91' },
|
|
12
|
+
{ code: '1', label: 'United States +1' },
|
|
13
|
+
{ code: '44', label: 'United Kingdom +44' },
|
|
14
|
+
{ code: '61', label: 'Australia +61' },
|
|
15
|
+
{ code: '81', label: 'Japan +81' },
|
|
16
|
+
{ code: '49', label: 'Germany +49' },
|
|
17
|
+
{ code: '33', label: 'France +33' },
|
|
18
|
+
{ code: '86', label: 'China +86' },
|
|
19
|
+
{ code: '971', label: 'UAE +971' },
|
|
20
|
+
{ code: '65', label: 'Singapore +65' },
|
|
21
|
+
{ code: '55', label: 'Brazil +55' },
|
|
22
|
+
{ code: '52', label: 'Mexico +52' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function createLoginIdentifierMobileScreen(onNext, onBack, onSignUp) {
|
|
26
|
+
const wrap = document.createElement('div');
|
|
27
|
+
wrap.className = 'wf-screen';
|
|
28
|
+
|
|
29
|
+
const countryOptions = COUNTRY_CODES.map((c) =>
|
|
30
|
+
`<option value="${c.code}"${c.code === '91' ? ' selected' : ''}>${c.label}</option>`
|
|
31
|
+
).join('');
|
|
32
|
+
|
|
33
|
+
wrap.innerHTML = `
|
|
34
|
+
<div class="wf-screen-header">
|
|
35
|
+
<div class="wf-icon-box">
|
|
36
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${PHONE_ICON}"/></svg>
|
|
37
|
+
</div>
|
|
38
|
+
<h2 class="wf-screen-title">Enter your mobile number</h2>
|
|
39
|
+
<p class="wf-screen-desc">We'll use this to sign you in</p>
|
|
40
|
+
</div>
|
|
41
|
+
<form class="wf-form" id="wf-login-mobile-form">
|
|
42
|
+
<div class="wf-field">
|
|
43
|
+
<label class="wf-label" for="wf-login-mobile">Mobile number</label>
|
|
44
|
+
<div class="wf-mobile-row">
|
|
45
|
+
<select id="wf-login-mobile-country" class="wf-input wf-country-select" aria-label="Country code">
|
|
46
|
+
${countryOptions}
|
|
47
|
+
</select>
|
|
48
|
+
<input type="tel" id="wf-login-mobile" placeholder="98765 43210" class="wf-input wf-mobile-input" inputmode="numeric" required autocomplete="tel" />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="wf-btn-row">
|
|
52
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" data-action="back" aria-label="Go back">
|
|
53
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_LEFT}"/></svg>
|
|
54
|
+
</button>
|
|
55
|
+
<button type="submit" class="wf-btn wf-btn-primary wf-btn-lg wf-btn-flex">
|
|
56
|
+
Continue
|
|
57
|
+
<svg class="wf-icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${ARROW_RIGHT}"/></svg>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</form>
|
|
61
|
+
<p class="wf-footer-link">
|
|
62
|
+
Don't have an account? <button type="button" class="wf-link" data-action="signup">Sign up</button>
|
|
63
|
+
</p>
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const form = wrap.querySelector('#wf-login-mobile-form');
|
|
67
|
+
const countrySelect = wrap.querySelector('#wf-login-mobile-country');
|
|
68
|
+
const input = wrap.querySelector('#wf-login-mobile');
|
|
69
|
+
|
|
70
|
+
form.addEventListener('submit', (e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
const digits = input.value.trim().replace(/\D/g, '');
|
|
73
|
+
if (digits.length >= 10) {
|
|
74
|
+
const full = '+' + countrySelect.value + digits;
|
|
75
|
+
onNext(full);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
79
|
+
const signUpBtn = wrap.querySelector('[data-action="signup"]');
|
|
80
|
+
if (signUpBtn && typeof onSignUp === 'function') {
|
|
81
|
+
signUpBtn.addEventListener('click', () => onSignUp());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return wrap;
|
|
85
|
+
}
|