@netlify/identity 0.1.1-alpha.20 → 0.1.1-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,18 +19,20 @@ npm install @netlify/identity
19
19
 
20
20
  ## Quick start
21
21
 
22
- ### Browser
22
+ ### Log in (browser)
23
23
 
24
24
  ```ts
25
- import { getUser } from '@netlify/identity'
25
+ import { login, getUser } from '@netlify/identity'
26
26
 
27
- const user = getUser()
28
- if (user) {
29
- console.log(`Hello, ${user.name}`)
30
- }
27
+ // Log in
28
+ const user = await login('jane@example.com', 'password123')
29
+ console.log(`Hello, ${user.name}`)
30
+
31
+ // Later, check auth state synchronously
32
+ const currentUser = getUser()
31
33
  ```
32
34
 
33
- ### Netlify Function
35
+ ### Protect a Netlify Function
34
36
 
35
37
  ```ts
36
38
  import { getUser } from '@netlify/identity'
@@ -43,7 +45,7 @@ export default async (req: Request, context: Context) => {
43
45
  }
44
46
  ```
45
47
 
46
- ### Edge Function
48
+ ### Protect an Edge Function
47
49
 
48
50
  ```ts
49
51
  import { getUser } from '@netlify/identity'
@@ -66,7 +68,9 @@ export default async (req: Request, context: Context) => {
66
68
  getUser(): User | null
67
69
  ```
68
70
 
69
- Returns the current authenticated user, or `null` if not logged in. Synchronous, never throws.
71
+ Returns the current authenticated user, or `null` if not logged in. Synchronous. Never throws.
72
+
73
+ > **Next.js note:** Calling `getUser()` in a Server Component opts the page into [dynamic rendering](https://nextjs.org/docs/app/building-your-application/rendering/server-components#dynamic-rendering) because it reads cookies. This is expected and correct for authenticated pages. Next.js handles the internal dynamic rendering signal automatically.
70
74
 
71
75
  #### `isAuthenticated`
72
76
 
@@ -109,12 +113,14 @@ In the browser, uses gotrue-js and emits a `'login'` event. On the server (Netli
109
113
  #### `signup`
110
114
 
111
115
  ```ts
112
- signup(email: string, password: string, data?: Record<string, unknown>): Promise<User>
116
+ signup(email: string, password: string, data?: SignupData): Promise<User>
113
117
  ```
114
118
 
115
119
  Creates a new account. Works in both browser and server contexts.
116
120
 
117
- In the browser, uses gotrue-js and emits `'login'` if autoconfirm is enabled. On the server, calls the GoTrue HTTP API directly and sets the `nf_jwt` cookie if the user is auto-confirmed.
121
+ If autoconfirm is enabled in your Identity settings, the user is logged in immediately: cookies are set and a `'login'` event is emitted. If autoconfirm is **disabled** (the default), the user receives a confirmation email and must click the link before they can log in. In that case, no cookies are set and no auth event is emitted.
122
+
123
+ The optional `data` parameter sets user metadata (e.g., `{ full_name: 'Jane Doe' }`), stored in the user's `user_metadata` field.
118
124
 
119
125
  **Throws:** `AuthError` on failure (e.g., email already registered, signup disabled). In the browser, `MissingIdentityError` if Identity is not configured. On the server, `AuthError` if the Netlify Functions runtime is not available.
120
126
 
@@ -126,9 +132,9 @@ logout(): Promise<void>
126
132
 
127
133
  Logs out the current user and clears the session. Works in both browser and server contexts.
128
134
 
129
- In the browser, uses gotrue-js and emits a `'logout'` event. On the server, calls GoTrue's `/logout` endpoint with the JWT from the `nf_jwt` cookie, then deletes the cookie.
135
+ In the browser, uses gotrue-js and emits a `'logout'` event. On the server, calls GoTrue's `/logout` endpoint with the JWT from the `nf_jwt` cookie, then deletes the cookie. Auth cookies are always cleared, even if the GoTrue call fails.
130
136
 
131
- **Throws:** `AuthError` on network failure. In the browser, `MissingIdentityError` if Identity is not configured. On the server, `AuthError` if the Netlify Functions runtime is not available.
137
+ **Throws:** In the browser, `MissingIdentityError` if Identity is not configured. On the server, `AuthError` if the Netlify Functions runtime is not available.
132
138
 
133
139
  #### `oauthLogin`
134
140
 
@@ -140,7 +146,7 @@ Redirects to an OAuth provider. The page navigates away, so this function never
140
146
 
141
147
  The `provider` argument should be one of the `AuthProvider` values: `'google'`, `'github'`, `'gitlab'`, `'bitbucket'`, `'facebook'`, or `'saml'`.
142
148
 
143
- **Throws:** `MissingIdentityError` if Identity is not configured. `Error` if called on the server.
149
+ **Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if called on the server.
144
150
 
145
151
  #### `handleAuthCallback`
146
152
 
@@ -160,6 +166,28 @@ onAuthChange(callback: AuthCallback): () => void
160
166
 
161
167
  Subscribes to auth state changes (login, logout, token refresh, user updates). Returns an unsubscribe function. Also fires on cross-tab session changes. No-op on the server.
162
168
 
169
+ #### `hydrateSession`
170
+
171
+ ```ts
172
+ hydrateSession(): Promise<User | null>
173
+ ```
174
+
175
+ Bootstraps the browser-side gotrue-js session from server-set auth cookies (`nf_jwt`, `nf_refresh`). Returns the hydrated `User`, or `null` if no auth cookies are present. No-op on the server.
176
+
177
+ **When to use:** After a server-side login (e.g., via a Netlify Function or Server Action), the `nf_jwt` cookie is set but gotrue-js has no browser session yet. `getUser()` works immediately (it decodes the cookie), but account operations like `updateUser()` or `verifyEmailChange()` require a live gotrue-js session. Call `hydrateSession()` once on page load to bridge this gap.
178
+
179
+ If a gotrue-js session already exists (e.g., from a browser-side login), this is a no-op and returns the existing user.
180
+
181
+ ```ts
182
+ import { hydrateSession, updateUser } from '@netlify/identity'
183
+
184
+ // On page load, hydrate the session from server-set cookies
185
+ await hydrateSession()
186
+
187
+ // Now browser account operations work
188
+ await updateUser({ data: { full_name: 'Jane Doe' } })
189
+ ```
190
+
163
191
  #### `requestPasswordRecovery`
164
192
 
165
193
  ```ts
@@ -213,10 +241,10 @@ Redeems a recovery token and sets a new password. Logs the user in on success.
213
241
  #### `updateUser`
214
242
 
215
243
  ```ts
216
- updateUser(updates: Record<string, unknown>): Promise<User>
244
+ updateUser(updates: UserUpdates): Promise<User>
217
245
  ```
218
246
 
219
- Updates the current user's metadata or credentials. Requires an active session.
247
+ Updates the current user's metadata or credentials. Requires an active session. Pass `email` or `password` to change credentials, or `data` to update user metadata (e.g., `{ data: { full_name: 'New Name' } }`).
220
248
 
221
249
  **Throws:** `MissingIdentityError` if Identity is not configured. `AuthError` if no user is logged in, or the update fails.
222
250
 
@@ -264,6 +292,27 @@ interface IdentityConfig {
264
292
  type AuthProvider = 'google' | 'github' | 'gitlab' | 'bitbucket' | 'facebook' | 'saml' | 'email'
265
293
  ```
266
294
 
295
+ #### `UserUpdates`
296
+
297
+ ```ts
298
+ interface UserUpdates {
299
+ email?: string
300
+ password?: string
301
+ data?: Record<string, unknown>
302
+ [key: string]: unknown
303
+ }
304
+ ```
305
+
306
+ Fields accepted by `updateUser()`. All fields are optional.
307
+
308
+ #### `SignupData`
309
+
310
+ ```ts
311
+ type SignupData = Record<string, unknown>
312
+ ```
313
+
314
+ User metadata passed as the third argument to `signup()`. Stored in the user's `user_metadata` field.
315
+
267
316
  #### `AppMetadata`
268
317
 
269
318
  ```ts
@@ -422,7 +471,7 @@ export async function loader() {
422
471
  }
423
472
  ```
424
473
 
425
- Remix actions return HTTP responses, so `redirect()` after server-side `login()` works correctly with cookies.
474
+ Remix `redirect()` works after server-side `login()` because Remix actions return real HTTP responses. The browser receives a 302 with the `Set-Cookie` header already applied, so the next request includes the auth cookie. This is different from Next.js, where `redirect()` in a Server Action triggers a client-side (soft) navigation that may not include newly-set cookies.
426
475
 
427
476
  ### TanStack Start
428
477
 
@@ -526,37 +575,133 @@ if (!user) return Astro.redirect('/login')
526
575
  <h1>Hello, {user.email}</h1>
527
576
  ```
528
577
 
578
+ ### SvelteKit
579
+
580
+ **Login from the browser (recommended):**
581
+
582
+ ```svelte
583
+ <!-- src/routes/login/+page.svelte -->
584
+ <script lang="ts">
585
+ import { login } from '@netlify/identity'
586
+
587
+ let email = ''
588
+ let password = ''
589
+ let error = ''
590
+
591
+ async function handleLogin() {
592
+ try {
593
+ await login(email, password)
594
+ window.location.href = '/dashboard'
595
+ } catch (e) {
596
+ error = (e as Error).message
597
+ }
598
+ }
599
+ </script>
600
+
601
+ <form on:submit|preventDefault={handleLogin}>
602
+ <input bind:value={email} type="email" />
603
+ <input bind:value={password} type="password" />
604
+ <button type="submit">Log in</button>
605
+ {#if error}<p>{error}</p>{/if}
606
+ </form>
607
+ ```
608
+
609
+ ```ts
610
+ // src/routes/dashboard/+page.server.ts
611
+ import { getUser } from '@netlify/identity'
612
+ import { redirect } from '@sveltejs/kit'
613
+
614
+ export function load() {
615
+ const user = getUser()
616
+ if (!user) redirect(302, '/login')
617
+ return { user }
618
+ }
619
+ ```
620
+
529
621
  ### Handling OAuth callbacks in SPAs
530
622
 
531
- All SPA frameworks need a callback handler that runs on page load to process OAuth redirects, email confirmations, and password recovery tokens.
623
+ All SPA frameworks need a callback handler that runs on page load to process OAuth redirects, email confirmations, and password recovery tokens. Use a **wrapper component** that blocks page content while processing tokens. This prevents a flash of unauthenticated content that occurs when the page renders before the callback completes.
532
624
 
533
625
  ```tsx
534
626
  // React component (works with Next.js, Remix, TanStack Start)
535
- import { useEffect } from 'react'
627
+ import { useEffect, useState } from 'react'
536
628
  import { handleAuthCallback } from '@netlify/identity'
537
629
 
538
- export function CallbackHandler() {
630
+ const AUTH_HASH_PATTERN = /^#(confirmation_token|recovery_token|invite_token|email_change_token|access_token)=/
631
+
632
+ export function CallbackHandler({ children }: { children: React.ReactNode }) {
633
+ const [processing, setProcessing] = useState(
634
+ () => typeof window !== 'undefined' && AUTH_HASH_PATTERN.test(window.location.hash),
635
+ )
636
+ const [error, setError] = useState<string | null>(null)
637
+
539
638
  useEffect(() => {
540
- if (!window.location.hash) return
541
-
542
- handleAuthCallback().then((result) => {
543
- if (!result) return
544
- if (result.type === 'invite') {
545
- window.location.href = `/accept-invite?token=${result.token}`
546
- } else {
547
- window.location.href = '/dashboard'
548
- }
549
- })
639
+ if (!window.location.hash || !AUTH_HASH_PATTERN.test(window.location.hash)) return
640
+
641
+ handleAuthCallback()
642
+ .then((result) => {
643
+ if (!result) {
644
+ setProcessing(false)
645
+ return
646
+ }
647
+ if (result.type === 'invite') {
648
+ window.location.href = `/accept-invite?token=${result.token}`
649
+ } else {
650
+ window.location.href = '/dashboard'
651
+ }
652
+ })
653
+ .catch((err) => {
654
+ setError(err instanceof Error ? err.message : 'Callback failed')
655
+ setProcessing(false)
656
+ })
550
657
  }, [])
551
658
 
552
- return null
659
+ if (error) return <div>Auth error: {error}</div>
660
+ if (processing) return <div>Confirming your account...</div>
661
+ return <>{children}</>
553
662
  }
554
663
  ```
555
664
 
556
- Mount this component in your root layout so it processes callbacks on any page.
665
+ Wrap your page content with this component in your **root layout** so it runs on every page:
666
+
667
+ ```tsx
668
+ // Root layout
669
+ <CallbackHandler>
670
+ <Outlet /> {/* or {children} in Next.js */}
671
+ </CallbackHandler>
672
+ ```
673
+
674
+ If you only mount it on a `/callback` route, OAuth redirects and email confirmation links that land on other pages will not be processed.
557
675
 
558
676
  ## Guides
559
677
 
678
+ ### React `useAuth` hook
679
+
680
+ The library is framework-agnostic, but here's a simple React hook for keeping components in sync with auth state:
681
+
682
+ ```tsx
683
+ import { useState, useEffect } from 'react'
684
+ import { getUser, onAuthChange } from '@netlify/identity'
685
+ import type { User } from '@netlify/identity'
686
+
687
+ export function useAuth() {
688
+ const [user, setUser] = useState<User | null>(getUser())
689
+
690
+ useEffect(() => {
691
+ return onAuthChange((_event, user) => setUser(user))
692
+ }, [])
693
+
694
+ return user
695
+ }
696
+ ```
697
+
698
+ ```tsx
699
+ function NavBar() {
700
+ const user = useAuth()
701
+ return user ? <p>Hello, {user.name}</p> : <a href="/login">Log in</a>
702
+ }
703
+ ```
704
+
560
705
  ### Listening for auth changes
561
706
 
562
707
  Use `onAuthChange` to keep your UI in sync with auth state. It fires on login, logout, token refresh, and user updates. It also detects session changes in other browser tabs (via `localStorage`).
@@ -656,6 +801,17 @@ if (result?.type === 'invite' && result.token) {
656
801
  }
657
802
  ```
658
803
 
804
+ ### Session lifetime
805
+
806
+ Sessions are managed by Netlify Identity (GoTrue) on the server side. The library stores two cookies:
807
+
808
+ - **`nf_jwt`**: A short-lived JWT access token (default: 1 hour). Automatically refreshed by gotrue-js in the browser using the refresh token.
809
+ - **`nf_refresh`**: A long-lived refresh token used to obtain new access tokens without re-authenticating.
810
+
811
+ In the browser, gotrue-js handles token refresh automatically in the background. On the server, the access token in the `nf_jwt` cookie is validated as-is; if it has expired, `getUser()` returns `null`. The user will need to refresh the page (which triggers a browser-side token refresh) or log in again.
812
+
813
+ Session lifetime is configured in your GoTrue/Identity server settings, not in this library.
814
+
659
815
  ## License
660
816
 
661
817
  MIT
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  getSettings: () => getSettings,
39
39
  getUser: () => getUser,
40
40
  handleAuthCallback: () => handleAuthCallback,
41
+ hydrateSession: () => hydrateSession,
41
42
  isAuthenticated: () => isAuthenticated,
42
43
  login: () => login,
43
44
  logout: () => logout,
@@ -69,7 +70,7 @@ var AuthError = class extends Error {
69
70
  }
70
71
  };
71
72
  var MissingIdentityError = class extends Error {
72
- constructor(message = "Identity is not available in this environment") {
73
+ constructor(message = "Netlify Identity is not available. Enable Identity in your site dashboard and use `netlify dev` for local development.") {
73
74
  super(message);
74
75
  this.name = "MissingIdentityError";
75
76
  }
@@ -247,6 +248,9 @@ var backgroundHydrate = (accessToken) => {
247
248
  if (hydrating) return;
248
249
  hydrating = true;
249
250
  const refreshToken = getCookie(NF_REFRESH_COOKIE) ?? "";
251
+ const decoded = decodeJwtPayload(accessToken);
252
+ const expiresAt = decoded?.exp ?? Math.floor(Date.now() / 1e3) + 3600;
253
+ const expiresIn = Math.max(0, expiresAt - Math.floor(Date.now() / 1e3));
250
254
  setTimeout(() => {
251
255
  try {
252
256
  const client = getClient();
@@ -254,8 +258,8 @@ var backgroundHydrate = (accessToken) => {
254
258
  {
255
259
  access_token: accessToken,
256
260
  token_type: "bearer",
257
- expires_in: 3600,
258
- expires_at: Math.floor(Date.now() / 1e3) + 3600,
261
+ expires_in: expiresIn,
262
+ expires_at: expiresAt,
259
263
  refresh_token: refreshToken
260
264
  },
261
265
  true
@@ -363,15 +367,19 @@ var persistSession = true;
363
367
  var listeners = /* @__PURE__ */ new Set();
364
368
  var emitAuthEvent = (event, user) => {
365
369
  for (const listener of listeners) {
366
- listener(event, user);
370
+ try {
371
+ listener(event, user);
372
+ } catch {
373
+ }
367
374
  }
368
375
  };
376
+ var GOTRUE_STORAGE_KEY = "gotrue.user";
369
377
  var storageListenerAttached = false;
370
378
  var attachStorageListener = () => {
371
379
  if (storageListenerAttached) return;
372
380
  storageListenerAttached = true;
373
381
  window.addEventListener("storage", (event) => {
374
- if (event.key !== "gotrue.user") return;
382
+ if (event.key !== GOTRUE_STORAGE_KEY) return;
375
383
  if (event.newValue) {
376
384
  const client = getGoTrueClient();
377
385
  const currentUser = client?.currentUser();
@@ -486,6 +494,10 @@ var signup = async (email, password, data) => {
486
494
  const response = await client.signup(email, password, data);
487
495
  const user = toUser(response);
488
496
  if (response.confirmed_at) {
497
+ const jwt = await response.jwt?.();
498
+ if (jwt) {
499
+ setBrowserAuthCookies(jwt);
500
+ }
489
501
  emitAuthEvent("login", user);
490
502
  }
491
503
  return user;
@@ -504,8 +516,7 @@ var logout = async () => {
504
516
  method: "POST",
505
517
  headers: { Authorization: `Bearer ${jwt}` }
506
518
  });
507
- } catch (error) {
508
- throw new AuthError(error.message, void 0, { cause: error });
519
+ } catch {
509
520
  }
510
521
  }
511
522
  deleteAuthCookies(cookies);
@@ -525,11 +536,11 @@ var logout = async () => {
525
536
  };
526
537
  var oauthLogin = (provider) => {
527
538
  if (!isBrowser()) {
528
- throw new Error("oauthLogin() is only available in the browser");
539
+ throw new AuthError("oauthLogin() is only available in the browser");
529
540
  }
530
541
  const client = getClient();
531
542
  window.location.href = client.loginExternalUrl(provider);
532
- throw new Error("Redirecting to OAuth provider");
543
+ throw new AuthError("Redirecting to OAuth provider");
533
544
  };
534
545
  var handleAuthCallback = async () => {
535
546
  if (!isBrowser()) return null;
@@ -613,6 +624,7 @@ var handleAuthCallback = async () => {
613
624
  }
614
625
  return null;
615
626
  } catch (error) {
627
+ if (error instanceof AuthError) throw error;
616
628
  throw new AuthError(error.message, void 0, { cause: error });
617
629
  }
618
630
  };
@@ -627,12 +639,15 @@ var hydrateSession = async () => {
627
639
  const accessToken = getCookie(NF_JWT_COOKIE);
628
640
  if (!accessToken) return null;
629
641
  const refreshToken = getCookie(NF_REFRESH_COOKIE) ?? "";
642
+ const decoded = decodeJwtPayload(accessToken);
643
+ const expiresAt = decoded?.exp ?? Math.floor(Date.now() / 1e3) + 3600;
644
+ const expiresIn = Math.max(0, expiresAt - Math.floor(Date.now() / 1e3));
630
645
  const gotrueUser = await client.createUser(
631
646
  {
632
647
  access_token: accessToken,
633
648
  token_type: "bearer",
634
- expires_in: 3600,
635
- expires_at: Math.floor(Date.now() / 1e3) + 3600,
649
+ expires_in: expiresIn,
650
+ expires_at: expiresAt,
636
651
  refresh_token: refreshToken
637
652
  },
638
653
  persistSession
@@ -749,6 +764,7 @@ var updateUser = async (updates) => {
749
764
  getSettings,
750
765
  getUser,
751
766
  handleAuthCallback,
767
+ hydrateSession,
752
768
  isAuthenticated,
753
769
  login,
754
770
  logout,