mnfst 0.5.136 → 0.5.138

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.
@@ -69,12 +69,14 @@ async function getAppwriteConfig() {
69
69
  // Get auth methods from config (defaults to ["magic", "oauth"] if not specified)
70
70
  const authMethods = appwriteConfig.auth?.methods || ["magic", "oauth"];
71
71
 
72
- // Guest session support: "guest-auto" = automatic, "guest-manual" = manual only
73
- const guestAuto = authMethods.includes("guest-auto");
72
+ // Guest session support: "guest"/"guest-auto" = automatic, "guest-manual" = manual only.
73
+ // ("guest" is the schema's documented spelling; "guest-auto" is accepted as a synonym.)
74
+ const guestAuto = authMethods.includes("guest") || authMethods.includes("guest-auto");
74
75
  const guestManual = authMethods.includes("guest-manual");
75
76
  const hasGuest = guestAuto || guestManual;
76
77
 
77
78
  const magicEnabled = authMethods.includes("magic");
79
+ const otpEnabled = authMethods.includes("otp");
78
80
  const oauthEnabled = authMethods.includes("oauth");
79
81
 
80
82
  // Teams support: presence of teams object enables it
@@ -82,6 +84,15 @@ async function getAppwriteConfig() {
82
84
  const permanentTeams = appwriteConfig.auth?.teams?.permanent || null; // Array of team names (immutable)
83
85
  const templateTeams = appwriteConfig.auth?.teams?.template || null; // Array of team names (can be deleted and reapplied)
84
86
  const teamsPollInterval = appwriteConfig.auth?.teams?.pollInterval || null; // Polling interval in milliseconds (null = disabled)
87
+ const guestTeams = !!appwriteConfig.auth?.teams?.guests; // Seed default teams for guest (anonymous) sessions
88
+
89
+ // Guest upgrade: preserve the anonymous account (and its teams) when a guest signs in,
90
+ // rather than discarding it and minting a fresh user. Supported by magic links and OAuth;
91
+ // email OTP cannot convert anonymous accounts (Appwrite limitation). Defaults to guestTeams,
92
+ // since orphaning guest-created teams on signup is the failure mode guest teams introduce.
93
+ const guestUpgrade = appwriteConfig.auth?.guestUpgrade !== undefined
94
+ ? !!appwriteConfig.auth.guestUpgrade
95
+ : guestTeams;
85
96
 
86
97
  // Default roles: permanent (cannot be deleted) and template (can be deleted)
87
98
  // These are objects mapping role names to permissions: { "Admin": ["inviteMembers", ...] }
@@ -97,6 +108,11 @@ async function getAppwriteConfig() {
97
108
  // Creator role: string reference to a role in memberRoles (role creator gets by default)
98
109
  const creatorRole = appwriteConfig.auth?.creatorRole || null;
99
110
 
111
+ // Guest migration: id of the deployed guest-migration Appwrite Function. When set,
112
+ // a guest's teams are carried over to the account they sign into via OTP (which
113
+ // Appwrite can't convert in place). See templates/guest-migration-function.
114
+ const guestMigrationFunctionId = appwriteConfig.auth?.guestMigration?.functionId || null;
115
+
100
116
  return {
101
117
  endpoint,
102
118
  projectId,
@@ -107,11 +123,15 @@ async function getAppwriteConfig() {
107
123
  guestManual: guestManual,
108
124
  anonymous: guestAuto, // For backwards compatibility with existing code
109
125
  magic: magicEnabled,
126
+ otp: otpEnabled, // Email one-time-passcode authentication
110
127
  oauth: oauthEnabled,
111
128
  teams: teamsEnabled,
112
129
  permanentTeams: permanentTeams, // Array of team names (cannot be deleted)
113
130
  templateTeams: templateTeams, // Array of team names (can be deleted and reapplied)
114
131
  teamsPollInterval: teamsPollInterval, // Polling interval in milliseconds (null = disabled)
132
+ guestTeams: guestTeams, // Seed default teams for guest (anonymous) sessions
133
+ guestUpgrade: guestUpgrade, // Preserve guest account + teams on sign-in (magic/oauth)
134
+ guestMigrationFunctionId: guestMigrationFunctionId, // Carry guest teams to the OTP account via this function
115
135
  memberRoles: memberRoles, // Role definitions: { "RoleName": ["permission1", "permission2"] }
116
136
  permanentRoles: permanentRoles, // Object: { "RoleName": ["permission1", ...] } (cannot be deleted)
117
137
  templateRoles: templateRoles, // Object: { "RoleName": ["permission1", ...] } (can be deleted)
@@ -161,6 +181,7 @@ async function getAppwriteClient() {
161
181
  account: appwriteAccount,
162
182
  teams: appwriteTeams,
163
183
  users: appwriteUsers, // Add users service for fetching user details
184
+ functions: window.Appwrite?.Functions ? new window.Appwrite.Functions(appwriteClient) : null, // For guest-migration function calls
164
185
  realtime: window.Appwrite?.Realtime ? new window.Appwrite.Realtime(appwriteClient) : null // Realtime service for subscriptions
165
186
  };
166
187
  }
@@ -223,6 +244,8 @@ function initializeAuthStore() {
223
244
  store.session = state.session;
224
245
  store.magicLinkSent = state.magicLinkSent || false;
225
246
  store.magicLinkExpired = state.magicLinkExpired || false;
247
+ store.otpSent = state.otpSent || false;
248
+ store.otpExpired = state.otpExpired || false;
226
249
  store.error = state.error;
227
250
  }
228
251
  } catch (error) {
@@ -241,6 +264,8 @@ function initializeAuthStore() {
241
264
  session: sanitizeSessionForStorage(store.session),
242
265
  magicLinkSent: store.magicLinkSent,
243
266
  magicLinkExpired: store.magicLinkExpired,
267
+ otpSent: store.otpSent,
268
+ otpExpired: store.otpExpired,
244
269
  error: store.error
245
270
  };
246
271
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
@@ -258,6 +283,10 @@ function initializeAuthStore() {
258
283
  error: null,
259
284
  magicLinkSent: false,
260
285
  magicLinkExpired: false,
286
+ otpSent: false, // Email OTP: a code has been emailed and is awaiting entry
287
+ otpExpired: false, // Email OTP: the entered code was wrong/expired
288
+ otpPhrase: null, // Email OTP: security phrase to display (when enabled)
289
+ _otpUserId: null, // Email OTP: userId returned by createEmailToken, used by verifyOTP
261
290
  teams: [], // List of user's teams
262
291
  currentTeam: null, // Currently selected/active team
263
292
  _teamsPollInterval: null, // Interval ID for teams polling (deprecated, use realtime instead)
@@ -390,15 +419,20 @@ function initializeAuthStore() {
390
419
  return null;
391
420
  },
392
421
 
393
- // Get authentication method (oauth, magic, anonymous)
422
+ // Get authentication method (anonymous, magic, otp, phone, oauth)
394
423
  getMethod() {
395
424
  if (!this.session) return null;
396
- const provider = this.session.provider;
397
- if (provider === 'anonymous') return 'anonymous';
398
- if (provider === 'magic-url') return 'magic';
399
- // OAuth providers return their name (google, github, etc.)
400
- if (provider && provider !== 'anonymous' && provider !== 'magic-url') return 'oauth';
401
- return null;
425
+ // Appwrite session.provider: anonymous | magic-url | email (OTP) |
426
+ // token (OTP, some versions) | phone | oauth2 (or a specific provider).
427
+ switch (this.session.provider) {
428
+ case 'anonymous': return 'anonymous';
429
+ case 'magic-url': return 'magic';
430
+ case 'email': return 'otp'; // this plugin uses email tokens for OTP
431
+ case 'token': return 'otp';
432
+ case 'phone': return 'phone';
433
+ case 'oauth2': return 'oauth';
434
+ default: return this.session.provider ? 'oauth' : null;
435
+ }
402
436
  },
403
437
 
404
438
  // Get OAuth provider name (google, github, etc.) or null for non-OAuth methods
@@ -411,8 +445,10 @@ function initializeAuthStore() {
411
445
  const sessionProvider = this.session.provider;
412
446
 
413
447
  // For OAuth, return the stored provider name (google, github, etc.)
414
- // session.provider returns "oauth2" generically, so we use _oauthProvider
415
- if (sessionProvider && sessionProvider !== 'anonymous' && sessionProvider !== 'magic-url') {
448
+ // session.provider returns "oauth2" generically, so we use _oauthProvider.
449
+ // Only OAuth sessions have a provider name magic/otp/phone/anonymous don't,
450
+ // so gate on getMethod() to avoid a pointless identities lookup for those.
451
+ if (this.getMethod() === 'oauth') {
416
452
  // Try to get from store first, then localStorage, then sessionStorage
417
453
  let provider = this._oauthProvider;
418
454
  if (!provider) {
@@ -548,16 +584,17 @@ function initializeAuthStore() {
548
584
  this.isAnonymous = false;
549
585
  }
550
586
 
551
- // Load teams if enabled and user is authenticated
552
- if (this.isAuthenticated && appwriteConfig?.teams && this.listTeams) {
587
+ // Load teams if enabled and user is authenticated. Guests (anonymous)
588
+ // only seed default teams when auth.teams.guests is enabled.
589
+ // Note: do NOT gate on this.listTeams here — _loadTeamsAndSeed waits
590
+ // for the teams module to attach (it can lose the startup race to init).
591
+ if (this.isAuthenticated && appwriteConfig?.teams
592
+ && (!this.isAnonymous || appwriteConfig.guestTeams)) {
553
593
  try {
554
- await this.listTeams();
555
- // Auto-create default teams if enabled
556
- if ((appwriteConfig.permanentTeams || appwriteConfig.templateTeams) && window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams) {
557
- await window.ManifestAppwriteAuthTeamsDefaults.ensureDefaultTeams(this);
558
- }
594
+ await this._loadTeamsAndSeed(appwriteConfig);
559
595
  } catch (teamsError) {
560
- // Don't fail initialization if teams fail to load
596
+ // Don't fail initialization if teams fail to load, but surface why
597
+ console.warn('[Manifest Appwrite Auth] Failed to load/seed teams on restore:', teamsError);
561
598
  }
562
599
  }
563
600
  } catch (error) {
@@ -589,6 +626,72 @@ function initializeAuthStore() {
589
626
  }
590
627
  },
591
628
 
629
+ // Clear all team-related state. Used when the active identity changes to a
630
+ // DIFFERENT user — e.g. a guest replaced by a fresh account on OTP sign-in
631
+ // (Appwrite can't convert anonymous → OTP). Without this, the previous user's
632
+ // currentTeam/teams leak into the new session and listTeams queries teams the
633
+ // new user can't access, producing 404s on prefs/memberships.
634
+ _resetTeamsState() {
635
+ this.teams = [];
636
+ this.currentTeam = null;
637
+ this.currentTeamMemberships = [];
638
+ this.deletedTemplateTeams = [];
639
+ this.deletedTemplateRoles = [];
640
+ this._teamImmutableCache = {};
641
+ if (this.stopTeamsRealtime) {
642
+ try { this.stopTeamsRealtime(); } catch (e) { /* ignore */ }
643
+ }
644
+ },
645
+
646
+ // Call the deployed guest-migration function (templates/guest-migration-function).
647
+ // The current Appwrite session authenticates the call — Appwrite forwards the
648
+ // user id to the function. Returns the parsed JSON response, or null on any
649
+ // failure (migration is best-effort: a failure must never block sign-in).
650
+ async _callGuestMigration(path, body) {
651
+ const appwriteConfig = await config.getAppwriteConfig();
652
+ const fnId = appwriteConfig?.guestMigrationFunctionId;
653
+ if (!fnId || !this._appwrite?.functions) {
654
+ return null;
655
+ }
656
+ try {
657
+ const exec = await this._appwrite.functions.createExecution(
658
+ fnId, JSON.stringify(body || {}), false, path, 'POST'
659
+ );
660
+ const raw = exec?.responseBody ?? exec?.response ?? '';
661
+ try { return JSON.parse(raw); } catch (e) { return null; }
662
+ } catch (e) {
663
+ console.warn(`[Manifest Appwrite Auth] Guest migration ${path} failed:`, e.message);
664
+ return null;
665
+ }
666
+ },
667
+
668
+ // Load the user's teams and seed any configured default (permanent/template)
669
+ // teams. Shared by the guest, magic-link, OAuth, and init/restore paths.
670
+ async _loadTeamsAndSeed(appwriteConfig) {
671
+ const cfg = appwriteConfig || await config.getAppwriteConfig();
672
+ if (!cfg?.teams) {
673
+ return;
674
+ }
675
+ // Startup race: init() (and an early requestGuest) can reach here before
676
+ // teams.core.js / teams.defaults.js have finished wiring listTeams +
677
+ // ensureDefaultTeams onto the store. Wait briefly for them rather than
678
+ // silently skipping (which left guests with no teams on reload).
679
+ const needsSeed = !!(cfg.permanentTeams || cfg.templateTeams);
680
+ const ready = () => typeof this.listTeams === 'function'
681
+ && (!needsSeed || typeof window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams === 'function');
682
+ for (let i = 0; i < 40 && !ready(); i++) {
683
+ await new Promise(r => setTimeout(r, 50));
684
+ }
685
+ if (typeof this.listTeams !== 'function') {
686
+ console.warn('[Manifest Appwrite Auth] Teams module never became ready; skipping team load/seed.');
687
+ return;
688
+ }
689
+ await this.listTeams();
690
+ if (needsSeed && window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams) {
691
+ await window.ManifestAppwriteAuthTeamsDefaults.ensureDefaultTeams(this);
692
+ }
693
+ },
694
+
592
695
  // Manually create guest session (only works if guest-manual is enabled)
593
696
  async createGuest() {
594
697
  if (!this._guestManual) {
@@ -632,9 +735,16 @@ function initializeAuthStore() {
632
735
  // Ignore
633
736
  }
634
737
 
635
- // Clear teams for guest sessions (guests don't have teams)
636
- this.teams = [];
637
- this.currentTeam = null;
738
+ // Guests are full Appwrite sessions, so they can own teams. When
739
+ // auth.teams.guests is enabled, seed their default teams just like a
740
+ // signed-in user; otherwise keep the historical behavior (no teams).
741
+ const cfg = await config.getAppwriteConfig();
742
+ if (cfg?.guestTeams) {
743
+ await this._loadTeamsAndSeed(cfg);
744
+ } else {
745
+ this.teams = [];
746
+ this.currentTeam = null;
747
+ }
638
748
 
639
749
  syncStateToStorage(this);
640
750
  window.dispatchEvent(new CustomEvent('manifest:auth:anonymous', {
@@ -698,6 +808,12 @@ function initializeAuthStore() {
698
808
  this.magicLinkSent = false;
699
809
  this.magicLinkExpired = false;
700
810
 
811
+ // Clear email OTP flags
812
+ this.otpSent = false;
813
+ this.otpExpired = false;
814
+ this.otpPhrase = null;
815
+ this._otpUserId = null;
816
+
701
817
  // Stop teams realtime subscription if active
702
818
  if (this.stopTeamsRealtime) {
703
819
  this.stopTeamsRealtime();
@@ -2601,9 +2717,11 @@ async function ensureDefaultTeams(store) {
2601
2717
 
2602
2718
  if (result.success) {
2603
2719
  createdTeams.push(result.team);
2720
+ } else {
2721
+ console.warn(`[Manifest Appwrite Auth] Could not seed permanent team "${teamName}":`, result.error);
2604
2722
  }
2605
2723
  } catch (error) {
2606
- // Error creating permanent team
2724
+ console.warn(`[Manifest Appwrite Auth] Error seeding permanent team "${teamName}":`, error);
2607
2725
  }
2608
2726
  }
2609
2727
  }
@@ -2643,9 +2761,11 @@ async function ensureDefaultTeams(store) {
2643
2761
 
2644
2762
  if (result.success) {
2645
2763
  createdTeams.push(result.team);
2764
+ } else {
2765
+ console.warn(`[Manifest Appwrite Auth] Could not seed template team "${teamName}":`, result.error);
2646
2766
  }
2647
2767
  } catch (error) {
2648
- // Error creating template team
2768
+ console.warn(`[Manifest Appwrite Auth] Error seeding template team "${teamName}":`, error);
2649
2769
  }
2650
2770
  }
2651
2771
  }
@@ -4031,6 +4151,11 @@ window.ManifestAppwriteAuthTeamsUserRoles = {
4031
4151
 
4032
4152
  /* Auth teams - Membership operations */
4033
4153
 
4154
+ // The Users service is server-only (node-appwrite); it is never present on the
4155
+ // browser SDK, so member-email enrichment degrades gracefully. Warn once rather
4156
+ // than on every membership lookup to avoid flooding the console.
4157
+ let _usersServiceWarned = false;
4158
+
4034
4159
  // Add membership methods to auth store
4035
4160
  function initializeTeamsMembers() {
4036
4161
  if (typeof Alpine === 'undefined') {
@@ -4176,7 +4301,10 @@ function initializeTeamsMembers() {
4176
4301
  try {
4177
4302
  // Check if users service is available
4178
4303
  if (!this._appwrite || !this._appwrite.users || typeof this._appwrite.users.get !== 'function') {
4179
- console.warn('[Manifest Appwrite Auth] Users service not available on Appwrite client');
4304
+ if (!_usersServiceWarned) {
4305
+ _usersServiceWarned = true;
4306
+ console.warn('[Manifest Appwrite Auth] Users service unavailable on the browser SDK — member emails will not be enriched. (This is expected; logged once.)');
4307
+ }
4180
4308
  } else {
4181
4309
  const user = await this._appwrite.users.get({ userId: membership.userId });
4182
4310
  if (user && user.email) {
@@ -4211,7 +4339,10 @@ function initializeTeamsMembers() {
4211
4339
  try {
4212
4340
  // Check if users service is available
4213
4341
  if (!this._appwrite || !this._appwrite.users || typeof this._appwrite.users.get !== 'function') {
4214
- console.warn('[Manifest Appwrite Auth] Users service not available for pending invite lookup');
4342
+ if (!_usersServiceWarned) {
4343
+ _usersServiceWarned = true;
4344
+ console.warn('[Manifest Appwrite Auth] Users service unavailable on the browser SDK — member emails will not be enriched. (This is expected; logged once.)');
4345
+ }
4215
4346
  } else {
4216
4347
  const user = await this._appwrite.users.get({ userId: membership.userId });
4217
4348
  if (user && user.email) {
@@ -5503,6 +5634,17 @@ function initializeAnonymous() {
5503
5634
  this.isAuthenticated = true;
5504
5635
  this.isAnonymous = true;
5505
5636
 
5637
+ // Seed default teams for the guest when auth.teams.guests is enabled
5638
+ const appwriteConfig = await config.getAppwriteConfig();
5639
+ if (appwriteConfig?.guestTeams && this._loadTeamsAndSeed) {
5640
+ try {
5641
+ await this._loadTeamsAndSeed(appwriteConfig);
5642
+ } catch (teamsError) {
5643
+ // Don't fail guest creation if teams fail to load, but surface why
5644
+ console.warn('[Manifest Appwrite Auth] Failed to seed guest teams:', teamsError);
5645
+ }
5646
+ }
5647
+
5506
5648
  // Sync state to localStorage for cross-tab synchronization
5507
5649
  if (this._syncStateToStorage) {
5508
5650
  this._syncStateToStorage(this);
@@ -5601,9 +5743,17 @@ function initializeMagicLinks() {
5601
5743
 
5602
5744
  const account = this._appwrite.account;
5603
5745
 
5746
+ // Guest upgrade: when enabled and we're currently a guest, pass the
5747
+ // anonymous user's own id so Appwrite links the email to that same
5748
+ // account (preserving its teams) rather than minting a fresh user.
5749
+ // Otherwise generate a unique id for a brand-new account.
5750
+ const magicUserId = (appwriteConfig?.guestUpgrade && this.isAnonymous && this.user?.$id)
5751
+ ? this.user.$id
5752
+ : ((window.Appwrite?.ID?.unique) ? window.Appwrite.ID.unique() : 'unique()');
5753
+
5604
5754
  // Try createMagicURLSession first (standard method)
5605
5755
  if (typeof account.createMagicURLSession === 'function') {
5606
- const token = await account.createMagicURLSession('unique()', email, cleanRedirectUrl);
5756
+ const token = await account.createMagicURLSession(magicUserId, email, cleanRedirectUrl);
5607
5757
  this.magicLinkSent = true;
5608
5758
  this.magicLinkExpired = false;
5609
5759
  this.error = null;
@@ -5615,7 +5765,7 @@ function initializeMagicLinks() {
5615
5765
 
5616
5766
  // Fallback: try createMagicURLToken (alternative method name)
5617
5767
  if (typeof account.createMagicURLToken === 'function') {
5618
- const token = await account.createMagicURLToken('unique()', email, redirectUrl);
5768
+ const token = await account.createMagicURLToken(magicUserId, email, redirectUrl);
5619
5769
  this.magicLinkSent = true;
5620
5770
  this.magicLinkExpired = false;
5621
5771
  this.error = null;
@@ -5758,8 +5908,18 @@ function initializeMagicLinks() {
5758
5908
  this.magicLinkSent = false;
5759
5909
 
5760
5910
  try {
5761
- // Delete any existing anonymous sessions first
5762
- if (this.session && this.isAnonymous) {
5911
+ const appwriteConfig = await config.getAppwriteConfig();
5912
+ const upgradingGuest = !!(appwriteConfig?.guestUpgrade && this.isAnonymous);
5913
+ // A guest being replaced (not upgraded) by a different account — its
5914
+ // team state must be cleared before loading the new user's teams.
5915
+ const replacingGuest = this.isAnonymous && !upgradingGuest;
5916
+
5917
+ // Delete the existing anonymous session first — UNLESS we're upgrading
5918
+ // the guest in place. For an upgrade the magic token was created against
5919
+ // the anonymous user's own id, so createSession converts that same
5920
+ // account (keeping its teams); deleting it first would orphan the teams
5921
+ // and force a brand-new user.
5922
+ if (this.session && this.isAnonymous && !upgradingGuest) {
5763
5923
  try {
5764
5924
  await this._appwrite.account.deleteSession(this.session.$id);
5765
5925
  } catch (deleteError) {
@@ -5767,8 +5927,20 @@ function initializeMagicLinks() {
5767
5927
  }
5768
5928
  }
5769
5929
 
5770
- // Create session from magic link credentials
5771
- const session = await this._appwrite.account.createSession(userId, secret);
5930
+ // Create session from magic link credentials. When upgrading a guest the
5931
+ // anonymous session may still be active; Appwrite can reject the duplicate
5932
+ // with a "prohibited" error, in which case the account is already upgraded
5933
+ // and we just reuse the current session.
5934
+ let session;
5935
+ try {
5936
+ session = await this._appwrite.account.createSession(userId, secret);
5937
+ } catch (createError) {
5938
+ if (upgradingGuest && createError.message?.includes('prohibited')) {
5939
+ session = await this._appwrite.account.getSession('current');
5940
+ } else {
5941
+ throw createError;
5942
+ }
5943
+ }
5772
5944
  this.session = session;
5773
5945
  this.user = await this._appwrite.account.get();
5774
5946
  this.isAuthenticated = true;
@@ -5784,20 +5956,21 @@ function initializeMagicLinks() {
5784
5956
  // Ignore
5785
5957
  }
5786
5958
 
5959
+ // Replacing a guest with a different account: drop the guest's stale
5960
+ // team state so listTeams doesn't query teams the new user can't access.
5961
+ if (replacingGuest && this._resetTeamsState) {
5962
+ this._resetTeamsState();
5963
+ }
5964
+
5787
5965
  // Sync state
5788
5966
  if (this._syncStateToStorage) {
5789
5967
  this._syncStateToStorage(this);
5790
5968
  }
5791
5969
 
5792
- // Load teams if enabled
5793
- const appwriteConfig = await config.getAppwriteConfig();
5970
+ // Load teams if enabled (and seed any configured default teams)
5794
5971
  if (appwriteConfig?.teams && this.listTeams) {
5795
5972
  try {
5796
- await this.listTeams();
5797
- // Auto-create default teams if enabled
5798
- if ((appwriteConfig.permanentTeams || appwriteConfig.templateTeams) && window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams) {
5799
- await window.ManifestAppwriteAuthTeamsDefaults.ensureDefaultTeams(this);
5800
- }
5973
+ await this._loadTeamsAndSeed(appwriteConfig);
5801
5974
  } catch (teamsError) {
5802
5975
  console.warn('[Manifest Appwrite Auth] Failed to load teams after magic link login:', teamsError);
5803
5976
  // Don't fail login if teams fail to load
@@ -5981,6 +6154,334 @@ window.ManifestAppwriteAuthMagicLinks = {
5981
6154
  handleCallbacks: handleMagicLinkCallbacks
5982
6155
  };
5983
6156
 
6157
+ /* Auth email OTP (one-time passcode) */
6158
+
6159
+ // Email OTP is a two-step, in-page flow (no redirect, unlike magic links):
6160
+ // 1. createEmailOTP(email) -> Appwrite emails a 6-digit code, returns a userId
6161
+ // 2. verifyOTP(code) -> createSession(userId, code) completes login
6162
+ // Because there is no URL round-trip, this module never touches users.callbacks.js.
6163
+ //
6164
+ // NOTE: Appwrite does NOT support converting an anonymous (guest) session via email
6165
+ // OTP — only magic links, phone, email/password, and OAuth can do that. So when a
6166
+ // guest verifies an OTP we mint a fresh account (the anonymous session is deleted),
6167
+ // which discards any guest-created teams. Use magic links if you need guest upgrade.
6168
+
6169
+ function initializeEmailOTP() {
6170
+ if (typeof Alpine === 'undefined') {
6171
+ return;
6172
+ }
6173
+
6174
+ const config = window.ManifestAppwriteAuthConfig;
6175
+ if (!config) {
6176
+ return;
6177
+ }
6178
+
6179
+ // Resolve an email string from the same range of inputs sendMagicLink accepts:
6180
+ // an input element, a selector, an Alpine { email } object, a bare string, or
6181
+ // nothing (auto-find the nearest email input). Returns { email, inputEl, dataObj }.
6182
+ function resolveEmailInput(emailInputOrRef) {
6183
+ let email = null;
6184
+ let inputEl = null;
6185
+ let dataObj = null;
6186
+
6187
+ if (emailInputOrRef === undefined || emailInputOrRef === null) {
6188
+ let eventTarget = (typeof window !== 'undefined' && window.event) ? window.event.target : null;
6189
+ if (eventTarget) {
6190
+ const form = eventTarget.closest('form');
6191
+ const scope = form || eventTarget.parentElement;
6192
+ if (scope) {
6193
+ inputEl = scope.querySelector('input[type="email"]');
6194
+ if (inputEl) email = inputEl.value;
6195
+ }
6196
+ }
6197
+ if (!inputEl) {
6198
+ inputEl = document.querySelector('input[type="email"]');
6199
+ if (inputEl) email = inputEl.value;
6200
+ }
6201
+ } else if (typeof emailInputOrRef === 'string') {
6202
+ try {
6203
+ const element = document.querySelector(emailInputOrRef);
6204
+ if (element && element.tagName === 'INPUT' && element.type === 'email') {
6205
+ inputEl = element;
6206
+ email = element.value;
6207
+ } else {
6208
+ email = emailInputOrRef; // Treat as a direct email string
6209
+ }
6210
+ } catch (e) {
6211
+ email = emailInputOrRef; // Invalid selector -> treat as email string
6212
+ }
6213
+ } else if (emailInputOrRef && typeof emailInputOrRef === 'object') {
6214
+ if (emailInputOrRef.tagName === 'INPUT' || emailInputOrRef.matches?.('input[type="email"]')) {
6215
+ inputEl = emailInputOrRef;
6216
+ email = inputEl.value;
6217
+ } else if ('email' in emailInputOrRef) {
6218
+ email = emailInputOrRef.email;
6219
+ dataObj = emailInputOrRef;
6220
+ }
6221
+ }
6222
+
6223
+ return { email, inputEl, dataObj };
6224
+ }
6225
+
6226
+ const waitForStore = () => {
6227
+ const store = Alpine.store('auth');
6228
+ if (store && !store.createEmailOTP) {
6229
+ // Step 1: request a one-time passcode by email.
6230
+ // Pass { phrase: true } to enable Appwrite's security phrase (anti-phishing);
6231
+ // when enabled the phrase is stored on the store as `otpPhrase` for display.
6232
+ store.createEmailOTP = async function (email, options = {}) {
6233
+ if (!this._appwrite) {
6234
+ this._appwrite = await config.getAppwriteClient();
6235
+ }
6236
+ if (!this._appwrite) {
6237
+ return { success: false, error: 'Appwrite not configured' };
6238
+ }
6239
+
6240
+ // Don't allow OTP request if already signed in (non-anonymous)
6241
+ if (this.isAuthenticated && !this.isAnonymous) {
6242
+ return { success: false, error: 'Already signed in. Please logout first.' };
6243
+ }
6244
+
6245
+ const appwriteConfig = await config.getAppwriteConfig();
6246
+ if (appwriteConfig && !appwriteConfig.otp) {
6247
+ return { success: false, error: 'Email OTP authentication is not enabled' };
6248
+ }
6249
+
6250
+ // Appwrite can't convert an anonymous account via OTP. Warn (once) so guest
6251
+ // teams aren't silently lost; the login still proceeds as a fresh account.
6252
+ if (this.isAnonymous && appwriteConfig?.guestUpgrade) {
6253
+ console.warn('[Manifest Appwrite Auth] Email 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.');
6254
+ }
6255
+
6256
+ const account = this._appwrite.account;
6257
+ if (typeof account.createEmailToken !== 'function') {
6258
+ return {
6259
+ success: false,
6260
+ error: 'Email OTP method not available. Please ensure you are using a recent Appwrite SDK.'
6261
+ };
6262
+ }
6263
+
6264
+ this.inProgress = true;
6265
+ this.error = null;
6266
+ this.otpExpired = false;
6267
+
6268
+ try {
6269
+ const uniqueId = (window.Appwrite?.ID?.unique) ? window.Appwrite.ID.unique() : 'unique()';
6270
+ // Third arg toggles Appwrite's security phrase feature.
6271
+ const token = await account.createEmailToken(uniqueId, email, options.phrase === true);
6272
+
6273
+ // Stash the userId Appwrite assigned; verifyOTP needs it to complete login.
6274
+ this._otpUserId = token.userId;
6275
+ this.otpPhrase = token.phrase || null;
6276
+ this.otpSent = true;
6277
+ this.otpExpired = false;
6278
+ this.error = null;
6279
+
6280
+ window.dispatchEvent(new CustomEvent('manifest:auth:otp-sent', {
6281
+ detail: { email, phrase: this.otpPhrase }
6282
+ }));
6283
+
6284
+ return { success: true, message: 'OTP sent to email', phrase: this.otpPhrase };
6285
+ } catch (error) {
6286
+ // Appwrite returns 501 Not Implemented when Email OTP isn't enabled
6287
+ // for the project. Surface an actionable message instead of the raw error.
6288
+ const code = error.code || error.statusCode;
6289
+ const notEnabled = code === 501 || /not implemented/i.test(error.message || '');
6290
+ this.error = notEnabled
6291
+ ? 'Email OTP is not enabled for this Appwrite project. Enable it under Auth → Settings.'
6292
+ : error.message;
6293
+ this.otpSent = false;
6294
+ this.otpExpired = false;
6295
+ return { success: false, error: this.error };
6296
+ } finally {
6297
+ this.inProgress = false;
6298
+ }
6299
+ };
6300
+
6301
+ // Convenience: resolve the email from an input/selector/object/string and send.
6302
+ // Clears the email input on success (mirrors sendMagicLink).
6303
+ store.sendEmailOTP = async function (emailInputOrRef, options = {}) {
6304
+ const { email, inputEl, dataObj } = resolveEmailInput(emailInputOrRef);
6305
+
6306
+ if (!email || !email.trim()) {
6307
+ return { success: false, error: 'Email is required' };
6308
+ }
6309
+
6310
+ const result = await this.createEmailOTP(email.trim(), options);
6311
+
6312
+ if (result.success) {
6313
+ Promise.resolve().then(() => {
6314
+ if (inputEl) {
6315
+ inputEl.value = '';
6316
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
6317
+ } else if (dataObj) {
6318
+ dataObj.email = '';
6319
+ }
6320
+ });
6321
+ }
6322
+
6323
+ return result;
6324
+ };
6325
+
6326
+ // Step 2: verify the code and create the session.
6327
+ store.verifyOTP = async function (code) {
6328
+ if (!this._appwrite) {
6329
+ this._appwrite = await config.getAppwriteClient();
6330
+ }
6331
+ if (!this._appwrite) {
6332
+ return { success: false, error: 'Appwrite not configured' };
6333
+ }
6334
+ if (!this._otpUserId) {
6335
+ return { success: false, error: 'Request a code first' };
6336
+ }
6337
+ if (!code || !String(code).trim()) {
6338
+ return { success: false, error: 'Code is required' };
6339
+ }
6340
+
6341
+ this.inProgress = true;
6342
+ this.error = null;
6343
+
6344
+ try {
6345
+ const appwriteConfig = await config.getAppwriteConfig();
6346
+ const wasGuest = this.isAnonymous;
6347
+
6348
+ // Guest team carryover: OTP can't convert the anonymous account, so we
6349
+ // migrate its teams to the new account instead. Issue the migration
6350
+ // ticket NOW, while the guest session is still authenticated — it's
6351
+ // redeemed after the new session exists. Best-effort; never blocks login.
6352
+ let migrationTicket = null;
6353
+ if (wasGuest && appwriteConfig?.guestMigrationFunctionId && this._callGuestMigration) {
6354
+ const prep = await this._callGuestMigration('/prepare', {});
6355
+ if (prep?.ok && prep.ticket) migrationTicket = prep.ticket;
6356
+ }
6357
+
6358
+ // Appwrite can't convert anonymous accounts via OTP, so delete the
6359
+ // guest session first to avoid a "session prohibited" conflict.
6360
+ if (this.session && this.isAnonymous) {
6361
+ try {
6362
+ await this._appwrite.account.deleteSession(this.session.$id);
6363
+ } catch (deleteError) {
6364
+ // Could not delete anonymous session
6365
+ }
6366
+ }
6367
+
6368
+ const session = await this._appwrite.account.createSession(this._otpUserId, String(code).trim());
6369
+ this.session = session;
6370
+ this.user = await this._appwrite.account.get();
6371
+ this.isAuthenticated = true;
6372
+ this.isAnonymous = false;
6373
+ this.otpSent = false;
6374
+ this.otpExpired = false;
6375
+ this.otpPhrase = null;
6376
+ this._otpUserId = null;
6377
+ this.error = null;
6378
+
6379
+ // OTP replaces any prior guest with a different account (no conversion),
6380
+ // so clear the guest's team state before loading the new user's teams —
6381
+ // otherwise the stale currentTeam triggers 404s in listTeams.
6382
+ if (this._resetTeamsState) {
6383
+ this._resetTeamsState();
6384
+ }
6385
+
6386
+ // Redeem the migration ticket as the new account: carries the guest's
6387
+ // teams over. Best-effort — a failure leaves the guest's teams for GC
6388
+ // but never blocks the (already successful) sign-in.
6389
+ if (migrationTicket && this._callGuestMigration) {
6390
+ await this._callGuestMigration('/commit', { ticket: migrationTicket });
6391
+ }
6392
+
6393
+ if (this._syncStateToStorage) {
6394
+ this._syncStateToStorage(this);
6395
+ }
6396
+
6397
+ // Load teams + seed any configured default teams for the new account
6398
+ if (appwriteConfig?.teams && this.listTeams) {
6399
+ try {
6400
+ await this._loadTeamsAndSeed(appwriteConfig);
6401
+ } catch (teamsError) {
6402
+ console.warn('[Manifest Appwrite Auth] Failed to load teams after OTP login:', teamsError);
6403
+ }
6404
+ }
6405
+
6406
+ window.dispatchEvent(new CustomEvent('manifest:auth:login', {
6407
+ detail: { user: this.user }
6408
+ }));
6409
+
6410
+ return { success: true, user: this.user };
6411
+ } catch (error) {
6412
+ const errorMessage = error.message || '';
6413
+ const errorCode = error.code || error.statusCode || '';
6414
+ const isExpiredOrInvalid = errorMessage && (
6415
+ errorMessage.includes('expired') ||
6416
+ errorMessage.includes('Invalid token') ||
6417
+ errorMessage.includes('invalid') ||
6418
+ errorMessage.includes('not found') ||
6419
+ errorCode === 401 || errorCode === 404
6420
+ );
6421
+
6422
+ this.otpExpired = !!isExpiredOrInvalid;
6423
+ this.error = isExpiredOrInvalid ? null : error.message;
6424
+ this.isAuthenticated = false;
6425
+ this.isAnonymous = false;
6426
+
6427
+ if (this._syncStateToStorage) {
6428
+ this._syncStateToStorage(this);
6429
+ }
6430
+
6431
+ return { success: false, error: error.message };
6432
+ } finally {
6433
+ this.inProgress = false;
6434
+ }
6435
+ };
6436
+
6437
+ // Convenience: resolve the code from an input/selector/object/string and verify.
6438
+ store.submitOTP = async function (codeInputOrRef) {
6439
+ let code = null;
6440
+ if (codeInputOrRef === undefined || codeInputOrRef === null) {
6441
+ const el = document.querySelector('input[name="otp"], input[autocomplete="one-time-code"], input[inputmode="numeric"]');
6442
+ if (el) code = el.value;
6443
+ } else if (typeof codeInputOrRef === 'string') {
6444
+ try {
6445
+ const el = document.querySelector(codeInputOrRef);
6446
+ code = (el && el.tagName === 'INPUT') ? el.value : codeInputOrRef;
6447
+ } catch (e) {
6448
+ code = codeInputOrRef;
6449
+ }
6450
+ } else if (codeInputOrRef && typeof codeInputOrRef === 'object') {
6451
+ if (codeInputOrRef.tagName === 'INPUT') {
6452
+ code = codeInputOrRef.value;
6453
+ } else if ('code' in codeInputOrRef) {
6454
+ code = codeInputOrRef.code;
6455
+ } else if ('otp' in codeInputOrRef) {
6456
+ code = codeInputOrRef.otp;
6457
+ }
6458
+ }
6459
+
6460
+ return await this.verifyOTP(code);
6461
+ };
6462
+ } else if (!store) {
6463
+ setTimeout(waitForStore, 50);
6464
+ }
6465
+ };
6466
+
6467
+ setTimeout(waitForStore, 100);
6468
+ }
6469
+
6470
+ // Initialize when Alpine is ready
6471
+ document.addEventListener('alpine:init', () => {
6472
+ try {
6473
+ initializeEmailOTP();
6474
+ } catch (error) {
6475
+ // Failed to initialize email OTP
6476
+ }
6477
+ });
6478
+
6479
+ // Export email OTP interface
6480
+ window.ManifestAppwriteAuthEmailOTP = {
6481
+ initialize: initializeEmailOTP
6482
+ };
6483
+
6484
+
5984
6485
  /* Auth OAuth */
5985
6486
 
5986
6487
  // Add OAuth methods to auth store
@@ -6022,8 +6523,10 @@ function initializeOAuth() {
6022
6523
 
6023
6524
  // Delete any existing anonymous sessions before OAuth
6024
6525
  // This prevents conflicts where anonymous sessions might interfere with OAuth
6025
- // Appwrite will create a new account for OAuth if needed
6026
- if (this.isAnonymous && this.session) {
6526
+ // Appwrite will create a new account for OAuth if needed.
6527
+ // EXCEPTION: when guest upgrade is enabled we keep the anonymous session
6528
+ // active so Appwrite can link the OAuth identity to it (preserving teams).
6529
+ if (this.isAnonymous && this.session && !appwriteConfig?.guestUpgrade) {
6027
6530
  try {
6028
6531
  await this._appwrite.account.deleteSession(this.session.$id);
6029
6532
  this.session = null;
@@ -6175,8 +6678,16 @@ function handleOAuthCallbacks() {
6175
6678
  store.magicLinkSent = false;
6176
6679
 
6177
6680
  try {
6178
- // Delete any existing anonymous sessions first
6179
- if (store.session && store.isAnonymous) {
6681
+ const appwriteConfig = await window.ManifestAppwriteAuthConfig.getAppwriteConfig();
6682
+ const upgradingGuest = !!(appwriteConfig?.guestUpgrade && store.isAnonymous);
6683
+ // A guest being replaced (not upgraded) by a different account — its team
6684
+ // state must be cleared before loading the new user's teams.
6685
+ const replacingGuest = store.isAnonymous && !upgradingGuest;
6686
+
6687
+ // Delete the existing anonymous session first — UNLESS we're upgrading the
6688
+ // guest in place, in which case Appwrite linked the OAuth identity to that
6689
+ // account and the "prohibited" branch below reuses the upgraded session.
6690
+ if (store.session && store.isAnonymous && !upgradingGuest) {
6180
6691
  try {
6181
6692
  await store._appwrite.account.deleteSession(store.session.$id);
6182
6693
  } catch (deleteError) {
@@ -6223,20 +6734,21 @@ function handleOAuthCallbacks() {
6223
6734
  }
6224
6735
  }
6225
6736
 
6737
+ // Replacing a guest with a different account: drop the guest's stale team
6738
+ // state so listTeams doesn't query teams the new user can't access.
6739
+ if (replacingGuest && store._resetTeamsState) {
6740
+ store._resetTeamsState();
6741
+ }
6742
+
6226
6743
  // Sync state
6227
6744
  if (store._syncStateToStorage) {
6228
6745
  store._syncStateToStorage(store);
6229
6746
  }
6230
6747
 
6231
- // Load teams if enabled
6232
- const appwriteConfig = await window.ManifestAppwriteAuthConfig.getAppwriteConfig();
6748
+ // Load teams if enabled (and seed any configured default teams)
6233
6749
  if (appwriteConfig?.teams && store.listTeams) {
6234
6750
  try {
6235
- await store.listTeams();
6236
- // Auto-create default teams if enabled
6237
- if ((appwriteConfig.permanentTeams || appwriteConfig.templateTeams) && window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams) {
6238
- await window.ManifestAppwriteAuthTeamsDefaults.ensureDefaultTeams(store);
6239
- }
6751
+ await store._loadTeamsAndSeed(appwriteConfig);
6240
6752
  } catch (teamsError) {
6241
6753
  console.warn('[Manifest Appwrite Auth] Failed to load teams after OAuth login:', teamsError);
6242
6754
  // Don't fail login if teams fail to load