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.
- package/INTEGRATION_GUIDE.md +80 -2
- package/core/api.js +37 -0
- package/index.js +1 -1
- package/login.js +4 -0
- package/package.json +4 -1
- package/signup.js +4 -0
- package/styles.css +188 -0
- package/ui/layout.js +28 -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/INTEGRATION_GUIDE.md
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
*
|
|
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
|
+
}
|