mnfst 0.5.135 → 0.5.137

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,14 +584,12 @@ 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
+ if (this.isAuthenticated && appwriteConfig?.teams && this.listTeams
590
+ && (!this.isAnonymous || appwriteConfig.guestTeams)) {
553
591
  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
- }
592
+ await this._loadTeamsAndSeed(appwriteConfig);
559
593
  } catch (teamsError) {
560
594
  // Don't fail initialization if teams fail to load
561
595
  }
@@ -589,6 +623,58 @@ function initializeAuthStore() {
589
623
  }
590
624
  },
591
625
 
626
+ // Clear all team-related state. Used when the active identity changes to a
627
+ // DIFFERENT user — e.g. a guest replaced by a fresh account on OTP sign-in
628
+ // (Appwrite can't convert anonymous → OTP). Without this, the previous user's
629
+ // currentTeam/teams leak into the new session and listTeams queries teams the
630
+ // new user can't access, producing 404s on prefs/memberships.
631
+ _resetTeamsState() {
632
+ this.teams = [];
633
+ this.currentTeam = null;
634
+ this.currentTeamMemberships = [];
635
+ this.deletedTemplateTeams = [];
636
+ this.deletedTemplateRoles = [];
637
+ this._teamImmutableCache = {};
638
+ if (this.stopTeamsRealtime) {
639
+ try { this.stopTeamsRealtime(); } catch (e) { /* ignore */ }
640
+ }
641
+ },
642
+
643
+ // Call the deployed guest-migration function (templates/guest-migration-function).
644
+ // The current Appwrite session authenticates the call — Appwrite forwards the
645
+ // user id to the function. Returns the parsed JSON response, or null on any
646
+ // failure (migration is best-effort: a failure must never block sign-in).
647
+ async _callGuestMigration(path, body) {
648
+ const appwriteConfig = await config.getAppwriteConfig();
649
+ const fnId = appwriteConfig?.guestMigrationFunctionId;
650
+ if (!fnId || !this._appwrite?.functions) {
651
+ return null;
652
+ }
653
+ try {
654
+ const exec = await this._appwrite.functions.createExecution(
655
+ fnId, JSON.stringify(body || {}), false, path, 'POST'
656
+ );
657
+ const raw = exec?.responseBody ?? exec?.response ?? '';
658
+ try { return JSON.parse(raw); } catch (e) { return null; }
659
+ } catch (e) {
660
+ console.warn(`[Manifest Appwrite Auth] Guest migration ${path} failed:`, e.message);
661
+ return null;
662
+ }
663
+ },
664
+
665
+ // Load the user's teams and seed any configured default (permanent/template)
666
+ // teams. Shared by the guest, magic-link, OAuth, and init/restore paths.
667
+ async _loadTeamsAndSeed(appwriteConfig) {
668
+ const cfg = appwriteConfig || await config.getAppwriteConfig();
669
+ if (!cfg?.teams || !this.listTeams) {
670
+ return;
671
+ }
672
+ await this.listTeams();
673
+ if ((cfg.permanentTeams || cfg.templateTeams) && window.ManifestAppwriteAuthTeamsDefaults?.ensureDefaultTeams) {
674
+ await window.ManifestAppwriteAuthTeamsDefaults.ensureDefaultTeams(this);
675
+ }
676
+ },
677
+
592
678
  // Manually create guest session (only works if guest-manual is enabled)
593
679
  async createGuest() {
594
680
  if (!this._guestManual) {
@@ -632,9 +718,16 @@ function initializeAuthStore() {
632
718
  // Ignore
633
719
  }
634
720
 
635
- // Clear teams for guest sessions (guests don't have teams)
636
- this.teams = [];
637
- this.currentTeam = null;
721
+ // Guests are full Appwrite sessions, so they can own teams. When
722
+ // auth.teams.guests is enabled, seed their default teams just like a
723
+ // signed-in user; otherwise keep the historical behavior (no teams).
724
+ const cfg = await config.getAppwriteConfig();
725
+ if (cfg?.guestTeams) {
726
+ await this._loadTeamsAndSeed(cfg);
727
+ } else {
728
+ this.teams = [];
729
+ this.currentTeam = null;
730
+ }
638
731
 
639
732
  syncStateToStorage(this);
640
733
  window.dispatchEvent(new CustomEvent('manifest:auth:anonymous', {
@@ -698,6 +791,12 @@ function initializeAuthStore() {
698
791
  this.magicLinkSent = false;
699
792
  this.magicLinkExpired = false;
700
793
 
794
+ // Clear email OTP flags
795
+ this.otpSent = false;
796
+ this.otpExpired = false;
797
+ this.otpPhrase = null;
798
+ this._otpUserId = null;
799
+
701
800
  // Stop teams realtime subscription if active
702
801
  if (this.stopTeamsRealtime) {
703
802
  this.stopTeamsRealtime();
@@ -4031,6 +4130,11 @@ window.ManifestAppwriteAuthTeamsUserRoles = {
4031
4130
 
4032
4131
  /* Auth teams - Membership operations */
4033
4132
 
4133
+ // The Users service is server-only (node-appwrite); it is never present on the
4134
+ // browser SDK, so member-email enrichment degrades gracefully. Warn once rather
4135
+ // than on every membership lookup to avoid flooding the console.
4136
+ let _usersServiceWarned = false;
4137
+
4034
4138
  // Add membership methods to auth store
4035
4139
  function initializeTeamsMembers() {
4036
4140
  if (typeof Alpine === 'undefined') {
@@ -4176,7 +4280,10 @@ function initializeTeamsMembers() {
4176
4280
  try {
4177
4281
  // Check if users service is available
4178
4282
  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');
4283
+ if (!_usersServiceWarned) {
4284
+ _usersServiceWarned = true;
4285
+ console.warn('[Manifest Appwrite Auth] Users service unavailable on the browser SDK — member emails will not be enriched. (This is expected; logged once.)');
4286
+ }
4180
4287
  } else {
4181
4288
  const user = await this._appwrite.users.get({ userId: membership.userId });
4182
4289
  if (user && user.email) {
@@ -4211,7 +4318,10 @@ function initializeTeamsMembers() {
4211
4318
  try {
4212
4319
  // Check if users service is available
4213
4320
  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');
4321
+ if (!_usersServiceWarned) {
4322
+ _usersServiceWarned = true;
4323
+ console.warn('[Manifest Appwrite Auth] Users service unavailable on the browser SDK — member emails will not be enriched. (This is expected; logged once.)');
4324
+ }
4215
4325
  } else {
4216
4326
  const user = await this._appwrite.users.get({ userId: membership.userId });
4217
4327
  if (user && user.email) {
@@ -5503,6 +5613,16 @@ function initializeAnonymous() {
5503
5613
  this.isAuthenticated = true;
5504
5614
  this.isAnonymous = true;
5505
5615
 
5616
+ // Seed default teams for the guest when auth.teams.guests is enabled
5617
+ const appwriteConfig = await config.getAppwriteConfig();
5618
+ if (appwriteConfig?.guestTeams && this._loadTeamsAndSeed) {
5619
+ try {
5620
+ await this._loadTeamsAndSeed(appwriteConfig);
5621
+ } catch (teamsError) {
5622
+ // Don't fail guest creation if teams fail to load
5623
+ }
5624
+ }
5625
+
5506
5626
  // Sync state to localStorage for cross-tab synchronization
5507
5627
  if (this._syncStateToStorage) {
5508
5628
  this._syncStateToStorage(this);
@@ -5601,9 +5721,17 @@ function initializeMagicLinks() {
5601
5721
 
5602
5722
  const account = this._appwrite.account;
5603
5723
 
5724
+ // Guest upgrade: when enabled and we're currently a guest, pass the
5725
+ // anonymous user's own id so Appwrite links the email to that same
5726
+ // account (preserving its teams) rather than minting a fresh user.
5727
+ // Otherwise generate a unique id for a brand-new account.
5728
+ const magicUserId = (appwriteConfig?.guestUpgrade && this.isAnonymous && this.user?.$id)
5729
+ ? this.user.$id
5730
+ : ((window.Appwrite?.ID?.unique) ? window.Appwrite.ID.unique() : 'unique()');
5731
+
5604
5732
  // Try createMagicURLSession first (standard method)
5605
5733
  if (typeof account.createMagicURLSession === 'function') {
5606
- const token = await account.createMagicURLSession('unique()', email, cleanRedirectUrl);
5734
+ const token = await account.createMagicURLSession(magicUserId, email, cleanRedirectUrl);
5607
5735
  this.magicLinkSent = true;
5608
5736
  this.magicLinkExpired = false;
5609
5737
  this.error = null;
@@ -5615,7 +5743,7 @@ function initializeMagicLinks() {
5615
5743
 
5616
5744
  // Fallback: try createMagicURLToken (alternative method name)
5617
5745
  if (typeof account.createMagicURLToken === 'function') {
5618
- const token = await account.createMagicURLToken('unique()', email, redirectUrl);
5746
+ const token = await account.createMagicURLToken(magicUserId, email, redirectUrl);
5619
5747
  this.magicLinkSent = true;
5620
5748
  this.magicLinkExpired = false;
5621
5749
  this.error = null;
@@ -5758,8 +5886,18 @@ function initializeMagicLinks() {
5758
5886
  this.magicLinkSent = false;
5759
5887
 
5760
5888
  try {
5761
- // Delete any existing anonymous sessions first
5762
- if (this.session && this.isAnonymous) {
5889
+ const appwriteConfig = await config.getAppwriteConfig();
5890
+ const upgradingGuest = !!(appwriteConfig?.guestUpgrade && this.isAnonymous);
5891
+ // A guest being replaced (not upgraded) by a different account — its
5892
+ // team state must be cleared before loading the new user's teams.
5893
+ const replacingGuest = this.isAnonymous && !upgradingGuest;
5894
+
5895
+ // Delete the existing anonymous session first — UNLESS we're upgrading
5896
+ // the guest in place. For an upgrade the magic token was created against
5897
+ // the anonymous user's own id, so createSession converts that same
5898
+ // account (keeping its teams); deleting it first would orphan the teams
5899
+ // and force a brand-new user.
5900
+ if (this.session && this.isAnonymous && !upgradingGuest) {
5763
5901
  try {
5764
5902
  await this._appwrite.account.deleteSession(this.session.$id);
5765
5903
  } catch (deleteError) {
@@ -5767,8 +5905,20 @@ function initializeMagicLinks() {
5767
5905
  }
5768
5906
  }
5769
5907
 
5770
- // Create session from magic link credentials
5771
- const session = await this._appwrite.account.createSession(userId, secret);
5908
+ // Create session from magic link credentials. When upgrading a guest the
5909
+ // anonymous session may still be active; Appwrite can reject the duplicate
5910
+ // with a "prohibited" error, in which case the account is already upgraded
5911
+ // and we just reuse the current session.
5912
+ let session;
5913
+ try {
5914
+ session = await this._appwrite.account.createSession(userId, secret);
5915
+ } catch (createError) {
5916
+ if (upgradingGuest && createError.message?.includes('prohibited')) {
5917
+ session = await this._appwrite.account.getSession('current');
5918
+ } else {
5919
+ throw createError;
5920
+ }
5921
+ }
5772
5922
  this.session = session;
5773
5923
  this.user = await this._appwrite.account.get();
5774
5924
  this.isAuthenticated = true;
@@ -5784,20 +5934,21 @@ function initializeMagicLinks() {
5784
5934
  // Ignore
5785
5935
  }
5786
5936
 
5937
+ // Replacing a guest with a different account: drop the guest's stale
5938
+ // team state so listTeams doesn't query teams the new user can't access.
5939
+ if (replacingGuest && this._resetTeamsState) {
5940
+ this._resetTeamsState();
5941
+ }
5942
+
5787
5943
  // Sync state
5788
5944
  if (this._syncStateToStorage) {
5789
5945
  this._syncStateToStorage(this);
5790
5946
  }
5791
5947
 
5792
- // Load teams if enabled
5793
- const appwriteConfig = await config.getAppwriteConfig();
5948
+ // Load teams if enabled (and seed any configured default teams)
5794
5949
  if (appwriteConfig?.teams && this.listTeams) {
5795
5950
  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
- }
5951
+ await this._loadTeamsAndSeed(appwriteConfig);
5801
5952
  } catch (teamsError) {
5802
5953
  console.warn('[Manifest Appwrite Auth] Failed to load teams after magic link login:', teamsError);
5803
5954
  // Don't fail login if teams fail to load
@@ -5981,6 +6132,334 @@ window.ManifestAppwriteAuthMagicLinks = {
5981
6132
  handleCallbacks: handleMagicLinkCallbacks
5982
6133
  };
5983
6134
 
6135
+ /* Auth email OTP (one-time passcode) */
6136
+
6137
+ // Email OTP is a two-step, in-page flow (no redirect, unlike magic links):
6138
+ // 1. createEmailOTP(email) -> Appwrite emails a 6-digit code, returns a userId
6139
+ // 2. verifyOTP(code) -> createSession(userId, code) completes login
6140
+ // Because there is no URL round-trip, this module never touches users.callbacks.js.
6141
+ //
6142
+ // NOTE: Appwrite does NOT support converting an anonymous (guest) session via email
6143
+ // OTP — only magic links, phone, email/password, and OAuth can do that. So when a
6144
+ // guest verifies an OTP we mint a fresh account (the anonymous session is deleted),
6145
+ // which discards any guest-created teams. Use magic links if you need guest upgrade.
6146
+
6147
+ function initializeEmailOTP() {
6148
+ if (typeof Alpine === 'undefined') {
6149
+ return;
6150
+ }
6151
+
6152
+ const config = window.ManifestAppwriteAuthConfig;
6153
+ if (!config) {
6154
+ return;
6155
+ }
6156
+
6157
+ // Resolve an email string from the same range of inputs sendMagicLink accepts:
6158
+ // an input element, a selector, an Alpine { email } object, a bare string, or
6159
+ // nothing (auto-find the nearest email input). Returns { email, inputEl, dataObj }.
6160
+ function resolveEmailInput(emailInputOrRef) {
6161
+ let email = null;
6162
+ let inputEl = null;
6163
+ let dataObj = null;
6164
+
6165
+ if (emailInputOrRef === undefined || emailInputOrRef === null) {
6166
+ let eventTarget = (typeof window !== 'undefined' && window.event) ? window.event.target : null;
6167
+ if (eventTarget) {
6168
+ const form = eventTarget.closest('form');
6169
+ const scope = form || eventTarget.parentElement;
6170
+ if (scope) {
6171
+ inputEl = scope.querySelector('input[type="email"]');
6172
+ if (inputEl) email = inputEl.value;
6173
+ }
6174
+ }
6175
+ if (!inputEl) {
6176
+ inputEl = document.querySelector('input[type="email"]');
6177
+ if (inputEl) email = inputEl.value;
6178
+ }
6179
+ } else if (typeof emailInputOrRef === 'string') {
6180
+ try {
6181
+ const element = document.querySelector(emailInputOrRef);
6182
+ if (element && element.tagName === 'INPUT' && element.type === 'email') {
6183
+ inputEl = element;
6184
+ email = element.value;
6185
+ } else {
6186
+ email = emailInputOrRef; // Treat as a direct email string
6187
+ }
6188
+ } catch (e) {
6189
+ email = emailInputOrRef; // Invalid selector -> treat as email string
6190
+ }
6191
+ } else if (emailInputOrRef && typeof emailInputOrRef === 'object') {
6192
+ if (emailInputOrRef.tagName === 'INPUT' || emailInputOrRef.matches?.('input[type="email"]')) {
6193
+ inputEl = emailInputOrRef;
6194
+ email = inputEl.value;
6195
+ } else if ('email' in emailInputOrRef) {
6196
+ email = emailInputOrRef.email;
6197
+ dataObj = emailInputOrRef;
6198
+ }
6199
+ }
6200
+
6201
+ return { email, inputEl, dataObj };
6202
+ }
6203
+
6204
+ const waitForStore = () => {
6205
+ const store = Alpine.store('auth');
6206
+ if (store && !store.createEmailOTP) {
6207
+ // Step 1: request a one-time passcode by email.
6208
+ // Pass { phrase: true } to enable Appwrite's security phrase (anti-phishing);
6209
+ // when enabled the phrase is stored on the store as `otpPhrase` for display.
6210
+ store.createEmailOTP = async function (email, options = {}) {
6211
+ if (!this._appwrite) {
6212
+ this._appwrite = await config.getAppwriteClient();
6213
+ }
6214
+ if (!this._appwrite) {
6215
+ return { success: false, error: 'Appwrite not configured' };
6216
+ }
6217
+
6218
+ // Don't allow OTP request if already signed in (non-anonymous)
6219
+ if (this.isAuthenticated && !this.isAnonymous) {
6220
+ return { success: false, error: 'Already signed in. Please logout first.' };
6221
+ }
6222
+
6223
+ const appwriteConfig = await config.getAppwriteConfig();
6224
+ if (appwriteConfig && !appwriteConfig.otp) {
6225
+ return { success: false, error: 'Email OTP authentication is not enabled' };
6226
+ }
6227
+
6228
+ // Appwrite can't convert an anonymous account via OTP. Warn (once) so guest
6229
+ // teams aren't silently lost; the login still proceeds as a fresh account.
6230
+ if (this.isAnonymous && appwriteConfig?.guestUpgrade) {
6231
+ 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.');
6232
+ }
6233
+
6234
+ const account = this._appwrite.account;
6235
+ if (typeof account.createEmailToken !== 'function') {
6236
+ return {
6237
+ success: false,
6238
+ error: 'Email OTP method not available. Please ensure you are using a recent Appwrite SDK.'
6239
+ };
6240
+ }
6241
+
6242
+ this.inProgress = true;
6243
+ this.error = null;
6244
+ this.otpExpired = false;
6245
+
6246
+ try {
6247
+ const uniqueId = (window.Appwrite?.ID?.unique) ? window.Appwrite.ID.unique() : 'unique()';
6248
+ // Third arg toggles Appwrite's security phrase feature.
6249
+ const token = await account.createEmailToken(uniqueId, email, options.phrase === true);
6250
+
6251
+ // Stash the userId Appwrite assigned; verifyOTP needs it to complete login.
6252
+ this._otpUserId = token.userId;
6253
+ this.otpPhrase = token.phrase || null;
6254
+ this.otpSent = true;
6255
+ this.otpExpired = false;
6256
+ this.error = null;
6257
+
6258
+ window.dispatchEvent(new CustomEvent('manifest:auth:otp-sent', {
6259
+ detail: { email, phrase: this.otpPhrase }
6260
+ }));
6261
+
6262
+ return { success: true, message: 'OTP sent to email', phrase: this.otpPhrase };
6263
+ } catch (error) {
6264
+ // Appwrite returns 501 Not Implemented when Email OTP isn't enabled
6265
+ // for the project. Surface an actionable message instead of the raw error.
6266
+ const code = error.code || error.statusCode;
6267
+ const notEnabled = code === 501 || /not implemented/i.test(error.message || '');
6268
+ this.error = notEnabled
6269
+ ? 'Email OTP is not enabled for this Appwrite project. Enable it under Auth → Settings.'
6270
+ : error.message;
6271
+ this.otpSent = false;
6272
+ this.otpExpired = false;
6273
+ return { success: false, error: this.error };
6274
+ } finally {
6275
+ this.inProgress = false;
6276
+ }
6277
+ };
6278
+
6279
+ // Convenience: resolve the email from an input/selector/object/string and send.
6280
+ // Clears the email input on success (mirrors sendMagicLink).
6281
+ store.sendEmailOTP = async function (emailInputOrRef, options = {}) {
6282
+ const { email, inputEl, dataObj } = resolveEmailInput(emailInputOrRef);
6283
+
6284
+ if (!email || !email.trim()) {
6285
+ return { success: false, error: 'Email is required' };
6286
+ }
6287
+
6288
+ const result = await this.createEmailOTP(email.trim(), options);
6289
+
6290
+ if (result.success) {
6291
+ Promise.resolve().then(() => {
6292
+ if (inputEl) {
6293
+ inputEl.value = '';
6294
+ inputEl.dispatchEvent(new Event('input', { bubbles: true }));
6295
+ } else if (dataObj) {
6296
+ dataObj.email = '';
6297
+ }
6298
+ });
6299
+ }
6300
+
6301
+ return result;
6302
+ };
6303
+
6304
+ // Step 2: verify the code and create the session.
6305
+ store.verifyOTP = async function (code) {
6306
+ if (!this._appwrite) {
6307
+ this._appwrite = await config.getAppwriteClient();
6308
+ }
6309
+ if (!this._appwrite) {
6310
+ return { success: false, error: 'Appwrite not configured' };
6311
+ }
6312
+ if (!this._otpUserId) {
6313
+ return { success: false, error: 'Request a code first' };
6314
+ }
6315
+ if (!code || !String(code).trim()) {
6316
+ return { success: false, error: 'Code is required' };
6317
+ }
6318
+
6319
+ this.inProgress = true;
6320
+ this.error = null;
6321
+
6322
+ try {
6323
+ const appwriteConfig = await config.getAppwriteConfig();
6324
+ const wasGuest = this.isAnonymous;
6325
+
6326
+ // Guest team carryover: OTP can't convert the anonymous account, so we
6327
+ // migrate its teams to the new account instead. Issue the migration
6328
+ // ticket NOW, while the guest session is still authenticated — it's
6329
+ // redeemed after the new session exists. Best-effort; never blocks login.
6330
+ let migrationTicket = null;
6331
+ if (wasGuest && appwriteConfig?.guestMigrationFunctionId && this._callGuestMigration) {
6332
+ const prep = await this._callGuestMigration('/prepare', {});
6333
+ if (prep?.ok && prep.ticket) migrationTicket = prep.ticket;
6334
+ }
6335
+
6336
+ // Appwrite can't convert anonymous accounts via OTP, so delete the
6337
+ // guest session first to avoid a "session prohibited" conflict.
6338
+ if (this.session && this.isAnonymous) {
6339
+ try {
6340
+ await this._appwrite.account.deleteSession(this.session.$id);
6341
+ } catch (deleteError) {
6342
+ // Could not delete anonymous session
6343
+ }
6344
+ }
6345
+
6346
+ const session = await this._appwrite.account.createSession(this._otpUserId, String(code).trim());
6347
+ this.session = session;
6348
+ this.user = await this._appwrite.account.get();
6349
+ this.isAuthenticated = true;
6350
+ this.isAnonymous = false;
6351
+ this.otpSent = false;
6352
+ this.otpExpired = false;
6353
+ this.otpPhrase = null;
6354
+ this._otpUserId = null;
6355
+ this.error = null;
6356
+
6357
+ // OTP replaces any prior guest with a different account (no conversion),
6358
+ // so clear the guest's team state before loading the new user's teams —
6359
+ // otherwise the stale currentTeam triggers 404s in listTeams.
6360
+ if (this._resetTeamsState) {
6361
+ this._resetTeamsState();
6362
+ }
6363
+
6364
+ // Redeem the migration ticket as the new account: carries the guest's
6365
+ // teams over. Best-effort — a failure leaves the guest's teams for GC
6366
+ // but never blocks the (already successful) sign-in.
6367
+ if (migrationTicket && this._callGuestMigration) {
6368
+ await this._callGuestMigration('/commit', { ticket: migrationTicket });
6369
+ }
6370
+
6371
+ if (this._syncStateToStorage) {
6372
+ this._syncStateToStorage(this);
6373
+ }
6374
+
6375
+ // Load teams + seed any configured default teams for the new account
6376
+ if (appwriteConfig?.teams && this.listTeams) {
6377
+ try {
6378
+ await this._loadTeamsAndSeed(appwriteConfig);
6379
+ } catch (teamsError) {
6380
+ console.warn('[Manifest Appwrite Auth] Failed to load teams after OTP login:', teamsError);
6381
+ }
6382
+ }
6383
+
6384
+ window.dispatchEvent(new CustomEvent('manifest:auth:login', {
6385
+ detail: { user: this.user }
6386
+ }));
6387
+
6388
+ return { success: true, user: this.user };
6389
+ } catch (error) {
6390
+ const errorMessage = error.message || '';
6391
+ const errorCode = error.code || error.statusCode || '';
6392
+ const isExpiredOrInvalid = errorMessage && (
6393
+ errorMessage.includes('expired') ||
6394
+ errorMessage.includes('Invalid token') ||
6395
+ errorMessage.includes('invalid') ||
6396
+ errorMessage.includes('not found') ||
6397
+ errorCode === 401 || errorCode === 404
6398
+ );
6399
+
6400
+ this.otpExpired = !!isExpiredOrInvalid;
6401
+ this.error = isExpiredOrInvalid ? null : error.message;
6402
+ this.isAuthenticated = false;
6403
+ this.isAnonymous = false;
6404
+
6405
+ if (this._syncStateToStorage) {
6406
+ this._syncStateToStorage(this);
6407
+ }
6408
+
6409
+ return { success: false, error: error.message };
6410
+ } finally {
6411
+ this.inProgress = false;
6412
+ }
6413
+ };
6414
+
6415
+ // Convenience: resolve the code from an input/selector/object/string and verify.
6416
+ store.submitOTP = async function (codeInputOrRef) {
6417
+ let code = null;
6418
+ if (codeInputOrRef === undefined || codeInputOrRef === null) {
6419
+ const el = document.querySelector('input[name="otp"], input[autocomplete="one-time-code"], input[inputmode="numeric"]');
6420
+ if (el) code = el.value;
6421
+ } else if (typeof codeInputOrRef === 'string') {
6422
+ try {
6423
+ const el = document.querySelector(codeInputOrRef);
6424
+ code = (el && el.tagName === 'INPUT') ? el.value : codeInputOrRef;
6425
+ } catch (e) {
6426
+ code = codeInputOrRef;
6427
+ }
6428
+ } else if (codeInputOrRef && typeof codeInputOrRef === 'object') {
6429
+ if (codeInputOrRef.tagName === 'INPUT') {
6430
+ code = codeInputOrRef.value;
6431
+ } else if ('code' in codeInputOrRef) {
6432
+ code = codeInputOrRef.code;
6433
+ } else if ('otp' in codeInputOrRef) {
6434
+ code = codeInputOrRef.otp;
6435
+ }
6436
+ }
6437
+
6438
+ return await this.verifyOTP(code);
6439
+ };
6440
+ } else if (!store) {
6441
+ setTimeout(waitForStore, 50);
6442
+ }
6443
+ };
6444
+
6445
+ setTimeout(waitForStore, 100);
6446
+ }
6447
+
6448
+ // Initialize when Alpine is ready
6449
+ document.addEventListener('alpine:init', () => {
6450
+ try {
6451
+ initializeEmailOTP();
6452
+ } catch (error) {
6453
+ // Failed to initialize email OTP
6454
+ }
6455
+ });
6456
+
6457
+ // Export email OTP interface
6458
+ window.ManifestAppwriteAuthEmailOTP = {
6459
+ initialize: initializeEmailOTP
6460
+ };
6461
+
6462
+
5984
6463
  /* Auth OAuth */
5985
6464
 
5986
6465
  // Add OAuth methods to auth store
@@ -6022,8 +6501,10 @@ function initializeOAuth() {
6022
6501
 
6023
6502
  // Delete any existing anonymous sessions before OAuth
6024
6503
  // 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) {
6504
+ // Appwrite will create a new account for OAuth if needed.
6505
+ // EXCEPTION: when guest upgrade is enabled we keep the anonymous session
6506
+ // active so Appwrite can link the OAuth identity to it (preserving teams).
6507
+ if (this.isAnonymous && this.session && !appwriteConfig?.guestUpgrade) {
6027
6508
  try {
6028
6509
  await this._appwrite.account.deleteSession(this.session.$id);
6029
6510
  this.session = null;
@@ -6175,8 +6656,16 @@ function handleOAuthCallbacks() {
6175
6656
  store.magicLinkSent = false;
6176
6657
 
6177
6658
  try {
6178
- // Delete any existing anonymous sessions first
6179
- if (store.session && store.isAnonymous) {
6659
+ const appwriteConfig = await window.ManifestAppwriteAuthConfig.getAppwriteConfig();
6660
+ const upgradingGuest = !!(appwriteConfig?.guestUpgrade && store.isAnonymous);
6661
+ // A guest being replaced (not upgraded) by a different account — its team
6662
+ // state must be cleared before loading the new user's teams.
6663
+ const replacingGuest = store.isAnonymous && !upgradingGuest;
6664
+
6665
+ // Delete the existing anonymous session first — UNLESS we're upgrading the
6666
+ // guest in place, in which case Appwrite linked the OAuth identity to that
6667
+ // account and the "prohibited" branch below reuses the upgraded session.
6668
+ if (store.session && store.isAnonymous && !upgradingGuest) {
6180
6669
  try {
6181
6670
  await store._appwrite.account.deleteSession(store.session.$id);
6182
6671
  } catch (deleteError) {
@@ -6223,20 +6712,21 @@ function handleOAuthCallbacks() {
6223
6712
  }
6224
6713
  }
6225
6714
 
6715
+ // Replacing a guest with a different account: drop the guest's stale team
6716
+ // state so listTeams doesn't query teams the new user can't access.
6717
+ if (replacingGuest && store._resetTeamsState) {
6718
+ store._resetTeamsState();
6719
+ }
6720
+
6226
6721
  // Sync state
6227
6722
  if (store._syncStateToStorage) {
6228
6723
  store._syncStateToStorage(store);
6229
6724
  }
6230
6725
 
6231
- // Load teams if enabled
6232
- const appwriteConfig = await window.ManifestAppwriteAuthConfig.getAppwriteConfig();
6726
+ // Load teams if enabled (and seed any configured default teams)
6233
6727
  if (appwriteConfig?.teams && store.listTeams) {
6234
6728
  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
- }
6729
+ await store._loadTeamsAndSeed(appwriteConfig);
6240
6730
  } catch (teamsError) {
6241
6731
  console.warn('[Manifest Appwrite Auth] Failed to load teams after OAuth login:', teamsError);
6242
6732
  // Don't fail login if teams fail to load