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 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
@@ -302,6 +302,7 @@ export function mountLogin(container, options = {}) {
302
302
  onSocialRedirect: handlers.onSocialRedirect,
303
303
  onSignUp: handlers.onSignUp,
304
304
  onSwitchToOtp: handlers.onSwitchToOtp,
305
+ api,
305
306
  });
306
307
  layout.setContent(screen);
307
308
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "central-auth-js-sdk",
3
- "version": "1.0.11",
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
- * v2.0 — warm, modern design
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
- errorEl.textContent = 'Camera access denied. Please enable camera permissions.';
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
  }
@@ -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:
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Login — Face verification (FRS) as second factor or email+face only
3
- * v2.0 — warm, modern design
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 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
- });
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
+ }