mnfst 0.5.223 → 0.5.225

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.
@@ -70,6 +70,7 @@ async function getAppwriteConfig() {
70
70
 
71
71
  const magicEnabled = authMethods.includes("magic");
72
72
  const otpEnabled = authMethods.includes("otp");
73
+ const phoneEnabled = authMethods.includes("phone");
73
74
  const oauthEnabled = authMethods.includes("oauth");
74
75
 
75
76
  // Teams (presence of teams object enables it)
@@ -92,7 +93,7 @@ async function getAppwriteConfig() {
92
93
  : toBool(appwriteConfig.auth.teams.authenticated);
93
94
 
94
95
  // Guest upgrade: preserve the anonymous account + teams on sign-in (magic/oauth;
95
- // OTP can't convert anonymous accounts). Defaults to guestTeams.
96
+ // email/phone OTP can't convert anonymous accounts). Defaults to guestTeams.
96
97
  const guestUpgrade = appwriteConfig.auth?.guestUpgrade !== undefined
97
98
  ? toBool(appwriteConfig.auth.guestUpgrade)
98
99
  : guestTeams;
@@ -135,6 +136,7 @@ async function getAppwriteConfig() {
135
136
  anonymous: guestAuto, // back-compat alias
136
137
  magic: magicEnabled,
137
138
  otp: otpEnabled,
139
+ phone: phoneEnabled,
138
140
  oauth: oauthEnabled,
139
141
  teams: teamsEnabled,
140
142
  permanentTeams: permanentTeams,
@@ -218,6 +220,32 @@ function initializeAuthStore() {
218
220
 
219
221
  // Cross-tab synchronization using localStorage events
220
222
  const STORAGE_KEY = 'manifest:auth:state';
223
+ // Cap on holding scoped data for a guest/callback sign-in that may fail
224
+ const SIGN_IN_PENDING_MS = 5000;
225
+ ['manifest:auth:login', 'manifest:auth:anonymous', 'manifest:auth:logout', 'manifest:auth:session-cleared'].forEach(type =>
226
+ window.addEventListener(type, () => { const store = Alpine.store('auth'); if (store) { store._signInPendingUntil = 0; store._authEpoch++; markVerified(store); } }));
227
+
228
+ // Unverified session: recheck on reconnect, on return to the tab, and on a bounded backoff
229
+ const RECHECK_DELAYS_MS = [2000, 5000, 15000, 60000];
230
+ const recheck = () => { Alpine.store('auth')?._recheckSession?.(); };
231
+ window.addEventListener('online', recheck);
232
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') recheck(); });
233
+
234
+ function markVerified(store) {
235
+ store._sessionUnverified = false;
236
+ store._recheckAttempt = 0;
237
+ if (store._recheckTimer) { clearTimeout(store._recheckTimer); store._recheckTimer = null; }
238
+ }
239
+
240
+ function scheduleRecheck(store) {
241
+ if (!store._sessionUnverified || store._recheckTimer) return;
242
+ const delay = RECHECK_DELAYS_MS[Math.min(store._recheckAttempt++, RECHECK_DELAYS_MS.length - 1)];
243
+ store._recheckTimer = setTimeout(() => { store._recheckTimer = null; store._recheckSession(); }, delay);
244
+ }
245
+
246
+ // Appwrite's "no session" answer; anything else (offline, 5xx) leaves the session unknown
247
+ const NO_SESSION_TYPES = ['general_unauthorized_scope', 'user_unauthorized', 'user_session_not_found', 'user_jwt_invalid', 'user_not_found', 'user_blocked'];
248
+ const isNoSession = error => error?.code === 401 || NO_SESSION_TYPES.includes(error?.type);
221
249
 
222
250
  // Session fields safe to mirror across tabs. Excludes `secret` and provider
223
251
  // tokens — this copy is only for UI cross-tab sync, not the auth of record.
@@ -243,7 +271,10 @@ function initializeAuthStore() {
243
271
  const state = JSON.parse(e.newValue);
244
272
  const store = Alpine.store('auth');
245
273
  if (store) {
274
+ const prevId = store.user?.$id || null;
246
275
  // Update store state from other tab
276
+ store._authEpoch++;
277
+ if (state.isAuthenticated) markVerified(store);
247
278
  store.isAuthenticated = state.isAuthenticated;
248
279
  store.isAnonymous = state.isAnonymous;
249
280
  store.user = state.user;
@@ -253,6 +284,15 @@ function initializeAuthStore() {
253
284
  store.otpSent = state.otpSent || false;
254
285
  store.otpExpired = state.otpExpired || false;
255
286
  store.error = state.error;
287
+ // Identity ended or changed in another tab: scoped data must drop this tab's rows
288
+ const nextId = state.isAuthenticated ? (state.user?.$id || null) : null;
289
+ if (prevId && nextId !== prevId) {
290
+ if (!nextId) {
291
+ store.teams = [];
292
+ store.currentTeam = null;
293
+ }
294
+ window.dispatchEvent(new CustomEvent('manifest:auth:session-cleared'));
295
+ }
256
296
  }
257
297
  } catch (error) {
258
298
  // Failed to sync state from other tab
@@ -262,6 +302,8 @@ function initializeAuthStore() {
262
302
 
263
303
  // Helper to sync state to localStorage (for cross-tab communication)
264
304
  function syncStateToStorage(store) {
305
+ // An unverified signed-out state must not sign other tabs out
306
+ if (store._sessionUnverified && !store.isAuthenticated) return;
265
307
  try {
266
308
  const state = {
267
309
  isAuthenticated: store.isAuthenticated,
@@ -289,10 +331,10 @@ function initializeAuthStore() {
289
331
  error: null,
290
332
  magicLinkSent: false,
291
333
  magicLinkExpired: false,
292
- otpSent: false, // Email OTP: a code has been emailed and is awaiting entry
293
- otpExpired: false, // Email OTP: the entered code was wrong/expired
294
- otpPhrase: null, // Email OTP: security phrase to display (when enabled)
295
- _otpUserId: null, // Email OTP: userId returned by createEmailToken, used by verifyOTP
334
+ otpSent: false, // OTP (email/phone): a code has been sent and is awaiting entry
335
+ otpExpired: false, // OTP (email/phone): the entered code was wrong/expired
336
+ otpPhrase: null, // Email OTP: security phrase to display (when enabled; phone has none)
337
+ _otpUserId: null, // OTP: userId returned by createEmailToken/createPhoneToken, used by verifyOTP
296
338
  teams: [], // List of user's teams
297
339
  currentTeam: null, // Currently selected/active team
298
340
  _teamsPollInterval: null, // Interval ID for teams polling (deprecated, use realtime instead)
@@ -340,6 +382,11 @@ function initializeAuthStore() {
340
382
  _appwrite: null,
341
383
  _guestAuto: false,
342
384
  _guestManual: false,
385
+ _signInPendingUntil: 0, // signed-out init with guest-auto or an auth callback still to sign in
386
+ _sessionUnverified: false, // init could not reach Appwrite: signed out locally, never broadcast
387
+ _authEpoch: 0, // bumped by every identity change; an in-flight recheck from an older epoch is dropped
388
+ _recheckTimer: null,
389
+ _recheckAttempt: 0,
343
390
  guestManualEnabled: false,
344
391
  _oauthProvider: null, // Store OAuth provider name (google, github, etc.) when login is initiated
345
392
  _syncStateToStorage: syncStateToStorage,
@@ -545,57 +592,18 @@ function initializeAuthStore() {
545
592
 
546
593
  // Try to restore existing session
547
594
  try {
548
- this.user = await appwrite.account.get();
549
- const sessionsResponse = await appwrite.account.listSessions();
550
- const allSessions = sessionsResponse.sessions || [];
551
- const currentSession = allSessions.find(s => s.current === true) || allSessions[0];
552
-
553
- if (currentSession) {
554
- this.session = currentSession;
555
- this.isAuthenticated = true;
556
- this.isAnonymous = currentSession.provider === 'anonymous';
557
-
558
- // Restore OAuth provider from storage (persists across redirects/refresh)
559
- if (!this.isAnonymous && currentSession.provider !== 'magic-url') {
560
- try {
561
- // Try localStorage first (persists across redirects), fallback to sessionStorage
562
- let storedProvider = localStorage.getItem('manifest:oauth:provider');
563
- if (!storedProvider) {
564
- storedProvider = sessionStorage.getItem('manifest:oauth:provider');
565
- }
566
- if (storedProvider) {
567
- this._oauthProvider = storedProvider;
568
- }
569
- } catch (e) {
570
- // Storage error
571
- }
572
- }
573
-
574
- // If guest is disabled but we have anonymous session, clear it
575
- if (this.isAnonymous && !this._guestAuto && !this._guestManual) {
576
- try {
577
- await appwrite.account.deleteSession(this.session.$id);
578
- this.isAuthenticated = false;
579
- this.isAnonymous = false;
580
- this.user = null;
581
- this.session = null;
582
- } catch (deleteError) {
583
- // Failed to delete guest session
584
- }
585
- }
586
- } else {
587
- this.isAuthenticated = true; // User exists, session might be managed by cookies
588
- this.isAnonymous = false;
589
- }
595
+ await this._restoreSession(appwrite);
590
596
 
591
597
  // Team loading is deferred (not awaited) — runs in the background after
592
598
  // manifest:auth:initialized so a session gate isn't held up by it.
593
599
  } catch (error) {
594
- // No existing session - this is expected
595
- this.isAuthenticated = false;
596
- this.isAnonymous = false;
597
- this.user = null;
598
- this.session = null;
600
+ this._clearIdentity();
601
+ // Signed out for now; other tabs keep their session
602
+ if (!isNoSession(error)) {
603
+ this._sessionUnverified = true;
604
+ scheduleRecheck(this);
605
+ console.warn('[Manifest Appwrite Auth] Session check failed (signed out until reachable):', error?.message || error);
606
+ }
599
607
  }
600
608
 
601
609
  // Sync state to localStorage
@@ -611,6 +619,17 @@ function initializeAuthStore() {
611
619
  this._initialized = true;
612
620
  this._initializing = false;
613
621
 
622
+ // Guest-auto / callback sign-in follows: scoped data holds instead of flashing signed-out
623
+ if (!this.isAuthenticated) {
624
+ let callback = false;
625
+ try {
626
+ // Team invites need an existing session (acceptInvite refuses signed-out), so they don't sign in
627
+ const info = window.ManifestAppwriteAuthCallbacks?.detect?.();
628
+ callback = !!(info?.hasCallback && !info.isTeamInvite);
629
+ } catch (e) { /* no-op */ }
630
+ if (this._guestAuto || callback) this._signInPendingUntil = Date.now() + SIGN_IN_PENDING_MS;
631
+ }
632
+
614
633
  // Fire as soon as identity is known, before teams load, so a session
615
634
  // gate / splash clears in a few hundred ms.
616
635
  window.dispatchEvent(new CustomEvent('manifest:auth:initialized', {
@@ -633,6 +652,95 @@ function initializeAuthStore() {
633
652
  }
634
653
  },
635
654
 
655
+ // Adopt the session Appwrite reports (throws when there is none or it is unreachable)
656
+ async _restoreSession(appwrite, isCurrent = () => true) {
657
+ const user = await appwrite.account.get();
658
+ const sessionsResponse = await appwrite.account.listSessions();
659
+ if (!isCurrent()) return false;
660
+ this.user = user;
661
+ const allSessions = sessionsResponse.sessions || [];
662
+ const currentSession = allSessions.find(s => s.current === true) || allSessions[0];
663
+
664
+ if (currentSession) {
665
+ this.session = currentSession;
666
+ this.isAuthenticated = true;
667
+ this.isAnonymous = currentSession.provider === 'anonymous';
668
+
669
+ // Restore OAuth provider from storage (persists across redirects/refresh)
670
+ if (!this.isAnonymous && currentSession.provider !== 'magic-url') {
671
+ try {
672
+ // Try localStorage first (persists across redirects), fallback to sessionStorage
673
+ let storedProvider = localStorage.getItem('manifest:oauth:provider');
674
+ if (!storedProvider) {
675
+ storedProvider = sessionStorage.getItem('manifest:oauth:provider');
676
+ }
677
+ if (storedProvider) {
678
+ this._oauthProvider = storedProvider;
679
+ }
680
+ } catch (e) {
681
+ // Storage error
682
+ }
683
+ }
684
+
685
+ // If guest is disabled but we have anonymous session, clear it
686
+ if (this.isAnonymous && !this._guestAuto && !this._guestManual) {
687
+ try {
688
+ await appwrite.account.deleteSession(this.session.$id);
689
+ this.isAuthenticated = false;
690
+ this.isAnonymous = false;
691
+ this.user = null;
692
+ this.session = null;
693
+ } catch (deleteError) {
694
+ // Failed to delete guest session
695
+ }
696
+ }
697
+ } else {
698
+ this.isAuthenticated = true; // User exists, session might be managed by cookies
699
+ this.isAnonymous = false;
700
+ }
701
+ return true;
702
+ },
703
+
704
+ // Offline-booted tab: re-run the session check once Appwrite is reachable
705
+ async _recheckSession() {
706
+ if (!this._sessionUnverified || this._rechecking || !this._appwrite) return;
707
+ if (this._recheckTimer) { clearTimeout(this._recheckTimer); this._recheckTimer = null; }
708
+ this._rechecking = true;
709
+ const epoch = this._authEpoch;
710
+ const current = () => this._authEpoch === epoch && this._sessionUnverified && !this.isAuthenticated;
711
+ try {
712
+ if (!await this._restoreSession(this._appwrite, current)) return;
713
+ } catch (error) {
714
+ if (!current()) return;
715
+ if (!isNoSession(error)) { scheduleRecheck(this); return; }
716
+ this._clearIdentity();
717
+ markVerified(this);
718
+ syncStateToStorage(this);
719
+ if (this._guestAuto && this._createAnonymousSession) await this._createAnonymousSession();
720
+ return;
721
+ } finally {
722
+ this._rechecking = false;
723
+ }
724
+ markVerified(this);
725
+ const cfg = await config.getAppwriteConfig();
726
+ if (this._authEpoch !== epoch) return;
727
+ if (this.isAuthenticated && cfg?.teams && (!this.isAnonymous || cfg.guestTeams)) {
728
+ try { await this._loadTeamsAndSeed(cfg); } catch (e) { console.warn('[Manifest Appwrite Auth] Team load failed:', e?.message || e); }
729
+ if (this._authEpoch !== epoch) return;
730
+ }
731
+ syncStateToStorage(this);
732
+ if (this.isAuthenticated) {
733
+ window.dispatchEvent(new CustomEvent(this.isAnonymous ? 'manifest:auth:anonymous' : 'manifest:auth:login', { detail: { user: this.user } }));
734
+ }
735
+ },
736
+
737
+ _clearIdentity() {
738
+ this.isAuthenticated = false;
739
+ this.isAnonymous = false;
740
+ this.user = null;
741
+ this.session = null;
742
+ },
743
+
636
744
  // Clear team state when the identity changes to a different user (e.g. guest
637
745
  // replaced on OTP sign-in). Otherwise stale currentTeam/teams cause 404s.
638
746
  _resetTeamsState() {
@@ -804,6 +912,8 @@ function initializeAuthStore() {
804
912
  return { success: true };
805
913
  }
806
914
 
915
+ this._authEpoch++;
916
+ markVerified(this);
807
917
  this.inProgress = true;
808
918
 
809
919
  try {
@@ -825,7 +935,7 @@ function initializeAuthStore() {
825
935
  this.magicLinkSent = false;
826
936
  this.magicLinkExpired = false;
827
937
 
828
- // Clear email OTP flags
938
+ // Clear OTP flags (email/phone)
829
939
  this.otpSent = false;
830
940
  this.otpExpired = false;
831
941
  this.otpPhrase = null;
@@ -847,15 +957,12 @@ function initializeAuthStore() {
847
957
 
848
958
  // Restore to guest state after logout (guest-auto only, and not when
849
959
  // already a guest — don't mint a new guest on top).
960
+ let restored = false;
850
961
  if (!this.isAnonymous && this._guestAuto && this._createAnonymousSession) {
851
962
  await this._createAnonymousSession();
852
- } else {
853
- // Clear auth state completely
854
- this.isAuthenticated = false;
855
- this.isAnonymous = false;
856
- this.user = null;
857
- this.session = null;
963
+ restored = this.isAuthenticated && this.isAnonymous;
858
964
  }
965
+ if (!restored) this._clearIdentity();
859
966
 
860
967
  syncStateToStorage(this);
861
968
  window.dispatchEvent(new CustomEvent('manifest:auth:logout'));
@@ -893,6 +1000,10 @@ function initializeAuthStore() {
893
1000
  // Clear teams on logout error too
894
1001
  this.teams = [];
895
1002
  this.currentTeam = null;
1003
+ if (!this.isAuthenticated) this._clearIdentity();
1004
+ // Expired session (401) is still a logout: scoped data must drop the old rows
1005
+ syncStateToStorage(this);
1006
+ window.dispatchEvent(new CustomEvent('manifest:auth:logout'));
896
1007
  return { success: false, error: error.message };
897
1008
  } finally {
898
1009
  this.inProgress = false;
@@ -905,6 +1016,8 @@ function initializeAuthStore() {
905
1016
  return { success: false, error: 'Appwrite not configured' };
906
1017
  }
907
1018
 
1019
+ this._authEpoch++;
1020
+ markVerified(this);
908
1021
  this.inProgress = true;
909
1022
 
910
1023
  try {
@@ -944,6 +1057,10 @@ function initializeAuthStore() {
944
1057
  this.session = null;
945
1058
  this.magicLinkSent = false;
946
1059
  this.magicLinkExpired = false;
1060
+ this.teams = [];
1061
+ this.currentTeam = null;
1062
+ syncStateToStorage(this);
1063
+ window.dispatchEvent(new CustomEvent('manifest:auth:session-cleared'));
947
1064
  return { success: false, error: error.message };
948
1065
  } finally {
949
1066
  this.inProgress = false;
@@ -958,10 +1075,12 @@ function initializeAuthStore() {
958
1075
 
959
1076
  try {
960
1077
  this.user = await this._appwrite.account.get();
1078
+ if (this.isAuthenticated) markVerified(this);
961
1079
  syncStateToStorage(this);
962
1080
  return this.user;
963
1081
  } catch (error) {
964
- // Session may have expired
1082
+ if (!isNoSession(error)) throw error;
1083
+ markVerified(this);
965
1084
  this.isAuthenticated = false;
966
1085
  this.isAnonymous = false;
967
1086
  this.user = null;
@@ -1038,8 +1157,8 @@ async function initializeAppwriteAuthPlugin() {
1038
1157
  }
1039
1158
  }
1040
1159
 
1041
- // If no session and guest-auto is enabled, create guest session
1042
- if (!store.isAuthenticated && store._guestAuto && store._createAnonymousSession) {
1160
+ // If no session and guest-auto is enabled, create guest session (not while the session check is unreachable)
1161
+ if (!store.isAuthenticated && !store._sessionUnverified && store._guestAuto && store._createAnonymousSession) {
1043
1162
  store._createAnonymousSession();
1044
1163
  }
1045
1164
  }, { once: true });
@@ -6177,7 +6296,8 @@ window.ManifestAppwriteAuthMagicLinks = {
6177
6296
  /* Auth email OTP (one-time passcode) */
6178
6297
 
6179
6298
  // Two-step in-page flow (no redirect): createEmailOTP(email) emails a code + returns
6180
- // a userId, verifyOTP(code) creates the session.
6299
+ // a userId, verifyOTP(code) creates the session. verifyOTP/submitOTP are shared
6300
+ // with phone OTP (users.phone.js).
6181
6301
  // Gotcha: Appwrite can't convert an anonymous guest via OTP — a guest verifying an OTP
6182
6302
  // gets a fresh account (guest teams lost). Use magic links for guest upgrade.
6183
6303
 
@@ -6247,17 +6367,20 @@ function initializeEmailOTP() {
6247
6367
  this._appwrite = await config.getAppwriteClient();
6248
6368
  }
6249
6369
  if (!this._appwrite) {
6250
- return { success: false, error: 'Appwrite not configured' };
6370
+ this.error = 'Appwrite not configured';
6371
+ return { success: false, error: this.error };
6251
6372
  }
6252
6373
 
6253
6374
  // Don't allow OTP request if already signed in (non-anonymous)
6254
6375
  if (this.isAuthenticated && !this.isAnonymous) {
6255
- return { success: false, error: 'Already signed in. Please logout first.' };
6376
+ this.error = 'Already signed in. Please logout first.';
6377
+ return { success: false, error: this.error };
6256
6378
  }
6257
6379
 
6258
6380
  const appwriteConfig = await config.getAppwriteConfig();
6259
6381
  if (appwriteConfig && !appwriteConfig.otp) {
6260
- return { success: false, error: 'Email OTP authentication is not enabled' };
6382
+ this.error = 'Email OTP authentication is not enabled';
6383
+ return { success: false, error: this.error };
6261
6384
  }
6262
6385
 
6263
6386
  // OTP can't convert a guest — warn so lost guest teams aren't a surprise.
@@ -6267,10 +6390,8 @@ function initializeEmailOTP() {
6267
6390
 
6268
6391
  const account = this._appwrite.account;
6269
6392
  if (typeof account.createEmailToken !== 'function') {
6270
- return {
6271
- success: false,
6272
- error: 'Email OTP method not available. Please ensure you are using a recent Appwrite SDK.'
6273
- };
6393
+ this.error = 'Email OTP method not available. Please ensure you are using a recent Appwrite SDK.';
6394
+ return { success: false, error: this.error };
6274
6395
  }
6275
6396
 
6276
6397
  this.inProgress = true;
@@ -6316,7 +6437,8 @@ function initializeEmailOTP() {
6316
6437
  const { email, inputEl, dataObj } = resolveEmailInput(emailInputOrRef);
6317
6438
 
6318
6439
  if (!email || !email.trim()) {
6319
- return { success: false, error: 'Email is required' };
6440
+ this.error = 'Email is required';
6441
+ return { success: false, error: this.error };
6320
6442
  }
6321
6443
 
6322
6444
  const result = await this.createEmailOTP(email.trim(), options);
@@ -6490,6 +6612,197 @@ window.ManifestAppwriteAuthEmailOTP = {
6490
6612
  };
6491
6613
 
6492
6614
 
6615
+ /* Auth phone OTP (SMS one-time passcode) */
6616
+
6617
+ // Two-step in-page flow (no redirect): createPhoneOTP(phone) texts a code + returns
6618
+ // a userId, then the shared verifyOTP(code)/submitOTP() (users.otp.js) creates the
6619
+ // session. Phone numbers must be E.164 (+ country code).
6620
+ // Gotcha: like email OTP, Appwrite can't convert an anonymous guest via phone OTP —
6621
+ // a guest verifying a code gets a fresh account. Use magic links for guest upgrade.
6622
+
6623
+ function initializePhoneOTP() {
6624
+ if (typeof Alpine === 'undefined') {
6625
+ return;
6626
+ }
6627
+
6628
+ const config = window.ManifestAppwriteAuthConfig;
6629
+ if (!config) {
6630
+ return;
6631
+ }
6632
+
6633
+ // Resolve a phone from an input/selector/{ phone } object/string, or auto-find
6634
+ // the nearest tel input. Returns { phone, inputEl, dataObj }.
6635
+ function resolvePhoneInput(phoneInputOrRef) {
6636
+ let phone = null;
6637
+ let inputEl = null;
6638
+ let dataObj = null;
6639
+
6640
+ if (phoneInputOrRef === undefined || phoneInputOrRef === null) {
6641
+ let eventTarget = (typeof window !== 'undefined' && window.event) ? window.event.target : null;
6642
+ if (eventTarget) {
6643
+ const form = eventTarget.closest('form');
6644
+ const scope = form || eventTarget.parentElement;
6645
+ if (scope) {
6646
+ inputEl = scope.querySelector('input[type="tel"]');
6647
+ if (inputEl) phone = inputEl.value;
6648
+ }
6649
+ }
6650
+ if (!inputEl) {
6651
+ inputEl = document.querySelector('input[type="tel"]');
6652
+ if (inputEl) phone = inputEl.value;
6653
+ }
6654
+ } else if (typeof phoneInputOrRef === 'string') {
6655
+ try {
6656
+ const element = document.querySelector(phoneInputOrRef);
6657
+ if (element && element.tagName === 'INPUT' && element.type === 'tel') {
6658
+ inputEl = element;
6659
+ phone = element.value;
6660
+ } else {
6661
+ phone = phoneInputOrRef; // Treat as a direct phone string
6662
+ }
6663
+ } catch (e) {
6664
+ phone = phoneInputOrRef; // Invalid selector -> treat as phone string
6665
+ }
6666
+ } else if (phoneInputOrRef && typeof phoneInputOrRef === 'object') {
6667
+ if (phoneInputOrRef.tagName === 'INPUT' || phoneInputOrRef.matches?.('input[type="tel"]')) {
6668
+ inputEl = phoneInputOrRef;
6669
+ phone = inputEl.value;
6670
+ } else if ('phone' in phoneInputOrRef) {
6671
+ phone = phoneInputOrRef.phone;
6672
+ dataObj = phoneInputOrRef;
6673
+ }
6674
+ }
6675
+
6676
+ return { phone, inputEl, dataObj };
6677
+ }
6678
+
6679
+ const waitForStore = () => {
6680
+ const store = Alpine.store('auth');
6681
+ if (store && !store.createPhoneOTP) {
6682
+ // Step 1: text a passcode. Verification reuses verifyOTP/submitOTP.
6683
+ store.createPhoneOTP = async function (phone) {
6684
+ if (!this._appwrite) {
6685
+ this._appwrite = await config.getAppwriteClient();
6686
+ }
6687
+ if (!this._appwrite) {
6688
+ this.error = 'Appwrite not configured';
6689
+ return { success: false, error: this.error };
6690
+ }
6691
+
6692
+ // Don't allow OTP request if already signed in (non-anonymous)
6693
+ if (this.isAuthenticated && !this.isAnonymous) {
6694
+ this.error = 'Already signed in. Please logout first.';
6695
+ return { success: false, error: this.error };
6696
+ }
6697
+
6698
+ const appwriteConfig = await config.getAppwriteConfig();
6699
+ if (appwriteConfig && !appwriteConfig.phone) {
6700
+ this.error = 'Phone OTP authentication is not enabled';
6701
+ return { success: false, error: this.error };
6702
+ }
6703
+
6704
+ // Normalize to E.164: strip separators, require the country code.
6705
+ const normalized = String(phone).replace(/[\s().-]/g, '');
6706
+ if (!/^\+\d{6,15}$/.test(normalized)) {
6707
+ this.error = 'Phone number must be in international format, e.g. +14155550123';
6708
+ return { success: false, error: this.error };
6709
+ }
6710
+
6711
+ // Phone OTP can't convert a guest — warn so lost guest teams aren't a surprise.
6712
+ if (this.isAnonymous && appwriteConfig?.guestUpgrade) {
6713
+ console.warn('[Manifest Appwrite Auth] Phone OTP cannot upgrade a guest account (Appwrite limitation); the guest session and any guest-created teams will be replaced. Use magic links for guest upgrade.');
6714
+ }
6715
+
6716
+ const account = this._appwrite.account;
6717
+ if (typeof account.createPhoneToken !== 'function') {
6718
+ this.error = 'Phone OTP method not available. Please ensure you are using a recent Appwrite SDK.';
6719
+ return { success: false, error: this.error };
6720
+ }
6721
+
6722
+ this.inProgress = true;
6723
+ this.error = null;
6724
+ this.otpExpired = false;
6725
+
6726
+ try {
6727
+ const uniqueId = (window.Appwrite?.ID?.unique) ? window.Appwrite.ID.unique() : 'unique()';
6728
+ const token = await account.createPhoneToken(uniqueId, normalized);
6729
+
6730
+ // Stash the userId Appwrite assigned; verifyOTP needs it to complete login.
6731
+ this._otpUserId = token.userId;
6732
+ this.otpPhrase = null; // phone tokens have no security phrase
6733
+ this.otpSent = true;
6734
+ this.otpExpired = false;
6735
+ this.error = null;
6736
+
6737
+ window.dispatchEvent(new CustomEvent('manifest:auth:otp-sent', {
6738
+ detail: { phone: normalized }
6739
+ }));
6740
+
6741
+ return { success: true, message: 'OTP sent by SMS' };
6742
+ } catch (error) {
6743
+ // Appwrite returns 501 when phone auth/SMS isn't set up — surface an
6744
+ // actionable message rather than the raw error.
6745
+ const code = error.code || error.statusCode;
6746
+ const notEnabled = code === 501 || /not implemented/i.test(error.message || '');
6747
+ this.error = notEnabled
6748
+ ? 'Phone OTP is not enabled for this Appwrite project. Enable Phone under Auth → Settings and configure an SMS provider under Messaging.'
6749
+ : error.message;
6750
+ this.otpSent = false;
6751
+ this.otpExpired = false;
6752
+ return { success: false, error: this.error };
6753
+ } finally {
6754
+ this.inProgress = false;
6755
+ }
6756
+ };
6757
+
6758
+ // Convenience: resolve the phone from an input/selector/object/string and send.
6759
+ // Clears the phone input on success (mirrors sendEmailOTP).
6760
+ store.sendPhoneOTP = async function (phoneInputOrRef) {
6761
+ const { phone, inputEl, dataObj } = resolvePhoneInput(phoneInputOrRef);
6762
+
6763
+ if (!phone || !String(phone).trim()) {
6764
+ this.error = 'Phone number is required';
6765
+ return { success: false, error: this.error };
6766
+ }
6767
+
6768
+ const result = await this.createPhoneOTP(String(phone).trim());
6769
+
6770
+ if (result.success) {
6771
+ Promise.resolve().then(() => {
6772
+ if (inputEl) {
6773
+ inputEl.value = '';
6774
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
6775
+ } else if (dataObj) {
6776
+ dataObj.phone = '';
6777
+ }
6778
+ });
6779
+ }
6780
+
6781
+ return result;
6782
+ };
6783
+ } else if (!store) {
6784
+ setTimeout(waitForStore, 50);
6785
+ }
6786
+ };
6787
+
6788
+ setTimeout(waitForStore, 100);
6789
+ }
6790
+
6791
+ // Initialize when Alpine is ready
6792
+ document.addEventListener('alpine:init', () => {
6793
+ try {
6794
+ initializePhoneOTP();
6795
+ } catch (error) {
6796
+ // Failed to initialize phone OTP
6797
+ }
6798
+ });
6799
+
6800
+ // Export phone OTP interface
6801
+ window.ManifestAppwriteAuthPhoneOTP = {
6802
+ initialize: initializePhoneOTP
6803
+ };
6804
+
6805
+
6493
6806
  /* Auth OAuth */
6494
6807
 
6495
6808
  // Add OAuth methods to auth store