@blackcode_sa/metaestetics-api 1.14.17 → 1.14.23
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/dist/admin/index.js +2 -13
- package/dist/admin/index.mjs +2 -13
- package/dist/index.d.mts +11 -14
- package/dist/index.d.ts +11 -14
- package/dist/index.js +66 -170
- package/dist/index.mjs +119 -223
- package/package.json +1 -1
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +2 -13
- package/src/services/auth/auth.service.ts +9 -197
- package/src/services/practitioner/practitioner.service.ts +79 -4
- package/src/services/user/user.service.ts +1 -51
package/package.json
CHANGED
|
@@ -78,21 +78,10 @@ export const practitionerInvitationTemplate = `
|
|
|
78
78
|
|
|
79
79
|
<p>This token will expire on <strong>{{expirationDate}}</strong>.</p>
|
|
80
80
|
|
|
81
|
-
<p
|
|
82
|
-
|
|
83
|
-
<p><strong>Option 1: Sign in with Google (Recommended)</strong></p>
|
|
84
|
-
<ol>
|
|
85
|
-
<li>Open the MetaEsthetics Doctor App</li>
|
|
86
|
-
<li>Click "Sign in with Google" on the login screen</li>
|
|
87
|
-
<li>Select your Google account (use the email address: {{practitionerEmail}})</li>
|
|
88
|
-
<li>You'll see an invitation to join {{clinicName}} - simply select it and join!</li>
|
|
89
|
-
</ol>
|
|
90
|
-
|
|
91
|
-
<p><strong>Option 2: Use Email/Password with Token</strong></p>
|
|
81
|
+
<p>To create your account:</p>
|
|
92
82
|
<ol>
|
|
93
83
|
<li>Visit {{registrationUrl}}</li>
|
|
94
|
-
<li>
|
|
95
|
-
<li>Enter your email ({{practitionerEmail}}) and create a password</li>
|
|
84
|
+
<li>Enter your email and create a password</li>
|
|
96
85
|
<li>When prompted, enter the token above</li>
|
|
97
86
|
</ol>
|
|
98
87
|
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
Timestamp,
|
|
36
36
|
runTransaction,
|
|
37
37
|
Firestore,
|
|
38
|
-
serverTimestamp,
|
|
39
38
|
} from 'firebase/firestore';
|
|
40
39
|
import { FirebaseApp } from 'firebase/app';
|
|
41
40
|
import { User, UserRole, USERS_COLLECTION } from '../../types';
|
|
@@ -91,91 +90,40 @@ export class AuthService extends BaseService {
|
|
|
91
90
|
constructor(db: Firestore, auth: Auth, app: FirebaseApp, userService: UserService) {
|
|
92
91
|
super(db, auth, app);
|
|
93
92
|
this.userService = userService || new UserService(db, auth, app);
|
|
94
|
-
|
|
95
|
-
// Initialize auth state change listener for debugging
|
|
96
|
-
// This helps track when auth.currentUser changes, which is critical for debugging
|
|
97
|
-
// the permission-denied issue during user document creation
|
|
98
|
-
onAuthStateChanged(this.auth, (user) => {
|
|
99
|
-
const timestamp = new Date().toISOString();
|
|
100
|
-
const stackTrace = new Error().stack?.split('\n').slice(2, 5).join('\n') || 'N/A';
|
|
101
|
-
console.log(`[AUTH STATE CHANGE] ${timestamp}`);
|
|
102
|
-
console.log(`[AUTH STATE CHANGE] User: ${user?.uid || 'NULL'} (email: ${user?.email || 'N/A'})`);
|
|
103
|
-
console.log(`[AUTH STATE CHANGE] auth.currentUser: ${this.auth.currentUser?.uid || 'NULL'}`);
|
|
104
|
-
console.log(`[AUTH STATE CHANGE] Stack trace (first 3 frames):\n${stackTrace}`);
|
|
105
|
-
console.log('[AUTH STATE CHANGE] ---');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// Log initial auth state
|
|
109
|
-
console.log('[AUTH] AuthService initialized');
|
|
110
|
-
console.log('[AUTH] Initial auth.currentUser:', this.auth.currentUser?.uid || 'NULL');
|
|
111
93
|
}
|
|
112
94
|
|
|
113
95
|
/**
|
|
114
96
|
* Waits for Firebase Auth state to settle after sign-in.
|
|
115
|
-
*
|
|
116
|
-
* In React Native with AsyncStorage persistence, there's a critical issue:
|
|
117
|
-
* 1. signInWithCredential() sets auth.currentUser in memory immediately
|
|
118
|
-
* 2. But AsyncStorage persistence happens asynchronously
|
|
119
|
-
* 3. If AsyncStorage reads an old NULL value, it can OVERWRITE the current auth state
|
|
120
|
-
* 4. This causes auth.currentUser to become NULL even after it was set
|
|
121
|
-
*
|
|
122
|
-
* This method uses onAuthStateChanged to wait for the auth state to be SET and STABLE.
|
|
123
|
-
* It ensures the auth state persists through AsyncStorage operations.
|
|
124
|
-
*
|
|
125
|
-
* @param expectedUid - The UID we expect to see in auth.currentUser
|
|
126
|
-
* @param timeoutMs - Maximum time to wait (default 5 seconds)
|
|
127
|
-
* @returns Promise that resolves when auth state is ready and stable
|
|
97
|
+
* In React Native with AsyncStorage persistence, auth state may not be immediately available.
|
|
128
98
|
*/
|
|
129
99
|
private async waitForAuthStateToSettle(expectedUid: string, timeoutMs: number = 5000): Promise<void> {
|
|
130
|
-
// If already correct, still wait a bit to ensure it's stable (not just set in memory)
|
|
131
100
|
if (this.auth.currentUser?.uid === expectedUid) {
|
|
132
|
-
console.log('[AUTH] Auth state appears set, waiting for stability...');
|
|
133
|
-
// Wait a small amount to ensure AsyncStorage persistence completes
|
|
134
101
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
135
|
-
// Check again after wait
|
|
136
102
|
if (this.auth.currentUser?.uid === expectedUid) {
|
|
137
|
-
console.log('[AUTH] ✅ Auth state stable for:', expectedUid);
|
|
138
103
|
return;
|
|
139
104
|
}
|
|
140
105
|
}
|
|
141
|
-
|
|
142
|
-
console.log('[AUTH] Waiting for auth state to settle for:', expectedUid);
|
|
143
|
-
console.log('[AUTH] Current auth.currentUser:', this.auth.currentUser?.uid || 'NULL');
|
|
144
106
|
|
|
145
107
|
return new Promise((resolve, reject) => {
|
|
146
108
|
const startTime = Date.now();
|
|
147
109
|
let resolved = false;
|
|
148
110
|
|
|
149
|
-
// Use onAuthStateChanged to wait for auth state to be SET
|
|
150
|
-
// This is more reliable than polling auth.currentUser
|
|
151
111
|
const unsubscribe = onAuthStateChanged(this.auth, (user) => {
|
|
152
112
|
if (resolved) return;
|
|
153
113
|
|
|
154
114
|
const currentUid = user?.uid || null;
|
|
155
|
-
console.log('[AUTH] onAuthStateChanged fired:', currentUid || 'NULL');
|
|
156
115
|
|
|
157
116
|
if (currentUid === expectedUid) {
|
|
158
|
-
// Auth state is set, but wait a bit more to ensure it's stable
|
|
159
|
-
// AsyncStorage might still be writing/reading
|
|
160
117
|
setTimeout(() => {
|
|
161
118
|
if (resolved) return;
|
|
162
119
|
|
|
163
|
-
// Final check - is it still set?
|
|
164
120
|
if (this.auth.currentUser?.uid === expectedUid) {
|
|
165
121
|
resolved = true;
|
|
166
122
|
unsubscribe();
|
|
167
123
|
clearTimeout(timeout);
|
|
168
|
-
const elapsed = Date.now() - startTime;
|
|
169
|
-
console.log(`[AUTH] ✅ Auth state settled and stable after ${elapsed}ms for:`, expectedUid);
|
|
170
124
|
resolve();
|
|
171
|
-
} else {
|
|
172
|
-
console.warn('[AUTH] ⚠️ Auth state became NULL after being set, waiting more...');
|
|
173
125
|
}
|
|
174
|
-
}, 300);
|
|
175
|
-
} else if (currentUid === null && Date.now() - startTime > 1000) {
|
|
176
|
-
// Auth state became NULL after being set - this is the bug we're trying to fix
|
|
177
|
-
console.error('[AUTH] ❌ Auth state became NULL after being set!');
|
|
178
|
-
console.error('[AUTH] This indicates AsyncStorage persistence issue');
|
|
126
|
+
}, 300);
|
|
179
127
|
}
|
|
180
128
|
});
|
|
181
129
|
|
|
@@ -183,9 +131,6 @@ export class AuthService extends BaseService {
|
|
|
183
131
|
if (resolved) return;
|
|
184
132
|
resolved = true;
|
|
185
133
|
unsubscribe();
|
|
186
|
-
console.error('[AUTH] ❌ Timeout waiting for auth state to settle');
|
|
187
|
-
console.error('[AUTH] Expected UID:', expectedUid);
|
|
188
|
-
console.error('[AUTH] Actual auth.currentUser:', this.auth.currentUser?.uid || 'NULL');
|
|
189
134
|
reject(new Error(`Timeout waiting for auth state to settle. Expected: ${expectedUid}, Got: ${this.auth.currentUser?.uid || 'NULL'}`));
|
|
190
135
|
}, timeoutMs);
|
|
191
136
|
});
|
|
@@ -935,31 +880,16 @@ export class AuthService extends BaseService {
|
|
|
935
880
|
throw new AuthError('No practitioner profiles selected to claim', 'AUTH/NO_PROFILES_SELECTED', 400);
|
|
936
881
|
}
|
|
937
882
|
|
|
938
|
-
// Check auth state BEFORE sign-in
|
|
939
|
-
console.log('[AUTH] currentUser BEFORE sign-in:', this.auth.currentUser?.uid || 'NULL');
|
|
940
|
-
|
|
941
|
-
// Sign in with Google credential
|
|
942
|
-
console.log('[AUTH] Signing in with Google credential...');
|
|
943
883
|
const credential = GoogleAuthProvider.credential(idToken);
|
|
944
884
|
const result = await signInWithCredential(this.auth, credential);
|
|
945
885
|
const firebaseUser = result.user;
|
|
946
|
-
|
|
947
|
-
// Check auth state IMMEDIATELY AFTER sign-in
|
|
948
|
-
console.log('[AUTH] currentUser IMMEDIATELY AFTER sign-in:', this.auth.currentUser?.uid || 'NULL');
|
|
949
|
-
console.log('[AUTH] User returned from signInWithCredential:', firebaseUser.uid);
|
|
950
886
|
|
|
951
887
|
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
952
888
|
|
|
953
|
-
// Step 1: Get User document (should already exist from signUpPractitionerWithGoogle)
|
|
954
|
-
// The User document is created IMMEDIATELY after Google sign-in in signUpPractitionerWithGoogle
|
|
955
|
-
// This matches the token flow pattern - no delays between sign-in and User doc creation
|
|
956
889
|
let user: User;
|
|
957
890
|
try {
|
|
958
891
|
user = await this.userService.getUserById(firebaseUser.uid);
|
|
959
|
-
console.log('[AUTH] User document found:', user.uid);
|
|
960
892
|
} catch (userError) {
|
|
961
|
-
console.error('[AUTH] ❌ User document should already exist! It should have been created in signUpPractitionerWithGoogle.');
|
|
962
|
-
console.error('[AUTH] This indicates a bug - User doc creation failed in the initial Google sign-in flow.');
|
|
963
893
|
throw new AuthError(
|
|
964
894
|
'User account not properly initialized. Please try signing in again.',
|
|
965
895
|
'AUTH/USER_NOT_INITIALIZED',
|
|
@@ -967,39 +897,27 @@ export class AuthService extends BaseService {
|
|
|
967
897
|
);
|
|
968
898
|
}
|
|
969
899
|
|
|
970
|
-
// Step 3: Claim the draft profiles
|
|
971
900
|
let practitioner: Practitioner;
|
|
972
901
|
if (practitionerIds.length === 1) {
|
|
973
|
-
console.log('[AUTH] Claiming single draft profile:', practitionerIds[0]);
|
|
974
902
|
practitioner = await practitionerService.claimDraftProfileWithGoogle(
|
|
975
903
|
practitionerIds[0],
|
|
976
904
|
firebaseUser.uid
|
|
977
905
|
);
|
|
978
906
|
} else {
|
|
979
|
-
console.log('[AUTH] Claiming multiple draft profiles:', practitionerIds);
|
|
980
907
|
practitioner = await practitionerService.claimMultipleDraftProfilesWithGoogle(
|
|
981
908
|
practitionerIds,
|
|
982
909
|
firebaseUser.uid
|
|
983
910
|
);
|
|
984
911
|
}
|
|
985
|
-
console.log('[AUTH] Draft profiles claimed:', practitioner.id);
|
|
986
912
|
|
|
987
|
-
// Step 4: Link practitioner to user
|
|
988
913
|
if (!user.practitionerProfile || user.practitionerProfile !== practitioner.id) {
|
|
989
|
-
console.log('[AUTH] Linking practitioner to user');
|
|
990
914
|
await this.userService.updateUser(firebaseUser.uid, {
|
|
991
915
|
practitionerProfile: practitioner.id,
|
|
992
916
|
});
|
|
993
917
|
}
|
|
994
918
|
|
|
995
|
-
// Fetch updated user (with practitionerProfile reference)
|
|
996
919
|
const updatedUser = await this.userService.getUserById(firebaseUser.uid);
|
|
997
920
|
|
|
998
|
-
console.log('[AUTH] Draft profiles claimed successfully', {
|
|
999
|
-
userId: updatedUser.uid,
|
|
1000
|
-
practitionerId: practitioner.id,
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
921
|
return {
|
|
1004
922
|
user: updatedUser,
|
|
1005
923
|
practitioner,
|
|
@@ -1180,13 +1098,6 @@ export class AuthService extends BaseService {
|
|
|
1180
1098
|
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1181
1099
|
console.log('[AUTH] Firebase user signed in:', firebaseUser.uid);
|
|
1182
1100
|
|
|
1183
|
-
// CRITICAL: Wait for auth state to settle before making Firestore queries
|
|
1184
|
-
// In React Native with AsyncStorage persistence, auth.currentUser can be NULL
|
|
1185
|
-
// immediately after signInWithCredential due to async persistence race condition
|
|
1186
|
-
console.log('[AUTH] Waiting for auth state to settle after sign-in...');
|
|
1187
|
-
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1188
|
-
console.log('[AUTH] ✅ Auth state settled, proceeding with Firestore queries');
|
|
1189
|
-
|
|
1190
1101
|
// 4) Load our domain user document.
|
|
1191
1102
|
const existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1192
1103
|
if (existingUser) {
|
|
@@ -1252,69 +1163,37 @@ export class AuthService extends BaseService {
|
|
|
1252
1163
|
}
|
|
1253
1164
|
|
|
1254
1165
|
const normalizedEmail = email.toLowerCase().trim();
|
|
1255
|
-
console.log('[AUTH] Extracted email from Google token:', normalizedEmail);
|
|
1256
1166
|
|
|
1257
|
-
// Check if user already exists in Firebase Auth
|
|
1258
1167
|
const methods = await fetchSignInMethodsForEmail(this.auth, normalizedEmail);
|
|
1259
1168
|
const hasGoogleMethod = methods.includes(GoogleAuthProvider.GOOGLE_SIGN_IN_METHOD);
|
|
1260
1169
|
const hasEmailMethod = methods.includes(EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD);
|
|
1261
1170
|
|
|
1262
1171
|
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1263
1172
|
|
|
1264
|
-
// Case 1: User exists with Google provider - sign in and check for practitioner profile
|
|
1265
1173
|
if (hasGoogleMethod) {
|
|
1266
|
-
console.log('[AUTH] User exists with Google provider, signing in');
|
|
1267
1174
|
const credential = GoogleAuthProvider.credential(idToken);
|
|
1268
1175
|
const { user: firebaseUser } = await signInWithCredential(this.auth, credential);
|
|
1269
1176
|
|
|
1270
|
-
// CRITICAL: Wait for auth state to settle before making Firestore queries
|
|
1271
|
-
// In React Native with AsyncStorage persistence, auth.currentUser can be NULL
|
|
1272
|
-
// immediately after signInWithCredential due to async persistence race condition
|
|
1273
|
-
console.log('[AUTH] Waiting for auth state to settle after sign-in...');
|
|
1274
1177
|
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1275
|
-
console.log('[AUTH] ✅ Auth state settled, proceeding with Firestore queries');
|
|
1276
1178
|
|
|
1277
1179
|
let existingUser: User | null = null;
|
|
1278
1180
|
try {
|
|
1279
1181
|
existingUser = await this.userService.getUserById(firebaseUser.uid);
|
|
1280
|
-
console.log('[AUTH] User document found:', existingUser.uid);
|
|
1281
1182
|
} catch (userError: any) {
|
|
1282
|
-
// User document doesn't exist in Firestore
|
|
1283
|
-
console.log('[AUTH] User document not found in Firestore, checking for draft profiles', {
|
|
1284
|
-
errorCode: userError?.code,
|
|
1285
|
-
errorMessage: userError?.message,
|
|
1286
|
-
errorType: userError?.constructor?.name,
|
|
1287
|
-
isAuthError: userError instanceof AuthError,
|
|
1288
|
-
});
|
|
1289
|
-
|
|
1290
|
-
// Check for draft profiles before signing out
|
|
1291
|
-
// CRITICAL: Verify auth.currentUser is still set before Firestore query
|
|
1292
|
-
// AsyncStorage persistence can cause it to become NULL even after being set
|
|
1293
1183
|
if (!this.auth.currentUser || this.auth.currentUser.uid !== firebaseUser.uid) {
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
console.error('[AUTH] Actual auth.currentUser:', this.auth.currentUser?.uid || 'NULL');
|
|
1297
|
-
console.log('[AUTH] Waiting for auth state to recover...');
|
|
1298
|
-
// Try to wait for it to come back
|
|
1184
|
+
const credential = GoogleAuthProvider.credential(idToken);
|
|
1185
|
+
await signInWithCredential(this.auth, credential);
|
|
1299
1186
|
await this.waitForAuthStateToSettle(firebaseUser.uid, 2000);
|
|
1300
1187
|
}
|
|
1301
1188
|
|
|
1302
1189
|
const practitionerService = new PractitionerService(this.db, this.auth, this.app);
|
|
1303
1190
|
const draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1304
1191
|
|
|
1305
|
-
console.log('[AUTH] Draft profiles check result:', {
|
|
1306
|
-
email: normalizedEmail,
|
|
1307
|
-
draftProfilesCount: draftProfiles.length,
|
|
1308
|
-
draftProfileIds: draftProfiles.map(p => p.id),
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
1192
|
if (draftProfiles.length === 0) {
|
|
1312
|
-
// No draft profiles - sign out and throw error
|
|
1313
|
-
console.log('[AUTH] No draft profiles found, signing out and throwing error');
|
|
1314
1193
|
try {
|
|
1315
1194
|
await firebaseSignOut(this.auth);
|
|
1316
1195
|
} catch (signOutError) {
|
|
1317
|
-
console.warn('[AUTH] Error signing out
|
|
1196
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1318
1197
|
}
|
|
1319
1198
|
throw new AuthError(
|
|
1320
1199
|
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
@@ -1323,35 +1202,21 @@ export class AuthService extends BaseService {
|
|
|
1323
1202
|
);
|
|
1324
1203
|
}
|
|
1325
1204
|
|
|
1326
|
-
// Draft profiles exist - CREATE USER DOCUMENT IMMEDIATELY (like token flow)
|
|
1327
|
-
// This matches the token flow pattern: create User doc right after sign-in, before any delays
|
|
1328
|
-
console.log('[AUTH] Draft profiles found, creating User document IMMEDIATELY after sign-in');
|
|
1329
|
-
console.log('[AUTH] auth.currentUser at User creation time:', this.auth.currentUser?.uid || 'NULL');
|
|
1330
|
-
|
|
1331
1205
|
try {
|
|
1332
|
-
// Create User document immediately (same pattern as token flow)
|
|
1333
1206
|
const newUser = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1334
1207
|
skipProfileCreation: true,
|
|
1335
1208
|
});
|
|
1336
|
-
console.log('[AUTH] ✅ User document created successfully:', newUser.uid);
|
|
1337
1209
|
|
|
1338
|
-
// Return user + draft profiles (user doc already exists, just need to claim profiles)
|
|
1339
1210
|
return {
|
|
1340
1211
|
user: newUser,
|
|
1341
1212
|
practitioner: null,
|
|
1342
1213
|
draftProfiles: draftProfiles,
|
|
1343
1214
|
};
|
|
1344
1215
|
} catch (createUserError: any) {
|
|
1345
|
-
console.error('[AUTH] ❌ Failed to create User document:', {
|
|
1346
|
-
errorCode: createUserError?.code,
|
|
1347
|
-
errorMessage: createUserError?.message,
|
|
1348
|
-
uid: firebaseUser.uid,
|
|
1349
|
-
});
|
|
1350
|
-
// Sign out on failure
|
|
1351
1216
|
try {
|
|
1352
1217
|
await firebaseSignOut(this.auth);
|
|
1353
1218
|
} catch (signOutError) {
|
|
1354
|
-
console.warn('[AUTH] Error signing out
|
|
1219
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1355
1220
|
}
|
|
1356
1221
|
throw createUserError;
|
|
1357
1222
|
}
|
|
@@ -1383,11 +1248,7 @@ export class AuthService extends BaseService {
|
|
|
1383
1248
|
};
|
|
1384
1249
|
}
|
|
1385
1250
|
|
|
1386
|
-
// Case 2: User exists with email/password - need to link Google provider
|
|
1387
|
-
// Firebase doesn't allow linking without being signed in first
|
|
1388
|
-
// So we need to ask user to sign in with email/password first, then link
|
|
1389
1251
|
if (hasEmailMethod && !hasGoogleMethod) {
|
|
1390
|
-
console.log('[AUTH] User exists with email/password only');
|
|
1391
1252
|
throw new AuthError(
|
|
1392
1253
|
'An account with this email already exists. Please sign in with your email and password, then link your Google account in settings.',
|
|
1393
1254
|
'AUTH/EMAIL_ALREADY_EXISTS',
|
|
@@ -1395,8 +1256,6 @@ export class AuthService extends BaseService {
|
|
|
1395
1256
|
);
|
|
1396
1257
|
}
|
|
1397
1258
|
|
|
1398
|
-
// Case 3: New user - sign in with Google and check for draft profiles
|
|
1399
|
-
console.log('[AUTH] Signing in with Google credential');
|
|
1400
1259
|
const credential = GoogleAuthProvider.credential(idToken);
|
|
1401
1260
|
|
|
1402
1261
|
let firebaseUser: FirebaseUser;
|
|
@@ -1415,39 +1274,26 @@ export class AuthService extends BaseService {
|
|
|
1415
1274
|
throw error;
|
|
1416
1275
|
}
|
|
1417
1276
|
|
|
1418
|
-
// CRITICAL: Wait for auth state to settle before making Firestore queries
|
|
1419
|
-
// In React Native with AsyncStorage persistence, auth.currentUser can be NULL
|
|
1420
|
-
// immediately after signInWithCredential due to async persistence race condition
|
|
1421
|
-
console.log('[AUTH] Waiting for auth state to settle after sign-in...');
|
|
1422
1277
|
await this.waitForAuthStateToSettle(firebaseUser.uid);
|
|
1423
|
-
console.log('[AUTH] ✅ Auth state settled, proceeding with Firestore queries');
|
|
1424
1278
|
|
|
1425
|
-
// Check for existing User document (in case user had email/password account that was just linked)
|
|
1426
1279
|
let existingUser: User | null = null;
|
|
1427
1280
|
try {
|
|
1428
1281
|
const existingUserDoc = await this.userService.getUserById(firebaseUser.uid);
|
|
1429
1282
|
if (existingUserDoc) {
|
|
1430
1283
|
existingUser = existingUserDoc;
|
|
1431
|
-
console.log('[AUTH] Found existing User document');
|
|
1432
1284
|
}
|
|
1433
1285
|
} catch (error) {
|
|
1434
|
-
console.error('[AUTH] Error checking for existing user:', error);
|
|
1435
1286
|
// Continue with new user creation
|
|
1436
1287
|
}
|
|
1437
1288
|
|
|
1438
|
-
// Check for draft profiles
|
|
1439
|
-
console.log('[AUTH] Checking for draft profiles');
|
|
1440
1289
|
let draftProfiles: Practitioner[] = [];
|
|
1441
1290
|
try {
|
|
1442
1291
|
draftProfiles = await practitionerService.getDraftProfilesByEmail(normalizedEmail);
|
|
1443
|
-
console.log('[AUTH] Draft profiles check complete', { count: draftProfiles.length });
|
|
1444
1292
|
} catch (draftCheckError: any) {
|
|
1445
|
-
console.error('[AUTH] Error checking draft profiles:', draftCheckError);
|
|
1446
|
-
// If checking draft profiles fails, sign out and throw appropriate error
|
|
1447
1293
|
try {
|
|
1448
1294
|
await firebaseSignOut(this.auth);
|
|
1449
1295
|
} catch (signOutError) {
|
|
1450
|
-
console.warn('[AUTH] Error signing out
|
|
1296
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1451
1297
|
}
|
|
1452
1298
|
throw new AuthError(
|
|
1453
1299
|
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
@@ -1458,72 +1304,43 @@ export class AuthService extends BaseService {
|
|
|
1458
1304
|
|
|
1459
1305
|
let user: User;
|
|
1460
1306
|
if (existingUser) {
|
|
1461
|
-
// User exists - use existing account
|
|
1462
1307
|
user = existingUser;
|
|
1463
|
-
console.log('[AUTH] Using existing user account');
|
|
1464
1308
|
} else {
|
|
1465
|
-
// For new users: Only create account if there are draft profiles OR if user already has a practitioner profile
|
|
1466
|
-
// Since doctors can only join via clinic invitation, we should not create accounts without invitations
|
|
1467
1309
|
if (draftProfiles.length === 0) {
|
|
1468
|
-
console.log('[AUTH] No draft profiles found, signing out and throwing error');
|
|
1469
|
-
// Sign out the Firebase user since we're not creating an account
|
|
1470
|
-
// Wrap in try-catch to handle any sign-out errors gracefully
|
|
1471
1310
|
try {
|
|
1472
1311
|
await firebaseSignOut(this.auth);
|
|
1473
1312
|
} catch (signOutError) {
|
|
1474
|
-
console.warn('[AUTH] Error signing out
|
|
1475
|
-
// Continue anyway - the important part is we're not creating the account
|
|
1313
|
+
console.warn('[AUTH] Error signing out:', signOutError);
|
|
1476
1314
|
}
|
|
1477
|
-
|
|
1315
|
+
throw new AuthError(
|
|
1478
1316
|
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1479
1317
|
'AUTH/NO_DRAFT_PROFILES',
|
|
1480
1318
|
404,
|
|
1481
1319
|
);
|
|
1482
|
-
console.log('[AUTH] Throwing NO_DRAFT_PROFILES error:', noDraftError.code);
|
|
1483
|
-
throw noDraftError;
|
|
1484
1320
|
}
|
|
1485
1321
|
|
|
1486
|
-
// Create new user document only if draft profiles exist
|
|
1487
1322
|
user = await this.userService.createUser(firebaseUser, [UserRole.PRACTITIONER], {
|
|
1488
1323
|
skipProfileCreation: true,
|
|
1489
1324
|
});
|
|
1490
|
-
console.log('[AUTH] Created new user account with draft profiles available');
|
|
1491
1325
|
}
|
|
1492
1326
|
|
|
1493
|
-
// Check if user already has practitioner profile
|
|
1494
1327
|
let practitioner: Practitioner | null = null;
|
|
1495
1328
|
if (user.practitionerProfile) {
|
|
1496
1329
|
practitioner = await practitionerService.getPractitioner(user.practitionerProfile);
|
|
1497
1330
|
}
|
|
1498
1331
|
|
|
1499
|
-
console.log('[AUTH] Google Sign-In complete', {
|
|
1500
|
-
userId: user.uid,
|
|
1501
|
-
hasPractitioner: !!practitioner,
|
|
1502
|
-
draftProfilesCount: draftProfiles.length,
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
1332
|
return {
|
|
1506
1333
|
user,
|
|
1507
1334
|
practitioner,
|
|
1508
1335
|
draftProfiles,
|
|
1509
1336
|
};
|
|
1510
1337
|
} catch (error: any) {
|
|
1511
|
-
console.error('[AUTH] Error in signUpPractitionerWithGoogle:', error);
|
|
1512
|
-
console.error('[AUTH] Error type:', error?.constructor?.name);
|
|
1513
|
-
console.error('[AUTH] Error instanceof AuthError:', error instanceof AuthError);
|
|
1514
|
-
console.error('[AUTH] Error code:', error?.code);
|
|
1515
|
-
console.error('[AUTH] Error message:', error?.message);
|
|
1516
|
-
|
|
1517
|
-
// Preserve AuthError instances (like NO_DRAFT_PROFILES) without wrapping
|
|
1518
1338
|
if (error instanceof AuthError) {
|
|
1519
|
-
console.log('[AUTH] Preserving AuthError:', error.code);
|
|
1520
1339
|
throw error;
|
|
1521
1340
|
}
|
|
1522
1341
|
|
|
1523
|
-
// Check if error message contains NO_DRAFT_PROFILES before wrapping
|
|
1524
1342
|
const errorMessage = error?.message || error?.toString() || '';
|
|
1525
1343
|
if (errorMessage.includes('NO_DRAFT_PROFILES') || errorMessage.includes('clinic invitation')) {
|
|
1526
|
-
console.log('[AUTH] Detected clinic invitation error in message, converting to AuthError');
|
|
1527
1344
|
throw new AuthError(
|
|
1528
1345
|
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1529
1346
|
'AUTH/NO_DRAFT_PROFILES',
|
|
@@ -1531,14 +1348,9 @@ export class AuthService extends BaseService {
|
|
|
1531
1348
|
);
|
|
1532
1349
|
}
|
|
1533
1350
|
|
|
1534
|
-
// For other errors, wrap them but preserve the original message if it's helpful
|
|
1535
1351
|
const wrappedError = handleFirebaseError(error);
|
|
1536
|
-
console.log('[AUTH] Wrapped error:', wrappedError.message);
|
|
1537
1352
|
|
|
1538
|
-
// If the wrapped error message is generic, try to preserve more context
|
|
1539
1353
|
if (wrappedError.message.includes('permissions') || wrappedError.message.includes('Account creation failed')) {
|
|
1540
|
-
// This might be a permissions error during sign-out or user creation
|
|
1541
|
-
// If we got here and there were no draft profiles, it's likely the same issue
|
|
1542
1354
|
throw new AuthError(
|
|
1543
1355
|
'No clinic invitation found for this email. Please contact your clinic administrator to receive an invitation, or use the token provided by your clinic.',
|
|
1544
1356
|
'AUTH/NO_DRAFT_PROFILES',
|
|
@@ -483,6 +483,34 @@ export class PractitionerService extends BaseService {
|
|
|
483
483
|
throw new Error("Practitioner is not associated with this clinic");
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
// Security check: Verify that the clinic belongs to the clinic group of the user creating the token
|
|
487
|
+
// createdBy can be either clinicGroupId or clinicId
|
|
488
|
+
let expectedClinicGroupId: string | null = null;
|
|
489
|
+
|
|
490
|
+
// First, check if createdBy matches the clinic's clinicGroupId directly
|
|
491
|
+
if (clinic.clinicGroupId === createdBy) {
|
|
492
|
+
// createdBy is the clinicGroupId, which matches - this is valid
|
|
493
|
+
expectedClinicGroupId = createdBy;
|
|
494
|
+
} else {
|
|
495
|
+
// createdBy might be a clinicId, check if that clinic belongs to the same group
|
|
496
|
+
try {
|
|
497
|
+
const creatorClinic = await this.getClinicService().getClinic(createdBy);
|
|
498
|
+
if (creatorClinic && creatorClinic.clinicGroupId === clinic.clinicGroupId) {
|
|
499
|
+
// Both clinics belong to the same group - valid
|
|
500
|
+
expectedClinicGroupId = clinic.clinicGroupId;
|
|
501
|
+
} else {
|
|
502
|
+
throw new Error("Clinic does not belong to your clinic group");
|
|
503
|
+
}
|
|
504
|
+
} catch (error: any) {
|
|
505
|
+
// If createdBy is not a valid clinicId, or clinics don't match, reject
|
|
506
|
+
if (error.message === "Clinic does not belong to your clinic group") {
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
// If getClinic fails, createdBy might be a clinicGroupId that doesn't match
|
|
510
|
+
throw new Error("Clinic does not belong to your clinic group");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
486
514
|
// Default expiration is 7 days from now if not specified
|
|
487
515
|
const expiration =
|
|
488
516
|
validatedData.expiresAt ||
|
|
@@ -522,21 +550,29 @@ export class PractitionerService extends BaseService {
|
|
|
522
550
|
/**
|
|
523
551
|
* Gets active tokens for a practitioner
|
|
524
552
|
* @param practitionerId ID of the practitioner
|
|
553
|
+
* @param clinicId Optional clinic ID to filter tokens by. If provided, only returns tokens for this clinic.
|
|
525
554
|
* @returns Array of active tokens
|
|
526
555
|
*/
|
|
527
556
|
async getPractitionerActiveTokens(
|
|
528
|
-
practitionerId: string
|
|
557
|
+
practitionerId: string,
|
|
558
|
+
clinicId?: string
|
|
529
559
|
): Promise<PractitionerToken[]> {
|
|
530
560
|
const tokensRef = collection(
|
|
531
561
|
this.db,
|
|
532
562
|
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
533
563
|
);
|
|
534
564
|
|
|
535
|
-
const
|
|
536
|
-
tokensRef,
|
|
565
|
+
const conditions = [
|
|
537
566
|
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
538
567
|
where("expiresAt", ">", Timestamp.now())
|
|
539
|
-
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
// Filter by clinic if provided
|
|
571
|
+
if (clinicId) {
|
|
572
|
+
conditions.push(where("clinicId", "==", clinicId));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const q = query(tokensRef, ...conditions);
|
|
540
576
|
|
|
541
577
|
const querySnapshot = await getDocs(q);
|
|
542
578
|
return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
|
|
@@ -628,6 +664,45 @@ export class PractitionerService extends BaseService {
|
|
|
628
664
|
});
|
|
629
665
|
}
|
|
630
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Revokes a token by setting its status to REVOKED
|
|
669
|
+
* @param tokenId ID of the token
|
|
670
|
+
* @param practitionerId ID of the practitioner
|
|
671
|
+
* @param clinicId ID of the clinic that owns the token. Used to verify ownership before revoking.
|
|
672
|
+
* @throws Error if token doesn't exist or doesn't belong to the specified clinic
|
|
673
|
+
*/
|
|
674
|
+
async revokeToken(
|
|
675
|
+
tokenId: string,
|
|
676
|
+
practitionerId: string,
|
|
677
|
+
clinicId: string
|
|
678
|
+
): Promise<void> {
|
|
679
|
+
const tokenRef = doc(
|
|
680
|
+
this.db,
|
|
681
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// First, verify the token exists and belongs to the clinic
|
|
685
|
+
const tokenDoc = await getDoc(tokenRef);
|
|
686
|
+
if (!tokenDoc.exists()) {
|
|
687
|
+
throw new Error("Token not found");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const tokenData = tokenDoc.data() as PractitionerToken;
|
|
691
|
+
if (tokenData.clinicId !== clinicId) {
|
|
692
|
+
throw new Error("Token does not belong to the specified clinic");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Only revoke if token is still active
|
|
696
|
+
if (tokenData.status !== PractitionerTokenStatus.ACTIVE) {
|
|
697
|
+
throw new Error("Token is not active and cannot be revoked");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await updateDoc(tokenRef, {
|
|
701
|
+
status: PractitionerTokenStatus.REVOKED,
|
|
702
|
+
updatedAt: serverTimestamp(),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
631
706
|
/**
|
|
632
707
|
* Dohvata zdravstvenog radnika po ID-u
|
|
633
708
|
*/
|