central-auth-js-sdk 1.0.11 → 1.0.13

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.
@@ -96,6 +96,7 @@ Call once (e.g. at app load) to set defaults. Returns `{ startSignup, startLogin
96
96
  | `loginMethods` | object | — | Default login methods (see Login config) |
97
97
  | `frsAsSecondFactor` | boolean | `false` | Default: require face verification after primary auth |
98
98
  | `enableFaceRegistration` | boolean | `true` | Whether sign-up includes face registration step |
99
+ | `onBack` | function | — | Default callback when user clicks ← Back on the first screen of signup/login (see [§ 9](#9-returning-to-the-parent-application)) |
99
100
 
100
101
  **Environments:** The SDK uses only `env` to choose the API base URL:
101
102
 
@@ -142,6 +143,7 @@ Starts the **sign-up flow** (email + name → verify → mobile → verify → p
142
143
  | `enableFaceRegistration` | boolean | no | Include face registration step (default from init or `true`) |
143
144
  | **`onSignupComplete(payload)`** | function | no | Called **once** when signup completes. `payload`: `email`, `mobile`, `userId` + any fields from final API (e.g. `access_token`, `refresh_token`). |
144
145
  | **`onContinue()`** | function | no | Called when user clicks **Continue to application** on the success screen. Use to unmount and redirect. |
146
+ | **`onBack()`** | function | no | Called when user clicks **← Back** on the **first screen** of signup. Use to unmount the SDK and return to your app. See [§ 9](#9-returning-to-the-parent-application). |
145
147
  | **`onSwitchToLogin()`** | function | no | Called when user clicks **Sign in** (e.g. “Already have an account? Sign in”). Use to unmount signup and start the login flow in the **same container** (e.g. login + FRS). |
146
148
 
147
149
  **Returns:** `{ unmount, getState, getFlow, getCurrentStep }` or `null` if container is invalid.
@@ -163,6 +165,10 @@ const instance = startSignup({
163
165
  instance.unmount();
164
166
  redirectToDashboard();
165
167
  },
168
+ onBack() {
169
+ instance.unmount();
170
+ showYourAppScreen();
171
+ },
166
172
  onSwitchToLogin() {
167
173
  instance.unmount();
168
174
  startLogin({ container: '#auth-container', env: 'dev', appIdentifier: 'my-app', loginMethods: { emailFrs: true }, onContinue: goToApp, onSignUp: showSignup });
@@ -266,6 +272,7 @@ Starts the **login flow**. If the parent enables **only one** login method, the
266
272
  | **`onSignUp()`** | function | no | Called when user clicks **Sign up** on the login screen (e.g. unmount login and start signup in the **same container**). |
267
273
  | **`onLoginComplete(payload)`** | function | no | Called **once** when login completes. `payload`: `identifier` + any fields from final API (e.g. `access_token`, `refresh_token`). |
268
274
  | **`onContinue()`** | function | no | Called when user clicks **Continue to application** on the success screen. |
275
+ | **`onBack()`** | function | no | Called when user clicks **← Back** on the **first screen** of login. Use to unmount the SDK and return to your app. See [§ 9](#9-returning-to-the-parent-application). |
269
276
 
270
277
  **Login methods** (`loginMethods`):
271
278
 
@@ -298,6 +305,10 @@ startLogin({
298
305
  instance.unmount();
299
306
  redirectToApp();
300
307
  },
308
+ onBack() {
309
+ instance.unmount();
310
+ redirectToApp();
311
+ },
301
312
  onSignUp() {
302
313
  instance.unmount();
303
314
  startSignup({ container: '#auth-container', env: 'dev', appIdentifier: '...', onContinue: goToApp, onSwitchToLogin: showLoginFrs });
@@ -321,6 +332,7 @@ startLogin({
321
332
  frsAsSecondFactor: true,
322
333
  onLoginComplete(payload) { /* ... */ },
323
334
  onContinue() { /* ... */ },
335
+ onBack() { /* unmount + return to parent app */ },
324
336
  onSignUp() { /* ... */ },
325
337
  });
326
338
  ```
@@ -372,16 +384,79 @@ You can also read **`instance.getState()`** after the flow completes; it include
372
384
 
373
385
  ## 9. Returning to the parent application
374
386
 
375
- When the user clicks **Continue to application** on the success screen, the SDK calls **`onContinue()`** if you provided it. Use it to unmount the SDK and show your app (e.g. redirect or switch view):
387
+ The SDK does **not** automatically navigate your app when the user leaves auth. Your parent app is responsible for showing/hiding the auth container and routing the user back to your own UI. Use the callbacks below.
388
+
389
+ ### Two kinds of “Back”
390
+
391
+ | Where | What happens |
392
+ |-------|----------------|
393
+ | **First screen** (signup email entry, login method picker, or single-method identifier screen) | **← Back** calls **`onBack()`** — intended to leave auth and return to your app |
394
+ | **Later steps** (password, OTP, face capture, etc.) | **← Back** goes to the **previous step inside the SDK** only. Does **not** call `onBack()` |
395
+
396
+ The first-screen back button is always shown. What happens when it is clicked depends on whether you pass **`onBack`**.
397
+
398
+ ### If you omit `onBack`
399
+
400
+ The SDK uses a **default**: it unmounts itself (removes its UI from the container and cleans up theme). It does **not** show your app’s main screen, change routes, or hide overlays — you must do that yourself if needed.
401
+
402
+ ### What the parent app should do (recommended)
403
+
404
+ Define one helper (e.g. `returnToApp`) and pass it to **`onBack`** and usually **`onContinue`** as well:
405
+
406
+ ```javascript
407
+ let authInstance = null;
408
+
409
+ function returnToApp() {
410
+ if (authInstance) authInstance.unmount();
411
+ authInstance = null;
412
+ document.getElementById('auth-container').style.display = 'none';
413
+ document.getElementById('app-root').style.display = 'block';
414
+ // or: router.push('/'); window.history.back(); etc.
415
+ }
416
+
417
+ authInstance = startLogin({
418
+ container: '#auth-container',
419
+ env: 'dev',
420
+ loginMethods: { emailPassword: true },
421
+ onBack: returnToApp,
422
+ onContinue: returnToApp,
423
+ onLoginComplete(payload) {
424
+ saveToken(payload.access_token);
425
+ returnToApp();
426
+ },
427
+ });
428
+ ```
429
+
430
+ **Typical steps inside `returnToApp` (or equivalent):**
431
+
432
+ 1. Call **`instance.unmount()`** — removes SDK DOM and theme
433
+ 2. Clear your reference to the instance (`authInstance = null`)
434
+ 3. Hide the auth container and show your main app UI (or navigate with your router)
435
+
436
+ You can set **`onBack`** once in **`initializeAuth({ onBack: returnToApp, ... })`** and reuse it in `startSignup` / `startLogin` (per-call config overrides defaults).
437
+
438
+ ### Success screen — `onContinue()`
439
+
440
+ When the user clicks **Continue to application** on the success screen, the SDK calls **`onContinue()`** if you provided it. Use the same pattern as **`onBack`**: unmount the SDK and show your app:
376
441
 
377
442
  ```javascript
378
443
  onContinue() {
379
444
  instance.unmount();
380
445
  window.location.href = '/dashboard';
381
- // or: showYourAppView();
446
+ // or: returnToApp();
382
447
  }
383
448
  ```
384
449
 
450
+ ### Summary
451
+
452
+ | Callback | When it fires | Parent app action |
453
+ |----------|---------------|-------------------|
454
+ | **`onBack()`** | User clicks ← Back on **first screen** | Unmount SDK + return to your app UI |
455
+ | **`onContinue()`** | User clicks Continue on **success screen** | Unmount SDK + enter your app (often same as `onBack`) |
456
+ | *(none)* | First-screen back, if `onBack` omitted | SDK unmounts only; parent UI unchanged |
457
+
458
+ Returning to the parent app is **not automatic** beyond SDK unmount. Integrators should pass **`onBack`** (and **`onContinue`**) to fully control navigation in their application.
459
+
385
460
  ---
386
461
 
387
462
  ## 10. Full integration example
@@ -421,6 +496,7 @@ function showSignup() {
421
496
  goToApp();
422
497
  },
423
498
  onContinue: goToApp,
499
+ onBack: goToApp,
424
500
  onSwitchToLogin: showLoginFrs,
425
501
  });
426
502
  }
@@ -435,6 +511,7 @@ function showLoginFrs() {
435
511
  goToApp();
436
512
  },
437
513
  onContinue: goToApp,
514
+ onBack: goToApp,
438
515
  onSignUp: showSignup,
439
516
  });
440
517
  }
@@ -449,6 +526,7 @@ function showLogin(loginMethods = { emailPassword: true }) {
449
526
  goToApp();
450
527
  },
451
528
  onContinue: goToApp,
529
+ onBack: goToApp,
452
530
  onSignUp: showSignup,
453
531
  });
454
532
  }
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
@@ -208,6 +208,7 @@ export function mountLogin(container, options = {}) {
208
208
  const identifier = state.get('identifier');
209
209
  let apiResult = null;
210
210
 
211
+ layout.setLoading(true, 'Verifying your face…');
211
212
  try {
212
213
  if (method === LOGIN_METHODS.EMAIL_FRS) {
213
214
  apiResult = await api.loginEmailFr(identifier, file, null, config.appIdentifier);
@@ -230,6 +231,8 @@ export function mountLogin(container, options = {}) {
230
231
  const msg = err?.data?.error || err?.message || 'Verification failed';
231
232
  state.set('error', msg);
232
233
  showError(msg);
234
+ } finally {
235
+ layout.setLoading(false);
233
236
  }
234
237
  }
235
238
 
@@ -302,6 +305,7 @@ export function mountLogin(container, options = {}) {
302
305
  onSocialRedirect: handlers.onSocialRedirect,
303
306
  onSignUp: handlers.onSignUp,
304
307
  onSwitchToOtp: handlers.onSwitchToOtp,
308
+ api,
305
309
  });
306
310
  layout.setContent(screen);
307
311
  }
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.13",
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
@@ -229,6 +229,7 @@ export function mount(container, options = {}) {
229
229
  showError('Please capture your face first.');
230
230
  return;
231
231
  }
232
+ layout.setLoading(true, 'Registering your face…');
232
233
  try {
233
234
  let apiResult = null;
234
235
  if (isSocial()) {
@@ -253,6 +254,8 @@ export function mount(container, options = {}) {
253
254
  const msg = err?.data?.error || err?.message || 'Face registration failed';
254
255
  state.set('error', msg);
255
256
  showError(msg);
257
+ } finally {
258
+ layout.setLoading(false);
256
259
  }
257
260
  }
258
261
 
@@ -283,6 +286,7 @@ export function mount(container, options = {}) {
283
286
  onContinue: typeof onContinue === 'function' ? onContinue : () => {},
284
287
  onSwitchToLogin: typeof onSwitchToLogin === 'function' ? onSwitchToLogin : () => {},
285
288
  onBackToApp: typeof onBack === 'function' ? onBack : () => { cleanupTheme(); layout.root.remove(); },
289
+ api,
286
290
  };
287
291
 
288
292
  function render() {
package/styles.css CHANGED
@@ -300,6 +300,67 @@
300
300
  display: none;
301
301
  }
302
302
 
303
+ /* ============================= LOADING BAR ============================= */
304
+
305
+ .wf-loading-bar {
306
+ height: 3px;
307
+ margin: -0.25rem 0 1rem;
308
+ border-radius: 999px;
309
+ background: var(--wf-muted-bg, rgba(0, 0, 0, 0.06));
310
+ overflow: hidden;
311
+ }
312
+
313
+ .wf-loading-bar.hidden {
314
+ display: none;
315
+ }
316
+
317
+ .wf-loading-bar-indeterminate {
318
+ height: 100%;
319
+ width: 40%;
320
+ border-radius: 999px;
321
+ background: linear-gradient(90deg, var(--wf-primary-grad-start) 0%, var(--wf-primary) 50%, var(--wf-primary-grad-end) 100%);
322
+ animation: wf-loading-slide 1.2s ease-in-out infinite;
323
+ }
324
+
325
+ @keyframes wf-loading-slide {
326
+ 0% { transform: translateX(-100%); }
327
+ 100% { transform: translateX(350%); }
328
+ }
329
+
330
+ .wf-loading-status {
331
+ display: flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ gap: 0.5rem;
335
+ margin-bottom: 1rem;
336
+ padding: 0.5rem 0.75rem;
337
+ border-radius: var(--wf-radius);
338
+ background: var(--wf-muted-bg, rgba(0, 0, 0, 0.04));
339
+ color: var(--wf-fg-soft);
340
+ font-size: 0.8125rem;
341
+ font-weight: 500;
342
+ }
343
+
344
+ .wf-loading-status.hidden {
345
+ display: none;
346
+ }
347
+
348
+ .wf-loading-status::before {
349
+ content: '';
350
+ width: 1rem;
351
+ height: 1rem;
352
+ border: 2px solid var(--wf-border);
353
+ border-top-color: var(--wf-primary);
354
+ border-radius: 50%;
355
+ animation: wf-spin 0.7s linear infinite;
356
+ flex-shrink: 0;
357
+ }
358
+
359
+ #wf-form-inner.wf-loading-active {
360
+ pointer-events: none;
361
+ opacity: 0.65;
362
+ }
363
+
303
364
  /* ============================= PAGE FOOTER ============================= */
304
365
 
305
366
  .wf-page-footer {
@@ -772,6 +833,133 @@
772
833
 
773
834
  .wf-face-actions { display: flex; flex-direction: column; gap: 0.625rem; }
774
835
 
836
+ /* ============================= PHONE CAPTURE (QR FALLBACK) ============================= */
837
+
838
+ .wf-phone-capture {
839
+ display: flex;
840
+ flex-direction: column;
841
+ gap: 1rem;
842
+ align-items: center;
843
+ text-align: center;
844
+ }
845
+
846
+ .wf-phone-capture-header { display: flex; flex-direction: column; align-items: center; gap: 0.375rem; }
847
+ .wf-phone-capture-title { font-size: 1.0625rem; font-weight: 700; color: var(--wf-fg); margin: 0; }
848
+ .wf-phone-capture-desc { font-size: 0.8125rem; color: var(--wf-muted); margin: 0; max-width: 22rem; line-height: 1.45; }
849
+
850
+ .wf-phone-capture-loading {
851
+ display: flex;
852
+ flex-direction: column;
853
+ align-items: center;
854
+ gap: 0.75rem;
855
+ padding: 2rem 0;
856
+ }
857
+ .wf-phone-capture-loading p { font-size: 0.8125rem; color: var(--wf-muted); margin: 0; }
858
+
859
+ .wf-spinner {
860
+ width: 2rem;
861
+ height: 2rem;
862
+ border: 3px solid var(--wf-border);
863
+ border-top-color: var(--wf-primary);
864
+ border-radius: 50%;
865
+ animation: wf-spin 0.7s linear infinite;
866
+ }
867
+ @keyframes wf-spin { to { transform: rotate(360deg); } }
868
+
869
+ .wf-phone-capture-body {
870
+ display: flex;
871
+ flex-direction: column;
872
+ align-items: center;
873
+ gap: 1rem;
874
+ animation: wf-screen-in 0.3s var(--wf-ease) both;
875
+ }
876
+
877
+ .wf-qr-code {
878
+ padding: 0.75rem;
879
+ background: #fff;
880
+ border-radius: var(--wf-radius);
881
+ border: 1px solid var(--wf-border);
882
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
883
+ line-height: 0;
884
+ }
885
+ .wf-qr-code img { width: 180px; height: 180px; border-radius: 4px; }
886
+
887
+ .wf-btn-copy-link {
888
+ display: inline-flex;
889
+ align-items: center;
890
+ gap: 0.375rem;
891
+ padding: 0.375rem 0.75rem;
892
+ font-size: 0.75rem;
893
+ font-weight: 600;
894
+ font-family: var(--wf-font);
895
+ color: var(--wf-primary);
896
+ background: transparent;
897
+ border: 1px solid var(--wf-border);
898
+ border-radius: var(--wf-radius-sm);
899
+ cursor: pointer;
900
+ transition: all 0.15s var(--wf-ease);
901
+ }
902
+ .wf-btn-copy-link:hover { border-color: var(--wf-primary); background: var(--wf-muted-bg, rgba(0,0,0,0.03)); }
903
+ .wf-btn-copy-link svg { width: 0.875rem; height: 0.875rem; }
904
+ .wf-btn-copy-link-done { color: var(--wf-success); border-color: var(--wf-success); }
905
+
906
+ .wf-pin-section { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
907
+ .wf-pin-label { font-size: 0.75rem; color: var(--wf-muted); margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
908
+
909
+ .wf-pin-display {
910
+ display: flex;
911
+ gap: 0.375rem;
912
+ }
913
+ .wf-pin-digit {
914
+ display: flex;
915
+ align-items: center;
916
+ justify-content: center;
917
+ width: 2.25rem;
918
+ height: 2.75rem;
919
+ background: var(--wf-input-bg, var(--wf-card));
920
+ border: 2px solid var(--wf-primary);
921
+ border-radius: var(--wf-radius-sm);
922
+ font-size: 1.375rem;
923
+ font-weight: 700;
924
+ color: var(--wf-fg);
925
+ font-family: var(--wf-font);
926
+ }
927
+
928
+ .wf-capture-meta { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
929
+ .wf-capture-timer { font-size: 0.75rem; color: var(--wf-muted); margin: 0; font-variant-numeric: tabular-nums; }
930
+ .wf-capture-status { font-size: 0.8125rem; color: var(--wf-fg-soft); margin: 0; }
931
+ .wf-capture-status-done { color: var(--wf-success); font-weight: 600; }
932
+
933
+ .wf-phone-capture-error { text-align: center; padding: 1rem 0; }
934
+
935
+ .wf-phone-capture-success {
936
+ display: flex;
937
+ flex-direction: column;
938
+ align-items: center;
939
+ gap: 0.75rem;
940
+ padding: 1.5rem 0;
941
+ animation: wf-success-pop 0.5s var(--wf-ease-spring) both;
942
+ }
943
+ .wf-capture-success-icon {
944
+ width: 3rem;
945
+ height: 3rem;
946
+ border-radius: 50%;
947
+ background: var(--wf-success-bg);
948
+ display: flex;
949
+ align-items: center;
950
+ justify-content: center;
951
+ }
952
+ .wf-capture-success-icon svg { width: 1.5rem; height: 1.5rem; color: var(--wf-success); }
953
+
954
+ .wf-phone-capture-actions {
955
+ display: flex;
956
+ gap: 0.5rem;
957
+ width: 100%;
958
+ justify-content: center;
959
+ }
960
+
961
+ .wf-phone-fallback-link { text-align: center; margin-top: 0.25rem; }
962
+
775
963
  /* ============================= SUCCESS ============================= */
776
964
 
777
965
  .wf-screen-success { align-items: center; text-align: center; }
package/ui/layout.js CHANGED
@@ -45,6 +45,10 @@ export function createLayout(container) {
45
45
  </div>
46
46
  </div>
47
47
  <div class="wf-card-body">
48
+ <div class="wf-loading-bar hidden" id="wf-loading-bar" role="progressbar" aria-busy="false" aria-label="Loading">
49
+ <div class="wf-loading-bar-indeterminate"></div>
50
+ </div>
51
+ <div class="wf-loading-status hidden" id="wf-loading-status"></div>
48
52
  <div class="wf-error-banner hidden" id="wf-error-banner" role="alert"></div>
49
53
  <div id="wf-form-inner"></div>
50
54
  </div>
@@ -60,6 +64,8 @@ export function createLayout(container) {
60
64
  const ringNum = root.querySelector('#wf-ring-num');
61
65
  const ring = root.querySelector('.wf-ring');
62
66
  const errorBanner = root.querySelector('#wf-error-banner');
67
+ const loadingBar = root.querySelector('#wf-loading-bar');
68
+ const loadingStatus = root.querySelector('#wf-loading-status');
63
69
 
64
70
  function setContent(el) {
65
71
  formInner.innerHTML = '';
@@ -90,11 +96,33 @@ export function createLayout(container) {
90
96
  errorBanner.classList.remove('hidden');
91
97
  }
92
98
 
99
+ function setLoading(active, message = '') {
100
+ if (active) {
101
+ loadingBar.classList.remove('hidden');
102
+ loadingBar.setAttribute('aria-busy', 'true');
103
+ formInner.classList.add('wf-loading-active');
104
+ if (message) {
105
+ loadingStatus.textContent = message;
106
+ loadingStatus.classList.remove('hidden');
107
+ } else {
108
+ loadingStatus.textContent = '';
109
+ loadingStatus.classList.add('hidden');
110
+ }
111
+ } else {
112
+ loadingBar.classList.add('hidden');
113
+ loadingBar.setAttribute('aria-busy', 'false');
114
+ formInner.classList.remove('wf-loading-active');
115
+ loadingStatus.classList.add('hidden');
116
+ loadingStatus.textContent = '';
117
+ }
118
+ }
119
+
93
120
  return {
94
121
  root,
95
122
  setContent,
96
123
  updateProgress,
97
124
  showError,
125
+ setLoading,
98
126
  renderContent: setContent,
99
127
  };
100
128
  }
@@ -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
+ }