central-auth-js-sdk 1.0.11 → 1.0.12
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/core/api.js +37 -0
- package/index.js +1 -1
- package/login.js +1 -0
- package/package.json +4 -1
- package/signup.js +1 -0
- package/styles.css +127 -0
- package/ui/screens/face-registration.js +59 -6
- package/ui/screens/index.js +3 -1
- package/ui/screens/login/frs.js +70 -17
- package/ui/screens/login/index.js +2 -0
- package/ui/screens/phone-capture.js +278 -0
- package/ui/webcam-utils.js +26 -0
package/core/api.js
CHANGED
|
@@ -193,6 +193,32 @@ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
|
|
|
193
193
|
if (appIdentifier) fd.append('app_identifier', appIdentifier);
|
|
194
194
|
return request('POST', '/login/email/fr', fd);
|
|
195
195
|
},
|
|
196
|
+
|
|
197
|
+
// --- Phone Face Capture ---
|
|
198
|
+
|
|
199
|
+
/** POST /fr/session — Create a phone capture session (QR fallback) */
|
|
200
|
+
async createCaptureSession() {
|
|
201
|
+
return request('POST', '/fr/session', null);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* GET /fr/capture/result — Poll for phone-captured face image
|
|
206
|
+
* Returns { status: 'waiting' } on 202 or { status: 'ready', image } on 200.
|
|
207
|
+
* Throws on 404/403 (expired/invalid).
|
|
208
|
+
*/
|
|
209
|
+
async pollCaptureResult(sessionId, secret) {
|
|
210
|
+
const url = `${base}/fr/capture/result?sid=${encodeURIComponent(sessionId)}&secret=${encodeURIComponent(secret)}`;
|
|
211
|
+
const res = await fetch(url);
|
|
212
|
+
const text = await res.text();
|
|
213
|
+
let data = null;
|
|
214
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { _raw: text }; }
|
|
215
|
+
if (res.status === 200 && data?.status === 'ready') return data;
|
|
216
|
+
if (res.status === 202) return data;
|
|
217
|
+
const err = new Error(data?.error || 'Session expired or invalid');
|
|
218
|
+
err.status = res.status;
|
|
219
|
+
err.data = data;
|
|
220
|
+
throw err;
|
|
221
|
+
},
|
|
196
222
|
};
|
|
197
223
|
}
|
|
198
224
|
|
|
@@ -224,3 +250,14 @@ export function dataUrlToFile(dataUrl, filename = 'capture.jpg') {
|
|
|
224
250
|
while (n--) u8[n] = bstr.charCodeAt(n);
|
|
225
251
|
return new File([u8], filename, { type: mime });
|
|
226
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Convert a raw base64 string (no data: prefix) to a File for FormData
|
|
256
|
+
*/
|
|
257
|
+
export function base64ToFile(base64, filename = 'phone-capture.jpg') {
|
|
258
|
+
const byteString = atob(base64);
|
|
259
|
+
const ab = new ArrayBuffer(byteString.length);
|
|
260
|
+
const ia = new Uint8Array(ab);
|
|
261
|
+
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
|
262
|
+
return new File([ab], filename, { type: 'image/jpeg' });
|
|
263
|
+
}
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
export { mount } from './signup.js';
|
|
15
15
|
export { FlowController, STEPS } from './core/flow.js';
|
|
16
16
|
export { StateManager } from './core/state.js';
|
|
17
|
-
export { createApi, dataUrlToFile } from './core/api.js';
|
|
17
|
+
export { createApi, dataUrlToFile, base64ToFile } from './core/api.js';
|
|
18
18
|
export { mountLogin } from './login.js';
|
|
19
19
|
export { initializeAuth, startSignup, startLogin } from './auth.js';
|
|
20
20
|
export { applyTheme, THEME_PRESETS } from './theme.js';
|
package/login.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "central-auth-js-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Vanilla JS sign-up and login flow SDK — embeddable auth UI with configurable methods",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"qrcode": "^1.5.4"
|
|
34
37
|
}
|
|
35
38
|
}
|
package/signup.js
CHANGED
|
@@ -283,6 +283,7 @@ export function mount(container, options = {}) {
|
|
|
283
283
|
onContinue: typeof onContinue === 'function' ? onContinue : () => {},
|
|
284
284
|
onSwitchToLogin: typeof onSwitchToLogin === 'function' ? onSwitchToLogin : () => {},
|
|
285
285
|
onBackToApp: typeof onBack === 'function' ? onBack : () => { cleanupTheme(); layout.root.remove(); },
|
|
286
|
+
api,
|
|
286
287
|
};
|
|
287
288
|
|
|
288
289
|
function render() {
|
package/styles.css
CHANGED
|
@@ -772,6 +772,133 @@
|
|
|
772
772
|
|
|
773
773
|
.wf-face-actions { display: flex; flex-direction: column; gap: 0.625rem; }
|
|
774
774
|
|
|
775
|
+
/* ============================= PHONE CAPTURE (QR FALLBACK) ============================= */
|
|
776
|
+
|
|
777
|
+
.wf-phone-capture {
|
|
778
|
+
display: flex;
|
|
779
|
+
flex-direction: column;
|
|
780
|
+
gap: 1rem;
|
|
781
|
+
align-items: center;
|
|
782
|
+
text-align: center;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
.wf-phone-capture-header { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
|
|
786
|
+
.wf-phone-capture-title { font-size: 1.0625rem; font-weight: 700; color: var(--wf-fg); margin: 0; }
|
|
787
|
+
.wf-phone-capture-desc { font-size: 0.8125rem; color: var(--wf-muted); margin: 0; max-width: 22rem; line-height: 1.45; }
|
|
788
|
+
|
|
789
|
+
.wf-phone-capture-loading {
|
|
790
|
+
display: flex;
|
|
791
|
+
flex-direction: column;
|
|
792
|
+
align-items: center;
|
|
793
|
+
gap: 0.75rem;
|
|
794
|
+
padding: 2rem 0;
|
|
795
|
+
}
|
|
796
|
+
.wf-phone-capture-loading p { font-size: 0.8125rem; color: var(--wf-muted); margin: 0; }
|
|
797
|
+
|
|
798
|
+
.wf-spinner {
|
|
799
|
+
width: 2rem;
|
|
800
|
+
height: 2rem;
|
|
801
|
+
border: 3px solid var(--wf-border);
|
|
802
|
+
border-top-color: var(--wf-primary);
|
|
803
|
+
border-radius: 50%;
|
|
804
|
+
animation: wf-spin 0.7s linear infinite;
|
|
805
|
+
}
|
|
806
|
+
@keyframes wf-spin { to { transform: rotate(360deg); } }
|
|
807
|
+
|
|
808
|
+
.wf-phone-capture-body {
|
|
809
|
+
display: flex;
|
|
810
|
+
flex-direction: column;
|
|
811
|
+
align-items: center;
|
|
812
|
+
gap: 1rem;
|
|
813
|
+
animation: wf-screen-in 0.3s var(--wf-ease) both;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.wf-qr-code {
|
|
817
|
+
padding: 0.75rem;
|
|
818
|
+
background: #fff;
|
|
819
|
+
border-radius: var(--wf-radius);
|
|
820
|
+
border: 1px solid var(--wf-border);
|
|
821
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
822
|
+
line-height: 0;
|
|
823
|
+
}
|
|
824
|
+
.wf-qr-code img { width: 180px; height: 180px; border-radius: 4px; }
|
|
825
|
+
|
|
826
|
+
.wf-btn-copy-link {
|
|
827
|
+
display: inline-flex;
|
|
828
|
+
align-items: center;
|
|
829
|
+
gap: 0.375rem;
|
|
830
|
+
padding: 0.375rem 0.75rem;
|
|
831
|
+
font-size: 0.75rem;
|
|
832
|
+
font-weight: 600;
|
|
833
|
+
font-family: var(--wf-font);
|
|
834
|
+
color: var(--wf-primary);
|
|
835
|
+
background: transparent;
|
|
836
|
+
border: 1px solid var(--wf-border);
|
|
837
|
+
border-radius: var(--wf-radius-sm);
|
|
838
|
+
cursor: pointer;
|
|
839
|
+
transition: all 0.15s var(--wf-ease);
|
|
840
|
+
}
|
|
841
|
+
.wf-btn-copy-link:hover { border-color: var(--wf-primary); background: var(--wf-muted-bg, rgba(0,0,0,0.03)); }
|
|
842
|
+
.wf-btn-copy-link svg { width: 0.875rem; height: 0.875rem; }
|
|
843
|
+
.wf-btn-copy-link-done { color: var(--wf-success); border-color: var(--wf-success); }
|
|
844
|
+
|
|
845
|
+
.wf-pin-section { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
|
846
|
+
.wf-pin-label { font-size: 0.75rem; color: var(--wf-muted); margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
847
|
+
|
|
848
|
+
.wf-pin-display {
|
|
849
|
+
display: flex;
|
|
850
|
+
gap: 0.375rem;
|
|
851
|
+
}
|
|
852
|
+
.wf-pin-digit {
|
|
853
|
+
display: flex;
|
|
854
|
+
align-items: center;
|
|
855
|
+
justify-content: center;
|
|
856
|
+
width: 2.25rem;
|
|
857
|
+
height: 2.75rem;
|
|
858
|
+
background: var(--wf-input-bg, var(--wf-card));
|
|
859
|
+
border: 2px solid var(--wf-primary);
|
|
860
|
+
border-radius: var(--wf-radius-sm);
|
|
861
|
+
font-size: 1.375rem;
|
|
862
|
+
font-weight: 700;
|
|
863
|
+
color: var(--wf-fg);
|
|
864
|
+
font-family: var(--wf-font);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
.wf-capture-meta { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
|
868
|
+
.wf-capture-timer { font-size: 0.75rem; color: var(--wf-muted); margin: 0; font-variant-numeric: tabular-nums; }
|
|
869
|
+
.wf-capture-status { font-size: 0.8125rem; color: var(--wf-fg-soft); margin: 0; }
|
|
870
|
+
.wf-capture-status-done { color: var(--wf-success); font-weight: 600; }
|
|
871
|
+
|
|
872
|
+
.wf-phone-capture-error { text-align: center; padding: 1rem 0; }
|
|
873
|
+
|
|
874
|
+
.wf-phone-capture-success {
|
|
875
|
+
display: flex;
|
|
876
|
+
flex-direction: column;
|
|
877
|
+
align-items: center;
|
|
878
|
+
gap: 0.75rem;
|
|
879
|
+
padding: 1.5rem 0;
|
|
880
|
+
animation: wf-success-pop 0.5s var(--wf-ease-spring) both;
|
|
881
|
+
}
|
|
882
|
+
.wf-capture-success-icon {
|
|
883
|
+
width: 3rem;
|
|
884
|
+
height: 3rem;
|
|
885
|
+
border-radius: 50%;
|
|
886
|
+
background: var(--wf-success-bg);
|
|
887
|
+
display: flex;
|
|
888
|
+
align-items: center;
|
|
889
|
+
justify-content: center;
|
|
890
|
+
}
|
|
891
|
+
.wf-capture-success-icon svg { width: 1.5rem; height: 1.5rem; color: var(--wf-success); }
|
|
892
|
+
|
|
893
|
+
.wf-phone-capture-actions {
|
|
894
|
+
display: flex;
|
|
895
|
+
gap: 0.5rem;
|
|
896
|
+
width: 100%;
|
|
897
|
+
justify-content: center;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
.wf-phone-fallback-link { text-align: center; margin-top: 0.25rem; }
|
|
901
|
+
|
|
775
902
|
/* ============================= SUCCESS ============================= */
|
|
776
903
|
|
|
777
904
|
.wf-screen-success { align-items: center; text-align: center; }
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Face Registration Screen — Camera capture for biometric verification
|
|
3
|
-
*
|
|
3
|
+
* Falls back to phone QR capture when no webcam is available.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { hasWebcam } from '../webcam-utils.js';
|
|
7
|
+
import { createPhoneCaptureComponent } from './phone-capture.js';
|
|
8
|
+
|
|
6
9
|
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
10
|
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
11
|
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
9
12
|
const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
10
13
|
const ROTATE_ICON = 'M1 4v6h6M23 20v-6h-6';
|
|
11
14
|
|
|
12
|
-
export function createFaceRegistrationScreen(onNext, onBack) {
|
|
15
|
+
export function createFaceRegistrationScreen(onNext, onBack, api) {
|
|
13
16
|
const wrap = document.createElement('div');
|
|
14
17
|
wrap.className = 'wf-screen';
|
|
15
18
|
|
|
@@ -57,9 +60,11 @@ export function createFaceRegistrationScreen(onNext, onBack) {
|
|
|
57
60
|
</button>
|
|
58
61
|
</div>
|
|
59
62
|
</div>
|
|
63
|
+
${api ? '<p class="wf-footer-link wf-phone-fallback-link" id="wf-use-phone"><button type="button" class="wf-link" data-action="use-phone">Use phone instead</button></p>' : ''}
|
|
60
64
|
</div>
|
|
61
65
|
`;
|
|
62
66
|
|
|
67
|
+
const cameraView = wrap.querySelector('#wf-camera-view');
|
|
63
68
|
const placeholder = wrap.querySelector('#wf-camera-placeholder');
|
|
64
69
|
const video = wrap.querySelector('#wf-video');
|
|
65
70
|
const captured = wrap.querySelector('#wf-captured');
|
|
@@ -70,13 +75,58 @@ export function createFaceRegistrationScreen(onNext, onBack) {
|
|
|
70
75
|
const captureBtn = wrap.querySelector('#wf-capture-btn');
|
|
71
76
|
const capturedActions = wrap.querySelector('#wf-captured-actions');
|
|
72
77
|
const backOnly = wrap.querySelector('#wf-back-only');
|
|
78
|
+
const faceArea = wrap.querySelector('.wf-face-area');
|
|
79
|
+
|
|
80
|
+
const usePhoneLink = wrap.querySelector('#wf-use-phone');
|
|
73
81
|
|
|
74
82
|
let stream = null;
|
|
83
|
+
let phoneCaptureRef = null;
|
|
84
|
+
|
|
85
|
+
function stopStream() {
|
|
86
|
+
if (stream) { stream.getTracks().forEach((t) => t.stop()); stream = null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function showPhoneCapture(canSwitchBack) {
|
|
90
|
+
if (!api) return;
|
|
91
|
+
stopStream();
|
|
92
|
+
cameraView.classList.add('hidden');
|
|
93
|
+
errorEl.classList.add('hidden');
|
|
94
|
+
captureBtn.classList.add('hidden');
|
|
95
|
+
capturedActions.classList.add('hidden');
|
|
96
|
+
captured.classList.add('hidden');
|
|
97
|
+
backOnly.classList.add('hidden');
|
|
98
|
+
if (usePhoneLink) usePhoneLink.classList.add('hidden');
|
|
99
|
+
|
|
100
|
+
if (phoneCaptureRef) { phoneCaptureRef.destroy(); phoneCaptureRef = null; }
|
|
101
|
+
const cancelAction = canSwitchBack ? () => switchToCamera() : onBack;
|
|
102
|
+
phoneCaptureRef = createPhoneCaptureComponent(api, onNext, cancelAction);
|
|
103
|
+
faceArea.insertBefore(phoneCaptureRef.element, faceArea.querySelector('.wf-face-actions'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function switchToCamera() {
|
|
107
|
+
if (phoneCaptureRef) { phoneCaptureRef.destroy(); phoneCaptureRef.element.remove(); phoneCaptureRef = null; }
|
|
108
|
+
cameraView.classList.remove('hidden');
|
|
109
|
+
placeholder.classList.remove('hidden');
|
|
110
|
+
video.classList.add('hidden');
|
|
111
|
+
overlay.classList.add('hidden');
|
|
112
|
+
captureBtn.classList.add('hidden');
|
|
113
|
+
capturedActions.classList.add('hidden');
|
|
114
|
+
captured.classList.add('hidden');
|
|
115
|
+
backOnly.classList.remove('hidden');
|
|
116
|
+
if (usePhoneLink) usePhoneLink.classList.remove('hidden');
|
|
117
|
+
}
|
|
75
118
|
|
|
76
119
|
async function startCamera() {
|
|
120
|
+
errorEl.classList.add('hidden');
|
|
121
|
+
errorEl.textContent = '';
|
|
122
|
+
|
|
123
|
+
const webcamAvailable = await hasWebcam();
|
|
124
|
+
if (!webcamAvailable) {
|
|
125
|
+
showPhoneCapture(false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
77
129
|
try {
|
|
78
|
-
errorEl.classList.add('hidden');
|
|
79
|
-
errorEl.textContent = '';
|
|
80
130
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
|
81
131
|
video.srcObject = stream;
|
|
82
132
|
video.classList.remove('hidden');
|
|
@@ -84,9 +134,9 @@ export function createFaceRegistrationScreen(onNext, onBack) {
|
|
|
84
134
|
overlay.classList.remove('hidden');
|
|
85
135
|
captureBtn.classList.remove('hidden');
|
|
86
136
|
backOnly.classList.add('hidden');
|
|
137
|
+
if (usePhoneLink) usePhoneLink.classList.remove('hidden');
|
|
87
138
|
} catch {
|
|
88
|
-
|
|
89
|
-
errorEl.classList.remove('hidden');
|
|
139
|
+
showPhoneCapture(false);
|
|
90
140
|
}
|
|
91
141
|
}
|
|
92
142
|
|
|
@@ -121,5 +171,8 @@ export function createFaceRegistrationScreen(onNext, onBack) {
|
|
|
121
171
|
});
|
|
122
172
|
wrap.querySelector('[data-action="back"]').addEventListener('click', onBack);
|
|
123
173
|
|
|
174
|
+
const usePhoneBtn = wrap.querySelector('[data-action="use-phone"]');
|
|
175
|
+
if (usePhoneBtn) usePhoneBtn.addEventListener('click', () => showPhoneCapture(true));
|
|
176
|
+
|
|
124
177
|
return wrap;
|
|
125
178
|
}
|
package/ui/screens/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export function createScreen(step, state, flow, callbacks) {
|
|
|
25
25
|
onContinue,
|
|
26
26
|
onSwitchToLogin,
|
|
27
27
|
onBackToApp,
|
|
28
|
+
api,
|
|
28
29
|
} = callbacks;
|
|
29
30
|
|
|
30
31
|
switch (step) {
|
|
@@ -55,7 +56,8 @@ export function createScreen(step, state, flow, callbacks) {
|
|
|
55
56
|
case STEPS.FACE:
|
|
56
57
|
return createFaceRegistrationScreen(
|
|
57
58
|
(capture) => onFaceSubmit(capture),
|
|
58
|
-
onBack
|
|
59
|
+
onBack,
|
|
60
|
+
api
|
|
59
61
|
);
|
|
60
62
|
|
|
61
63
|
case STEPS.SUCCESS:
|
package/ui/screens/login/frs.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Login — Face verification (FRS) as second factor or email+face only
|
|
3
|
-
*
|
|
3
|
+
* Falls back to phone QR capture when no webcam is available.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { hasWebcam } from '../../webcam-utils.js';
|
|
7
|
+
import { createPhoneCaptureComponent } from '../phone-capture.js';
|
|
8
|
+
|
|
6
9
|
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
10
|
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
11
|
const ARROW_LEFT = 'M19 12H5m7 7l-7-7 7-7';
|
|
@@ -10,7 +13,7 @@ const ARROW_RIGHT = 'M5 12h14m-7-7l7 7-7 7';
|
|
|
10
13
|
const ROTATE_ICON = 'M1 4v6h6M23 20v-6h-6';
|
|
11
14
|
|
|
12
15
|
export function createLoginFrsScreen(onNext, onBack, opts = {}) {
|
|
13
|
-
const { onSwitchToOtp, showOtpFallback } = opts;
|
|
16
|
+
const { onSwitchToOtp, showOtpFallback, api } = opts;
|
|
14
17
|
const wrap = document.createElement('div');
|
|
15
18
|
wrap.className = 'wf-screen';
|
|
16
19
|
|
|
@@ -64,34 +67,81 @@ export function createLoginFrsScreen(onNext, onBack, opts = {}) {
|
|
|
64
67
|
</button>
|
|
65
68
|
</div>
|
|
66
69
|
</div>
|
|
70
|
+
${api ? '<p class="wf-footer-link wf-phone-fallback-link" id="wf-login-use-phone"><button type="button" class="wf-link" data-action="use-phone">Use phone instead</button></p>' : ''}
|
|
67
71
|
${otpFallbackHtml}
|
|
68
72
|
</div>
|
|
69
73
|
`;
|
|
70
74
|
|
|
75
|
+
const cameraView = wrap.querySelector('#wf-login-camera-view');
|
|
71
76
|
const placeholder = wrap.querySelector('#wf-login-camera-placeholder');
|
|
72
77
|
const video = wrap.querySelector('#wf-login-video');
|
|
73
78
|
const captured = wrap.querySelector('#wf-login-captured');
|
|
74
79
|
const overlay = wrap.querySelector('#wf-login-face-overlay');
|
|
75
80
|
const canvas = wrap.querySelector('#wf-login-canvas');
|
|
76
81
|
const errorEl = wrap.querySelector('#wf-login-camera-error');
|
|
82
|
+
const faceArea = wrap.querySelector('.wf-face-area');
|
|
83
|
+
|
|
84
|
+
const usePhoneLink = wrap.querySelector('#wf-login-use-phone');
|
|
77
85
|
|
|
78
86
|
let stream = null;
|
|
87
|
+
let phoneCaptureRef = null;
|
|
88
|
+
|
|
89
|
+
function stopStream() {
|
|
90
|
+
if (stream) { stream.getTracks().forEach((t) => t.stop()); stream = null; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function showPhoneCapture(canSwitchBack) {
|
|
94
|
+
if (!api) return;
|
|
95
|
+
stopStream();
|
|
96
|
+
cameraView.classList.add('hidden');
|
|
97
|
+
errorEl.classList.add('hidden');
|
|
98
|
+
wrap.querySelector('#wf-login-capture-btn').classList.add('hidden');
|
|
99
|
+
wrap.querySelector('#wf-login-captured-actions').classList.add('hidden');
|
|
100
|
+
captured.classList.add('hidden');
|
|
101
|
+
wrap.querySelector('#wf-login-back-only').classList.add('hidden');
|
|
102
|
+
if (usePhoneLink) usePhoneLink.classList.add('hidden');
|
|
103
|
+
|
|
104
|
+
if (phoneCaptureRef) { phoneCaptureRef.destroy(); phoneCaptureRef = null; }
|
|
105
|
+
const cancelAction = canSwitchBack ? () => switchToCamera() : onBack;
|
|
106
|
+
phoneCaptureRef = createPhoneCaptureComponent(api, onNext, cancelAction);
|
|
107
|
+
const actionsEl = faceArea.querySelector('.wf-face-actions');
|
|
108
|
+
faceArea.insertBefore(phoneCaptureRef.element, actionsEl);
|
|
109
|
+
}
|
|
79
110
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
function switchToCamera() {
|
|
112
|
+
if (phoneCaptureRef) { phoneCaptureRef.destroy(); phoneCaptureRef.element.remove(); phoneCaptureRef = null; }
|
|
113
|
+
cameraView.classList.remove('hidden');
|
|
114
|
+
placeholder.classList.remove('hidden');
|
|
115
|
+
video.classList.add('hidden');
|
|
116
|
+
overlay.classList.add('hidden');
|
|
117
|
+
wrap.querySelector('#wf-login-capture-btn').classList.add('hidden');
|
|
118
|
+
wrap.querySelector('#wf-login-captured-actions').classList.add('hidden');
|
|
119
|
+
captured.classList.add('hidden');
|
|
120
|
+
wrap.querySelector('#wf-login-back-only').classList.remove('hidden');
|
|
121
|
+
if (usePhoneLink) usePhoneLink.classList.remove('hidden');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function startCamera() {
|
|
125
|
+
errorEl.classList.add('hidden');
|
|
126
|
+
errorEl.textContent = '';
|
|
127
|
+
|
|
128
|
+
const webcamAvailable = await hasWebcam();
|
|
129
|
+
if (!webcamAvailable) {
|
|
130
|
+
showPhoneCapture(false);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
|
136
|
+
video.srcObject = stream;
|
|
137
|
+
video.classList.remove('hidden');
|
|
138
|
+
placeholder.classList.add('hidden');
|
|
139
|
+
overlay.classList.remove('hidden');
|
|
140
|
+
wrap.querySelector('#wf-login-capture-btn').classList.remove('hidden');
|
|
141
|
+
if (usePhoneLink) usePhoneLink.classList.remove('hidden');
|
|
142
|
+
} catch {
|
|
143
|
+
showPhoneCapture(false);
|
|
144
|
+
}
|
|
95
145
|
}
|
|
96
146
|
|
|
97
147
|
function captureImage() {
|
|
@@ -130,5 +180,8 @@ export function createLoginFrsScreen(onNext, onBack, opts = {}) {
|
|
|
130
180
|
switchToOtpBtn.addEventListener('click', () => onSwitchToOtp());
|
|
131
181
|
}
|
|
132
182
|
|
|
183
|
+
const usePhoneBtn = wrap.querySelector('[data-action="use-phone"]');
|
|
184
|
+
if (usePhoneBtn) usePhoneBtn.addEventListener('click', () => showPhoneCapture(true));
|
|
185
|
+
|
|
133
186
|
return wrap;
|
|
134
187
|
}
|
|
@@ -27,6 +27,7 @@ export function createLoginScreen(stepId, flow, state, config, handlers) {
|
|
|
27
27
|
onSocialRedirect,
|
|
28
28
|
onSignUp,
|
|
29
29
|
onSwitchToOtp,
|
|
30
|
+
api,
|
|
30
31
|
} = handlers;
|
|
31
32
|
|
|
32
33
|
const enabledMethods = getEnabledLoginMethods(config);
|
|
@@ -91,6 +92,7 @@ export function createLoginScreen(stepId, flow, state, config, handlers) {
|
|
|
91
92
|
{
|
|
92
93
|
onSwitchToOtp,
|
|
93
94
|
showOtpFallback: flow.currentMethod === LOGIN_METHODS.EMAIL_FRS,
|
|
95
|
+
api,
|
|
94
96
|
}
|
|
95
97
|
);
|
|
96
98
|
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone Face Capture — QR code fallback when desktop webcam is unavailable.
|
|
3
|
+
* Creates a session via POST /fr/session, displays QR + PIN, polls for result.
|
|
4
|
+
* Returns { element, destroy } — embed `element` in the face capture screen area.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import QRCode from 'qrcode';
|
|
8
|
+
|
|
9
|
+
const POLL_INTERVAL = 2000;
|
|
10
|
+
const MAX_CONSECUTIVE_ERRORS = 3;
|
|
11
|
+
const PHONE_ICON = 'M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z';
|
|
12
|
+
const REFRESH_ICON = 'M1 4v6h6M23 20v-6h-6';
|
|
13
|
+
const COPY_ICON = 'M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} api - SDK api instance (needs createCaptureSession, pollCaptureResult)
|
|
17
|
+
* @param {Function} onCaptured - Called with data URL when phone capture succeeds
|
|
18
|
+
* @param {Function} onCancel - Called when user clicks Cancel
|
|
19
|
+
* @returns {{ element: HTMLElement, destroy: Function }}
|
|
20
|
+
*/
|
|
21
|
+
export function createPhoneCaptureComponent(api, onCaptured, onCancel) {
|
|
22
|
+
let destroyed = false;
|
|
23
|
+
let pollTimer = null;
|
|
24
|
+
let countdownTimer = null;
|
|
25
|
+
let session = null;
|
|
26
|
+
|
|
27
|
+
const wrap = document.createElement('div');
|
|
28
|
+
wrap.className = 'wf-phone-capture';
|
|
29
|
+
|
|
30
|
+
wrap.innerHTML = `
|
|
31
|
+
<div class="wf-phone-capture-header">
|
|
32
|
+
<div class="wf-icon-box">
|
|
33
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${PHONE_ICON}"/></svg>
|
|
34
|
+
</div>
|
|
35
|
+
<h3 class="wf-phone-capture-title">Use your phone</h3>
|
|
36
|
+
<p class="wf-phone-capture-desc">Scan the QR code with your phone and enter the PIN to capture your face</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="wf-phone-capture-loading" id="wf-pc-loading">
|
|
40
|
+
<div class="wf-spinner"></div>
|
|
41
|
+
<p>Creating session…</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="wf-phone-capture-body hidden" id="wf-pc-body">
|
|
45
|
+
<div class="wf-qr-code">
|
|
46
|
+
<img id="wf-pc-qr" alt="QR Code" />
|
|
47
|
+
</div>
|
|
48
|
+
<button type="button" class="wf-btn-copy-link" id="wf-pc-copy-link">
|
|
49
|
+
<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="${COPY_ICON}"/></svg>
|
|
50
|
+
<span>Copy link</span>
|
|
51
|
+
</button>
|
|
52
|
+
|
|
53
|
+
<div class="wf-pin-section">
|
|
54
|
+
<p class="wf-pin-label">Enter this PIN on your phone</p>
|
|
55
|
+
<div class="wf-pin-display" id="wf-pc-pin"></div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="wf-capture-meta">
|
|
59
|
+
<p class="wf-capture-timer" id="wf-pc-timer"></p>
|
|
60
|
+
<p class="wf-capture-status" id="wf-pc-status">Waiting for phone capture…</p>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="wf-phone-capture-error hidden" id="wf-pc-error">
|
|
65
|
+
<p class="wf-error" id="wf-pc-error-msg"></p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="wf-phone-capture-success hidden" id="wf-pc-success">
|
|
69
|
+
<div class="wf-capture-success-icon">
|
|
70
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="wf-capture-status wf-capture-status-done">Face captured!</p>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="wf-phone-capture-actions">
|
|
76
|
+
<button type="button" class="wf-btn wf-btn-outline wf-btn-lg" id="wf-pc-cancel">Cancel</button>
|
|
77
|
+
<button type="button" class="wf-btn wf-btn-primary wf-btn-lg hidden" id="wf-pc-retry">
|
|
78
|
+
<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="${REFRESH_ICON}"/></svg>
|
|
79
|
+
Try Again
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const loadingEl = wrap.querySelector('#wf-pc-loading');
|
|
85
|
+
const bodyEl = wrap.querySelector('#wf-pc-body');
|
|
86
|
+
const qrImg = wrap.querySelector('#wf-pc-qr');
|
|
87
|
+
const pinEl = wrap.querySelector('#wf-pc-pin');
|
|
88
|
+
const timerEl = wrap.querySelector('#wf-pc-timer');
|
|
89
|
+
const statusEl = wrap.querySelector('#wf-pc-status');
|
|
90
|
+
const errorWrap = wrap.querySelector('#wf-pc-error');
|
|
91
|
+
const errorMsg = wrap.querySelector('#wf-pc-error-msg');
|
|
92
|
+
const successEl = wrap.querySelector('#wf-pc-success');
|
|
93
|
+
const cancelBtn = wrap.querySelector('#wf-pc-cancel');
|
|
94
|
+
const retryBtn = wrap.querySelector('#wf-pc-retry');
|
|
95
|
+
const copyLinkBtn = wrap.querySelector('#wf-pc-copy-link');
|
|
96
|
+
const copyLinkLabel = copyLinkBtn.querySelector('span');
|
|
97
|
+
|
|
98
|
+
cancelBtn.addEventListener('click', () => {
|
|
99
|
+
destroy();
|
|
100
|
+
onCancel();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
retryBtn.addEventListener('click', () => {
|
|
104
|
+
startSession();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
copyLinkBtn.addEventListener('click', async () => {
|
|
108
|
+
if (!session?.captureUrl) return;
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.writeText(session.captureUrl);
|
|
111
|
+
copyLinkLabel.textContent = 'Copied!';
|
|
112
|
+
copyLinkBtn.classList.add('wf-btn-copy-link-done');
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
copyLinkLabel.textContent = 'Copy link';
|
|
115
|
+
copyLinkBtn.classList.remove('wf-btn-copy-link-done');
|
|
116
|
+
}, 2000);
|
|
117
|
+
} catch {
|
|
118
|
+
copyLinkLabel.textContent = 'Failed to copy';
|
|
119
|
+
setTimeout(() => { copyLinkLabel.textContent = 'Copy link'; }, 2000);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function showView(view) {
|
|
124
|
+
loadingEl.classList.add('hidden');
|
|
125
|
+
bodyEl.classList.add('hidden');
|
|
126
|
+
errorWrap.classList.add('hidden');
|
|
127
|
+
successEl.classList.add('hidden');
|
|
128
|
+
retryBtn.classList.add('hidden');
|
|
129
|
+
cancelBtn.classList.remove('hidden');
|
|
130
|
+
|
|
131
|
+
if (view === 'loading') loadingEl.classList.remove('hidden');
|
|
132
|
+
else if (view === 'body') bodyEl.classList.remove('hidden');
|
|
133
|
+
else if (view === 'error') { errorWrap.classList.remove('hidden'); retryBtn.classList.remove('hidden'); }
|
|
134
|
+
else if (view === 'success') { successEl.classList.remove('hidden'); cancelBtn.classList.add('hidden'); }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderPin(pin) {
|
|
138
|
+
pinEl.innerHTML = '';
|
|
139
|
+
for (const ch of pin) {
|
|
140
|
+
const span = document.createElement('span');
|
|
141
|
+
span.className = 'wf-pin-digit';
|
|
142
|
+
span.textContent = ch;
|
|
143
|
+
pinEl.appendChild(span);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function startCountdown(expiresAt) {
|
|
148
|
+
clearInterval(countdownTimer);
|
|
149
|
+
const expiry = new Date(expiresAt).getTime();
|
|
150
|
+
|
|
151
|
+
function tick() {
|
|
152
|
+
const remaining = Math.max(0, expiry - Date.now());
|
|
153
|
+
if (remaining <= 0) {
|
|
154
|
+
clearInterval(countdownTimer);
|
|
155
|
+
stopPolling();
|
|
156
|
+
showError('Session expired');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const mins = Math.floor(remaining / 60000);
|
|
160
|
+
const secs = Math.floor((remaining % 60000) / 1000);
|
|
161
|
+
timerEl.textContent = `Expires in ${mins}:${String(secs).padStart(2, '0')}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
tick();
|
|
165
|
+
countdownTimer = setInterval(tick, 1000);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function showError(msg) {
|
|
169
|
+
errorMsg.textContent = msg;
|
|
170
|
+
showView('error');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopPolling() {
|
|
174
|
+
clearTimeout(pollTimer);
|
|
175
|
+
pollTimer = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function startSession() {
|
|
179
|
+
if (destroyed) return;
|
|
180
|
+
stopPolling();
|
|
181
|
+
clearInterval(countdownTimer);
|
|
182
|
+
showView('loading');
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const res = await api.createCaptureSession();
|
|
186
|
+
if (destroyed) return;
|
|
187
|
+
session = {
|
|
188
|
+
id: res.session_id,
|
|
189
|
+
secret: res.secret,
|
|
190
|
+
pin: res.pin,
|
|
191
|
+
captureUrl: res.capture_url,
|
|
192
|
+
expiresAt: res.expires_at,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const qrDataUrl = await QRCode.toDataURL(session.captureUrl, {
|
|
196
|
+
width: 220,
|
|
197
|
+
margin: 2,
|
|
198
|
+
color: { dark: '#1A1714', light: '#FFFFFF' },
|
|
199
|
+
});
|
|
200
|
+
if (destroyed) return;
|
|
201
|
+
|
|
202
|
+
qrImg.src = qrDataUrl;
|
|
203
|
+
renderPin(session.pin);
|
|
204
|
+
statusEl.textContent = 'Waiting for phone capture…';
|
|
205
|
+
showView('body');
|
|
206
|
+
startCountdown(session.expiresAt);
|
|
207
|
+
startPolling();
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (destroyed) return;
|
|
210
|
+
if (err?.status === 429) {
|
|
211
|
+
showError('Too many attempts. Please wait and try again.');
|
|
212
|
+
} else {
|
|
213
|
+
showError(err?.data?.error || err?.message || 'Failed to create capture session');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function startPolling() {
|
|
219
|
+
let consecutiveErrors = 0;
|
|
220
|
+
let backoff = POLL_INTERVAL;
|
|
221
|
+
|
|
222
|
+
async function poll() {
|
|
223
|
+
if (destroyed || !session) return;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const res = await api.pollCaptureResult(session.id, session.secret);
|
|
227
|
+
if (destroyed) return;
|
|
228
|
+
|
|
229
|
+
if (res.status === 'ready' && res.image) {
|
|
230
|
+
stopPolling();
|
|
231
|
+
clearInterval(countdownTimer);
|
|
232
|
+
const dataUrl = `data:image/jpeg;base64,${res.image}`;
|
|
233
|
+
showView('success');
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
if (!destroyed) onCaptured(dataUrl);
|
|
236
|
+
}, 800);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
consecutiveErrors = 0;
|
|
241
|
+
backoff = POLL_INTERVAL;
|
|
242
|
+
pollTimer = setTimeout(poll, POLL_INTERVAL);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (destroyed) return;
|
|
245
|
+
consecutiveErrors++;
|
|
246
|
+
|
|
247
|
+
if (err?.status === 404 || err?.status === 403) {
|
|
248
|
+
stopPolling();
|
|
249
|
+
clearInterval(countdownTimer);
|
|
250
|
+
showError('Session expired or invalid');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
255
|
+
stopPolling();
|
|
256
|
+
clearInterval(countdownTimer);
|
|
257
|
+
showError('Connection issue. Please try again.');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
backoff = Math.min(backoff * 2, 8000);
|
|
262
|
+
pollTimer = setTimeout(poll, backoff);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
pollTimer = setTimeout(poll, POLL_INTERVAL);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function destroy() {
|
|
270
|
+
destroyed = true;
|
|
271
|
+
stopPolling();
|
|
272
|
+
clearInterval(countdownTimer);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
startSession();
|
|
276
|
+
|
|
277
|
+
return { element: wrap, destroy };
|
|
278
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webcam detection utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Check if the device has at least one video input (webcam) */
|
|
6
|
+
export async function hasWebcam() {
|
|
7
|
+
try {
|
|
8
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return false;
|
|
9
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
10
|
+
return devices.some((d) => d.kind === 'videoinput');
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Attempt to get a user-facing camera stream.
|
|
18
|
+
* Returns the MediaStream on success, or null on failure.
|
|
19
|
+
*/
|
|
20
|
+
export async function tryGetUserMedia() {
|
|
21
|
+
try {
|
|
22
|
+
return await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } });
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|