central-auth-js-sdk 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/api.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Central Auth API client
3
+ * Matches endpoints from central_auth_collection.json
4
+ * All signup/login bodies sent as FormData per collection
5
+ */
6
+
7
+ const DEFAULT_BASE = 'http://localhost:8080';
8
+
9
+ /**
10
+ * @param {string} baseUrl - e.g. 'http://localhost:8080'
11
+ * @param {Object} options - reserved (entity derived from email in backend)
12
+ */
13
+ export function createApi(baseUrl = DEFAULT_BASE, options = {}) {
14
+ const base = baseUrl.replace(/\/$/, '');
15
+
16
+ async function request(method, path, body = null, isFormData = true) {
17
+ const url = path.startsWith('http') ? path : `${base}${path}`;
18
+ const headers = {};
19
+ if (!isFormData && body && typeof body === 'object' && !(body instanceof FormData)) {
20
+ headers['Content-Type'] = 'application/json';
21
+ }
22
+
23
+ const config = { method, headers };
24
+ if (body) {
25
+ if (body instanceof FormData) {
26
+ config.body = body;
27
+ } else if (typeof body === 'object') {
28
+ config.body = isFormData ? toFormData(body) : JSON.stringify(body);
29
+ }
30
+ }
31
+
32
+ const res = await fetch(url, config);
33
+ const text = await res.text();
34
+ let data = null;
35
+ try {
36
+ data = text ? JSON.parse(text) : null;
37
+ } catch {
38
+ data = { _raw: text };
39
+ }
40
+
41
+ if (!res.ok) {
42
+ const err = new Error(data?.message || data?.error || `HTTP ${res.status}`);
43
+ err.status = res.status;
44
+ err.data = data;
45
+ throw err;
46
+ }
47
+ return data;
48
+ }
49
+
50
+ return {
51
+ /** POST /signup/start — Email entry: send email OTP */
52
+ async signupStart(email) {
53
+ return request('POST', '/signup/start', { email });
54
+ },
55
+
56
+ /** POST /signup/email/verify — Email verify: OTP + names (entity auto-detected from email in backend) */
57
+ async signupEmailVerify({ email, otp, first_name, last_name }) {
58
+ return request('POST', '/signup/email/verify', {
59
+ email,
60
+ otp,
61
+ first_name,
62
+ last_name,
63
+ });
64
+ },
65
+
66
+ /** POST /signup/mobile/send_otp — Mobile entry: send mobile OTP */
67
+ async signupMobileSendOtp(email, mobile) {
68
+ return request('POST', '/signup/mobile/send_otp', { email, mobile });
69
+ },
70
+
71
+ /** POST /signup/mobile/verify — Mobile verify */
72
+ async signupMobileVerify(email, mobile, otp) {
73
+ return request('POST', '/signup/mobile/verify', { email, mobile, otp });
74
+ },
75
+
76
+ /** POST /signup/password/set — Password step */
77
+ async signupPasswordSet(email, password) {
78
+ return request('POST', '/signup/password/set', { email, password });
79
+ },
80
+
81
+ /** POST /signup/fr/enroll — Face enroll (optional). fr_image, fr_image2 as File or blob data URL */
82
+ async signupFrEnroll(email, frImageFile1, frImageFile2 = null) {
83
+ const fd = new FormData();
84
+ fd.append('email', email);
85
+ fd.append('fr_image', frImageFile1);
86
+ fd.append('fr_image2', frImageFile2 || frImageFile1);
87
+ return request('POST', '/signup/fr/enroll', fd);
88
+ },
89
+
90
+ // --- Social (Google / Microsoft) ---
91
+
92
+ /** GET redirect URL for Google OAuth. app_identifier only added when appIdentifier is provided. */
93
+ getGoogleStartUrl(redirectUri, appIdentifier) {
94
+ const params = new URLSearchParams({ redirect_uri: redirectUri });
95
+ if (appIdentifier) params.set('app_identifier', appIdentifier);
96
+ return `${base}/login/google/start?${params}`;
97
+ },
98
+
99
+ /** GET redirect URL for Microsoft OAuth. app_identifier only added when appIdentifier is provided. */
100
+ getMicrosoftStartUrl(redirectUri, appIdentifier) {
101
+ const params = new URLSearchParams({ redirect_uri: redirectUri });
102
+ if (appIdentifier) params.set('app_identifier', appIdentifier);
103
+ return `${base}/login/microsoft/start?${params}`;
104
+ },
105
+
106
+ /** POST /login/google/mobile/send — after OAuth, send mobile OTP. app_identifier only when provided. */
107
+ async googleMobileSend(loginToken, mobile, appIdentifier) {
108
+ const body = { login_token: loginToken, mobile };
109
+ if (appIdentifier) body.app_identifier = appIdentifier;
110
+ return request('POST', '/login/google/mobile/send', body);
111
+ },
112
+
113
+ async googleMobileVerify(loginToken, mobile, otp, appIdentifier) {
114
+ const body = { login_token: loginToken, mobile, otp };
115
+ if (appIdentifier) body.app_identifier = appIdentifier;
116
+ return request('POST', '/login/google/mobile/verify', body);
117
+ },
118
+
119
+ async googleFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier) {
120
+ const fd = new FormData();
121
+ fd.append('login_token', loginToken);
122
+ fd.append('fr_image', frImageFile1);
123
+ fd.append('fr_image2', frImageFile2 || frImageFile1);
124
+ if (appIdentifier) fd.append('app_identifier', appIdentifier);
125
+ return request('POST', '/login/google/fr/enroll', fd);
126
+ },
127
+
128
+ async microsoftMobileSend(loginToken, mobile, appIdentifier) {
129
+ const body = { login_token: loginToken, mobile };
130
+ if (appIdentifier) body.app_identifier = appIdentifier;
131
+ return request('POST', '/login/microsoft/mobile/send', body);
132
+ },
133
+
134
+ async microsoftMobileVerify(loginToken, mobile, otp, appIdentifier) {
135
+ const body = { login_token: loginToken, mobile, otp };
136
+ if (appIdentifier) body.app_identifier = appIdentifier;
137
+ return request('POST', '/login/microsoft/mobile/verify', body);
138
+ },
139
+
140
+ async microsoftFrEnroll(loginToken, frImageFile1, frImageFile2 = null, appIdentifier) {
141
+ const fd = new FormData();
142
+ fd.append('login_token', loginToken);
143
+ fd.append('fr_image', frImageFile1);
144
+ fd.append('fr_image2', frImageFile2 || frImageFile1);
145
+ if (appIdentifier) fd.append('app_identifier', appIdentifier);
146
+ return request('POST', '/login/microsoft/fr/enroll', fd);
147
+ },
148
+
149
+ // --- Login (custom, OTP, email+FR) ---
150
+
151
+ /** 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) {
153
+ const body = { identifier, password };
154
+ if (appIdentifier) body.app_identifier = appIdentifier;
155
+ if (frImageFiles && frImageFiles.length) {
156
+ const fd = new FormData();
157
+ fd.append('identifier', identifier);
158
+ fd.append('password', password);
159
+ if (appIdentifier) fd.append('app_identifier', appIdentifier);
160
+ fd.append('fr_image', frImageFiles[0]);
161
+ fd.append('fr_image2', frImageFiles[1] || frImageFiles[0]);
162
+ return request('POST', '/login/custom', fd);
163
+ }
164
+ return request('POST', '/login/custom', body);
165
+ },
166
+
167
+ /** POST /otp/send — identifier (email or mobile), domain, channel (e.g. sms, email) */
168
+ async otpSend(identifier, domain, channel = 'sms') {
169
+ return request('POST', '/otp/send', { identifier, domain, channel });
170
+ },
171
+
172
+ /** POST /otp/verify — optional frImageFiles for 2FA */
173
+ async otpVerify(identifier, domain, channel, otp, frImageFiles = null) {
174
+ if (frImageFiles && frImageFiles.length) {
175
+ const fd = new FormData();
176
+ fd.append('identifier', identifier);
177
+ if (domain) fd.append('domain', domain);
178
+ fd.append('channel', channel);
179
+ fd.append('otp', otp);
180
+ fd.append('fr_image', frImageFiles[0]);
181
+ fd.append('fr_image2', frImageFiles[1] || frImageFiles[0]);
182
+ return request('POST', '/otp/verify', fd);
183
+ }
184
+ return request('POST', '/otp/verify', { identifier, domain, channel, otp });
185
+ },
186
+
187
+ /** POST /login/email/fr — Email + face only (no password). app_identifier only when provided. */
188
+ async loginEmailFr(email, frImageFile1, frImageFile2 = null, appIdentifier) {
189
+ const fd = new FormData();
190
+ fd.append('email', email);
191
+ fd.append('fr_image', frImageFile1);
192
+ fd.append('fr_image2', frImageFile2 || frImageFile1);
193
+ if (appIdentifier) fd.append('app_identifier', appIdentifier);
194
+ return request('POST', '/login/email/fr', fd);
195
+ },
196
+ };
197
+ }
198
+
199
+ function toFormData(obj) {
200
+ const fd = new FormData();
201
+ for (const [k, v] of Object.entries(obj)) {
202
+ if (v == null) continue;
203
+ if (v instanceof File || v instanceof Blob) {
204
+ fd.append(k, v);
205
+ } else {
206
+ fd.append(k, String(v));
207
+ }
208
+ }
209
+ return fd;
210
+ }
211
+
212
+ /**
213
+ * Convert a data URL (e.g. from canvas.toDataURL('image/jpeg')) to a File for FormData
214
+ */
215
+ export function dataUrlToFile(dataUrl, filename = 'capture.jpg') {
216
+ if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) {
217
+ return null;
218
+ }
219
+ const arr = dataUrl.split(',');
220
+ const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg';
221
+ const bstr = atob(arr[1]);
222
+ let n = bstr.length;
223
+ const u8 = new Uint8Array(n);
224
+ while (n--) u8[n] = bstr.charCodeAt(n);
225
+ return new File([u8], filename, { type: mime });
226
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Environment → API base URL mapping.
3
+ * Parent app can pass env: 'local' | 'dev' | 'staging' | 'prod' when initializing
4
+ * so the SDK hits the correct backend without hardcoding URLs.
5
+ */
6
+
7
+ const DEFAULT_BASE = 'http://localhost:8080';
8
+
9
+ /** @type {Record<string, string>} */
10
+ export const ENV_API_URLS = {
11
+ local: 'http://localhost:8080',
12
+ dev: 'https://central-auth-dev.soham.ai/apis',
13
+ staging: 'https://central-auth-staging.soham.ai/apis',
14
+ prod: 'https://central-auth.soham.ai/apis',
15
+ };
16
+
17
+ /**
18
+ * Resolve API base URL from env. Only env is supported; defaults to 'local' if missing/invalid.
19
+ * @param {Object} config - Must have env: 'local' | 'dev' | 'staging' | 'prod'
20
+ * @returns {string} Base URL (no trailing slash)
21
+ */
22
+ export function resolveApiBaseUrl(config = {}) {
23
+ const env = (config.env && String(config.env).toLowerCase()) || 'local';
24
+ return ENV_API_URLS[env] || DEFAULT_BASE;
25
+ }
package/core/events.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Event Bus - Decouples UI from business logic
3
+ * UI emits events; core handles them; APIs integrate in handlers
4
+ */
5
+
6
+ export function createEventBus() {
7
+ const handlers = new Map();
8
+
9
+ return {
10
+ on(event, handler) {
11
+ if (!handlers.has(event)) handlers.set(event, new Set());
12
+ handlers.get(event).add(handler);
13
+ return () => handlers.get(event)?.delete(handler);
14
+ },
15
+
16
+ emit(event, payload) {
17
+ handlers.get(event)?.forEach((fn) => fn(payload));
18
+ },
19
+
20
+ off(event, handler) {
21
+ if (handler) {
22
+ handlers.get(event)?.delete(handler);
23
+ } else {
24
+ handlers.delete(event);
25
+ }
26
+ },
27
+ };
28
+ }
package/core/flow.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Flow Controller - Business logic for signup flow
3
+ * Keeps UI layer separate; APIs can be integrated here later
4
+ */
5
+
6
+ export const STEPS = {
7
+ EMAIL: 'email',
8
+ EMAIL_VERIFY: 'emailVerify',
9
+ MOBILE: 'mobile',
10
+ MOBILE_VERIFY: 'mobileVerify',
11
+ PASSWORD: 'password',
12
+ FACE: 'face',
13
+ SUCCESS: 'success',
14
+ };
15
+
16
+ export const FLOW_TYPES = {
17
+ STANDARD: 'standard',
18
+ SOCIAL: 'social',
19
+ };
20
+
21
+ const STEP_ORDER = {
22
+ [FLOW_TYPES.STANDARD]: [
23
+ STEPS.EMAIL,
24
+ STEPS.EMAIL_VERIFY,
25
+ STEPS.MOBILE,
26
+ STEPS.MOBILE_VERIFY,
27
+ STEPS.PASSWORD,
28
+ STEPS.FACE,
29
+ STEPS.SUCCESS,
30
+ ],
31
+ [FLOW_TYPES.SOCIAL]: [
32
+ STEPS.EMAIL,
33
+ STEPS.MOBILE,
34
+ STEPS.MOBILE_VERIFY,
35
+ STEPS.FACE,
36
+ STEPS.SUCCESS,
37
+ ],
38
+ };
39
+
40
+ /**
41
+ * Flow controller - manages step navigation and flow type
42
+ * API integration points are stubbed for later implementation
43
+ */
44
+ export class FlowController {
45
+ constructor() {
46
+ this.flowType = FLOW_TYPES.STANDARD;
47
+ this.currentStep = STEPS.EMAIL;
48
+ this.listeners = new Set();
49
+ }
50
+
51
+ getSteps() {
52
+ return [...STEP_ORDER[this.flowType]];
53
+ }
54
+
55
+ getStepIndex() {
56
+ return this.getSteps().indexOf(this.currentStep);
57
+ }
58
+
59
+ getCurrentStep() {
60
+ return this.currentStep;
61
+ }
62
+
63
+ getTotalSteps() {
64
+ return this.getSteps().length;
65
+ }
66
+
67
+ getProgress() {
68
+ const idx = this.getStepIndex();
69
+ const total = this.getTotalSteps();
70
+ return { current: idx + 1, total };
71
+ }
72
+
73
+ canGoNext() {
74
+ return this.getStepIndex() < this.getTotalSteps() - 1;
75
+ }
76
+
77
+ canGoBack() {
78
+ return this.getStepIndex() > 0;
79
+ }
80
+
81
+ goNext() {
82
+ if (this.canGoNext()) {
83
+ const steps = this.getSteps();
84
+ this.currentStep = steps[this.getStepIndex() + 1];
85
+ this._notify();
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ goBack() {
92
+ if (this.canGoBack()) {
93
+ const steps = this.getSteps();
94
+ this.currentStep = steps[this.getStepIndex() - 1];
95
+ this._notify();
96
+ return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ setStep(step) {
102
+ if (this.getSteps().includes(step)) {
103
+ this.currentStep = step;
104
+ this._notify();
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ setSocialFlow(email) {
111
+ this.flowType = FLOW_TYPES.SOCIAL;
112
+ this.currentStep = STEPS.MOBILE;
113
+ this._notify();
114
+ return email;
115
+ }
116
+
117
+ setStandardFlow() {
118
+ this.flowType = FLOW_TYPES.STANDARD;
119
+ this.currentStep = STEPS.EMAIL;
120
+ this._notify();
121
+ }
122
+
123
+ subscribe(fn) {
124
+ this.listeners.add(fn);
125
+ return () => this.listeners.delete(fn);
126
+ }
127
+
128
+ _notify() {
129
+ this.listeners.forEach((fn) => fn(this));
130
+ }
131
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Login configuration — which methods are enabled (set by parent app)
3
+ */
4
+
5
+ export const LOGIN_METHODS = {
6
+ EMAIL_PASSWORD: 'email_password',
7
+ MOBILE_PASSWORD: 'mobile_password',
8
+ EMAIL_OTP: 'email_otp',
9
+ MOBILE_OTP: 'mobile_otp',
10
+ SOCIAL_GOOGLE: 'social_google',
11
+ SOCIAL_MICROSOFT: 'social_microsoft',
12
+ EMAIL_FRS: 'email_frs',
13
+ };
14
+
15
+ /** Default: all methods off; parent must enable explicitly */
16
+ export function getDefaultLoginConfig() {
17
+ return {
18
+ emailPassword: false,
19
+ mobilePassword: false,
20
+ emailOtp: false,
21
+ mobileOtp: false,
22
+ social: { google: false, microsoft: false },
23
+ emailFrs: false,
24
+ frsAsSecondFactor: false,
25
+ appIdentifier: undefined,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Normalize and validate config from parent
31
+ * @param {Object} config - from mount(container, { loginMethods: {...}, frsAsSecondFactor: true })
32
+ * @returns {Object} normalized config
33
+ */
34
+ export function normalizeLoginConfig(config = {}) {
35
+ const defaults = getDefaultLoginConfig();
36
+ const loginMethods = config.loginMethods && typeof config.loginMethods === 'object' ? config.loginMethods : {};
37
+ const c = {
38
+ ...defaults,
39
+ ...loginMethods,
40
+ ...config,
41
+ social: {
42
+ ...defaults.social,
43
+ ...(loginMethods.social && typeof loginMethods.social === 'object' ? loginMethods.social : {}),
44
+ ...(config.social && typeof config.social === 'object' ? config.social : {}),
45
+ },
46
+ };
47
+ return {
48
+ emailPassword: !!c.emailPassword,
49
+ mobilePassword: !!c.mobilePassword,
50
+ emailOtp: !!c.emailOtp,
51
+ mobileOtp: !!c.mobileOtp,
52
+ social: {
53
+ google: !!((c.social && c.social.google) || c.socialGoogle),
54
+ microsoft: !!((c.social && c.social.microsoft) || c.socialMicrosoft),
55
+ },
56
+ emailFrs: !!c.emailFrs,
57
+ frsAsSecondFactor: !!c.frsAsSecondFactor,
58
+ appIdentifier: c.appIdentifier !== undefined && c.appIdentifier !== '' ? c.appIdentifier : undefined,
59
+ };
60
+ }
61
+
62
+ /** List of method keys that are enabled for the method picker */
63
+ export function getEnabledLoginMethods(config) {
64
+ const methods = [];
65
+ if (config.emailPassword) methods.push({ id: LOGIN_METHODS.EMAIL_PASSWORD, label: 'Email + Password', icon: 'mail' });
66
+ if (config.mobilePassword) methods.push({ id: LOGIN_METHODS.MOBILE_PASSWORD, label: 'Mobile + Password', icon: 'phone' });
67
+ if (config.emailOtp) methods.push({ id: LOGIN_METHODS.EMAIL_OTP, label: 'Email + OTP', icon: 'mail' });
68
+ if (config.mobileOtp) methods.push({ id: LOGIN_METHODS.MOBILE_OTP, label: 'Mobile + OTP', icon: 'phone' });
69
+ if (config.social.google) methods.push({ id: LOGIN_METHODS.SOCIAL_GOOGLE, label: 'Google', icon: 'google' });
70
+ if (config.social.microsoft) methods.push({ id: LOGIN_METHODS.SOCIAL_MICROSOFT, label: 'Microsoft', icon: 'microsoft' });
71
+ if (config.emailFrs) methods.push({ id: LOGIN_METHODS.EMAIL_FRS, label: 'Email + Face', icon: 'face' });
72
+ return methods;
73
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Login flow controller — step sequence depends on selected method and FRS config
3
+ */
4
+
5
+ import { LOGIN_METHODS } from './login-config.js';
6
+
7
+ // Step ids used by login screens
8
+ export const LOGIN_STEPS = {
9
+ METHOD_SELECT: 'method_select',
10
+ IDENTIFIER_EMAIL: 'identifier_email',
11
+ IDENTIFIER_MOBILE: 'identifier_mobile',
12
+ PASSWORD: 'password',
13
+ OTP: 'otp',
14
+ FRS: 'frs',
15
+ SUCCESS: 'success',
16
+ };
17
+
18
+ /** Steps for each method (without FRS); FRS step inserted before SUCCESS when frsAsSecondFactor */
19
+ function getStepsForMethod(methodId, frsAsSecondFactor) {
20
+ const frsStep = frsAsSecondFactor ? [LOGIN_STEPS.FRS] : [];
21
+ switch (methodId) {
22
+ case LOGIN_METHODS.EMAIL_PASSWORD:
23
+ return [LOGIN_STEPS.IDENTIFIER_EMAIL, LOGIN_STEPS.PASSWORD, ...frsStep, LOGIN_STEPS.SUCCESS];
24
+ case LOGIN_METHODS.MOBILE_PASSWORD:
25
+ return [LOGIN_STEPS.IDENTIFIER_MOBILE, LOGIN_STEPS.PASSWORD, ...frsStep, LOGIN_STEPS.SUCCESS];
26
+ case LOGIN_METHODS.EMAIL_OTP:
27
+ return [LOGIN_STEPS.IDENTIFIER_EMAIL, LOGIN_STEPS.OTP, ...frsStep, LOGIN_STEPS.SUCCESS];
28
+ case LOGIN_METHODS.MOBILE_OTP:
29
+ return [LOGIN_STEPS.IDENTIFIER_MOBILE, LOGIN_STEPS.OTP, ...frsStep, LOGIN_STEPS.SUCCESS];
30
+ case LOGIN_METHODS.EMAIL_FRS:
31
+ return [LOGIN_STEPS.IDENTIFIER_EMAIL, LOGIN_STEPS.FRS, LOGIN_STEPS.SUCCESS];
32
+ case LOGIN_METHODS.SOCIAL_GOOGLE:
33
+ case LOGIN_METHODS.SOCIAL_MICROSOFT:
34
+ return [...frsStep, LOGIN_STEPS.SUCCESS]; // redirect first; after callback we may land on FRS or SUCCESS
35
+ default:
36
+ return [];
37
+ }
38
+ }
39
+
40
+ export class LoginFlowController {
41
+ constructor(config) {
42
+ this.config = config;
43
+ this.currentMethod = null;
44
+ this.steps = [];
45
+ this.stepIndex = 0;
46
+ this.listeners = new Set();
47
+ }
48
+
49
+ getCurrentStep() {
50
+ if (this.currentMethod === null) return LOGIN_STEPS.METHOD_SELECT;
51
+ if (this.steps.length === 0) return LOGIN_STEPS.METHOD_SELECT;
52
+ return this.steps[this.stepIndex] ?? LOGIN_STEPS.METHOD_SELECT;
53
+ }
54
+
55
+ getSteps() {
56
+ if (this.currentMethod === null) return [LOGIN_STEPS.METHOD_SELECT];
57
+ return [...this.steps];
58
+ }
59
+
60
+ getStepIndex() {
61
+ if (this.currentMethod === null) return 0;
62
+ return this.stepIndex;
63
+ }
64
+
65
+ getTotalSteps() {
66
+ return this.steps.length;
67
+ }
68
+
69
+ getProgress() {
70
+ const total = this.steps.length;
71
+ const current = this.stepIndex + 1;
72
+ return { current, total };
73
+ }
74
+
75
+ selectMethod(methodId) {
76
+ this.currentMethod = methodId;
77
+ this.steps = getStepsForMethod(methodId, this.config.frsAsSecondFactor);
78
+ this.stepIndex = 0;
79
+ this._notify();
80
+ }
81
+
82
+ goNext() {
83
+ if (this.stepIndex < this.steps.length - 1) {
84
+ this.stepIndex++;
85
+ this._notify();
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ goBack() {
92
+ if (this.stepIndex > 0) {
93
+ this.stepIndex--;
94
+ this._notify();
95
+ return true;
96
+ }
97
+ this.currentMethod = null;
98
+ this.steps = [];
99
+ this.stepIndex = 0;
100
+ this._notify();
101
+ return true;
102
+ }
103
+
104
+ setStep(stepId) {
105
+ const idx = this.steps.indexOf(stepId);
106
+ if (idx !== -1) {
107
+ this.stepIndex = idx;
108
+ this._notify();
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** After social OAuth callback: we're in social flow, optionally at FRS */
115
+ setSocialCallback(provider) {
116
+ this.currentMethod = provider === 'microsoft' ? LOGIN_METHODS.SOCIAL_MICROSOFT : LOGIN_METHODS.SOCIAL_GOOGLE;
117
+ this.steps = getStepsForMethod(this.currentMethod, this.config.frsAsSecondFactor);
118
+ this.stepIndex = 0;
119
+ this._notify();
120
+ }
121
+
122
+ /** Dynamically inject FRS step after the current step (used when the backend requires 2FA that wasn't anticipated by config) */
123
+ injectFrsStep() {
124
+ const currentIdx = this.stepIndex;
125
+ const nextIdx = currentIdx + 1;
126
+ if (this.steps[nextIdx] === LOGIN_STEPS.FRS) return;
127
+ this.steps.splice(nextIdx, 0, LOGIN_STEPS.FRS);
128
+ this.stepIndex = nextIdx;
129
+ this._notify();
130
+ }
131
+
132
+ /** Switch from Email+FRS to Email+OTP (e.g. no webcam); keeps identifier, sends OTP and shows OTP step */
133
+ switchToEmailOtp() {
134
+ this.currentMethod = LOGIN_METHODS.EMAIL_OTP;
135
+ this.steps = getStepsForMethod(this.currentMethod, this.config.frsAsSecondFactor);
136
+ this.stepIndex = 1;
137
+ this._notify();
138
+ }
139
+
140
+ subscribe(fn) {
141
+ this.listeners.add(fn);
142
+ return () => this.listeners.delete(fn);
143
+ }
144
+
145
+ _notify() {
146
+ this.listeners.forEach((fn) => fn(this));
147
+ }
148
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Login state — identifier (email/mobile), password, otp, face capture, social token
3
+ */
4
+
5
+ export class LoginStateManager {
6
+ constructor() {
7
+ this.data = {
8
+ identifier: '',
9
+ password: '',
10
+ otp: '',
11
+ otpChannel: 'sms',
12
+ faceCapture: null,
13
+ loginToken: null,
14
+ socialProvider: null,
15
+ error: '',
16
+ };
17
+ this.listeners = new Set();
18
+ }
19
+
20
+ get(key) {
21
+ return this.data[key];
22
+ }
23
+
24
+ set(key, value) {
25
+ this.data[key] = value;
26
+ this._notify(key, value);
27
+ }
28
+
29
+ setMultiple(updates) {
30
+ Object.assign(this.data, updates);
31
+ this._notify('*', updates);
32
+ }
33
+
34
+ getAll() {
35
+ return { ...this.data };
36
+ }
37
+
38
+ clearError() {
39
+ this.set('error', '');
40
+ }
41
+
42
+ subscribe(fn) {
43
+ this.listeners.add(fn);
44
+ return () => this.listeners.delete(fn);
45
+ }
46
+
47
+ _notify(key, value) {
48
+ this.listeners.forEach((fn) => fn(key, value, this.data));
49
+ }
50
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Signup progress is not persisted in browser storage or cache.
3
+ * Status is always derived from the API response only (e.g. next_step from signup/verify endpoints).
4
+ * These exports remain as no-ops for backwards compatibility if referenced elsewhere.
5
+ */
6
+
7
+ export function saveProgress() {
8
+ /* no-op: do not store in localStorage or cache */
9
+ }
10
+
11
+ export function getProgress() {
12
+ return null;
13
+ }
14
+
15
+ export function clearProgress() {
16
+ /* no-op */
17
+ }