central-auth-js-sdk 1.0.12 → 1.0.14
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 +274 -51
- package/auth.js +78 -4
- package/core/api.js +8 -4
- package/core/login-flow.js +3 -2
- package/core/login-state.js +3 -0
- package/core/oauth-redirect.js +106 -0
- package/index.js +17 -3
- package/login.js +77 -35
- package/package.json +1 -1
- package/signup.js +16 -16
- package/styles.css +61 -0
- package/ui/layout.js +28 -0
package/INTEGRATION_GUIDE.md
CHANGED
|
@@ -13,7 +13,13 @@ npm install central-auth-js-sdk
|
|
|
13
13
|
Use in your app (with a bundler such as Vite, Webpack, or Parcel):
|
|
14
14
|
|
|
15
15
|
```javascript
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
initializeAuth,
|
|
18
|
+
startSignup,
|
|
19
|
+
startLogin,
|
|
20
|
+
autoResumeOAuthFlow,
|
|
21
|
+
hasPendingOAuthRedirect,
|
|
22
|
+
} from 'central-auth-js-sdk';
|
|
17
23
|
import 'central-auth-js-sdk/styles.css';
|
|
18
24
|
```
|
|
19
25
|
|
|
@@ -37,6 +43,8 @@ You need:
|
|
|
37
43
|
2. The **SDK CSS** loaded (so the UI looks correct).
|
|
38
44
|
3. A call to **`startSignup`** or **`startLogin`** with at least `container` and **`env`** (which backend to use: `local`, `dev`, `staging`, or `prod`). **`appIdentifier`** is optional; include it only if your backend expects it for OAuth or login APIs.
|
|
39
45
|
|
|
46
|
+
For **Google/Microsoft OAuth**, also call **`initializeAuth`** once with **`defaultContainer`** and **`autoResumeOAuth: true`** (default). After the user returns from the provider, the SDK mounts the correct step automatically — see [§ 12 OAuth redirect & auto-resume](#12-oauth-redirect--auto-resume).
|
|
47
|
+
|
|
40
48
|
**Minimal signup example:**
|
|
41
49
|
|
|
42
50
|
```html
|
|
@@ -76,26 +84,37 @@ You need:
|
|
|
76
84
|
|
|
77
85
|
| Function | Purpose |
|
|
78
86
|
|----------|--------|
|
|
79
|
-
| **`initializeAuth(config)`** | (Optional) Set default config once;
|
|
87
|
+
| **`initializeAuth(config)`** | (Optional) Set default config once; enables OAuth auto-resume when `defaultContainer` is set. |
|
|
80
88
|
| **`startSignup(config)`** | Run the sign-up flow in the given container. |
|
|
81
89
|
| **`startLogin(config)`** | Run the login flow in the given container. |
|
|
90
|
+
| **`autoResumeOAuthFlow(config)`** | After Google/Microsoft redirect, mount login or signup from URL params. No-op if URL has no OAuth params. |
|
|
91
|
+
| **`hasPendingOAuthRedirect()`** | Returns `true` when the current URL contains OAuth callback query params. |
|
|
92
|
+
|
|
93
|
+
Lower-level helpers (optional): `parseOAuthRedirect()`, `getOAuthRedirectFlowType()`, `markOAuthRedirectPending()`, `OAUTH_PENDING_CLASS`.
|
|
82
94
|
|
|
83
|
-
You can use **only** `startSignup` and `startLogin` and pass full config each time. Or call **`initializeAuth`** once and then call **`auth.startSignup({})`** / **`auth.startLogin({})`** so they use the stored defaults.
|
|
95
|
+
You can use **only** `startSignup` and `startLogin` and pass full config each time. Or call **`initializeAuth`** once and then call **`auth.startSignup({})`** / **`auth.startLogin({})`** so they use the stored defaults. For social login, prefer **`initializeAuth`** with **`autoResumeOAuth: true`** so the post-OAuth page reload is handled automatically.
|
|
84
96
|
|
|
85
97
|
---
|
|
86
98
|
|
|
87
99
|
## 4. initializeAuth(config)
|
|
88
100
|
|
|
89
|
-
Call once (e.g. at app load) to set defaults. Returns `{ startSignup, startLogin, getConfig }`.
|
|
101
|
+
Call once (e.g. at app load) to set defaults. Returns `{ startSignup, startLogin, autoResumeOAuthFlow, hasPendingOAuthRedirect, getConfig }`.
|
|
102
|
+
|
|
103
|
+
When **`autoResumeOAuth`** is `true` (default) and **`defaultContainer`** is set, the SDK automatically calls **`autoResumeOAuthFlow`** on page load if the URL contains OAuth callback params (e.g. after Google redirects back).
|
|
90
104
|
|
|
91
105
|
| Parameter | Type | Default | Description |
|
|
92
106
|
|-----------|------|---------|-------------|
|
|
93
107
|
| `env` | string | `'local'` | Environment: `'local'` \| `'dev'` \| `'staging'` \| `'prod'`. SDK uses the predefined API URL for that env. |
|
|
94
108
|
| `appIdentifier` | string | — | Optional. When provided, sent with OAuth and login-related API requests; omit to not send `app_identifier`. |
|
|
95
|
-
| `defaultContainer` | string \| HTMLElement | — | Default container; can override in `startSignup` / `startLogin
|
|
109
|
+
| `defaultContainer` | string \| HTMLElement | — | Default container; can override in `startSignup` / `startLogin`. **Required for OAuth auto-resume.** |
|
|
110
|
+
| `autoResumeOAuth` | boolean | `true` | When `true` and `defaultContainer` is set, auto-mount login/signup after OAuth redirect. |
|
|
96
111
|
| `loginMethods` | object | — | Default login methods (see Login config) |
|
|
97
112
|
| `frsAsSecondFactor` | boolean | `false` | Default: require face verification after primary auth |
|
|
98
113
|
| `enableFaceRegistration` | boolean | `true` | Whether sign-up includes face registration step |
|
|
114
|
+
| `onContinue` | function | — | Default callback when user clicks Continue on success (see [§ 9](#9-returning-to-the-parent-application)) |
|
|
115
|
+
| `onBack` | function | — | Default callback when user clicks ← Back on the first screen of signup/login (see [§ 9](#9-returning-to-the-parent-application)) |
|
|
116
|
+
| `onLoginComplete` | function | — | Default login-complete callback |
|
|
117
|
+
| `onSignupComplete` | function | — | Default signup-complete callback |
|
|
99
118
|
|
|
100
119
|
**Environments:** The SDK uses only `env` to choose the API base URL:
|
|
101
120
|
|
|
@@ -115,12 +134,15 @@ const auth = initializeAuth({
|
|
|
115
134
|
env: 'dev', // uses https://central-auth-dev.soham.ai/apis
|
|
116
135
|
appIdentifier: 'my-app',
|
|
117
136
|
defaultContainer: '#auth-container',
|
|
137
|
+
autoResumeOAuth: true,
|
|
118
138
|
loginMethods: {
|
|
119
139
|
emailPassword: true,
|
|
120
140
|
emailOtp: true,
|
|
121
141
|
social: { google: true, microsoft: true },
|
|
122
142
|
},
|
|
123
143
|
frsAsSecondFactor: true,
|
|
144
|
+
onContinue: goToApp,
|
|
145
|
+
onBack: goToApp,
|
|
124
146
|
});
|
|
125
147
|
|
|
126
148
|
// Later:
|
|
@@ -142,6 +164,7 @@ Starts the **sign-up flow** (email + name → verify → mobile → verify → p
|
|
|
142
164
|
| `enableFaceRegistration` | boolean | no | Include face registration step (default from init or `true`) |
|
|
143
165
|
| **`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
166
|
| **`onContinue()`** | function | no | Called when user clicks **Continue to application** on the success screen. Use to unmount and redirect. |
|
|
167
|
+
| **`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
168
|
| **`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
169
|
|
|
147
170
|
**Returns:** `{ unmount, getState, getFlow, getCurrentStep }` or `null` if container is invalid.
|
|
@@ -163,6 +186,10 @@ const instance = startSignup({
|
|
|
163
186
|
instance.unmount();
|
|
164
187
|
redirectToDashboard();
|
|
165
188
|
},
|
|
189
|
+
onBack() {
|
|
190
|
+
instance.unmount();
|
|
191
|
+
showYourAppScreen();
|
|
192
|
+
},
|
|
166
193
|
onSwitchToLogin() {
|
|
167
194
|
instance.unmount();
|
|
168
195
|
startLogin({ container: '#auth-container', env: 'dev', appIdentifier: 'my-app', loginMethods: { emailFrs: true }, onContinue: goToApp, onSignUp: showSignup });
|
|
@@ -262,10 +289,11 @@ Starts the **login flow**. If the parent enables **only one** login method, the
|
|
|
262
289
|
| `env` | string | no | Environment: `'local'` \| `'dev'` \| `'staging'` \| `'prod'` (default from init or `'local'`) |
|
|
263
290
|
| `appIdentifier` | string | no | Optional. When provided, sent with login/OAuth API requests; omit to not send. |
|
|
264
291
|
| **`loginMethods`** | object | no | Which login methods are enabled (see below) |
|
|
265
|
-
| `frsAsSecondFactor` | boolean | no | Require face verification after password/OTP/social |
|
|
292
|
+
| `frsAsSecondFactor` | boolean | no | Require face verification after password/OTP/social (client-side). Domain-based 2FA from the backend is handled automatically — see [§ 12](#12-oauth-redirect--auto-resume). |
|
|
266
293
|
| **`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
294
|
| **`onLoginComplete(payload)`** | function | no | Called **once** when login completes. `payload`: `identifier` + any fields from final API (e.g. `access_token`, `refresh_token`). |
|
|
268
295
|
| **`onContinue()`** | function | no | Called when user clicks **Continue to application** on the success screen. |
|
|
296
|
+
| **`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
297
|
|
|
270
298
|
**Login methods** (`loginMethods`):
|
|
271
299
|
|
|
@@ -281,6 +309,13 @@ Starts the **login flow**. If the parent enables **only one** login method, the
|
|
|
281
309
|
|
|
282
310
|
**Email + FRS:** On the face capture screen, if the user has no webcam, they can click **“No webcam? Sign in with OTP instead”**. The SDK sends an OTP to the email they entered and switches to the OTP step so they can complete login without face capture. No extra configuration is required.
|
|
283
311
|
|
|
312
|
+
**Phone face capture:** If the user has no webcam, they can click **“Use phone instead”** on the face screen. The SDK shows a QR code + PIN and polls the backend until a face image is captured on the phone.
|
|
313
|
+
|
|
314
|
+
**Backend-driven 2FA (face as second factor):** When your backend requires face verification (e.g. domain policy for `@gmail.com`), it returns `two_factor_required: true` and a short-lived `fr_token`. The SDK shows the face capture screen and submits the image with `fr_token` to complete login. This applies to:
|
|
315
|
+
|
|
316
|
+
- **Password / OTP login** — response from `POST /login/custom` or `POST /otp/verify`
|
|
317
|
+
- **Google / Microsoft login** — query params on the OAuth redirect URL (see [§ 12](#12-oauth-redirect--auto-resume))
|
|
318
|
+
|
|
284
319
|
**Example — single method (no method picker):**
|
|
285
320
|
|
|
286
321
|
```javascript
|
|
@@ -298,6 +333,10 @@ startLogin({
|
|
|
298
333
|
instance.unmount();
|
|
299
334
|
redirectToApp();
|
|
300
335
|
},
|
|
336
|
+
onBack() {
|
|
337
|
+
instance.unmount();
|
|
338
|
+
redirectToApp();
|
|
339
|
+
},
|
|
301
340
|
onSignUp() {
|
|
302
341
|
instance.unmount();
|
|
303
342
|
startSignup({ container: '#auth-container', env: 'dev', appIdentifier: '...', onContinue: goToApp, onSwitchToLogin: showLoginFrs });
|
|
@@ -321,6 +360,7 @@ startLogin({
|
|
|
321
360
|
frsAsSecondFactor: true,
|
|
322
361
|
onLoginComplete(payload) { /* ... */ },
|
|
323
362
|
onContinue() { /* ... */ },
|
|
363
|
+
onBack() { /* unmount + return to parent app */ },
|
|
324
364
|
onSignUp() { /* ... */ },
|
|
325
365
|
});
|
|
326
366
|
```
|
|
@@ -364,7 +404,8 @@ instance.unmount();
|
|
|
364
404
|
## 8. Getting tokens and details back
|
|
365
405
|
|
|
366
406
|
- **Signup:** Pass **`onSignupComplete(payload)`**. The SDK calls it once when signup finishes. `payload` includes `email`, `mobile`, `userId` and whatever your **final signup API** returns (e.g. `access_token`, `refresh_token`). Your backend should return these in the final signup response (e.g. `POST /signup/fr/enroll`).
|
|
367
|
-
- **Login:** Pass **`onLoginComplete(payload)`**. The SDK calls it once when login finishes. `payload` includes `identifier` and whatever your **final login API** returns (e.g. `access_token`, `refresh_token`). Your backend should return these in the final login response (e.g. `POST /login/custom`, `POST /otp/verify`, etc.).
|
|
407
|
+
- **Login:** Pass **`onLoginComplete(payload)`**. The SDK calls it once when login finishes. `payload` includes `identifier` and whatever your **final login API** returns (e.g. `access_token`, `refresh_token`, `token`). Your backend should return these in the final login response (e.g. `POST /login/custom`, `POST /otp/verify`, `POST /login/google/fr/enroll`, etc.).
|
|
408
|
+
- **OAuth login complete:** When the backend redirects with `token=...` (JWT) and no further steps, the SDK lands on the success screen and includes `token`, `email`, `user_id`, etc. in the payload passed to **`onLoginComplete`**.
|
|
368
409
|
|
|
369
410
|
You can also read **`instance.getState()`** after the flow completes; it includes **`signupResult`** or **`loginResult`** (the raw final API response).
|
|
370
411
|
|
|
@@ -372,92 +413,145 @@ You can also read **`instance.getState()`** after the flow completes; it include
|
|
|
372
413
|
|
|
373
414
|
## 9. Returning to the parent application
|
|
374
415
|
|
|
375
|
-
|
|
416
|
+
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.
|
|
417
|
+
|
|
418
|
+
### Two kinds of “Back”
|
|
419
|
+
|
|
420
|
+
| Where | What happens |
|
|
421
|
+
|-------|----------------|
|
|
422
|
+
| **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 |
|
|
423
|
+
| **Later steps** (password, OTP, face capture, etc.) | **← Back** goes to the **previous step inside the SDK** only. Does **not** call `onBack()` |
|
|
424
|
+
|
|
425
|
+
The first-screen back button is always shown. What happens when it is clicked depends on whether you pass **`onBack`**.
|
|
426
|
+
|
|
427
|
+
### If you omit `onBack`
|
|
428
|
+
|
|
429
|
+
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.
|
|
430
|
+
|
|
431
|
+
### What the parent app should do (recommended)
|
|
432
|
+
|
|
433
|
+
Define one helper (e.g. `returnToApp`) and pass it to **`onBack`** and usually **`onContinue`** as well:
|
|
434
|
+
|
|
435
|
+
```javascript
|
|
436
|
+
let authInstance = null;
|
|
437
|
+
|
|
438
|
+
function returnToApp() {
|
|
439
|
+
if (authInstance) authInstance.unmount();
|
|
440
|
+
authInstance = null;
|
|
441
|
+
document.getElementById('auth-container').style.display = 'none';
|
|
442
|
+
document.getElementById('app-root').style.display = 'block';
|
|
443
|
+
// or: router.push('/'); window.history.back(); etc.
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
authInstance = startLogin({
|
|
447
|
+
container: '#auth-container',
|
|
448
|
+
env: 'dev',
|
|
449
|
+
loginMethods: { emailPassword: true },
|
|
450
|
+
onBack: returnToApp,
|
|
451
|
+
onContinue: returnToApp,
|
|
452
|
+
onLoginComplete(payload) {
|
|
453
|
+
saveToken(payload.access_token);
|
|
454
|
+
returnToApp();
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Typical steps inside `returnToApp` (or equivalent):**
|
|
460
|
+
|
|
461
|
+
1. Call **`instance.unmount()`** — removes SDK DOM and theme
|
|
462
|
+
2. Clear your reference to the instance (`authInstance = null`)
|
|
463
|
+
3. Hide the auth container and show your main app UI (or navigate with your router)
|
|
464
|
+
|
|
465
|
+
You can set **`onBack`** once in **`initializeAuth({ onBack: returnToApp, ... })`** and reuse it in `startSignup` / `startLogin` (per-call config overrides defaults).
|
|
466
|
+
|
|
467
|
+
### Success screen — `onContinue()`
|
|
468
|
+
|
|
469
|
+
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
470
|
|
|
377
471
|
```javascript
|
|
378
472
|
onContinue() {
|
|
379
473
|
instance.unmount();
|
|
380
474
|
window.location.href = '/dashboard';
|
|
381
|
-
// or:
|
|
475
|
+
// or: returnToApp();
|
|
382
476
|
}
|
|
383
477
|
```
|
|
384
478
|
|
|
479
|
+
### Summary
|
|
480
|
+
|
|
481
|
+
| Callback | When it fires | Parent app action |
|
|
482
|
+
|----------|---------------|-------------------|
|
|
483
|
+
| **`onBack()`** | User clicks ← Back on **first screen** | Unmount SDK + return to your app UI |
|
|
484
|
+
| **`onContinue()`** | User clicks Continue on **success screen** | Unmount SDK + enter your app (often same as `onBack`) |
|
|
485
|
+
| *(none)* | First-screen back, if `onBack` omitted | SDK unmounts only; parent UI unchanged |
|
|
486
|
+
|
|
487
|
+
Returning to the parent app is **not automatic** beyond SDK unmount. Integrators should pass **`onBack`** (and **`onContinue`**) to fully control navigation in their application.
|
|
488
|
+
|
|
385
489
|
---
|
|
386
490
|
|
|
387
491
|
## 10. Full integration example
|
|
388
492
|
|
|
389
493
|
```javascript
|
|
390
|
-
import { initializeAuth
|
|
494
|
+
import { initializeAuth } from 'central-auth-js-sdk';
|
|
391
495
|
import 'central-auth-js-sdk/styles.css';
|
|
392
496
|
|
|
497
|
+
let currentInstance = null;
|
|
498
|
+
|
|
499
|
+
function goToApp() {
|
|
500
|
+
if (currentInstance) currentInstance.unmount();
|
|
501
|
+
currentInstance = null;
|
|
502
|
+
document.getElementById('auth-container').style.display = 'none';
|
|
503
|
+
document.getElementById('app-root').style.display = 'block';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function saveToken(token) {
|
|
507
|
+
if (token) localStorage.setItem('access_token', token);
|
|
508
|
+
}
|
|
509
|
+
|
|
393
510
|
const auth = initializeAuth({
|
|
394
511
|
env: 'dev',
|
|
395
512
|
appIdentifier: 'my-app',
|
|
396
513
|
defaultContainer: '#auth-container',
|
|
514
|
+
autoResumeOAuth: true, // auto-mount after Google/Microsoft redirect
|
|
397
515
|
loginMethods: {
|
|
398
516
|
emailPassword: true,
|
|
399
517
|
emailOtp: true,
|
|
400
518
|
social: { google: true, microsoft: true },
|
|
401
519
|
emailFrs: true,
|
|
402
520
|
},
|
|
403
|
-
|
|
521
|
+
onLoginComplete(payload) {
|
|
522
|
+
saveToken(payload.token || payload.access_token);
|
|
523
|
+
},
|
|
524
|
+
onSignupComplete(payload) {
|
|
525
|
+
saveToken(payload.access_token);
|
|
526
|
+
},
|
|
527
|
+
onContinue: goToApp,
|
|
528
|
+
onBack: goToApp,
|
|
404
529
|
});
|
|
405
530
|
|
|
406
|
-
let currentInstance = null;
|
|
407
|
-
|
|
408
|
-
function goToApp() {
|
|
409
|
-
if (currentInstance) currentInstance.unmount();
|
|
410
|
-
currentInstance = null;
|
|
411
|
-
document.getElementById('auth-container').style.display = 'none';
|
|
412
|
-
document.getElementById('app-root').style.display = 'block';
|
|
413
|
-
}
|
|
414
|
-
|
|
415
531
|
function showSignup() {
|
|
416
532
|
if (currentInstance) currentInstance.unmount();
|
|
417
533
|
document.getElementById('auth-container').style.display = 'block';
|
|
534
|
+
document.getElementById('app-root').style.display = 'none';
|
|
418
535
|
currentInstance = auth.startSignup({
|
|
419
|
-
|
|
420
|
-
saveToken(payload.access_token);
|
|
421
|
-
goToApp();
|
|
422
|
-
},
|
|
423
|
-
onContinue: goToApp,
|
|
424
|
-
onSwitchToLogin: showLoginFrs,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function showLoginFrs() {
|
|
429
|
-
if (currentInstance) currentInstance.unmount();
|
|
430
|
-
document.getElementById('auth-container').style.display = 'block';
|
|
431
|
-
currentInstance = auth.startLogin({
|
|
432
|
-
loginMethods: { emailFrs: true },
|
|
433
|
-
onLoginComplete(payload) {
|
|
434
|
-
saveToken(payload.access_token);
|
|
435
|
-
goToApp();
|
|
436
|
-
},
|
|
437
|
-
onContinue: goToApp,
|
|
438
|
-
onSignUp: showSignup,
|
|
536
|
+
onSwitchToLogin: () => showLogin({ emailFrs: true }),
|
|
439
537
|
});
|
|
440
538
|
}
|
|
441
539
|
|
|
442
540
|
function showLogin(loginMethods = { emailPassword: true }) {
|
|
443
541
|
if (currentInstance) currentInstance.unmount();
|
|
444
542
|
document.getElementById('auth-container').style.display = 'block';
|
|
543
|
+
document.getElementById('app-root').style.display = 'none';
|
|
445
544
|
currentInstance = auth.startLogin({
|
|
446
545
|
loginMethods,
|
|
447
|
-
onLoginComplete(payload) {
|
|
448
|
-
saveToken(payload.access_token);
|
|
449
|
-
goToApp();
|
|
450
|
-
},
|
|
451
|
-
onContinue: goToApp,
|
|
452
546
|
onSignUp: showSignup,
|
|
453
547
|
});
|
|
454
548
|
}
|
|
455
549
|
|
|
456
|
-
|
|
457
|
-
|
|
550
|
+
// If OAuth redirect params are present, initializeAuth already resumed the flow.
|
|
551
|
+
// Otherwise show your normal entry point:
|
|
552
|
+
if (!auth.hasPendingOAuthRedirect()) {
|
|
553
|
+
showLogin();
|
|
458
554
|
}
|
|
459
|
-
|
|
460
|
-
showLogin();
|
|
461
555
|
```
|
|
462
556
|
|
|
463
557
|
---
|
|
@@ -466,8 +560,137 @@ showLogin();
|
|
|
466
560
|
|
|
467
561
|
- **CORS:** Your API must allow requests from your app’s origin (e.g. `Access-Control-Allow-Origin`).
|
|
468
562
|
- **Signup:** The SDK calls your signup endpoints (e.g. `/signup/start`, `/signup/email/verify`, `/signup/fr/enroll`). The **final** response can include `access_token`, `refresh_token`, etc.; the SDK forwards them in `onSignupComplete(payload)` and `getState().signupResult`.
|
|
469
|
-
- **Login:** The SDK calls your login endpoints (e.g. `/login/custom`, `/otp/verify`, `/login/email/fr`). The **final** response can include tokens; the SDK forwards them in `onLoginComplete(payload)` and `getState().loginResult`.
|
|
470
|
-
- **
|
|
563
|
+
- **Login:** The SDK calls your login endpoints (e.g. `/login/custom`, `/otp/verify`, `/login/email/fr`, `/login/google/fr/enroll`). The **final** response can include tokens; the SDK forwards them in `onLoginComplete(payload)` and `getState().loginResult`.
|
|
564
|
+
- **2FA (face verification):** When face verification is required, the backend returns (JSON body or OAuth redirect query params):
|
|
565
|
+
|
|
566
|
+
```json
|
|
567
|
+
{
|
|
568
|
+
"success": true,
|
|
569
|
+
"two_factor_required": true,
|
|
570
|
+
"next_step": "FR_VERIFICATION",
|
|
571
|
+
"login_token": "...",
|
|
572
|
+
"fr_token": "...",
|
|
573
|
+
"fr_token_expires_in_s": 300,
|
|
574
|
+
"message": "Facial recognition verification required"
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The SDK shows the face capture screen and submits `fr_token` + face image to the appropriate endpoint (e.g. `POST /login/google/fr/enroll` for social, `POST /login/custom` with `fr_image` for password login).
|
|
579
|
+
- **OAuth:** Start Google/Microsoft login with `redirect_uri` pointing to the page where your SDK loads. After auth, the backend **302-redirects** to that URL with query params — see [§ 12](#12-oauth-redirect--auto-resume). The SDK reads those params and continues the flow (face verification, success, or error). Same behavior for Microsoft OAuth.
|
|
471
580
|
- **app_identifier:** When the parent app passes **`appIdentifier`**, the SDK sends it as `app_identifier` on login-related requests (e.g. `/login/custom`, `/login/email/fr`, OAuth start URLs, social mobile/FR). If the parent omits it, the SDK does not send the parameter.
|
|
472
581
|
|
|
473
582
|
For endpoint details and request/response shapes, see **API_INTEGRATION.md** and your backend docs.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## 12. OAuth redirect & auto-resume
|
|
587
|
+
|
|
588
|
+
Google and Microsoft login use a full-page redirect. When the user returns, the browser reloads your app with query params on the URL. **The SDK handles parsing, routing, and cleanup** — you do not need custom “if URL has `login_token` then …” logic in your app.
|
|
589
|
+
|
|
590
|
+
### What the parent app must do
|
|
591
|
+
|
|
592
|
+
1. Call **`initializeAuth({ defaultContainer, autoResumeOAuth: true, ... })`** once at app load, **or** call **`autoResumeOAuthFlow({ container, ... })`** when your app boots.
|
|
593
|
+
2. Pass the same **`redirect_uri`** to OAuth start that your app uses as the return page (the SDK builds this as `window.location.origin + window.location.pathname` when the user clicks Google/Microsoft).
|
|
594
|
+
3. Provide **`onContinue`**, **`onBack`**, and optionally **`onLoginComplete`** callbacks.
|
|
595
|
+
|
|
596
|
+
The SDK does **not** navigate your main app UI automatically. Hide/show your app shell in those callbacks (see [§ 9](#9-returning-to-the-parent-application)).
|
|
597
|
+
|
|
598
|
+
### Optional: reduce “home screen flash” after OAuth
|
|
599
|
+
|
|
600
|
+
When OAuth params are detected, the SDK adds **`central-auth-oauth-pending`** to `<html>`. Hide your main UI while the SDK loads:
|
|
601
|
+
|
|
602
|
+
```css
|
|
603
|
+
html.central-auth-oauth-pending [data-central-auth-main] {
|
|
604
|
+
display: none !important;
|
|
605
|
+
}
|
|
606
|
+
html.central-auth-oauth-pending [data-central-auth-sdk] {
|
|
607
|
+
display: flex !important;
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
Mark your main content with `data-central-auth-main` and your auth container wrapper with `data-central-auth-sdk`.
|
|
612
|
+
|
|
613
|
+
For the earliest possible switch (before the SDK module loads), you can add a tiny inline script in `<head>` that sets the same class when OAuth params are present — the demo app uses this pattern.
|
|
614
|
+
|
|
615
|
+
### Backend redirect examples
|
|
616
|
+
|
|
617
|
+
After Google auth, the backend redirects to your `redirect_uri` with query params:
|
|
618
|
+
|
|
619
|
+
**Login complete:**
|
|
620
|
+
|
|
621
|
+
```
|
|
622
|
+
https://yourapp.com/auth?success=true&provider=google&token=eyJ...&user_id=...&email=...&entity_domain=gmail.com&message=Login+successful
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**2FA — face verification required:**
|
|
626
|
+
|
|
627
|
+
```
|
|
628
|
+
https://yourapp.com/auth?success=true&two_factor_required=true&provider=google&next_step=FR_VERIFICATION&login_token=...&fr_token=...&fr_token_expires_in_s=300&message=Facial+recognition+verification+required&user_id=...&entity_domain=gmail.com
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
**Signup — face enrollment required:**
|
|
632
|
+
|
|
633
|
+
```
|
|
634
|
+
https://yourapp.com/auth?success=true&provider=google&next_step=FR_ENROLLMENT&login_token=...&user_id=...&entity_domain=gmail.com&message=Facial+recognition+enrollment+required
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
**Signup — mobile verification:**
|
|
638
|
+
|
|
639
|
+
```
|
|
640
|
+
https://yourapp.com/auth?success=true&provider=google&next_step=MOBILE_VERIFICATION&login_token=...&user_id=...
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**Error:**
|
|
644
|
+
|
|
645
|
+
```
|
|
646
|
+
https://yourapp.com/auth?success=false&provider=google&error=Entity+not+found+for+email+domain+gmail.com
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
### How the SDK routes each case
|
|
650
|
+
|
|
651
|
+
| Redirect | SDK action |
|
|
652
|
+
|----------|------------|
|
|
653
|
+
| `two_factor_required=true` or `next_step=FR_VERIFICATION` | Login flow → **face capture** → `POST /login/{google\|microsoft}/fr/enroll` with `login_token`, `fr_token`, `fr_image` |
|
|
654
|
+
| `token=...` (login complete) | Login flow → **success screen** → `onLoginComplete` with JWT |
|
|
655
|
+
| `next_step=FR_ENROLLMENT` | Signup flow → **face enrollment** |
|
|
656
|
+
| `next_step=MOBILE_VERIFICATION` | Signup flow → **mobile entry** |
|
|
657
|
+
| `success=false` or `error=...` | Login flow → show error banner |
|
|
658
|
+
| Params cleaned from URL | SDK removes OAuth query params via `history.replaceState` (no reload) |
|
|
659
|
+
|
|
660
|
+
### End-to-end flow (Google login + 2FA)
|
|
661
|
+
|
|
662
|
+
```
|
|
663
|
+
User clicks "Continue with Google"
|
|
664
|
+
→ GET /login/google/start?redirect_uri=...&app_identifier=...
|
|
665
|
+
→ Google OAuth
|
|
666
|
+
→ Backend 302 to your app with two_factor_required=true, login_token, fr_token, ...
|
|
667
|
+
→ SDK auto-resumes (face capture screen)
|
|
668
|
+
→ User captures face
|
|
669
|
+
→ POST /login/google/fr/enroll
|
|
670
|
+
→ Success screen
|
|
671
|
+
→ User clicks Continue → onContinue() → your app
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Manual auto-resume (without initializeAuth)
|
|
675
|
+
|
|
676
|
+
```javascript
|
|
677
|
+
import { autoResumeOAuthFlow, hasPendingOAuthRedirect } from 'central-auth-js-sdk';
|
|
678
|
+
|
|
679
|
+
if (hasPendingOAuthRedirect()) {
|
|
680
|
+
const resumed = autoResumeOAuthFlow({
|
|
681
|
+
container: '#auth-container',
|
|
682
|
+
env: 'dev',
|
|
683
|
+
appIdentifier: 'my-app',
|
|
684
|
+
onContinue: goToApp,
|
|
685
|
+
onBack: goToApp,
|
|
686
|
+
});
|
|
687
|
+
if (resumed) {
|
|
688
|
+
currentInstance = resumed.instance;
|
|
689
|
+
// resumed.flow is 'login' or 'signup'
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### Persist `env` across OAuth reload
|
|
695
|
+
|
|
696
|
+
OAuth causes a **full page reload**, so in-memory state is lost. Store `env` (and `appIdentifier` if needed) in **`sessionStorage`** before redirecting to Google, and pass the same values to **`autoResumeOAuthFlow`** or **`initializeAuth`**. The demo app uses `sessionStorage` for this.
|
package/auth.js
CHANGED
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { resolveApiBaseUrl } from './core/env-config.js';
|
|
7
|
+
import {
|
|
8
|
+
hasOAuthRedirect,
|
|
9
|
+
parseOAuthRedirect,
|
|
10
|
+
getOAuthRedirectFlowType,
|
|
11
|
+
markOAuthRedirectPending,
|
|
12
|
+
clearOAuthRedirectPendingMark,
|
|
13
|
+
} from './core/oauth-redirect.js';
|
|
7
14
|
import { mount } from './signup.js';
|
|
8
15
|
import { mountLogin } from './login.js';
|
|
9
16
|
|
|
10
17
|
/** Default configuration stored after initializeAuth() */
|
|
11
18
|
let defaultConfig = {};
|
|
12
19
|
|
|
20
|
+
const DEFAULT_SOCIAL_LOGIN_METHODS = {
|
|
21
|
+
social: { google: true, microsoft: true },
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
/**
|
|
14
25
|
* Merge config with defaults. Passed config overrides defaults.
|
|
15
26
|
* @param {Object} overrides - config passed to startSignup / startLogin
|
|
@@ -42,38 +53,99 @@ function resolveContainer(container) {
|
|
|
42
53
|
return el && el.nodeType === 1 ? el : null;
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resume login or signup automatically after an OAuth redirect.
|
|
58
|
+
* No-op when the URL has no OAuth callback params.
|
|
59
|
+
*
|
|
60
|
+
* @param {Object} [config] - Merged with initializeAuth defaults
|
|
61
|
+
* @param {string|HTMLElement} [config.container] - Required unless defaultContainer was set in initializeAuth
|
|
62
|
+
* @returns {{ flow: 'login'|'signup', instance: Object }|null}
|
|
63
|
+
*/
|
|
64
|
+
export function autoResumeOAuthFlow(config = {}) {
|
|
65
|
+
if (!hasOAuthRedirect()) return null;
|
|
66
|
+
|
|
67
|
+
markOAuthRedirectPending();
|
|
68
|
+
|
|
69
|
+
const merged = mergeConfig(config);
|
|
70
|
+
const container = config.container ?? merged.defaultContainer;
|
|
71
|
+
const el = resolveContainer(container);
|
|
72
|
+
if (!el) {
|
|
73
|
+
console.warn('[Central Auth SDK] autoResumeOAuthFlow: container is required (pass config.container or set defaultContainer in initializeAuth)');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const redirect = parseOAuthRedirect();
|
|
78
|
+
const flowType = getOAuthRedirectFlowType(redirect) || 'login';
|
|
79
|
+
|
|
80
|
+
let instance = null;
|
|
81
|
+
if (flowType === 'signup') {
|
|
82
|
+
instance = startSignup({ ...config, container: el });
|
|
83
|
+
} else {
|
|
84
|
+
instance = startLogin({
|
|
85
|
+
...config,
|
|
86
|
+
container: el,
|
|
87
|
+
loginMethods: config.loginMethods ?? merged.loginMethods ?? DEFAULT_SOCIAL_LOGIN_METHODS,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (instance) {
|
|
92
|
+
clearOAuthRedirectPendingMark();
|
|
93
|
+
return { flow: flowType, instance };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
/**
|
|
46
99
|
* Initialize the SDK with optional default configuration.
|
|
47
100
|
* Call this once (e.g. at app load) to set shared defaults; startSignup/startLogin can override per call.
|
|
48
101
|
*
|
|
102
|
+
* When `autoResumeOAuth` is true (default) and `defaultContainer` is set, the SDK automatically
|
|
103
|
+
* mounts the correct flow after Google/Microsoft OAuth redirects back to your app.
|
|
104
|
+
*
|
|
49
105
|
* @param {Object} config - Default static configuration
|
|
50
106
|
* @param {string} [config.env='local'] - Environment: 'local' | 'dev' | 'staging' | 'prod' — SDK uses the predefined API URL for that env
|
|
51
107
|
* @param {string} [config.appIdentifier] - Optional app identifier for OAuth / custom login; only sent when provided
|
|
52
108
|
* @param {string|HTMLElement} [config.defaultContainer] - Default container for rendering (can be overridden in start*)
|
|
109
|
+
* @param {boolean} [config.autoResumeOAuth=true] - Auto-mount login/signup when OAuth redirect params are in the URL
|
|
53
110
|
* @param {Object} [config.loginMethods] - Default enabled login methods (see startLogin)
|
|
54
111
|
* @param {boolean} [config.frsAsSecondFactor] - Default for FRS as second factor (see startLogin)
|
|
55
112
|
* @param {boolean} [config.enableFaceRegistration=true] - Whether sign-up flow includes face registration step
|
|
56
113
|
* @param {Object} [config.theme] - Optional theme overrides (primaryColor, secondaryColor, backgroundColor, backgroundImage, cardColor, textColor, fontFamily, mode)
|
|
57
|
-
* @returns {{ startSignup: Function, startLogin: Function, getConfig: Function }}
|
|
114
|
+
* @returns {{ startSignup: Function, startLogin: Function, autoResumeOAuthFlow: Function, hasPendingOAuthRedirect: Function, getConfig: Function }}
|
|
58
115
|
*/
|
|
59
116
|
export function initializeAuth(config = {}) {
|
|
60
117
|
defaultConfig = {
|
|
61
118
|
env: 'local',
|
|
62
119
|
appIdentifier: undefined,
|
|
63
120
|
defaultContainer: null,
|
|
121
|
+
autoResumeOAuth: true,
|
|
64
122
|
loginMethods: null,
|
|
65
123
|
frsAsSecondFactor: false,
|
|
66
124
|
enableFaceRegistration: true,
|
|
67
125
|
theme: null,
|
|
68
126
|
...config,
|
|
69
127
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
128
|
+
|
|
129
|
+
const api = {
|
|
130
|
+
startSignup,
|
|
131
|
+
startLogin,
|
|
132
|
+
autoResumeOAuthFlow,
|
|
133
|
+
hasPendingOAuthRedirect: hasOAuthRedirect,
|
|
73
134
|
getConfig() {
|
|
74
135
|
return { ...defaultConfig };
|
|
75
136
|
},
|
|
76
137
|
};
|
|
138
|
+
|
|
139
|
+
if (defaultConfig.autoResumeOAuth !== false && defaultConfig.defaultContainer && hasOAuthRedirect()) {
|
|
140
|
+
const run = () => { autoResumeOAuthFlow({}); };
|
|
141
|
+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
|
|
142
|
+
document.addEventListener('DOMContentLoaded', run, { once: true });
|
|
143
|
+
} else {
|
|
144
|
+
run();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return api;
|
|
77
149
|
}
|
|
78
150
|
|
|
79
151
|
/**
|
|
@@ -100,6 +172,7 @@ export function startSignup(config = {}) {
|
|
|
100
172
|
return null;
|
|
101
173
|
}
|
|
102
174
|
const options = {
|
|
175
|
+
env: merged.env,
|
|
103
176
|
apiBaseUrl: resolveApiBaseUrl(merged),
|
|
104
177
|
appIdentifier: merged.appIdentifier,
|
|
105
178
|
enableFaceRegistration: merged.enableFaceRegistration,
|
|
@@ -145,6 +218,7 @@ export function startLogin(config = {}) {
|
|
|
145
218
|
return null;
|
|
146
219
|
}
|
|
147
220
|
const options = {
|
|
221
|
+
env: merged.env,
|
|
148
222
|
apiBaseUrl: resolveApiBaseUrl(merged),
|
|
149
223
|
appIdentifier: merged.appIdentifier,
|
|
150
224
|
loginMethods: merged.loginMethods,
|
package/core/api.js
CHANGED
|
@@ -116,12 +116,13 @@ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
|
|
|
116
116
|
return request('POST', '/login/google/mobile/verify', body);
|
|
117
117
|
},
|
|
118
118
|
|
|
119
|
-
async googleFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier) {
|
|
119
|
+
async googleFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier, frToken = null) {
|
|
120
120
|
const fd = new FormData();
|
|
121
121
|
fd.append('login_token', loginToken);
|
|
122
122
|
fd.append('fr_image', frImageFile1);
|
|
123
123
|
fd.append('fr_image2', frImageFile2 || frImageFile1);
|
|
124
124
|
if (appIdentifier) fd.append('app_identifier', appIdentifier);
|
|
125
|
+
if (frToken) fd.append('fr_token', frToken);
|
|
125
126
|
return request('POST', '/login/google/fr/enroll', fd);
|
|
126
127
|
},
|
|
127
128
|
|
|
@@ -137,19 +138,20 @@ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
|
|
|
137
138
|
return request('POST', '/login/microsoft/mobile/verify', body);
|
|
138
139
|
},
|
|
139
140
|
|
|
140
|
-
async microsoftFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier) {
|
|
141
|
+
async microsoftFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier, frToken = null) {
|
|
141
142
|
const fd = new FormData();
|
|
142
143
|
fd.append('login_token', loginToken);
|
|
143
144
|
fd.append('fr_image', frImageFile1);
|
|
144
145
|
fd.append('fr_image2', frImageFile2 || frImageFile1);
|
|
145
146
|
if (appIdentifier) fd.append('app_identifier', appIdentifier);
|
|
147
|
+
if (frToken) fd.append('fr_token', frToken);
|
|
146
148
|
return request('POST', '/login/microsoft/fr/enroll', fd);
|
|
147
149
|
},
|
|
148
150
|
|
|
149
151
|
// --- Login (custom, OTP, email+FR) ---
|
|
150
152
|
|
|
151
153
|
/** POST /login/custom — Email or mobile + password; optional fr_image, fr_image2 for FRS. app_identifier only when provided. */
|
|
152
|
-
async loginCustom(identifier, password, appIdentifier, frImageFiles = null) {
|
|
154
|
+
async loginCustom(identifier, password, appIdentifier, frImageFiles = null, frToken = null) {
|
|
153
155
|
const body = { identifier, password };
|
|
154
156
|
if (appIdentifier) body.app_identifier = appIdentifier;
|
|
155
157
|
if (frImageFiles && frImageFiles.length) {
|
|
@@ -157,6 +159,7 @@ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
|
|
|
157
159
|
fd.append('identifier', identifier);
|
|
158
160
|
fd.append('password', password);
|
|
159
161
|
if (appIdentifier) fd.append('app_identifier', appIdentifier);
|
|
162
|
+
if (frToken) fd.append('fr_token', frToken);
|
|
160
163
|
fd.append('fr_image', frImageFiles[0]);
|
|
161
164
|
fd.append('fr_image2', frImageFiles[1] || frImageFiles[0]);
|
|
162
165
|
return request('POST', '/login/custom', fd);
|
|
@@ -170,13 +173,14 @@ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
|
|
|
170
173
|
},
|
|
171
174
|
|
|
172
175
|
/** POST /otp/verify — optional frImageFiles for 2FA */
|
|
173
|
-
async otpVerify(identifier, domain, channel, otp, frImageFiles = null) {
|
|
176
|
+
async otpVerify(identifier, domain, channel, otp, frImageFiles = null, frToken = null) {
|
|
174
177
|
if (frImageFiles && frImageFiles.length) {
|
|
175
178
|
const fd = new FormData();
|
|
176
179
|
fd.append('identifier', identifier);
|
|
177
180
|
if (domain) fd.append('domain', domain);
|
|
178
181
|
fd.append('channel', channel);
|
|
179
182
|
fd.append('otp', otp);
|
|
183
|
+
if (frToken) fd.append('fr_token', frToken);
|
|
180
184
|
fd.append('fr_image', frImageFiles[0]);
|
|
181
185
|
fd.append('fr_image2', frImageFiles[1] || frImageFiles[0]);
|
|
182
186
|
return request('POST', '/otp/verify', fd);
|
package/core/login-flow.js
CHANGED
|
@@ -112,9 +112,10 @@ export class LoginFlowController {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** After social OAuth callback: we're in social flow, optionally at FRS */
|
|
115
|
-
setSocialCallback(provider) {
|
|
115
|
+
setSocialCallback(provider, { twoFactorRequired = false } = {}) {
|
|
116
116
|
this.currentMethod = provider === 'microsoft' ? LOGIN_METHODS.SOCIAL_MICROSOFT : LOGIN_METHODS.SOCIAL_GOOGLE;
|
|
117
|
-
|
|
117
|
+
const useFrs = twoFactorRequired || this.config.frsAsSecondFactor;
|
|
118
|
+
this.steps = getStepsForMethod(this.currentMethod, useFrs);
|
|
118
119
|
this.stepIndex = 0;
|
|
119
120
|
this._notify();
|
|
120
121
|
}
|
package/core/login-state.js
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OAuth redirect handling for login and signup flows.
|
|
3
|
+
* Backend 302-redirects to redirect_uri with query params after Google/Microsoft auth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const OAUTH_PARAM_KEYS = [
|
|
7
|
+
'success', 'provider', 'token', 'login_token', 'two_factor_required', 'fr_token',
|
|
8
|
+
'fr_token_expires_in_s', 'next_step', 'message', 'user_id', 'email', 'entity_domain', 'error',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const OAUTH_PENDING_CLASS = 'central-auth-oauth-pending';
|
|
12
|
+
|
|
13
|
+
/** @returns {boolean} True when the URL looks like an OAuth redirect callback */
|
|
14
|
+
export function hasOAuthRedirect() {
|
|
15
|
+
if (typeof window === 'undefined' || !window.location) return false;
|
|
16
|
+
const params = new URLSearchParams(window.location.search);
|
|
17
|
+
return params.has('provider')
|
|
18
|
+
|| params.has('login_token')
|
|
19
|
+
|| params.has('token')
|
|
20
|
+
|| params.has('success');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse OAuth redirect query params.
|
|
25
|
+
* @returns {Object|null} Parsed redirect payload
|
|
26
|
+
*/
|
|
27
|
+
export function parseOAuthRedirect() {
|
|
28
|
+
if (!hasOAuthRedirect()) return null;
|
|
29
|
+
|
|
30
|
+
const params = new URLSearchParams(window.location.search);
|
|
31
|
+
const provider = (params.get('provider') || '').toLowerCase();
|
|
32
|
+
const hasProvider = provider === 'google' || provider === 'microsoft';
|
|
33
|
+
const loginToken = params.get('login_token');
|
|
34
|
+
const token = params.get('token');
|
|
35
|
+
const successParam = params.get('success');
|
|
36
|
+
const error = params.get('error');
|
|
37
|
+
const nextStep = (params.get('next_step') || '').toUpperCase();
|
|
38
|
+
|
|
39
|
+
const shared = {
|
|
40
|
+
provider: hasProvider ? provider : 'google',
|
|
41
|
+
email: params.get('email'),
|
|
42
|
+
userId: params.get('user_id'),
|
|
43
|
+
entityDomain: params.get('entity_domain'),
|
|
44
|
+
message: params.get('message'),
|
|
45
|
+
nextStep,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (successParam === 'false' || error) {
|
|
49
|
+
return { ...shared, kind: 'error', error: error || 'Login failed' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const twoFactorRaw = params.get('two_factor_required');
|
|
53
|
+
const twoFactorRequired = twoFactorRaw === 'true' || twoFactorRaw === '1' || nextStep === 'FR_VERIFICATION';
|
|
54
|
+
|
|
55
|
+
if (loginToken) {
|
|
56
|
+
return {
|
|
57
|
+
...shared,
|
|
58
|
+
kind: 'pending',
|
|
59
|
+
loginToken,
|
|
60
|
+
twoFactorRequired,
|
|
61
|
+
frToken: params.get('fr_token') || null,
|
|
62
|
+
frTokenExpiresInS: params.get('fr_token_expires_in_s') || null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token) {
|
|
67
|
+
return { ...shared, kind: 'complete', token };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {ReturnType<parseOAuthRedirect>} redirect */
|
|
74
|
+
export function getOAuthRedirectFlowType(redirect) {
|
|
75
|
+
if (!redirect) return null;
|
|
76
|
+
if (redirect.nextStep === 'FR_ENROLLMENT' || redirect.nextStep === 'MOBILE_VERIFICATION') {
|
|
77
|
+
return 'signup';
|
|
78
|
+
}
|
|
79
|
+
return 'login';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove OAuth params from the URL without reloading */
|
|
83
|
+
export function clearOAuthRedirectParams() {
|
|
84
|
+
if (typeof window === 'undefined' || !window.history) return;
|
|
85
|
+
const params = new URLSearchParams(window.location.search);
|
|
86
|
+
OAUTH_PARAM_KEYS.forEach((key) => params.delete(key));
|
|
87
|
+
const clean = params.toString();
|
|
88
|
+
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
89
|
+
window.history.replaceState({}, '', url);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mark the document while OAuth resume is pending (reduces parent-app flash).
|
|
94
|
+
* Parent apps can hide main UI with:
|
|
95
|
+
* html.central-auth-oauth-pending [data-central-auth-main] { display: none !important; }
|
|
96
|
+
* html.central-auth-oauth-pending [data-central-auth-sdk] { display: block !important; }
|
|
97
|
+
*/
|
|
98
|
+
export function markOAuthRedirectPending() {
|
|
99
|
+
if (!hasOAuthRedirect() || typeof document === 'undefined') return;
|
|
100
|
+
document.documentElement.classList.add(OAUTH_PENDING_CLASS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearOAuthRedirectPendingMark() {
|
|
104
|
+
if (typeof document === 'undefined') return;
|
|
105
|
+
document.documentElement.classList.remove(OAUTH_PENDING_CLASS);
|
|
106
|
+
}
|
package/index.js
CHANGED
|
@@ -4,17 +4,31 @@
|
|
|
4
4
|
* APIs wired to Central Auth (central_auth_collection.json)
|
|
5
5
|
*
|
|
6
6
|
* Public API for host applications:
|
|
7
|
-
* initializeAuth(config) — set default configuration
|
|
7
|
+
* initializeAuth(config) — set default configuration (+ auto-resume OAuth when configured)
|
|
8
8
|
* startSignup(config) — run sign-up flow in a container
|
|
9
|
-
* startLogin(config)
|
|
9
|
+
* startLogin(config) — run login flow in a container
|
|
10
|
+
* autoResumeOAuthFlow(config) — mount login/signup after OAuth redirect (no-op otherwise)
|
|
10
11
|
*
|
|
11
12
|
* Lower-level: mount (signup), mountLogin (login)
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
import { markOAuthRedirectPending } from './core/oauth-redirect.js';
|
|
16
|
+
|
|
17
|
+
markOAuthRedirectPending();
|
|
18
|
+
|
|
14
19
|
export { mount } from './signup.js';
|
|
15
20
|
export { FlowController, STEPS } from './core/flow.js';
|
|
16
21
|
export { StateManager } from './core/state.js';
|
|
17
22
|
export { createApi, dataUrlToFile, base64ToFile } from './core/api.js';
|
|
18
23
|
export { mountLogin } from './login.js';
|
|
19
|
-
export { initializeAuth, startSignup, startLogin } from './auth.js';
|
|
24
|
+
export { initializeAuth, startSignup, startLogin, autoResumeOAuthFlow } from './auth.js';
|
|
20
25
|
export { applyTheme, THEME_PRESETS } from './theme.js';
|
|
26
|
+
export {
|
|
27
|
+
hasOAuthRedirect,
|
|
28
|
+
hasOAuthRedirect as hasPendingOAuthRedirect,
|
|
29
|
+
parseOAuthRedirect,
|
|
30
|
+
getOAuthRedirectFlowType,
|
|
31
|
+
markOAuthRedirectPending,
|
|
32
|
+
clearOAuthRedirectPendingMark,
|
|
33
|
+
OAUTH_PENDING_CLASS,
|
|
34
|
+
} from './core/oauth-redirect.js';
|
package/login.js
CHANGED
|
@@ -8,29 +8,24 @@ import { resolveApiBaseUrl } from './core/env-config.js';
|
|
|
8
8
|
import { normalizeLoginConfig, getEnabledLoginMethods, LOGIN_METHODS } from './core/login-config.js';
|
|
9
9
|
import { LoginFlowController, LOGIN_STEPS } from './core/login-flow.js';
|
|
10
10
|
import { LoginStateManager } from './core/login-state.js';
|
|
11
|
+
import { parseOAuthRedirect, clearOAuthRedirectParams, clearOAuthRedirectPendingMark } from './core/oauth-redirect.js';
|
|
11
12
|
import { createLayout } from './ui/layout.js';
|
|
12
13
|
import { createLoginScreen } from './ui/screens/login/index.js';
|
|
13
14
|
import { applyTheme } from './theme.js';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const provider = (params.get('provider') || '').toLowerCase();
|
|
21
|
-
if (!loginToken) return null;
|
|
22
|
-
if (provider !== 'google' && provider !== 'microsoft') return { loginToken, provider: 'google' };
|
|
23
|
-
return { loginToken, provider };
|
|
16
|
+
function isTwoFactorRequired(res) {
|
|
17
|
+
if (!res || typeof res !== 'object') return false;
|
|
18
|
+
if (res.two_factor_required === true) return true;
|
|
19
|
+
const step = String(res.next_step || '').toUpperCase();
|
|
20
|
+
return step === 'FR_VERIFICATION';
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
33
|
-
window.history.replaceState({}, '', url);
|
|
23
|
+
function applyTwoFactorFromResponse(res, state) {
|
|
24
|
+
if (res.login_token) state.set('loginToken', res.login_token);
|
|
25
|
+
if (res.fr_token) state.set('frToken', res.fr_token);
|
|
26
|
+
if (res.fr_token_expires_in_s != null) state.set('frTokenExpiresInS', res.fr_token_expires_in_s);
|
|
27
|
+
if (res.provider) state.set('socialProvider', String(res.provider).toLowerCase());
|
|
28
|
+
if (res.message) state.set('twoFactorMessage', res.message);
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
/**
|
|
@@ -65,12 +60,36 @@ export function mountLogin(container, options = {}) {
|
|
|
65
60
|
const cleanupTheme = applyTheme(layout.root, theme);
|
|
66
61
|
const showError = (msg) => layout.showError(msg);
|
|
67
62
|
|
|
68
|
-
const oauth =
|
|
63
|
+
const oauth = parseOAuthRedirect();
|
|
69
64
|
if (oauth) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
clearOAuthRedirectParams();
|
|
66
|
+
if (oauth.kind === 'error') {
|
|
67
|
+
state.set('error', oauth.error);
|
|
68
|
+
} else if (oauth.kind === 'complete') {
|
|
69
|
+
if (oauth.email) state.set('identifier', oauth.email);
|
|
70
|
+
const result = {
|
|
71
|
+
token: oauth.token,
|
|
72
|
+
user_id: oauth.userId,
|
|
73
|
+
email: oauth.email,
|
|
74
|
+
entity_domain: oauth.entityDomain,
|
|
75
|
+
message: oauth.message,
|
|
76
|
+
provider: oauth.provider,
|
|
77
|
+
};
|
|
78
|
+
state.set('loginResult', result);
|
|
79
|
+
flow.setSocialCallback(oauth.provider, { twoFactorRequired: false });
|
|
80
|
+
flow.setStep(LOGIN_STEPS.SUCCESS);
|
|
81
|
+
if (typeof onLoginComplete === 'function') {
|
|
82
|
+
onLoginComplete(buildLoginCompletePayload(result));
|
|
83
|
+
}
|
|
84
|
+
} else if (oauth.kind === 'pending') {
|
|
85
|
+
state.set('loginToken', oauth.loginToken);
|
|
86
|
+
state.set('socialProvider', oauth.provider);
|
|
87
|
+
if (oauth.email) state.set('identifier', oauth.email);
|
|
88
|
+
if (oauth.frToken) state.set('frToken', oauth.frToken);
|
|
89
|
+
if (oauth.frTokenExpiresInS) state.set('frTokenExpiresInS', oauth.frTokenExpiresInS);
|
|
90
|
+
if (oauth.message) state.set('twoFactorMessage', oauth.message);
|
|
91
|
+
flow.setSocialCallback(oauth.provider, { twoFactorRequired: oauth.twoFactorRequired });
|
|
92
|
+
}
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
const enabled = getEnabledLoginMethods(config);
|
|
@@ -104,6 +123,19 @@ export function mountLogin(container, options = {}) {
|
|
|
104
123
|
return payload;
|
|
105
124
|
}
|
|
106
125
|
|
|
126
|
+
function completeLogin(apiResult) {
|
|
127
|
+
state.set('loginResult', apiResult);
|
|
128
|
+
flow.goNext();
|
|
129
|
+
if (typeof onLoginComplete === 'function') {
|
|
130
|
+
onLoginComplete(buildLoginCompletePayload(apiResult));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requireFaceVerification(res) {
|
|
135
|
+
applyTwoFactorFromResponse(res, state);
|
|
136
|
+
flow.injectFrsStep();
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
async function handleIdentifierEmail(email) {
|
|
108
140
|
state.clearError();
|
|
109
141
|
state.set('identifier', email);
|
|
@@ -153,11 +185,11 @@ export function mountLogin(container, options = {}) {
|
|
|
153
185
|
}
|
|
154
186
|
try {
|
|
155
187
|
const res = await api.loginCustom(identifier, password, config.appIdentifier, null);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
onLoginComplete(buildLoginCompletePayload(res));
|
|
188
|
+
if (isTwoFactorRequired(res)) {
|
|
189
|
+
requireFaceVerification(res);
|
|
190
|
+
return;
|
|
160
191
|
}
|
|
192
|
+
completeLogin(res);
|
|
161
193
|
} catch (err) {
|
|
162
194
|
if (isFrRequiredError(err)) {
|
|
163
195
|
flow.injectFrsStep();
|
|
@@ -180,6 +212,10 @@ export function mountLogin(container, options = {}) {
|
|
|
180
212
|
}
|
|
181
213
|
try {
|
|
182
214
|
const res = await api.otpVerify(identifier, '', channel, otp);
|
|
215
|
+
if (isTwoFactorRequired(res)) {
|
|
216
|
+
requireFaceVerification(res);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
183
219
|
state.set('loginResult', res);
|
|
184
220
|
flow.setStep(LOGIN_STEPS.SUCCESS);
|
|
185
221
|
if (typeof onLoginComplete === 'function') {
|
|
@@ -206,30 +242,30 @@ export function mountLogin(container, options = {}) {
|
|
|
206
242
|
}
|
|
207
243
|
const method = flow.currentMethod;
|
|
208
244
|
const identifier = state.get('identifier');
|
|
245
|
+
const frToken = state.get('frToken');
|
|
209
246
|
let apiResult = null;
|
|
210
247
|
|
|
248
|
+
layout.setLoading(true, 'Verifying your face…');
|
|
211
249
|
try {
|
|
212
250
|
if (method === LOGIN_METHODS.EMAIL_FRS) {
|
|
213
251
|
apiResult = await api.loginEmailFr(identifier, file, null, config.appIdentifier);
|
|
214
252
|
} else if (method === LOGIN_METHODS.EMAIL_PASSWORD || method === LOGIN_METHODS.MOBILE_PASSWORD) {
|
|
215
|
-
apiResult = await api.loginCustom(identifier, state.get('password'), config.appIdentifier, [file, file]);
|
|
253
|
+
apiResult = await api.loginCustom(identifier, state.get('password'), config.appIdentifier, [file, file], frToken);
|
|
216
254
|
} else if (method === LOGIN_METHODS.SOCIAL_GOOGLE) {
|
|
217
|
-
apiResult = await api.googleFrEnroll(state.get('loginToken'), file, null, config.appIdentifier);
|
|
255
|
+
apiResult = await api.googleFrEnroll(state.get('loginToken'), file, null, config.appIdentifier, frToken);
|
|
218
256
|
} else if (method === LOGIN_METHODS.SOCIAL_MICROSOFT) {
|
|
219
|
-
apiResult = await api.microsoftFrEnroll(state.get('loginToken'), file, null, config.appIdentifier);
|
|
257
|
+
apiResult = await api.microsoftFrEnroll(state.get('loginToken'), file, null, config.appIdentifier, frToken);
|
|
220
258
|
} else if (method === LOGIN_METHODS.EMAIL_OTP || method === LOGIN_METHODS.MOBILE_OTP) {
|
|
221
259
|
const channel = identifier.includes('@') ? 'email' : 'sms';
|
|
222
|
-
apiResult = await api.otpVerify(identifier, '', channel, state.get('otp'), [file, file]);
|
|
223
|
-
}
|
|
224
|
-
state.set('loginResult', apiResult);
|
|
225
|
-
flow.goNext();
|
|
226
|
-
if (typeof onLoginComplete === 'function') {
|
|
227
|
-
onLoginComplete(buildLoginCompletePayload(apiResult));
|
|
260
|
+
apiResult = await api.otpVerify(identifier, '', channel, state.get('otp'), [file, file], frToken);
|
|
228
261
|
}
|
|
262
|
+
completeLogin(apiResult);
|
|
229
263
|
} catch (err) {
|
|
230
264
|
const msg = err?.data?.error || err?.message || 'Verification failed';
|
|
231
265
|
state.set('error', msg);
|
|
232
266
|
showError(msg);
|
|
267
|
+
} finally {
|
|
268
|
+
layout.setLoading(false);
|
|
233
269
|
}
|
|
234
270
|
}
|
|
235
271
|
|
|
@@ -309,6 +345,12 @@ export function mountLogin(container, options = {}) {
|
|
|
309
345
|
|
|
310
346
|
flow.subscribe(render);
|
|
311
347
|
render();
|
|
348
|
+
if (oauth?.kind === 'error') {
|
|
349
|
+
showError(oauth.error);
|
|
350
|
+
}
|
|
351
|
+
if (oauth) {
|
|
352
|
+
clearOAuthRedirectPendingMark();
|
|
353
|
+
}
|
|
312
354
|
|
|
313
355
|
return {
|
|
314
356
|
unmount() { cleanupTheme(); layout.root.remove(); },
|
package/package.json
CHANGED
package/signup.js
CHANGED
|
@@ -7,30 +7,21 @@ import { FlowController, STEPS } from './core/flow.js';
|
|
|
7
7
|
import { StateManager } from './core/state.js';
|
|
8
8
|
import { createApi, dataUrlToFile } from './core/api.js';
|
|
9
9
|
import { resolveApiBaseUrl } from './core/env-config.js';
|
|
10
|
+
import { parseOAuthRedirect, clearOAuthRedirectParams } from './core/oauth-redirect.js';
|
|
10
11
|
import { createLayout } from './ui/layout.js';
|
|
11
12
|
import { createScreen } from './ui/screens/index.js';
|
|
12
13
|
import { applyTheme } from './theme.js';
|
|
13
14
|
|
|
14
15
|
/** Parse URL query for OAuth callback params (login_token, provider) */
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const provider = (params.get('provider') || '').toLowerCase();
|
|
20
|
-
if (!loginToken) return null;
|
|
21
|
-
if (provider !== 'google' && provider !== 'microsoft') return { loginToken, provider: 'google' };
|
|
22
|
-
return { loginToken, provider };
|
|
16
|
+
function parseSignupOAuthCallback() {
|
|
17
|
+
const redirect = parseOAuthRedirect();
|
|
18
|
+
if (!redirect || !redirect.loginToken) return null;
|
|
19
|
+
return redirect;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
/** Remove OAuth params from URL without reload */
|
|
26
23
|
function clearOAuthParams() {
|
|
27
|
-
|
|
28
|
-
const params = new URLSearchParams(window.location.search);
|
|
29
|
-
params.delete('login_token');
|
|
30
|
-
params.delete('provider');
|
|
31
|
-
const clean = params.toString();
|
|
32
|
-
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
33
|
-
window.history.replaceState({}, '', url);
|
|
24
|
+
clearOAuthRedirectParams();
|
|
34
25
|
}
|
|
35
26
|
|
|
36
27
|
/** Map API next_step from POST /signup/start to our flow step */
|
|
@@ -90,12 +81,18 @@ export function mount(container, options = {}) {
|
|
|
90
81
|
const showError = (msg) => layout.showError(msg);
|
|
91
82
|
|
|
92
83
|
// SDK handles OAuth callback: if URL has login_token, resume social flow
|
|
93
|
-
const oauth =
|
|
84
|
+
const oauth = parseSignupOAuthCallback();
|
|
94
85
|
if (oauth) {
|
|
95
86
|
state.set('loginToken', oauth.loginToken);
|
|
96
87
|
state.set('socialProvider', oauth.provider);
|
|
88
|
+
if (oauth.email) state.set('email', oauth.email);
|
|
97
89
|
clearOAuthParams();
|
|
98
90
|
flow.setSocialFlow();
|
|
91
|
+
if (oauth.nextStep === 'FR_ENROLLMENT') {
|
|
92
|
+
flow.setStep(STEPS.FACE);
|
|
93
|
+
} else if (oauth.nextStep === 'MOBILE_VERIFICATION') {
|
|
94
|
+
flow.setStep(STEPS.MOBILE);
|
|
95
|
+
}
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
const isSocial = () => !!state.get('loginToken');
|
|
@@ -229,6 +226,7 @@ export function mount(container, options = {}) {
|
|
|
229
226
|
showError('Please capture your face first.');
|
|
230
227
|
return;
|
|
231
228
|
}
|
|
229
|
+
layout.setLoading(true, 'Registering your face…');
|
|
232
230
|
try {
|
|
233
231
|
let apiResult = null;
|
|
234
232
|
if (isSocial()) {
|
|
@@ -253,6 +251,8 @@ export function mount(container, options = {}) {
|
|
|
253
251
|
const msg = err?.data?.error || err?.message || 'Face registration failed';
|
|
254
252
|
state.set('error', msg);
|
|
255
253
|
showError(msg);
|
|
254
|
+
} finally {
|
|
255
|
+
layout.setLoading(false);
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
|
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 {
|
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
|
}
|