mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 12:25:05 +03:00
Add magic link login
This commit is contained in:
1 parent
d786c1bdd4
commit
1915059629
71 files changed
+6704
-6
No files matched your search
@@ -19,6 +19,18 @@ export const Index = {
|
||||
component: React.lazy(() => import("@/registry/default/examples/password-based-auth.tsx")),
|
||||
}
|
||||
,
|
||||
"passwordless-auth-demo": {
|
||||
component: React.lazy(() => import("@/registry/default/examples/passwordless-auth.tsx")),
|
||||
}
|
||||
,
|
||||
"magic-link-auth-demo": {
|
||||
component: React.lazy(() => import("@/registry/default/examples/magic-link-auth.tsx")),
|
||||
}
|
||||
,
|
||||
"otp-auth-demo": {
|
||||
component: React.lazy(() => import("@/registry/default/examples/otp-auth.tsx")),
|
||||
}
|
||||
,
|
||||
"current-user-avatar-demo": {
|
||||
component: React.lazy(() => import("@/registry/default/examples/current-user-avatar-demo.tsx")),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Metadata } from 'next'
|
||||
import { BaseInjector } from './../base-injector'
|
||||
import { ThemeProvider } from '@/app/Providers'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Passwordless Auth Example',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<head>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
html, body, #root, main {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body style={{ height: '100%', margin: 0, padding: 0, overflow: 'hidden' }}>
|
||||
<BaseInjector />
|
||||
<ThemeProvider
|
||||
themes={['dark', 'light', 'classic-dark']}
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<div
|
||||
className="flex w-full h-full items-center justify-center p-6 md:p-10 preview bg-surface-100"
|
||||
style={{ minHeight: '100%' }}
|
||||
>
|
||||
<div className="z-0 pointer-events-none absolute h-full w-full bg-[radial-gradient(hsla(var(--foreground-default)/0.05)_1px,transparent_1px)] [background-size:16px_16px] [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"></div>
|
||||
<div className="w-full max-w-sm">{children}</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'
|
||||
|
||||
const PasswordlessAuthDemo = () => {
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
|
||||
export default PasswordlessAuthDemo
|
||||
@@ -66,6 +66,14 @@ export const componentPages: SidebarNavGroup = {
|
||||
items: [],
|
||||
commandItemLabel: 'Password-Based Auth',
|
||||
},
|
||||
{
|
||||
title: 'Passwordless Auth',
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'],
|
||||
href: '/docs/nextjs/passwordless-auth',
|
||||
items: [],
|
||||
new: true,
|
||||
commandItemLabel: 'Passwordless Auth',
|
||||
},
|
||||
{
|
||||
title: 'Social Auth',
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react', 'vue', 'nuxtjs'],
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Passwordless Authentication
|
||||
description: Passwordless authentication block for Next.js with Magic Link and OTP support
|
||||
---
|
||||
|
||||
<BlockPreview name="passwordless-auth-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="passwordless-auth-nextjs"
|
||||
description="All needed components for passwordless authentication with Magic Link and OTP"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block includes the [Supabase client](/ui/docs/nextjs/client). If you already have one installed, you can skip overwriting it.
|
||||
|
||||
<RegistryBlock itemName="passwordless-auth-nextjs" />
|
||||
|
||||
## Usage
|
||||
|
||||
Once you install the block in your Next.js project, you'll get all the necessary pages and components to set up passwordless email authentication with both Magic Link and OTP methods.
|
||||
|
||||
### Getting started
|
||||
|
||||
After installing the block, you'll have the following environment variables in your `.env.local` file:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=
|
||||
```
|
||||
|
||||
- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=nextjs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api).
|
||||
|
||||
- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running).
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
This block supports two passwordless authentication methods:
|
||||
|
||||
### Magic Link
|
||||
|
||||
Magic Links are one-time use URLs sent to the user's email. When clicked, they automatically authenticate the user.
|
||||
|
||||
**When to use**: Simple, one-click authentication with minimal user interaction.
|
||||
|
||||
### OTP (One-Time Password)
|
||||
|
||||
OTPs are 6-digit codes sent to the user's email that they manually enter to authenticate.
|
||||
|
||||
**When to use**: When you need more control over the authentication flow or for mobile apps.
|
||||
|
||||
## Components
|
||||
|
||||
### PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified passwordless authentication form. Use the `method` prop to choose between Magic Link or OTP authentication.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
// Defaults to 'magic-link' if method prop is not provided
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `method?: 'magic-link' | 'otp'` - Authentication method (default: `'magic-link'`)
|
||||
- `className?: string` - Additional CSS classes
|
||||
|
||||
**Examples:**
|
||||
|
||||
```tsx
|
||||
// Magic Link authentication
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP authentication
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
### MagicLinkForm
|
||||
|
||||
Standalone Magic Link authentication form.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### OTPRequestForm & OTPVerifyForm
|
||||
|
||||
Two-step OTP authentication flow.
|
||||
|
||||
```tsx
|
||||
// Request OTP
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function RequestPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OTPVerifyForm />
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Email Templates
|
||||
|
||||
You need to configure your email template based on which method you want to use:
|
||||
|
||||
#### For Magic Link (Default)
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template
|
||||
3. Use the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
|
||||
<p>Follow this link to login:</p>
|
||||
<p>
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### For OTP
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template (yes, the same one)
|
||||
3. Replace with the following HTML:
|
||||
|
||||
```html
|
||||
<h2>One Time Login Code</h2>
|
||||
|
||||
<p>Please enter this code to sign in:</p>
|
||||
<h1 style="font-size: 32px; letter-spacing: 4px;">{{ .Token }}</h1>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
If you want to support both methods, you can include both the link and the token in your email template.
|
||||
|
||||
</Callout>
|
||||
|
||||
### Setting up Redirect URLs
|
||||
|
||||
1. Set the site URL in the [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) settings in the Supabase Dashboard.
|
||||
|
||||
2. Add the following redirect URLs to your [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration):
|
||||
|
||||
- `http://localhost:3000/auth/confirm` (for development)
|
||||
- `https://yourdomain.com/auth/confirm` (for production)
|
||||
- `http://localhost:3000/protected` (or your authenticated route)
|
||||
|
||||
3. Update the redirect paths in the components to point to the logged-in routes in your app. Our examples use `/protected`, but you can set this to whatever fits your app.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Disable Auto Sign-Up
|
||||
|
||||
By default, users are automatically created if they don't exist. To prevent this:
|
||||
|
||||
```tsx
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Only allow existing users
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### OTP Expiration
|
||||
|
||||
Configure OTP expiration in the Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Email**
|
||||
2. Find **Email OTP Expiration**
|
||||
3. Set custom duration (default: 3600 seconds = 1 hour, max: 86400 seconds = 24 hours)
|
||||
|
||||
## Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
By default, users can request a magic link or OTP once every 60 seconds to prevent abuse.
|
||||
|
||||
### Token Expiration
|
||||
|
||||
- Magic Links expire after 1 hour by default
|
||||
- OTPs expire after 1 hour by default
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Magic Link not working
|
||||
|
||||
- Verify redirect URLs are configured in Supabase Dashboard
|
||||
- Check email template configuration
|
||||
- Check browser console for errors, output them yourself if none are present
|
||||
|
||||
### OTP not received
|
||||
|
||||
- Verify email template shows `{{ .Token }}`
|
||||
- Check spam folder
|
||||
- Verify rate limiting hasn't been triggered (wait 60 seconds)
|
||||
|
||||
### "Invalid token" error
|
||||
|
||||
- Check if token has expired (default: 1 hour)
|
||||
- Ensure user hasn't already used the token
|
||||
- Verify system clock is synchronized
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Passwordless email logins](https://supabase.com/docs/guides/auth/auth-email-passwordless)
|
||||
- [Magic Link authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-magic-link)
|
||||
- [OTP authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-otp)
|
||||
- [Email templates](https://supabase.com/docs/guides/auth/auth-email-templates)
|
||||
- [Authentication error codes](https://supabase.com/docs/guides/auth/debugging/error-codes)
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Passwordless Authentication
|
||||
description: Passwordless authentication block for React Router with Magic Link and OTP support
|
||||
---
|
||||
|
||||
<BlockPreview name="passwordless-auth-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="passwordless-auth-react-router"
|
||||
description="All needed components for passwordless authentication with Magic Link and OTP"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block includes the [Supabase client](/ui/docs/react-router/client). If you already have one installed, you can skip overwriting it.
|
||||
|
||||
<RegistryBlock itemName="passwordless-auth-react-router" />
|
||||
|
||||
## Usage
|
||||
|
||||
Once you install the block in your React Router project, you'll get all the necessary routes and components to set up passwordless email authentication with both Magic Link and OTP methods.
|
||||
|
||||
### Getting started
|
||||
|
||||
After installing the block, you'll have the following environment variables in your `.env.local` file:
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY=
|
||||
```
|
||||
|
||||
- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=nextjs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api).
|
||||
|
||||
- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running).
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
This block supports two passwordless authentication methods:
|
||||
|
||||
### Magic Link
|
||||
|
||||
Magic Links are one-time use URLs sent to the user's email. When clicked, they automatically authenticate the user.
|
||||
|
||||
**When to use**: Simple, one-click authentication with minimal user interaction.
|
||||
|
||||
### OTP (One-Time Password)
|
||||
|
||||
OTPs are 6-digit codes sent to the user's email that they manually enter to authenticate.
|
||||
|
||||
**When to use**: When you need more control over the authentication flow or for mobile apps.
|
||||
|
||||
## Components
|
||||
|
||||
### PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified passwordless authentication form. Use the `method` prop to choose between Magic Link or OTP authentication.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
// Defaults to 'magic-link' if method prop is not provided
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `method?: 'magic-link' | 'otp'` - Authentication method (default: `'magic-link'`)
|
||||
- `className?: string` - Additional CSS classes
|
||||
|
||||
**Examples:**
|
||||
|
||||
```tsx
|
||||
// Magic Link authentication
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP authentication
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
### MagicLinkForm
|
||||
|
||||
Standalone Magic Link authentication form.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### OTPRequestForm & OTPVerifyForm
|
||||
|
||||
Two-step OTP authentication flow.
|
||||
|
||||
```tsx
|
||||
// Request OTP
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function RequestPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OTPVerifyForm />
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Email Templates
|
||||
|
||||
You need to configure your email template based on which method you want to use:
|
||||
|
||||
#### For Magic Link (Default)
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template
|
||||
3. Use the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
|
||||
<p>Follow this link to login:</p>
|
||||
<p>
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### For OTP
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template (yes, the same one)
|
||||
3. Replace with the following HTML:
|
||||
|
||||
```html
|
||||
<h2>One Time Login Code</h2>
|
||||
|
||||
<p>Please enter this code to sign in:</p>
|
||||
<h1 style="font-size: 32px; letter-spacing: 4px;">{{ .Token }}</h1>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
If you want to support both methods, you can include both the link and the token in your email template.
|
||||
|
||||
</Callout>
|
||||
|
||||
### Setting up Redirect URLs
|
||||
|
||||
1. Set the site URL in the [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) settings in the Supabase Dashboard.
|
||||
|
||||
2. Add the following redirect URLs to your [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration):
|
||||
|
||||
- `http://localhost:3000/auth/confirm` (for development)
|
||||
- `https://yourdomain.com/auth/confirm` (for production)
|
||||
- `http://localhost:3000/protected` (or your authenticated route)
|
||||
|
||||
3. Update the redirect paths in the components to point to the logged-in routes in your app. Our examples use `/protected`, but you can set this to whatever fits your app.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Disable Auto Sign-Up
|
||||
|
||||
By default, users are automatically created if they don't exist. To prevent this:
|
||||
|
||||
```tsx
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Only allow existing users
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### OTP Expiration
|
||||
|
||||
Configure OTP expiration in the Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Email**
|
||||
2. Find **Email OTP Expiration**
|
||||
3. Set custom duration (default: 3600 seconds = 1 hour, max: 86400 seconds = 24 hours)
|
||||
|
||||
## Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
By default, users can request a magic link or OTP once every 60 seconds to prevent abuse.
|
||||
|
||||
### Token Expiration
|
||||
|
||||
- Magic Links expire after 1 hour by default
|
||||
- OTPs expire after 1 hour by default
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Magic Link not working
|
||||
|
||||
- Verify redirect URLs are configured in Supabase Dashboard
|
||||
- Check email template configuration
|
||||
- Check browser console for errors, output them yourself if none are present
|
||||
|
||||
### OTP not received
|
||||
|
||||
- Verify email template shows `{{ .Token }}`
|
||||
- Check spam folder
|
||||
- Verify rate limiting hasn't been triggered (wait 60 seconds)
|
||||
|
||||
### "Invalid token" error
|
||||
|
||||
- Check if token has expired (default: 1 hour)
|
||||
- Ensure user hasn't already used the token
|
||||
- Verify system clock is synchronized
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Passwordless email logins](https://supabase.com/docs/guides/auth/auth-email-passwordless)
|
||||
- [Magic Link authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-magic-link)
|
||||
- [OTP authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-otp)
|
||||
- [Email templates](https://supabase.com/docs/guides/auth/auth-email-templates)
|
||||
- [Authentication error codes](https://supabase.com/docs/guides/auth/debugging/error-codes)
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Passwordless Authentication
|
||||
description: Passwordless authentication block for React SPA with Magic Link and OTP support
|
||||
---
|
||||
|
||||
<BlockPreview name="passwordless-auth-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="passwordless-auth-react"
|
||||
description="All needed components for passwordless authentication with Magic Link and OTP"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block provides reusable components for passwordless authentication. Unlike framework-specific blocks, this doesn't include routing - you'll integrate these components into your own routing solution.
|
||||
|
||||
<RegistryBlock itemName="passwordless-auth-react" />
|
||||
|
||||
## Usage
|
||||
|
||||
Once you install the block in your React project, you'll get all the necessary components to set up passwordless email authentication with both Magic Link and OTP methods. You'll need to handle routing and navigation yourself.
|
||||
|
||||
### Getting started
|
||||
|
||||
After installing the block, you'll need to set up your Supabase client. Create a `.env` file with:
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY=
|
||||
```
|
||||
|
||||
- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=nextjs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api).
|
||||
|
||||
- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running).
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
This block supports two passwordless authentication methods:
|
||||
|
||||
### Magic Link
|
||||
|
||||
Magic Links are one-time use URLs sent to the user's email. When clicked, they automatically authenticate the user.
|
||||
|
||||
**When to use**: Simple, one-click authentication with minimal user interaction.
|
||||
|
||||
### OTP (One-Time Password)
|
||||
|
||||
OTPs are 6-digit codes sent to the user's email that they manually enter to authenticate.
|
||||
|
||||
**When to use**: When you need more control over the authentication flow or for mobile apps.
|
||||
|
||||
## Components
|
||||
|
||||
### PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified passwordless authentication form. Use the `method` prop to choose between Magic Link or OTP authentication.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
// Defaults to 'magic-link' if method prop is not provided
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `method?: 'magic-link' | 'otp'` - Authentication method (default: `'magic-link'`)
|
||||
- `className?: string` - Additional CSS classes
|
||||
|
||||
**Examples:**
|
||||
|
||||
```tsx
|
||||
// Magic Link authentication
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP authentication
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
### MagicLinkForm
|
||||
|
||||
Standalone Magic Link authentication form.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### OTPRequestForm & OTPVerifyForm
|
||||
|
||||
Two-step OTP authentication flow.
|
||||
|
||||
```tsx
|
||||
// Request OTP
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function RequestPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OTPVerifyForm />
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Email Templates
|
||||
|
||||
You need to configure your email template based on which method you want to use:
|
||||
|
||||
#### For Magic Link (Default)
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template
|
||||
3. Use the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
|
||||
<p>Follow this link to login:</p>
|
||||
<p>
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### For OTP
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template (yes, the same one)
|
||||
3. Replace with the following HTML:
|
||||
|
||||
```html
|
||||
<h2>One Time Login Code</h2>
|
||||
|
||||
<p>Please enter this code to sign in:</p>
|
||||
<h1 style="font-size: 32px; letter-spacing: 4px;">{{ .Token }}</h1>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
If you want to support both methods, you can include both the link and the token in your email template.
|
||||
|
||||
</Callout>
|
||||
|
||||
### Setting up Redirect URLs
|
||||
|
||||
1. Set the site URL in the [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) settings in the Supabase Dashboard.
|
||||
|
||||
2. Add the following redirect URLs to your [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration):
|
||||
|
||||
- `http://localhost:3000/auth/confirm` (for development)
|
||||
- `https://yourdomain.com/auth/confirm` (for production)
|
||||
- `http://localhost:3000/protected` (or your authenticated route)
|
||||
|
||||
3. Update the redirect paths in the components to point to the logged-in routes in your app. Our examples use `/protected`, but you can set this to whatever fits your app.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Disable Auto Sign-Up
|
||||
|
||||
By default, users are automatically created if they don't exist. To prevent this:
|
||||
|
||||
```tsx
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Only allow existing users
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### OTP Expiration
|
||||
|
||||
Configure OTP expiration in the Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Email**
|
||||
2. Find **Email OTP Expiration**
|
||||
3. Set custom duration (default: 3600 seconds = 1 hour, max: 86400 seconds = 24 hours)
|
||||
|
||||
## Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
By default, users can request a magic link or OTP once every 60 seconds to prevent abuse.
|
||||
|
||||
### Token Expiration
|
||||
|
||||
- Magic Links expire after 1 hour by default
|
||||
- OTPs expire after 1 hour by default
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Magic Link not working
|
||||
|
||||
- Verify redirect URLs are configured in Supabase Dashboard
|
||||
- Check email template configuration
|
||||
- Check browser console for errors, output them yourself if none are present
|
||||
|
||||
### OTP not received
|
||||
|
||||
- Verify email template shows `{{ .Token }}`
|
||||
- Check spam folder
|
||||
- Verify rate limiting hasn't been triggered (wait 60 seconds)
|
||||
|
||||
### "Invalid token" error
|
||||
|
||||
- Check if token has expired (default: 1 hour)
|
||||
- Ensure user hasn't already used the token
|
||||
- Verify system clock is synchronized
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Passwordless email logins](https://supabase.com/docs/guides/auth/auth-email-passwordless)
|
||||
- [Magic Link authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-magic-link)
|
||||
- [OTP authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-otp)
|
||||
- [Email templates](https://supabase.com/docs/guides/auth/auth-email-templates)
|
||||
- [Authentication error codes](https://supabase.com/docs/guides/auth/debugging/error-codes)
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Passwordless Authentication
|
||||
description: Passwordless authentication block for TanStack Start with Magic Link and OTP support
|
||||
---
|
||||
|
||||
<BlockPreview name="passwordless-auth-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="passwordless-auth-tanstack"
|
||||
description="All needed components for passwordless authentication with Magic Link and OTP"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block includes the [Supabase client](/ui/docs/tanstack/client). If you already have one installed, you can skip overwriting it.
|
||||
|
||||
<RegistryBlock itemName="passwordless-auth-tanstack" />
|
||||
|
||||
## Usage
|
||||
|
||||
Once you install the block in your TanStack Start project, you'll get all the necessary routes and components to set up passwordless email authentication with both Magic Link and OTP methods.
|
||||
|
||||
### Getting started
|
||||
|
||||
After installing the block, you'll have the following environment variables in your `.env` file:
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY=
|
||||
```
|
||||
|
||||
- If you're using supabase.com, you can find these values in the [Connect modal](https://supabase.com/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=nextjs) under App Frameworks or in your project's [API settings](https://supabase.com/dashboard/project/_/settings/api).
|
||||
|
||||
- If you're using a local instance of Supabase, you can find these values by running `supabase start` or `supabase status` (if you already have it running).
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
This block supports two passwordless authentication methods:
|
||||
|
||||
### Magic Link
|
||||
|
||||
Magic Links are one-time use URLs sent to the user's email. When clicked, they automatically authenticate the user.
|
||||
|
||||
**When to use**: Simple, one-click authentication with minimal user interaction.
|
||||
|
||||
### OTP (One-Time Password)
|
||||
|
||||
OTPs are 6-digit codes sent to the user's email that they manually enter to authenticate.
|
||||
|
||||
**When to use**: When you need more control over the authentication flow or for mobile apps.
|
||||
|
||||
## Components
|
||||
|
||||
### PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified passwordless authentication form. Use the `method` prop to choose between Magic Link or OTP authentication.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
// Defaults to 'magic-link' if method prop is not provided
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
|
||||
- `method?: 'magic-link' | 'otp'` - Authentication method (default: `'magic-link'`)
|
||||
- `className?: string` - Additional CSS classes
|
||||
|
||||
**Examples:**
|
||||
|
||||
```tsx
|
||||
// Magic Link authentication
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP authentication
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
### MagicLinkForm
|
||||
|
||||
Standalone Magic Link authentication form.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### OTPRequestForm & OTPVerifyForm
|
||||
|
||||
Two-step OTP authentication flow.
|
||||
|
||||
```tsx
|
||||
// Request OTP
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function RequestPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OTPVerifyForm />
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Email Templates
|
||||
|
||||
You need to configure your email template based on which method you want to use:
|
||||
|
||||
#### For Magic Link (Default)
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template
|
||||
3. Use the following HTML:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
|
||||
<p>Follow this link to login:</p>
|
||||
<p>
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### For OTP
|
||||
|
||||
1. Go to [Email Templates](https://supabase.com/dashboard/project/_/auth/templates) in the Supabase Dashboard
|
||||
2. Select "Magic Link" template (yes, the same one)
|
||||
3. Replace with the following HTML:
|
||||
|
||||
```html
|
||||
<h2>One Time Login Code</h2>
|
||||
|
||||
<p>Please enter this code to sign in:</p>
|
||||
<h1 style="font-size: 32px; letter-spacing: 4px;">{{ .Token }}</h1>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
|
||||
If you want to support both methods, you can include both the link and the token in your email template.
|
||||
|
||||
</Callout>
|
||||
|
||||
### Setting up Redirect URLs
|
||||
|
||||
1. Set the site URL in the [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) settings in the Supabase Dashboard.
|
||||
|
||||
2. Add the following redirect URLs to your [URL Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration):
|
||||
|
||||
- `http://localhost:3000/auth/confirm` (for development)
|
||||
- `https://yourdomain.com/auth/confirm` (for production)
|
||||
- `http://localhost:3000/protected` (or your authenticated route)
|
||||
|
||||
3. Update the redirect paths in the components to point to the logged-in routes in your app. Our examples use `/protected`, but you can set this to whatever fits your app.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Disable Auto Sign-Up
|
||||
|
||||
By default, users are automatically created if they don't exist. To prevent this:
|
||||
|
||||
```tsx
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Only allow existing users
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### OTP Expiration
|
||||
|
||||
Configure OTP expiration in the Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Email**
|
||||
2. Find **Email OTP Expiration**
|
||||
3. Set custom duration (default: 3600 seconds = 1 hour, max: 86400 seconds = 24 hours)
|
||||
|
||||
## Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
By default, users can request a magic link or OTP once every 60 seconds to prevent abuse.
|
||||
|
||||
### Token Expiration
|
||||
|
||||
- Magic Links expire after 1 hour by default
|
||||
- OTPs expire after 1 hour by default
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Magic Link not working
|
||||
|
||||
- Verify redirect URLs are configured in Supabase Dashboard
|
||||
- Check email template configuration
|
||||
- Check browser console for errors, output them yourself if none are present
|
||||
|
||||
### OTP not received
|
||||
|
||||
- Verify email template shows `{{ .Token }}`
|
||||
- Check spam folder
|
||||
- Verify rate limiting hasn't been triggered (wait 60 seconds)
|
||||
|
||||
### "Invalid token" error
|
||||
|
||||
- Check if token has expired (default: 1 hour)
|
||||
- Ensure user hasn't already used the token
|
||||
- Verify system clock is synchronized
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Passwordless email logins](https://supabase.com/docs/guides/auth/auth-email-passwordless)
|
||||
- [Magic Link authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-magic-link)
|
||||
- [OTP authentication](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-otp)
|
||||
- [Email templates](https://supabase.com/docs/guides/auth/auth-email-templates)
|
||||
- [Authentication error codes](https://supabase.com/docs/guides/auth/debugging/error-codes)
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc",
|
||||
"content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependecnies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n",
|
||||
"content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n",
|
||||
"type": "registry:file",
|
||||
"target": "~/.cursor/rules/writing-supabase-edge-functions.mdc"
|
||||
},
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-react-router/app/routes/sign-up.tsx",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher<typeof action>()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <fetcher.Form method=\"post\">\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n name=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input id=\"password\" name=\"password\" type=\"password\" required />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input id=\"repeat-password\" name=\"repeat-password\" type=\"password\" required />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={loading}>\n {loading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <Link to=\"/login\" className=\"underline underline-offset-4\">\n Login\n </Link>\n </div>\n </fetcher.Form>\n </CardContent>\n </Card>\n )}\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher<typeof action>()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your\n account before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <fetcher.Form method=\"post\">\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n name=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input id=\"password\" name=\"password\" type=\"password\" required />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input id=\"repeat-password\" name=\"repeat-password\" type=\"password\" required />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={loading}>\n {loading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <Link to=\"/login\" className=\"underline underline-offset-4\">\n Login\n </Link>\n </div>\n </fetcher.Form>\n </CardContent>\n </Card>\n )}\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/sign-up.tsx"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-react/components/sign-up-form.tsx",
|
||||
"content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account before\n signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSignUp}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n required\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input\n id=\"repeat-password\"\n type=\"password\"\n required\n value={repeatPassword}\n onChange={(e) => setRepeatPassword(e.target.value)}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <a href=\"/login\" className=\"underline underline-offset-4\">\n Login\n </a>\n </div>\n </form>\n </CardContent>\n </Card>\n )}\n </div>\n )\n}\n",
|
||||
"content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSignUp}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n required\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input\n id=\"repeat-password\"\n type=\"password\"\n required\n value={repeatPassword}\n onChange={(e) => setRepeatPassword(e.target.value)}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <a href=\"/login\" className=\"underline underline-offset-4\">\n Login\n </a>\n </div>\n </form>\n </CardContent>\n </Card>\n )}\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-tanstack/routes/sign-up-success.tsx",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/sign-up-success.tsx"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "passwordless-auth-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for Next.js and Supabase",
|
||||
"description": "Passwordless authentication for Next.js using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label",
|
||||
"link"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isSuccess, setIsSuccess] = useState(false)\n\n const handleMagicLinkRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n setIsSuccess(false)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Magic Link Login</CardTitle>\n <CardDescription>\n Enter your email to receive a magic link for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n {isSuccess ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click the\n link in the email to sign in.\n </p>\n </div>\n ) : (\n <form onSubmit={handleMagicLinkRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending magic link...' : 'Send magic link'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useRouter } from 'next/navigation'\nimport { useState } from 'react'\n\nexport function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const router = useRouter()\n\n const handleOTPRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">OTP Login</CardTitle>\n <CardDescription>\n Enter your email to receive a one-time password for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleOTPRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending code...' : 'Send verification code'}\n </Button>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { useState } from 'react'\n\nexport function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const searchParams = useSearchParams()\n const email = searchParams.get('email') || ''\n const [otp, setOTP] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isResending, setIsResending] = useState(false)\n const router = useRouter()\n\n const handleVerifyOTP = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.verifyOtp({\n email,\n token: otp,\n type: 'email',\n })\n if (error) throw error\n // Redirect to protected page after successful verification\n router.push('/protected')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n const handleResendOTP = async () => {\n const supabase = createClient()\n setIsResending(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: false,\n },\n })\n if (error) throw error\n setError(null)\n // Show success message\n alert('A new verification code has been sent to your email.')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'Failed to resend code')\n } finally {\n setIsResending(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Verify Your Email</CardTitle>\n <CardDescription>\n Enter the 6-digit code sent to{' '}\n <span className=\"font-medium text-foreground\">{email}</span>\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleVerifyOTP}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"otp\">Verification Code</Label>\n <Input\n id=\"otp\"\n type=\"text\"\n placeholder=\"123456\"\n required\n maxLength={6}\n pattern=\"[0-9]{6}\"\n value={otp}\n onChange={(e) => setOTP(e.target.value.replace(/\\D/g, ''))}\n disabled={isLoading}\n className=\"text-center text-2xl tracking-widest\"\n />\n <p className=\"text-xs text-muted-foreground\">Enter the 6-digit code</p>\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading || otp.length !== 6}>\n {isLoading ? 'Verifying...' : 'Verify Code'}\n </Button>\n <div className=\"text-center text-sm\">\n Didn't receive a code?{' '}\n <button\n type=\"button\"\n onClick={handleResendOTP}\n disabled={isResending}\n className=\"text-primary underline-offset-4 hover:underline disabled:opacity-50\"\n >\n {isResending ? 'Sending...' : 'Resend'}\n </button>\n </div>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useRouter } from 'next/navigation'\nimport { useState } from 'react'\n\ntype LoginMethod = 'magic-link' | 'otp'\n\ninterface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {\n method?: LoginMethod\n}\n\nexport function PasswordlessLoginForm({\n className,\n method = 'magic-link',\n ...props\n}: PasswordlessLoginFormProps) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)\n const router = useRouter()\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n const supabase = createClient()\n setIsLoading(true)\n setError(null)\n setIsMagicLinkSent(false)\n\n try {\n if (method === 'magic-link') {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsMagicLinkSent(true)\n } else {\n // OTP method\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`)\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Sign in or create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n {isMagicLinkSent ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click\n the link in the email to sign in.\n </p>\n <Button\n variant=\"outline\"\n className=\"mt-4 w-full\"\n onClick={() => {\n setIsMagicLinkSent(false)\n setEmail('')\n }}\n >\n Send another link\n </Button>\n </div>\n ) : (\n <form onSubmit={handleSubmit}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading\n ? method === 'magic-link'\n ? 'Sending magic link...'\n : 'Sending code...'\n : method === 'magic-link'\n ? 'Send magic link'\n : 'Send verification code'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/magic-link/page.tsx",
|
||||
"content": "import { MagicLinkForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form'\n\nexport default function MagicLinkPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <MagicLinkForm />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/magic-link/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/otp/page.tsx",
|
||||
"content": "import { OTPRequestForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form'\n\nexport default function OTPRequestPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <OTPRequestForm />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/verify-otp/page.tsx",
|
||||
"content": "import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form'\nimport { Suspense } from 'react'\n\nfunction OTPVerifyContent() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <OTPVerifyForm />\n </div>\n </div>\n )\n}\n\nexport default function OTPVerifyPage() {\n return (\n <Suspense fallback={<div>Loading...</div>}>\n <OTPVerifyContent />\n </Suspense>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/verify-otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/passwordless/page.tsx",
|
||||
"content": "import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'\n\nexport default function PasswordlessLoginPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <PasswordlessLoginForm />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/passwordless/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/confirm/route.ts",
|
||||
"content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/server'\nimport { type EmailOtpType } from '@supabase/supabase-js'\nimport { type NextRequest, NextResponse } from 'next/server'\n\nexport async function GET(request: NextRequest) {\n const { searchParams } = new URL(request.url)\n const token_hash = searchParams.get('token_hash')\n const type = searchParams.get('type') as EmailOtpType | null\n const next = searchParams.get('next') ?? '/protected'\n\n if (token_hash && type) {\n const supabase = await createClient()\n\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n if (!error) {\n // Redirect user to specified redirect URL or default to protected page\n return NextResponse.redirect(new URL(next, request.url))\n }\n }\n\n // Redirect the user to an error page with some instructions\n return NextResponse.redirect(new URL('/auth/error', request.url))\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/confirm/route.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/error/page.tsx",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Button } from '@/registry/default/components/ui/button'\nimport Link from 'next/link'\n\nexport default function AuthErrorPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Authentication Error</CardTitle>\n <CardDescription>There was an error authenticating your account</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <p className=\"text-sm text-muted-foreground\">\n The authentication link may have expired or is invalid. Please try requesting a new\n one.\n </p>\n <Button asChild className=\"w-full\">\n <Link href=\"/auth/passwordless\">Try again</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/error/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/protected/page.tsx",
|
||||
"content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/server'\nimport { redirect } from 'next/navigation'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Button } from '@/registry/default/components/ui/button'\n\nexport default async function ProtectedPage() {\n const supabase = await createClient()\n\n const {\n data: { user },\n } = await supabase.auth.getUser()\n\n if (!user) {\n redirect('/auth/passwordless')\n }\n\n const handleSignOut = async () => {\n 'use server'\n const supabase = await createClient()\n await supabase.auth.signOut()\n redirect('/auth/passwordless')\n }\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Protected Page</CardTitle>\n <CardDescription>You are logged in</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">Email</p>\n <p className=\"text-sm text-muted-foreground\">{user.email}</p>\n </div>\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">User ID</p>\n <p className=\"text-sm text-muted-foreground\">{user.id}</p>\n </div>\n <form action={handleSignOut}>\n <Button type=\"submit\" variant=\"outline\" className=\"w-full\">\n Sign out\n </Button>\n </form>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/protected/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/middleware.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function middleware(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n const {\n data: { user },\n } = await supabase.auth.getUser()\n\n // Protect /protected route\n if (request.nextUrl.pathname.startsWith('/protected') && !user) {\n const url = request.nextUrl.clone()\n url.pathname = '/auth/passwordless'\n return NextResponse.redirect(url)\n }\n\n return supabaseResponse\n}\n\nexport const config = {\n matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "middleware.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
|
||||
"content": "import { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { NextResponse, type NextRequest } from 'next/server'\n\nexport async function updateSession(request: NextRequest) {\n let supabaseResponse = NextResponse.next({\n request,\n })\n\n // With Fluid compute, don't put this client in a global environment\n // variable. Always create a new one on each request.\n const supabase = createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return request.cookies.getAll()\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))\n supabaseResponse = NextResponse.next({\n request,\n })\n cookiesToSet.forEach(({ name, value, options }) =>\n supabaseResponse.cookies.set(name, value, options)\n )\n },\n },\n }\n )\n\n // Do not run code between createServerClient and\n // supabase.auth.getClaims(). A simple mistake could make it very hard to debug\n // issues with users being randomly logged out.\n\n // IMPORTANT: If you remove getClaims() and you use server-side rendering\n // with the Supabase client, your users may be randomly logged out.\n const { data } = await supabase.auth.getClaims()\n const user = data?.claims\n\n if (\n !user &&\n !request.nextUrl.pathname.startsWith('/login') &&\n !request.nextUrl.pathname.startsWith('/auth')\n ) {\n // no user, potentially respond by redirecting the user to the login page\n const url = request.nextUrl.clone()\n url.pathname = '/auth/login'\n return NextResponse.redirect(url)\n }\n\n // IMPORTANT: You *must* return the supabaseResponse object as it is.\n // If you're creating a new response object with NextResponse.next() make sure to:\n // 1. Pass the request in it, like so:\n // const myNewResponse = NextResponse.next({ request })\n // 2. Copy over the cookies, like so:\n // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())\n // 3. Change the myNewResponse object to fit your needs, but avoid changing\n // the cookies!\n // 4. Finally:\n // return myNewResponse\n // If this is not done, you may be causing the browser and server to go out\n // of sync and terminate the user's session prematurely!\n\n return supabaseResponse\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { cookies } from 'next/headers'\n\n/**\n * If using Fluid compute: Don't put this client in a global variable. Always create a new client within each\n * function when using it.\n */\nexport async function createClient() {\n const cookieStore = await cookies()\n\n return createServerClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return cookieStore.getAll()\n },\n setAll(cookiesToSet) {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options)\n )\n } catch {\n // The `setAll` method was called from a Server Component.\n // This can be ignored if you have middleware refreshing\n // user sessions.\n }\n },\n },\n }\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"envVars": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "passwordless-auth-react-router",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React Router and Supabase",
|
||||
"description": "Passwordless authentication for React Router using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@react-router/dev@latest",
|
||||
"@react-router/fs-routes@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/passwordless-login-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate } from 'react-router'\nimport { useState } from 'react'\n\ntype LoginMethod = 'magic-link' | 'otp'\n\ninterface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {\n method?: LoginMethod\n}\n\nexport function PasswordlessLoginForm({\n className,\n method = 'magic-link',\n ...props\n}: PasswordlessLoginFormProps) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsMagicLinkSent(false)\n\n try {\n if (method === 'magic-link') {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsMagicLinkSent(true)\n } else {\n // OTP method\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n navigate(`/verify-otp?email=${encodeURIComponent(email)}`)\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Sign in or create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n {isMagicLinkSent ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click\n the link in the email to sign in.\n </p>\n <Button\n variant=\"outline\"\n className=\"mt-4 w-full\"\n onClick={() => {\n setIsMagicLinkSent(false)\n setEmail('')\n }}\n >\n Send another link\n </Button>\n </div>\n ) : (\n <form onSubmit={handleSubmit}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading\n ? method === 'magic-link'\n ? 'Sending magic link...'\n : 'Sending code...'\n : method === 'magic-link'\n ? 'Send magic link'\n : 'Send verification code'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/magic-link-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isSuccess, setIsSuccess] = useState(false)\n const supabase = createClient()\n\n const handleMagicLinkRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsSuccess(false)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Magic Link Login</CardTitle>\n <CardDescription>\n Enter your email to receive a magic link for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n {isSuccess ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click the\n link in the email to sign in.\n </p>\n </div>\n ) : (\n <form onSubmit={handleMagicLinkRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending magic link...' : 'Send magic link'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-request-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate } from 'react-router'\nimport { useState } from 'react'\n\nexport function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleOTPRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n navigate(`/verify-otp?email=${encodeURIComponent(email)}`)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">OTP Login</CardTitle>\n <CardDescription>\n Enter your email to receive a one-time password for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleOTPRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending code...' : 'Send verification code'}\n </Button>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-verify-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate, useSearchParams } from 'react-router'\nimport { useState } from 'react'\n\nexport function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [searchParams] = useSearchParams()\n const email = searchParams.get('email') || ''\n const [otp, setOTP] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isResending, setIsResending] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleVerifyOTP = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.verifyOtp({\n email,\n token: otp,\n type: 'email',\n })\n if (error) throw error\n // Redirect to protected page after successful verification\n navigate('/protected')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n const handleResendOTP = async () => {\n setIsResending(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: false,\n },\n })\n if (error) throw error\n setError(null)\n // Show success message\n alert('A new verification code has been sent to your email.')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'Failed to resend code')\n } finally {\n setIsResending(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Verify Your Email</CardTitle>\n <CardDescription>\n Enter the 6-digit code sent to{' '}\n <span className=\"font-medium text-foreground\">{email}</span>\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleVerifyOTP}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"otp\">Verification Code</Label>\n <Input\n id=\"otp\"\n type=\"text\"\n placeholder=\"123456\"\n required\n maxLength={6}\n pattern=\"[0-9]{6}\"\n value={otp}\n onChange={(e) => setOTP(e.target.value.replace(/\\D/g, ''))}\n disabled={isLoading}\n className=\"text-center text-2xl tracking-widest\"\n />\n <p className=\"text-xs text-muted-foreground\">Enter the 6-digit code</p>\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading || otp.length !== 6}>\n {isLoading ? 'Verifying...' : 'Verify Code'}\n </Button>\n <div className=\"text-center text-sm\">\n Didn't receive a code?{' '}\n <button\n type=\"button\"\n onClick={handleResendOTP}\n disabled={isResending}\n className=\"text-primary underline-offset-4 hover:underline disabled:opacity-50\"\n >\n {isResending ? 'Sending...' : 'Resend'}\n </button>\n </div>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.confirm.tsx",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { type EmailOtpType } from '@supabase/supabase-js'\nimport { type LoaderFunctionArgs, redirect } from 'react-router'\n\nexport async function loader({ request }: LoaderFunctionArgs) {\n const requestUrl = new URL(request.url)\n const token_hash = requestUrl.searchParams.get('token_hash')\n const type = requestUrl.searchParams.get('type') as EmailOtpType | null\n const _next = requestUrl.searchParams.get('next')\n const next = _next?.startsWith('/') ? _next : '/protected'\n\n if (token_hash && type) {\n const { supabase, headers } = createClient(request)\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n if (!error) {\n return redirect(next, { headers })\n } else {\n return redirect(`/auth/error?error=${error?.message}`)\n }\n }\n\n // redirect the user to an error page with some instructions\n return redirect(`/auth/error?error=No token hash or type`)\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.confirm.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.error.tsx",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Button } from '@/registry/default/components/ui/button'\nimport { Link } from 'react-router'\n\nexport default function AuthErrorPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Authentication Error</CardTitle>\n <CardDescription>There was an error authenticating your account</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <p className=\"text-sm text-muted-foreground\">\n The authentication link may have expired or is invalid. Please try requesting a new\n one.\n </p>\n <Button asChild className=\"w-full\">\n <Link to=\"/passwordless\">Try again</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/passwordless.tsx",
|
||||
"content": "import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-react-router/components/passwordless-login-form'\n\nexport default function PasswordlessPage() {\n return (\n <div className=\"flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <PasswordlessLoginForm method=\"magic-link\" />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/verify-otp.tsx",
|
||||
"content": "import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-react-router/components/otp-verify-form'\n\nexport default function VerifyOTPPage() {\n return (\n <div className=\"flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <OTPVerifyForm />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/protected.tsx",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { type LoaderFunctionArgs, redirect, useLoaderData } from 'react-router'\n\nexport const loader = async ({ request }: LoaderFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const { data, error } = await supabase.auth.getUser()\n if (error || !data?.user) {\n return redirect('/passwordless')\n }\n\n return data\n}\n\nexport default function ProtectedPage() {\n let data = useLoaderData<typeof loader>()\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Protected Page</CardTitle>\n <CardDescription>You are logged in</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">Email</p>\n <p className=\"text-sm text-muted-foreground\">{data.user.email}</p>\n </div>\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">User ID</p>\n <p className=\"text-sm text-muted-foreground\">{data.user.id}</p>\n </div>\n <form method=\"post\" action=\"/logout\">\n <Button type=\"submit\" variant=\"outline\" className=\"w-full\">\n Sign out\n </Button>\n </form>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react-router/lib/supabase/client.ts",
|
||||
"content": "/// <reference types=\"vite/types/importMeta.d.ts\" />\nimport { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react-router/lib/supabase/server.ts",
|
||||
"content": "import { createServerClient, parseCookieHeader, serializeCookieHeader } from '@supabase/ssr'\n\nexport function createClient(request: Request) {\n const headers = new Headers()\n\n const supabase = createServerClient(\n process.env.VITE_SUPABASE_URL!,\n process.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return parseCookieHeader(request.headers.get('Cookie') ?? '') as {\n name: string\n value: string\n }[]\n },\n setAll(cookiesToSet) {\n cookiesToSet.forEach(({ name, value, options }) =>\n headers.append('Set-Cookie', serializeCookieHeader(name, value, options))\n )\n },\n },\n }\n )\n\n return { supabase, headers }\n}\n",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "passwordless-auth-react",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React and Supabase",
|
||||
"description": "Passwordless authentication for React using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods. Components only, no routing included.",
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/passwordless-login-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\ntype LoginMethod = 'magic-link' | 'otp'\n\ninterface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {\n method?: LoginMethod\n onOtpRequested?: (email: string) => void\n}\n\nexport function PasswordlessLoginForm({\n className,\n method = 'magic-link',\n onOtpRequested,\n ...props\n}: PasswordlessLoginFormProps) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)\n const supabase = createClient()\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsMagicLinkSent(false)\n\n try {\n if (method === 'magic-link') {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsMagicLinkSent(true)\n } else {\n // OTP method\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Call callback to notify parent component\n if (onOtpRequested) {\n onOtpRequested(email)\n }\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Sign in or create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n {isMagicLinkSent ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click\n the link in the email to sign in.\n </p>\n <Button\n variant=\"outline\"\n className=\"mt-4 w-full\"\n onClick={() => {\n setIsMagicLinkSent(false)\n setEmail('')\n }}\n >\n Send another link\n </Button>\n </div>\n ) : (\n <form onSubmit={handleSubmit}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading\n ? method === 'magic-link'\n ? 'Sending magic link...'\n : 'Sending code...'\n : method === 'magic-link'\n ? 'Send magic link'\n : 'Send verification code'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/magic-link-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isSuccess, setIsSuccess] = useState(false)\n const supabase = createClient()\n\n const handleMagicLinkRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsSuccess(false)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Magic Link Login</CardTitle>\n <CardDescription>\n Enter your email to receive a magic link for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n {isSuccess ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click the\n link in the email to sign in.\n </p>\n </div>\n ) : (\n <form onSubmit={handleMagicLinkRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending magic link...' : 'Send magic link'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-request-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\ninterface OTPRequestFormProps extends React.ComponentPropsWithoutRef<'div'> {\n onOtpRequested?: (email: string) => void\n}\n\nexport function OTPRequestForm({ className, onOtpRequested, ...props }: OTPRequestFormProps) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const supabase = createClient()\n\n const handleOTPRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Call callback to notify parent component\n if (onOtpRequested) {\n onOtpRequested(email)\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">OTP Login</CardTitle>\n <CardDescription>\n Enter your email to receive a one-time password for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleOTPRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending code...' : 'Send verification code'}\n </Button>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-verify-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\ninterface OTPVerifyFormProps extends React.ComponentPropsWithoutRef<'div'> {\n email: string\n onVerified?: () => void\n}\n\nexport function OTPVerifyForm({ className, email, onVerified, ...props }: OTPVerifyFormProps) {\n const [otp, setOTP] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isResending, setIsResending] = useState(false)\n const supabase = createClient()\n\n const handleVerifyOTP = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.verifyOtp({\n email,\n token: otp,\n type: 'email',\n })\n if (error) throw error\n // Call callback to notify parent component\n if (onVerified) {\n onVerified()\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n const handleResendOTP = async () => {\n setIsResending(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: false,\n },\n })\n if (error) throw error\n setError(null)\n // Show success message\n alert('A new verification code has been sent to your email.')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'Failed to resend code')\n } finally {\n setIsResending(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Verify Your Email</CardTitle>\n <CardDescription>\n Enter the 6-digit code sent to{' '}\n <span className=\"font-medium text-foreground\">{email}</span>\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleVerifyOTP}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"otp\">Verification Code</Label>\n <Input\n id=\"otp\"\n type=\"text\"\n placeholder=\"123456\"\n required\n maxLength={6}\n pattern=\"[0-9]{6}\"\n value={otp}\n onChange={(e) => setOTP(e.target.value.replace(/\\D/g, ''))}\n disabled={isLoading}\n className=\"text-center text-2xl tracking-widest\"\n />\n <p className=\"text-xs text-muted-foreground\">Enter the 6-digit code</p>\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading || otp.length !== 6}>\n {isLoading ? 'Verifying...' : 'Verify Code'}\n </Button>\n <div className=\"text-center text-sm\">\n Didn't receive a code?{' '}\n <button\n type=\"button\"\n onClick={handleResendOTP}\n disabled={isResending}\n className=\"text-primary underline-offset-4 hover:underline disabled:opacity-50\"\n >\n {isResending ? 'Sending...' : 'Resend'}\n </button>\n </div>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react/lib/supabase/client.ts",
|
||||
"content": "import { createClient as createSupabaseClient } from '@supabase/supabase-js'\n\nexport function createClient() {\n return createSupabaseClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "passwordless-auth-tanstack",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for TanStack and Supabase",
|
||||
"description": "Passwordless authentication for TanStack using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/passwordless-login-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate } from '@tanstack/react-router'\nimport { useState } from 'react'\n\ntype LoginMethod = 'magic-link' | 'otp'\n\ninterface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {\n method?: LoginMethod\n}\n\nexport function PasswordlessLoginForm({\n className,\n method = 'magic-link',\n ...props\n}: PasswordlessLoginFormProps) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsMagicLinkSent(false)\n\n try {\n if (method === 'magic-link') {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsMagicLinkSent(true)\n } else {\n // OTP method\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n navigate({ to: '/verify-otp', search: { email } })\n }\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Sign in or create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n {isMagicLinkSent ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click\n the link in the email to sign in.\n </p>\n <Button\n variant=\"outline\"\n className=\"mt-4 w-full\"\n onClick={() => {\n setIsMagicLinkSent(false)\n setEmail('')\n }}\n >\n Send another link\n </Button>\n </div>\n ) : (\n <form onSubmit={handleSubmit}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading\n ? method === 'magic-link'\n ? 'Sending magic link...'\n : 'Sending code...'\n : method === 'magic-link'\n ? 'Send magic link'\n : 'Send verification code'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/magic-link-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isSuccess, setIsSuccess] = useState(false)\n const supabase = createClient()\n\n const handleMagicLinkRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n setIsSuccess(false)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n emailRedirectTo: `${window.location.origin}/auth/confirm`,\n },\n })\n if (error) throw error\n setIsSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Magic Link Login</CardTitle>\n <CardDescription>\n Enter your email to receive a magic link for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n {isSuccess ? (\n <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800\">\n <p className=\"font-semibold\">Check your email!</p>\n <p className=\"mt-1\">\n We've sent a magic link to <span className=\"font-medium\">{email}</span>. Click the\n link in the email to sign in.\n </p>\n </div>\n ) : (\n <form onSubmit={handleMagicLinkRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending magic link...' : 'Send magic link'}\n </Button>\n </div>\n </form>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-request-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate } from '@tanstack/react-router'\nimport { useState } from 'react'\n\nexport function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleOTPRequest = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n // Set this to false if you do not want the user to be automatically signed up\n shouldCreateUser: true,\n },\n })\n if (error) throw error\n // Navigate to OTP verification page\n navigate({ to: '/verify-otp', search: { email } })\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">OTP Login</CardTitle>\n <CardDescription>\n Enter your email to receive a one-time password for passwordless login\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleOTPRequest}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={isLoading}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Sending code...' : 'Send verification code'}\n </Button>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-verify-form.tsx",
|
||||
"content": "'use client'\n\nimport { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useNavigate, useSearch } from '@tanstack/react-router'\nimport { useState } from 'react'\n\nexport function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const search = useSearch({ strict: false })\n const email = (search as { email?: string })?.email || ''\n const [otp, setOTP] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isResending, setIsResending] = useState(false)\n const navigate = useNavigate()\n const supabase = createClient()\n\n const handleVerifyOTP = async (e: React.FormEvent) => {\n e.preventDefault()\n setIsLoading(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.verifyOtp({\n email,\n token: otp,\n type: 'email',\n })\n if (error) throw error\n // Redirect to protected page after successful verification\n navigate({ to: '/protected' })\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n const handleResendOTP = async () => {\n setIsResending(true)\n setError(null)\n\n try {\n const { error } = await supabase.auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: false,\n },\n })\n if (error) throw error\n setError(null)\n // Show success message\n alert('A new verification code has been sent to your email.')\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'Failed to resend code')\n } finally {\n setIsResending(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Verify Your Email</CardTitle>\n <CardDescription>\n Enter the 6-digit code sent to{' '}\n <span className=\"font-medium text-foreground\">{email}</span>\n </CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleVerifyOTP}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"otp\">Verification Code</Label>\n <Input\n id=\"otp\"\n type=\"text\"\n placeholder=\"123456\"\n required\n maxLength={6}\n pattern=\"[0-9]{6}\"\n value={otp}\n onChange={(e) => setOTP(e.target.value.replace(/\\D/g, ''))}\n disabled={isLoading}\n className=\"text-center text-2xl tracking-widest\"\n />\n <p className=\"text-xs text-muted-foreground\">Enter the 6-digit code</p>\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading || otp.length !== 6}>\n {isLoading ? 'Verifying...' : 'Verify Code'}\n </Button>\n <div className=\"text-center text-sm\">\n Didn't receive a code?{' '}\n <button\n type=\"button\"\n onClick={handleResendOTP}\n disabled={isResending}\n className=\"text-primary underline-offset-4 hover:underline disabled:opacity-50\"\n >\n {isResending ? 'Sending...' : 'Resend'}\n </button>\n </div>\n </div>\n </form>\n </CardContent>\n </Card>\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/passwordless.tsx",
|
||||
"content": "import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-tanstack/components/passwordless-login-form'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/passwordless')({\n component: PasswordlessPage,\n})\n\nfunction PasswordlessPage() {\n return (\n <div className=\"flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <PasswordlessLoginForm method=\"magic-link\" />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/error.tsx",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Button } from '@/registry/default/components/ui/button'\nimport { Link, createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/auth/error')({\n component: AuthErrorPage,\n})\n\nfunction AuthErrorPage() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Authentication Error</CardTitle>\n <CardDescription>There was an error authenticating your account</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <p className=\"text-sm text-muted-foreground\">\n The authentication link may have expired or is invalid. Please try requesting a new\n one.\n </p>\n <Button asChild className=\"w-full\">\n <Link to=\"/passwordless\">Try again</Link>\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected.tsx",
|
||||
"content": "import { fetchUserServerFn } from '@/registry/default/blocks/passwordless-auth-tanstack/lib/supabase/fetch-user-server-fn'\nimport { createFileRoute, Outlet, redirect } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/_protected')({\n beforeLoad: async () => {\n const user = await fetchUserServerFn()\n if (!user) {\n throw redirect({\n to: '/passwordless',\n })\n }\n return { user }\n },\n component: () => <Outlet />,\n})\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected/protected.tsx",
|
||||
"content": "import { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute, useRouter } from '@tanstack/react-router'\nimport { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'\n\nexport const Route = createFileRoute('/_protected/protected')({\n component: ProtectedPage,\n})\n\nfunction ProtectedPage() {\n const router = useRouter()\n const { user } = Route.useRouteContext()\n const supabase = createClient()\n\n const handleSignOut = async () => {\n await supabase.auth.signOut()\n router.navigate({ to: '/passwordless' })\n }\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Protected Page</CardTitle>\n <CardDescription>You are logged in</CardDescription>\n </CardHeader>\n <CardContent>\n <div className=\"flex flex-col gap-4\">\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">Email</p>\n <p className=\"text-sm text-muted-foreground\">{user.email}</p>\n </div>\n <div className=\"rounded-lg border p-4\">\n <p className=\"text-sm font-medium\">User ID</p>\n <p className=\"text-sm text-muted-foreground\">{user.id}</p>\n </div>\n <Button onClick={handleSignOut} variant=\"outline\" className=\"w-full\">\n Sign out\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected/protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/confirm.ts",
|
||||
"content": "import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\nimport { type EmailOtpType } from '@supabase/supabase-js'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { getWebRequest } from '@tanstack/react-start/server'\n\nconst confirmFn = createServerFn({ method: 'GET' })\n .validator((searchParams: unknown) => {\n if (\n searchParams &&\n typeof searchParams === 'object' &&\n 'token_hash' in searchParams &&\n 'type' in searchParams\n ) {\n return searchParams\n }\n throw new Error('Invalid search params')\n })\n .handler(async (ctx) => {\n const request = getWebRequest()\n\n if (!request) {\n throw redirect({ to: `/auth/error`, search: { error: 'No request' } })\n }\n\n const searchParams = ctx.data\n const token_hash = searchParams['token_hash'] as string\n const type = searchParams['type'] as EmailOtpType | null\n const _next = searchParams['next'] as string | undefined\n const next = _next?.startsWith('/') ? _next : '/protected'\n\n if (token_hash && type) {\n const supabase = createClient()\n\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n if (!error) {\n // redirect user to specified redirect URL or protected page\n throw redirect({ href: next })\n } else {\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: error?.message },\n })\n }\n }\n\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: 'No token hash or type' },\n })\n })\n\nexport const Route = createFileRoute('/auth/confirm')({\n preload: false,\n loader: (opts) => confirmFn({ data: opts.location.search }),\n})\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/confirm.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/verify-otp.tsx",
|
||||
"content": "import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-tanstack/components/otp-verify-form'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/verify-otp')({\n component: VerifyOTPPage,\n validateSearch: (search: Record<string, unknown>): { email?: string } => {\n return {\n email: (search.email as string) || undefined,\n }\n },\n})\n\nfunction VerifyOTPPage() {\n return (\n <div className=\"flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <OTPVerifyForm />\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/lib/supabase/fetch-user-server-fn.ts",
|
||||
"content": "import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\nimport type { Factor, User } from '@supabase/supabase-js'\nimport { createServerFn } from '@tanstack/react-start'\ntype SSRSafeUser = User & {\n factors: (Factor & { factor_type: 'phone' | 'totp' })[]\n}\n\nexport const fetchUserServerFn: () => Promise<SSRSafeUser | null> = createServerFn({\n method: 'GET',\n}).handler(async () => {\n const supabase = createClient()\n const { data, error } = await supabase.auth.getUser()\n\n if (error) {\n return null\n }\n\n return data.user as SSRSafeUser\n})\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/tanstack/lib/supabase/client.ts",
|
||||
"content": "/// <reference types=\"vite/types/importMeta.d.ts\" />\nimport { createBrowserClient } from '@supabase/ssr'\n\nexport function createClient() {\n return createBrowserClient(\n import.meta.env.VITE_SUPABASE_URL!,\n import.meta.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/tanstack/lib/supabase/server.ts",
|
||||
"content": "import { createServerClient } from '@supabase/ssr'\nimport { parseCookies, setCookie } from '@tanstack/react-start/server'\n\nexport function createClient() {\n return createServerClient(\n process.env.VITE_SUPABASE_URL!,\n process.env.VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,\n {\n cookies: {\n getAll() {\n return Object.entries(parseCookies()).map(\n ([name, value]) =>\n ({\n name,\n value,\n }) as { name: string; value: string }\n )\n },\n setAll(cookies) {\n cookies.forEach((cookie) => {\n setCookie(cookie.name, cookie.value)\n })\n },\n },\n }\n )\n}\n",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
},
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`."
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts",
|
||||
"content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n",
|
||||
"content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/api/supabase-proxy/[...path]/route.ts"
|
||||
},
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions.tsx",
|
||||
"content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n <div className=\"p-6 pt-4 lg:p-12 lg:pt-12 max-w-3xl mx-auto\">\n <h2 className=\"text-base lg:text-xl font-semibold mb-1\">Suggestions</h2>\n <p className=\"text-muted-foreground mb-4 lg:mb-8 text-sm lg:text-base\">\n Improve your project's security and performance.\n </p>\n {isLoading && (\n <div className=\"space-y-2\">\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n </div>\n )}\n {error && (\n <Alert variant=\"destructive\" className=\"mt-8\">\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>Error fetching suggestions</AlertTitle>\n <AlertDescription>\n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n </AlertDescription>\n </Alert>\n )}\n {suggestions && (\n <div>\n {sortedSuggestions.length > 0 ? (\n <div>\n {sortedSuggestions.map((suggestion: any) => (\n <div\n key={suggestion.cache_key}\n className=\"py-4 border-b last:border-b-0 group relative\"\n >\n <div className=\"flex-1\">\n <div className=\"flex justify-start items-start gap-4\">\n <h4 className=\"font-semibold text-sm\">{suggestion.title}</h4>\n <div className=\"flex items-center gap-1\">\n <Badge variant={getBadgeVariant(suggestion.level)} className=\"shrink-0\">\n {suggestion.level}\n </Badge>\n {suggestion.type && (\n <Badge\n variant={suggestion.type === 'security' ? 'destructive' : 'secondary'}\n className=\"shrink-0\"\n >\n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n </Badge>\n )}\n </div>\n </div>\n <div className=\"text-sm text-muted-foreground mt-2 prose prose-sm max-w-none\">\n <ReactMarkdown\n components={{\n code({ inline, children, ...props }: any) {\n return inline ? (\n <code className=\"bg-muted px-1 rounded\" {...props}>\n {children}\n </code>\n ) : (\n <pre className=\"bg-muted p-2 rounded overflow-x-auto\" {...props}>\n <code>{children}</code>\n </pre>\n )\n },\n }}\n >\n {suggestion.detail}\n </ReactMarkdown>\n </div>\n </div>\n </div>\n ))}\n </div>\n ) : (\n <Alert>\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>No suggestions found</AlertTitle>\n <AlertDescription>\n Your project looks good! No suggestions at this time.\n </AlertDescription>\n </Alert>\n )}\n </div>\n )}\n </div>\n )\n}\n",
|
||||
"content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n <div className=\"p-6 pt-4 lg:p-12 lg:pt-12 max-w-3xl mx-auto\">\n <h2 className=\"text-base lg:text-xl font-semibold mb-1\">Suggestions</h2>\n <p className=\"text-muted-foreground mb-4 lg:mb-8 text-sm lg:text-base\">\n Improve your project's security and performance.\n </p>\n {isLoading && (\n <div className=\"space-y-2\">\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n </div>\n )}\n {error && (\n <Alert variant=\"destructive\" className=\"mt-8\">\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>Error fetching suggestions</AlertTitle>\n <AlertDescription>\n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n </AlertDescription>\n </Alert>\n )}\n {suggestions && (\n <div>\n {sortedSuggestions.length > 0 ? (\n <div>\n {sortedSuggestions.map((suggestion: any) => (\n <div\n key={suggestion.cache_key}\n className=\"py-4 border-b last:border-b-0 group relative\"\n >\n <div className=\"flex-1\">\n <div className=\"flex justify-start items-start gap-4\">\n <h4 className=\"font-semibold text-sm\">{suggestion.title}</h4>\n <div className=\"flex items-center gap-1\">\n <Badge variant={getBadgeVariant(suggestion.level)} className=\"shrink-0\">\n {suggestion.level}\n </Badge>\n {suggestion.type && (\n <Badge\n variant={suggestion.type === 'security' ? 'destructive' : 'secondary'}\n className=\"shrink-0\"\n >\n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n </Badge>\n )}\n </div>\n </div>\n <div className=\"text-sm text-muted-foreground mt-2 prose prose-sm max-w-none\">\n <ReactMarkdown\n components={{\n code({ inline, children, ...props }: any) {\n return inline ? (\n <code className=\"bg-muted px-1 rounded\" {...props}>\n {children}\n </code>\n ) : (\n <pre className=\"bg-muted p-2 rounded overflow-x-auto\" {...props}>\n <code>{children}</code>\n </pre>\n )\n },\n }}\n >\n {suggestion.detail}\n </ReactMarkdown>\n </div>\n </div>\n </div>\n ))}\n </div>\n ) : (\n <Alert>\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>No suggestions found</AlertTitle>\n <AlertDescription>\n Your project looks good! No suggestions at this time.\n </AlertDescription>\n </Alert>\n )}\n </div>\n )}\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -319,6 +319,295 @@
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "passwordless-auth-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for Next.js and Supabase",
|
||||
"description": "Passwordless authentication for Next.js using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label",
|
||||
"link"
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/magic-link/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/magic-link/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/otp/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/verify-otp/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/verify-otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/passwordless/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/passwordless/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/confirm/route.ts",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/confirm/route.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/error/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/error/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/protected/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/protected/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/middleware.ts",
|
||||
"type": "registry:file",
|
||||
"target": "middleware.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/middleware.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/nextjs/lib/supabase/server.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"docs": "You'll need to set the following environment variables in your project: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY`.",
|
||||
"envVars": {
|
||||
"NEXT_PUBLIC_SUPABASE_URL": "",
|
||||
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "passwordless-auth-react",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React and Supabase",
|
||||
"description": "Passwordless authentication for React using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods. Components only, no routing included.",
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react/lib/supabase/client.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`.",
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "passwordless-auth-react-router",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React Router and Supabase",
|
||||
"description": "Passwordless authentication for React Router using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@react-router/dev@latest",
|
||||
"@react-router/fs-routes@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.confirm.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.confirm.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.error.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/passwordless.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/verify-otp.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react-router/lib/supabase/client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/react-router/lib/supabase/server.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`.",
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "passwordless-auth-tanstack",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for TanStack and Supabase",
|
||||
"description": "Passwordless authentication for TanStack using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": [
|
||||
"button",
|
||||
"card",
|
||||
"input",
|
||||
"label"
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@supabase/supabase-js@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/passwordless.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/error.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected/protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected/protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/confirm.ts",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/confirm.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/verify-otp.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/lib/supabase/fetch-user-server-fn.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/tanstack/lib/supabase/client.ts",
|
||||
"type": "registry:lib"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/clients/tanstack/lib/supabase/server.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
],
|
||||
"docs": "You'll need to set the following environment variables in your project: `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY`.",
|
||||
"envVars": {
|
||||
"VITE_SUPABASE_URL": "",
|
||||
"VITE_SUPABASE_PUBLISHABLE_OR_ANON_KEY": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "social-auth-nextjs",
|
||||
"type": "registry:block",
|
||||
|
||||
@@ -14,6 +14,11 @@ import passwordBasedAuthReactRouter from './default/blocks/password-based-auth-r
|
||||
import passwordBasedAuthReact from './default/blocks/password-based-auth-react/registry-item.json' with { type: 'json' }
|
||||
import passwordBasedAuthTanstack from './default/blocks/password-based-auth-tanstack/registry-item.json' with { type: 'json' }
|
||||
|
||||
import passwordlessAuthNextjs from './default/blocks/passwordless-auth-nextjs/registry-item.json' with { type: 'json' }
|
||||
import passwordlessAuthReactRouter from './default/blocks/passwordless-auth-react-router/registry-item.json' with { type: 'json' }
|
||||
import passwordlessAuthReact from './default/blocks/passwordless-auth-react/registry-item.json' with { type: 'json' }
|
||||
import passwordlessAuthTanstack from './default/blocks/passwordless-auth-tanstack/registry-item.json' with { type: 'json' }
|
||||
|
||||
import socialAuthNextjs from './default/blocks/social-auth-nextjs/registry-item.json' with { type: 'json' }
|
||||
import socialAuthReactRouter from './default/blocks/social-auth-react-router/registry-item.json' with { type: 'json' }
|
||||
import socialAuthReact from './default/blocks/social-auth-react/registry-item.json' with { type: 'json' }
|
||||
@@ -42,6 +47,11 @@ export const blocks = [
|
||||
registryItemAppend(passwordBasedAuthReactRouter as RegistryItem, [reactRouterClient!]),
|
||||
registryItemAppend(passwordBasedAuthTanstack as RegistryItem, [tanstackClient!]),
|
||||
|
||||
registryItemAppend(passwordlessAuthNextjs as RegistryItem, [nextjsClient!]),
|
||||
registryItemAppend(passwordlessAuthReact as RegistryItem, [reactClient!]),
|
||||
registryItemAppend(passwordlessAuthReactRouter as RegistryItem, [reactRouterClient!]),
|
||||
registryItemAppend(passwordlessAuthTanstack as RegistryItem, [tanstackClient!]),
|
||||
|
||||
registryItemAppend(socialAuthNextjs as RegistryItem, [nextjsClient!]),
|
||||
registryItemAppend(socialAuthReact as RegistryItem, [reactClient!]),
|
||||
registryItemAppend(socialAuthReactRouter as RegistryItem, [reactRouterClient!]),
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# Getting Started with Passwordless Authentication
|
||||
|
||||
This guide will help you set up passwordless authentication in your Next.js application in under 10 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Next.js 13+ application (App Router)
|
||||
- A Supabase project ([create one here](https://supabase.com/dashboard))
|
||||
- Node.js 18+ installed
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
### Step 2: Configure Environment Variables
|
||||
|
||||
Create a `.env.local` file in your project root:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
You can find these values in your Supabase Dashboard:
|
||||
|
||||
1. Go to [Supabase Dashboard](https://supabase.com/dashboard)
|
||||
2. Select your project
|
||||
3. Go to **Settings** > **API**
|
||||
4. Copy the **Project URL** and **anon/public key**
|
||||
|
||||
### Step 3: Configure Supabase Authentication
|
||||
|
||||
#### A. Enable Email Authentication
|
||||
|
||||
1. In Supabase Dashboard, go to **Authentication** > **Providers**
|
||||
2. Ensure **Email** is enabled (it's enabled by default)
|
||||
|
||||
#### B. Configure Redirect URLs
|
||||
|
||||
1. Go to **Authentication** > **URL Configuration**
|
||||
2. Add your redirect URLs:
|
||||
|
||||
**For Development:**
|
||||
|
||||
```
|
||||
Site URL: http://localhost:3000
|
||||
Redirect URLs:
|
||||
- http://localhost:3000/auth/confirm
|
||||
- http://localhost:3000/protected
|
||||
```
|
||||
|
||||
**For Production:**
|
||||
|
||||
```
|
||||
Site URL: https://yourdomain.com
|
||||
Redirect URLs:
|
||||
- https://yourdomain.com/auth/confirm
|
||||
- https://yourdomain.com/protected
|
||||
```
|
||||
|
||||
#### C. Configure Email Template (Choose One)
|
||||
|
||||
**Option 1: Magic Link (Default)**
|
||||
Go to **Authentication** > **Email Templates** > **Magic Link**
|
||||
|
||||
Use this template:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
<p>Follow this link to login:</p>
|
||||
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
|
||||
```
|
||||
|
||||
**Option 2: OTP**
|
||||
Go to **Authentication** > **Email Templates** > **Magic Link** (yes, same template)
|
||||
|
||||
Replace with this template:
|
||||
|
||||
```html
|
||||
<h2>One Time Login Code</h2>
|
||||
<p>Please enter this code to sign in:</p>
|
||||
<h1 style="font-size: 32px; letter-spacing: 4px;">{{ .Token }}</h1>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
**Option 3: Support Both**
|
||||
You can create a more sophisticated template that shows both the token and link.
|
||||
|
||||
### Step 4: Create Supabase Client
|
||||
|
||||
Create `lib/supabase/client.ts`:
|
||||
|
||||
```typescript
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Create `lib/supabase/server.ts`:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// The `setAll` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing
|
||||
// user sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Install UI Components
|
||||
|
||||
This block uses shadcn/ui components. Install the required components:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button card input label
|
||||
```
|
||||
|
||||
### Step 6: Copy the Components
|
||||
|
||||
Copy the passwordless authentication components to your project:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p components/auth
|
||||
mkdir -p app/auth/{magic-link,otp,verify-otp,confirm,error,passwordless}
|
||||
mkdir -p app/protected
|
||||
|
||||
# Copy components (adjust paths as needed)
|
||||
cp registry/default/blocks/passwordless-auth-nextjs/components/*.tsx components/auth/
|
||||
cp registry/default/blocks/passwordless-auth-nextjs/app/auth/*/page.tsx app/auth/
|
||||
cp registry/default/blocks/passwordless-auth-nextjs/app/auth/confirm/route.ts app/auth/confirm/
|
||||
```
|
||||
|
||||
Or manually copy the files from the `passwordless-auth-nextjs` block.
|
||||
|
||||
### Step 7: Add Middleware (Optional but Recommended)
|
||||
|
||||
Create or update `middleware.ts` in your project root:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
// Protect routes that start with /protected
|
||||
if (request.nextUrl.pathname.startsWith('/protected') && !user) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/auth/passwordless'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Create Your First Login Page
|
||||
|
||||
Choose one of the following implementations:
|
||||
|
||||
#### Option A: PasswordlessLoginForm with Magic Link (Recommended)
|
||||
|
||||
Create `app/auth/login/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-6">
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** You can change `method="magic-link"` to `method="otp"` to use OTP authentication instead.
|
||||
|
||||
#### Option B: Magic Link Only
|
||||
|
||||
Create `app/auth/login/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-6">
|
||||
<MagicLinkForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Option C: OTP Only
|
||||
|
||||
Create `app/auth/login/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md p-6">
|
||||
<OTPRequestForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 9: Create a Protected Page
|
||||
|
||||
Create `app/protected/page.tsx`:
|
||||
|
||||
```tsx
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default async function ProtectedPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login')
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
'use server'
|
||||
const supabase = await createClient()
|
||||
await supabase.auth.signOut()
|
||||
redirect('/auth/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-4 p-6">
|
||||
<h1 className="text-2xl font-bold">Protected Page</h1>
|
||||
<p>Welcome, {user.email}!</p>
|
||||
<form action={handleSignOut}>
|
||||
<Button type="submit" variant="outline">
|
||||
Sign Out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 10: Test Your Implementation
|
||||
|
||||
1. Start your development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Navigate to `http://localhost:3000/auth/login`
|
||||
|
||||
3. Enter your email address
|
||||
|
||||
4. Check your email for either:
|
||||
|
||||
- A magic link (click it to sign in)
|
||||
- An OTP code (enter it on the verification page)
|
||||
|
||||
5. You should be redirected to `/protected` after successful authentication
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Email not arriving
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Check your spam folder
|
||||
- Verify email provider settings in Supabase Dashboard
|
||||
- Check Supabase logs: **Authentication** > **Logs**
|
||||
|
||||
### Issue: "Invalid redirect URL" error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Verify the redirect URL is whitelisted in Supabase Dashboard
|
||||
- Ensure the URL matches exactly (including http/https)
|
||||
- Check for trailing slashes
|
||||
|
||||
### Issue: "User not found" error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Set `shouldCreateUser: true` to allow automatic user creation
|
||||
- Or manually create the user in Supabase Dashboard first
|
||||
|
||||
### Issue: Middleware not working
|
||||
|
||||
**Solutions:**
|
||||
|
||||
- Ensure middleware.ts is in the project root
|
||||
- Check the matcher configuration
|
||||
- Restart your development server
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Customize the UI**: Modify the components to match your brand
|
||||
2. **Add Analytics**: Track authentication events
|
||||
3. **Configure Email Templates**: Customize the email design
|
||||
4. **Add Social Auth**: Combine with OAuth providers
|
||||
5. **Implement Role-Based Access**: Add user roles and permissions
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Full Documentation](./README.md)
|
||||
- [Usage Guide](./USAGE.md)
|
||||
- [Supabase Auth Docs](https://supabase.com/docs/guides/auth)
|
||||
- [Next.js App Router](https://nextjs.org/docs/app)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- [Supabase Discord](https://discord.supabase.com)
|
||||
- [GitHub Issues](https://github.com/supabase/supabase/issues)
|
||||
- [Supabase Documentation](https://supabase.com/docs)
|
||||
|
||||
---
|
||||
|
||||
**Congratulations!** You now have passwordless email authentication set up in your Next.js application. 🎉
|
||||
@@ -0,0 +1,286 @@
|
||||
# Passwordless Authentication - Complete Documentation Index
|
||||
|
||||
## Overview
|
||||
|
||||
This is a complete passwordless authentication solution for Next.js applications using Supabase Auth. It supports both Magic Link and OTP (One-Time Password) authentication methods.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
### 📚 Getting Started
|
||||
- **[GETTING_STARTED.md](./GETTING_STARTED.md)** - Step-by-step setup guide (10 minutes)
|
||||
- Installation instructions
|
||||
- Configuration steps
|
||||
- Your first login page
|
||||
- Troubleshooting
|
||||
|
||||
### 📖 Main Documentation
|
||||
- **[README.md](./README.md)** - Complete feature documentation
|
||||
- Features overview
|
||||
- Components reference
|
||||
- Setup instructions
|
||||
- Configuration options
|
||||
- Security considerations
|
||||
|
||||
### 🎯 Quick Reference
|
||||
- **[QUICK_REFERENCE.md](./QUICK_REFERENCE.md)** - Quick lookup guide
|
||||
- Component comparison table
|
||||
- API reference
|
||||
- Configuration checklist
|
||||
- Common code snippets
|
||||
- Error messages reference
|
||||
|
||||
### 📘 Usage Guide
|
||||
- **[USAGE.md](./USAGE.md)** - Detailed usage instructions
|
||||
- Authentication methods comparison
|
||||
- Component API
|
||||
- Advanced configuration
|
||||
- Best practices
|
||||
- Migration guides
|
||||
|
||||
### 🔧 Integration Examples
|
||||
- **[INTEGRATION_EXAMPLES.md](./INTEGRATION_EXAMPLES.md)** - Real-world implementations
|
||||
- E-commerce application
|
||||
- SaaS dashboard
|
||||
- Blog platform
|
||||
- Multi-tenant application
|
||||
- Progressive Web App
|
||||
|
||||
## Components
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. PasswordlessLoginForm (Recommended)
|
||||
A unified form supporting both Magic Link and OTP authentication via the `method` prop.
|
||||
|
||||
**File**: `components/passwordless-login-form.tsx`
|
||||
|
||||
**Use when**: You want a single form that can handle either authentication method.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
// Magic Link (default)
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
#### 2. MagicLinkForm
|
||||
Standalone Magic Link authentication form.
|
||||
|
||||
**File**: `components/magic-link-form.tsx`
|
||||
|
||||
**Use when**: You only want to offer Magic Link authentication.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
```
|
||||
|
||||
#### 3. OTPRequestForm
|
||||
Request a one-time password via email.
|
||||
|
||||
**File**: `components/otp-request-form.tsx`
|
||||
|
||||
**Use when**: First step of OTP flow, or standalone OTP-only auth.
|
||||
|
||||
```tsx
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
```
|
||||
|
||||
#### 4. OTPVerifyForm
|
||||
Verify the OTP code received via email.
|
||||
|
||||
**File**: `components/otp-verify-form.tsx`
|
||||
|
||||
**Use when**: Second step of OTP flow for code verification.
|
||||
|
||||
```tsx
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
### Authentication Pages
|
||||
|
||||
- `app/auth/passwordless/page.tsx` - Combined login form
|
||||
- `app/auth/magic-link/page.tsx` - Magic Link only
|
||||
- `app/auth/otp/page.tsx` - OTP request
|
||||
- `app/auth/verify-otp/page.tsx` - OTP verification
|
||||
- `app/auth/error/page.tsx` - Error handling
|
||||
- `app/auth/confirm/route.ts` - Magic Link confirmation handler
|
||||
|
||||
### Example Pages
|
||||
|
||||
- `app/protected/page.tsx` - Example protected page
|
||||
|
||||
## Key Features
|
||||
|
||||
✅ **Magic Link Authentication**
|
||||
- One-click email authentication
|
||||
- Automatic session creation
|
||||
- Configurable redirect URLs
|
||||
|
||||
✅ **OTP Authentication**
|
||||
- 6-digit code verification
|
||||
- Resend functionality
|
||||
- Auto-format input
|
||||
|
||||
✅ **Unified Form**
|
||||
- Prop-based method selection
|
||||
- Single integration point
|
||||
- Consistent UI/UX
|
||||
|
||||
✅ **Security**
|
||||
- Rate limiting
|
||||
- Token expiration
|
||||
- HTTPS enforcement
|
||||
- Secure session management
|
||||
|
||||
✅ **User Experience**
|
||||
- Clear error messages
|
||||
- Loading states
|
||||
- Success confirmations
|
||||
- Responsive design
|
||||
|
||||
✅ **TypeScript**
|
||||
- Full type safety
|
||||
- IntelliSense support
|
||||
- Type definitions included
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
npm install @supabase/supabase-js @supabase/ssr
|
||||
```
|
||||
|
||||
### 2. Set Environment Variables
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
### 3. Configure Supabase
|
||||
- Add redirect URLs in Dashboard
|
||||
- Configure email template
|
||||
- Enable email authentication
|
||||
|
||||
### 4. Add Component
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
passwordless-auth-nextjs/
|
||||
├── components/
|
||||
│ ├── magic-link-form.tsx
|
||||
│ ├── otp-request-form.tsx
|
||||
│ ├── otp-verify-form.tsx
|
||||
│ └── passwordless-login-form.tsx
|
||||
├── app/
|
||||
│ ├── auth/
|
||||
│ │ ├── confirm/route.ts
|
||||
│ │ ├── error/page.tsx
|
||||
│ │ ├── magic-link/page.tsx
|
||||
│ │ ├── otp/page.tsx
|
||||
│ │ ├── verify-otp/page.tsx
|
||||
│ │ └── passwordless/page.tsx
|
||||
│ └── protected/page.tsx
|
||||
├── middleware.ts
|
||||
├── registry-item.json
|
||||
├── README.md
|
||||
├── GETTING_STARTED.md
|
||||
├── QUICK_REFERENCE.md
|
||||
├── USAGE.md
|
||||
├── INTEGRATION_EXAMPLES.md
|
||||
└── INDEX.md (this file)
|
||||
```
|
||||
|
||||
## Component Decision Tree
|
||||
|
||||
```
|
||||
Do you need passwordless auth?
|
||||
│
|
||||
├─ Yes → Do you want to offer both methods?
|
||||
│ │
|
||||
│ ├─ Yes → Use PasswordlessLoginForm
|
||||
│ │
|
||||
│ └─ No → Which method?
|
||||
│ │
|
||||
│ ├─ Magic Link → Use MagicLinkForm
|
||||
│ │
|
||||
│ └─ OTP → Use OTPRequestForm + OTPVerifyForm
|
||||
│
|
||||
└─ No → Consider password-based auth instead
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
| Use Case | Recommended Component | Documentation |
|
||||
|----------|----------------------|---------------|
|
||||
| Simple login | `PasswordlessLoginForm` | GETTING_STARTED.md |
|
||||
| E-commerce | `MagicLinkForm` | INTEGRATION_EXAMPLES.md |
|
||||
| Mobile app | `OTPRequestForm` + `OTPVerifyForm` | USAGE.md |
|
||||
| SaaS platform | `PasswordlessLoginForm` | INTEGRATION_EXAMPLES.md |
|
||||
| Blog comments | `MagicLinkForm` | INTEGRATION_EXAMPLES.md |
|
||||
| Admin panel | `OTPRequestForm` + `OTPVerifyForm` | USAGE.md |
|
||||
|
||||
## Configuration Matrix
|
||||
|
||||
| Feature | MagicLinkForm | OTPRequestForm + OTPVerifyForm | PasswordlessLoginForm |
|
||||
|---------|---------------|--------------------------------|----------------------|
|
||||
| One-click login | ✅ | ❌ | ✅ (with `method="magic-link"`) |
|
||||
| Code entry | ❌ | ✅ | ✅ (with `method="otp"`) |
|
||||
| Method selection | Fixed | Fixed | Via prop |
|
||||
| Email template changes | Optional | Required | Depends on method |
|
||||
| Pages needed | 1 | 2 | 1 |
|
||||
| Complexity | Low | Medium | Low |
|
||||
|
||||
## Support
|
||||
|
||||
### Documentation
|
||||
- Start with **GETTING_STARTED.md** for setup
|
||||
- Use **QUICK_REFERENCE.md** for quick lookups
|
||||
- Read **USAGE.md** for detailed usage
|
||||
- Check **INTEGRATION_EXAMPLES.md** for real-world patterns
|
||||
|
||||
### External Resources
|
||||
- [Supabase Auth Docs](https://supabase.com/docs/guides/auth)
|
||||
- [Next.js Documentation](https://nextjs.org/docs)
|
||||
- [Supabase Discord](https://discord.supabase.com)
|
||||
|
||||
### Troubleshooting
|
||||
See the Troubleshooting sections in:
|
||||
- GETTING_STARTED.md (setup issues)
|
||||
- USAGE.md (usage issues)
|
||||
- QUICK_REFERENCE.md (error messages)
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package | Version |
|
||||
|---------|---------|
|
||||
| Next.js | 13.x, 14.x, 15.x |
|
||||
| React | 18.x |
|
||||
| @supabase/supabase-js | 2.x |
|
||||
| @supabase/ssr | 0.x |
|
||||
|
||||
## License
|
||||
|
||||
This component block is part of the Supabase UI Library and follows the same license as the parent project.
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Start with [GETTING_STARTED.md](./GETTING_STARTED.md) for a 10-minute setup guide.
|
||||
|
||||
**Have questions?** Check [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) for quick answers.
|
||||
|
||||
**Ready to implement?** Read [USAGE.md](./USAGE.md) for detailed instructions.
|
||||
|
||||
**Looking for examples?** See [INTEGRATION_EXAMPLES.md](./INTEGRATION_EXAMPLES.md) for real-world patterns.
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
# Integration Examples
|
||||
|
||||
Real-world examples of integrating passwordless email authentication into various Next.js application patterns.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [E-commerce Application](#e-commerce-application)
|
||||
2. [SaaS Dashboard](#saas-dashboard)
|
||||
3. [Blog Platform](#blog-platform)
|
||||
4. [Multi-tenant Application](#multi-tenant-application)
|
||||
5. [Progressive Web App](#progressive-web-app)
|
||||
|
||||
## E-commerce Application
|
||||
|
||||
### Scenario
|
||||
|
||||
Users need to sign in to view order history and checkout.
|
||||
|
||||
### Implementation
|
||||
|
||||
**app/checkout/page.tsx**
|
||||
|
||||
```tsx
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { CheckoutForm } from '@/components/checkout-form'
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user) {
|
||||
redirect('/auth/login?redirect=/checkout')
|
||||
}
|
||||
|
||||
return <CheckoutForm user={user} />
|
||||
}
|
||||
```
|
||||
|
||||
**app/auth/login/page.tsx**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const redirect = searchParams.get('redirect') || '/account'
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN' && session) {
|
||||
router.push(redirect)
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [redirect, router])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-md py-10">
|
||||
<div className="mb-4 text-center">
|
||||
<h1 className="text-2xl font-bold">Sign in to continue</h1>
|
||||
<p className="text-muted-foreground">Sign in to view your cart and complete checkout</p>
|
||||
</div>
|
||||
<PasswordlessLoginForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## SaaS Dashboard
|
||||
|
||||
### Scenario
|
||||
|
||||
Multi-step onboarding after first login with role-based access.
|
||||
|
||||
### Implementation
|
||||
|
||||
**app/auth/login/page.tsx**
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
{/* Left side - Login form */}
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-3xl font-bold">Welcome back</h1>
|
||||
<p className="text-muted-foreground">Sign in to your account to continue</p>
|
||||
</div>
|
||||
<PasswordlessLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Marketing content */}
|
||||
<div className="hidden bg-gradient-to-br from-blue-500 to-purple-600 p-8 lg:block">
|
||||
<div className="flex h-full flex-col justify-center text-white">
|
||||
<h2 className="text-4xl font-bold">Secure, passwordless authentication</h2>
|
||||
<p className="mt-4 text-xl">No passwords to remember. Just enter your email.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**lib/auth/check-onboarding.ts**
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function checkOnboardingStatus() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) return null
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('onboarding_completed')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
return {
|
||||
user,
|
||||
needsOnboarding: !profile?.onboarding_completed,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**app/dashboard/page.tsx**
|
||||
|
||||
```tsx
|
||||
import { checkOnboardingStatus } from '@/lib/auth/check-onboarding'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { DashboardContent } from '@/components/dashboard-content'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const status = await checkOnboardingStatus()
|
||||
|
||||
if (!status) {
|
||||
redirect('/auth/login?redirect=/dashboard')
|
||||
}
|
||||
|
||||
if (status.needsOnboarding) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
return <DashboardContent user={status.user} />
|
||||
}
|
||||
```
|
||||
|
||||
## Blog Platform
|
||||
|
||||
### Scenario
|
||||
|
||||
Optional authentication for commenting, with guest browsing.
|
||||
|
||||
### Implementation
|
||||
|
||||
**app/blog/[slug]/page.tsx**
|
||||
|
||||
```tsx
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { BlogPost } from '@/components/blog-post'
|
||||
import { CommentSection } from '@/components/comment-section'
|
||||
import { AuthPrompt } from '@/components/auth-prompt'
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get blog post (public)
|
||||
const { data: post } = await supabase.from('posts').select('*').eq('slug', params.slug).single()
|
||||
|
||||
// Check auth status
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
return (
|
||||
<article className="container mx-auto max-w-3xl py-10">
|
||||
<BlogPost post={post} />
|
||||
|
||||
{user ? (
|
||||
<CommentSection postId={post.id} user={user} />
|
||||
) : (
|
||||
<AuthPrompt message="Sign in to leave a comment" />
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**components/auth-prompt.tsx**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Link } from '@/components/ui/link'
|
||||
|
||||
export function AuthPrompt({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mt-8 rounded-lg border bg-gray-50 p-6 text-center">
|
||||
<p className="mb-4 text-lg">{message}</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button asChild>
|
||||
<Link href="/auth/login">Sign in</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/auth/signup">Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-tenant Application
|
||||
|
||||
### Scenario
|
||||
|
||||
Different authentication flows for different organizations/tenants.
|
||||
|
||||
### Implementation
|
||||
|
||||
**app/[tenant]/auth/login/page.tsx**
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTenantConfig } from '@/lib/tenants'
|
||||
|
||||
export default async function TenantLoginPage({ params }: { params: { tenant: string } }) {
|
||||
const tenantConfig = await getTenantConfig(params.tenant)
|
||||
|
||||
if (!tenantConfig) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Tenant branding */}
|
||||
<div className="text-center">
|
||||
<img src={tenantConfig.logo} alt={tenantConfig.name} className="mx-auto h-12" />
|
||||
<h1 className="mt-4 text-2xl font-bold">{tenantConfig.name}</h1>
|
||||
<p className="text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<PasswordlessLoginForm />
|
||||
|
||||
{/* Tenant-specific footer */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<a href={tenantConfig.supportUrl}>Need help?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**lib/tenants.ts**
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function getTenantConfig(slug: string) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: tenant } = await supabase.from('tenants').select('*').eq('slug', slug).single()
|
||||
|
||||
return tenant
|
||||
}
|
||||
|
||||
export async function getUserTenant(userId: string) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('user_tenants')
|
||||
.select('tenant:tenants(*)')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
return data?.tenant
|
||||
}
|
||||
```
|
||||
|
||||
**middleware.ts**
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request })
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({ request })
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
// Extract tenant from URL
|
||||
const pathParts = request.nextUrl.pathname.split('/')
|
||||
const tenant = pathParts[1]
|
||||
|
||||
// Protected tenant routes
|
||||
if (pathParts[2] === 'dashboard' && !user) {
|
||||
return NextResponse.redirect(new URL(`/${tenant}/auth/login`, request.url))
|
||||
}
|
||||
|
||||
// Verify user belongs to tenant
|
||||
if (user && pathParts[2] === 'dashboard') {
|
||||
const { data: membership } = await supabase
|
||||
.from('user_tenants')
|
||||
.select('tenant_id, tenants(slug)')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (membership?.tenants?.slug !== tenant) {
|
||||
return NextResponse.redirect(new URL(`/${tenant}/auth/login?error=unauthorized`, request.url))
|
||||
}
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/:tenant/dashboard/:path*', '/:tenant/auth/:path*'],
|
||||
}
|
||||
```
|
||||
|
||||
## Progressive Web App
|
||||
|
||||
### Scenario
|
||||
|
||||
Offline-first PWA with authentication state persistence.
|
||||
|
||||
### Implementation
|
||||
|
||||
**lib/auth/auth-provider.tsx**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
signOut: async () => {},
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
// Check active session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setUser(session?.user ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [supabase.auth])
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{ user, loading, signOut }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
```
|
||||
|
||||
**app/layout.tsx**
|
||||
|
||||
```tsx
|
||||
import { AuthProvider } from '@/lib/auth/auth-provider'
|
||||
import { OfflineIndicator } from '@/components/offline-indicator'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
</head>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<OfflineIndicator />
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**components/protected-route.tsx**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/auth-provider'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
```
|
||||
|
||||
**app/offline/page.tsx**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/auth-provider'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function OfflinePage() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="max-w-md p-6">
|
||||
<h1 className="text-2xl font-bold">You're offline</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
No internet connection detected. Some features may be unavailable.
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<div className="mt-4 rounded-lg bg-green-50 p-4">
|
||||
<p className="text-sm text-green-800">✓ You're signed in as {user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="mt-6 w-full" onClick={() => window.location.reload()}>
|
||||
Try again
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices for Each Pattern
|
||||
|
||||
### E-commerce
|
||||
|
||||
- Store return URL for post-login redirect
|
||||
- Show items in cart even when not logged in
|
||||
- Prompt for login at checkout
|
||||
- Remember cart items after login
|
||||
|
||||
### SaaS
|
||||
|
||||
- Implement role-based access control
|
||||
- Check subscription status
|
||||
- Handle team/organization memberships
|
||||
- Redirect to onboarding for new users
|
||||
|
||||
### Blog/Content
|
||||
|
||||
- Allow anonymous browsing
|
||||
- Gate premium content behind auth
|
||||
- Show auth prompts contextually
|
||||
- Remember reading position
|
||||
|
||||
### Multi-tenant
|
||||
|
||||
- Validate user belongs to tenant
|
||||
- Custom branding per tenant
|
||||
- Tenant-specific routing
|
||||
- Isolated data per tenant
|
||||
|
||||
### PWA
|
||||
|
||||
- Cache authentication state
|
||||
- Handle offline gracefully
|
||||
- Show connection status
|
||||
- Sync when online
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Redirect After Login
|
||||
|
||||
```tsx
|
||||
// Store intended destination
|
||||
const intendedUrl = request.nextUrl.pathname
|
||||
redirect(`/auth/login?redirect=${encodeURIComponent(intendedUrl)}`)
|
||||
|
||||
// Restore after login
|
||||
const redirect = searchParams.get('redirect') || '/dashboard'
|
||||
router.push(redirect)
|
||||
```
|
||||
|
||||
### Role-Based Access
|
||||
|
||||
```typescript
|
||||
async function checkUserRole(userId: string, requiredRole: string) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: user_role } = await supabase
|
||||
.from('user_roles')
|
||||
.select('role')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
return user_role?.role === requiredRole
|
||||
}
|
||||
```
|
||||
|
||||
### Session Refresh
|
||||
|
||||
```typescript
|
||||
// Automatically refresh session
|
||||
useEffect(() => {
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'TOKEN_REFRESHED') {
|
||||
console.log('Session refreshed')
|
||||
}
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
```
|
||||
|
||||
## Testing Integration
|
||||
|
||||
```typescript
|
||||
// __tests__/auth-integration.test.ts
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
describe('Passwordless Auth Integration', () => {
|
||||
it('should send magic link when email submitted', async () => {
|
||||
render(<PasswordlessLoginForm />)
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i)
|
||||
const submitButton = screen.getByRole('button', { name: /send magic link/i })
|
||||
|
||||
await userEvent.type(emailInput, 'test@example.com')
|
||||
await userEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/check your email/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
These examples demonstrate how to integrate passwordless authentication into various application architectures. Adapt them to your specific needs!
|
||||
@@ -0,0 +1,312 @@
|
||||
# Quick Reference - Passwordless Authentication
|
||||
|
||||
## Components Overview
|
||||
|
||||
| Component | Purpose | Use Case |
|
||||
| ----------------------- | --------------------------------------- | -------------------------------- |
|
||||
| `MagicLinkForm` | Email-only form that sends a magic link | Simple, one-click authentication |
|
||||
| `OTPRequestForm` | Request an OTP code via email | Multi-step verification |
|
||||
| `OTPVerifyForm` | Verify the OTP code | Second step after OTPRequestForm |
|
||||
| `PasswordlessLoginForm` | Unified form supporting both methods | Prop-controlled method selection |
|
||||
|
||||
## Quick Implementation
|
||||
|
||||
### Magic Link (Simplest)
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/auth/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### OTP Flow (Two Pages)
|
||||
|
||||
**Page 1: Request OTP**
|
||||
|
||||
```tsx
|
||||
import { OTPRequestForm } from '@/components/auth/otp-request-form'
|
||||
|
||||
export default function RequestPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
```
|
||||
|
||||
**Page 2: Verify OTP**
|
||||
|
||||
```tsx
|
||||
import { OTPVerifyForm } from '@/components/auth/otp-verify-form'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<OTPVerifyForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### PasswordlessLoginForm (Recommended)
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/auth/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
// Magic Link (default)
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// Or OTP
|
||||
// return <PasswordlessLoginForm method="otp" />
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### signInWithOtp (Magic Link)
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.auth.signInWithOtp({
|
||||
email: 'user@example.com',
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: 'https://yourapp.com/auth/confirm',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### signInWithOtp (OTP)
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.auth.signInWithOtp({
|
||||
email: 'user@example.com',
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### verifyOtp
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.auth.verifyOtp({
|
||||
email: 'user@example.com',
|
||||
token: '123456',
|
||||
type: 'email',
|
||||
})
|
||||
```
|
||||
|
||||
### verifyOtp (Magic Link - Server)
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type: 'email',
|
||||
token_hash: 'hash_from_url',
|
||||
})
|
||||
```
|
||||
|
||||
## Configuration Checklist
|
||||
|
||||
- [ ] Environment variables set
|
||||
- [ ] Redirect URLs configured in Supabase Dashboard
|
||||
- [ ] Email template configured (Magic Link OR OTP)
|
||||
- [ ] Supabase client created (client.ts and server.ts)
|
||||
- [ ] Middleware added (optional)
|
||||
- [ ] UI components installed (button, card, input, label)
|
||||
- [ ] Auth components copied to project
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
your-app/
|
||||
├── lib/
|
||||
│ └── supabase/
|
||||
│ ├── client.ts
|
||||
│ └── server.ts
|
||||
├── components/
|
||||
│ └── auth/
|
||||
│ ├── magic-link-form.tsx
|
||||
│ ├── otp-request-form.tsx
|
||||
│ ├── otp-verify-form.tsx
|
||||
│ └── passwordless-login-form.tsx
|
||||
├── app/
|
||||
│ ├── auth/
|
||||
│ │ ├── confirm/
|
||||
│ │ │ └── route.ts
|
||||
│ │ ├── error/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── magic-link/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── otp/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ ├── verify-otp/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── passwordless/
|
||||
│ │ └── page.tsx
|
||||
│ └── protected/
|
||||
│ └── page.tsx
|
||||
├── middleware.ts
|
||||
└── .env.local
|
||||
```
|
||||
|
||||
## Common Code Snippets
|
||||
|
||||
### Check if User is Authenticated
|
||||
|
||||
```typescript
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login')
|
||||
}
|
||||
```
|
||||
|
||||
### Sign Out
|
||||
|
||||
```typescript
|
||||
const supabase = await createClient()
|
||||
await supabase.auth.signOut()
|
||||
redirect('/auth/login')
|
||||
```
|
||||
|
||||
### Get User Session
|
||||
|
||||
```typescript
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession()
|
||||
```
|
||||
|
||||
### Listen to Auth Changes
|
||||
|
||||
```typescript
|
||||
const supabase = createClient()
|
||||
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
console.log('User signed in:', session?.user)
|
||||
}
|
||||
if (event === 'SIGNED_OUT') {
|
||||
console.log('User signed out')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
### Magic Link Template
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
<p>Click here to sign in:</p>
|
||||
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign In</a></p>
|
||||
```
|
||||
|
||||
### OTP Template
|
||||
|
||||
```html
|
||||
<h2>Your Login Code</h2>
|
||||
<p>Enter this code to sign in:</p>
|
||||
<h1>{{ .Token }}</h1>
|
||||
<p>Expires in 1 hour</p>
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
## Supabase Dashboard URLs
|
||||
|
||||
- **Project Settings**: Settings > API
|
||||
- **Auth Configuration**: Authentication > Providers
|
||||
- **URL Configuration**: Authentication > URL Configuration
|
||||
- **Email Templates**: Authentication > Email Templates
|
||||
- **Rate Limits**: Authentication > Rate Limits
|
||||
- **Auth Logs**: Authentication > Logs
|
||||
|
||||
## Default Settings
|
||||
|
||||
| Setting | Default Value | Configurable |
|
||||
| --------------------- | ------------------- | ----------------- |
|
||||
| OTP Expiration | 1 hour | Yes (max 24h) |
|
||||
| Rate Limit | 1 req/60s per email | Yes |
|
||||
| Magic Link Expiration | 1 hour | Yes |
|
||||
| Auto Sign-Up | Enabled | Yes (per request) |
|
||||
|
||||
## Error Messages
|
||||
|
||||
| Error | Cause | Solution |
|
||||
| --------------------------- | ----------------------- | -------------------------------- |
|
||||
| "Email rate limit exceeded" | Too many requests | Wait 60 seconds |
|
||||
| "Invalid redirect URL" | URL not whitelisted | Add to Supabase Dashboard |
|
||||
| "Invalid token" | Expired or used token | Request new token |
|
||||
| "User not found" | shouldCreateUser: false | Set to true or create user first |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Magic link arrives in email
|
||||
- [ ] Magic link redirects correctly
|
||||
- [ ] OTP code arrives in email
|
||||
- [ ] OTP verification works
|
||||
- [ ] Expired tokens are rejected
|
||||
- [ ] Invalid tokens show error
|
||||
- [ ] Rate limiting works
|
||||
- [ ] Protected routes redirect
|
||||
- [ ] Sign out works
|
||||
- [ ] Middleware protects routes
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. Use Suspense for OTP verify page
|
||||
2. Implement loading states
|
||||
3. Add error boundaries
|
||||
4. Use optimistic UI updates
|
||||
5. Cache auth state when appropriate
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Always use HTTPS in production
|
||||
2. Implement rate limiting
|
||||
3. Monitor auth logs
|
||||
4. Set appropriate token expiration
|
||||
5. Validate email addresses
|
||||
6. Use secure cookies
|
||||
7. Enable CSRF protection
|
||||
|
||||
## Browser Support
|
||||
|
||||
- ✅ Chrome/Edge (Chromium)
|
||||
- ✅ Firefox
|
||||
- ✅ Safari
|
||||
- ✅ Mobile browsers
|
||||
- ⚠️ IE11 (not supported)
|
||||
|
||||
## Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"@supabase/supabase-js": "^2.x.x",
|
||||
"@supabase/ssr": "^0.x.x",
|
||||
"next": "^13.x.x || ^14.x.x || ^15.x.x",
|
||||
"react": "^18.x.x"
|
||||
}
|
||||
```
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [Full Documentation](./README.md)
|
||||
- [Getting Started Guide](./GETTING_STARTED.md)
|
||||
- [Usage Guide](./USAGE.md)
|
||||
- [Supabase Auth Docs](https://supabase.com/docs/guides/auth)
|
||||
|
||||
---
|
||||
|
||||
**Pro Tip**: Start with `PasswordlessLoginForm` - it gives users both options and you can always switch to individual forms later!
|
||||
@@ -0,0 +1,293 @@
|
||||
# Passwordless Authentication for Next.js
|
||||
|
||||
A complete passwordless authentication solution for Next.js applications using Supabase Auth. This block includes support for both Magic Link and OTP (One-Time Password) login methods.
|
||||
|
||||
## Features
|
||||
|
||||
- **Magic Link Login**: Users receive a clickable link in their email to sign in
|
||||
- **OTP Login**: Users receive a 6-digit code to enter for authentication
|
||||
- **Combined Form**: Single form that supports both methods
|
||||
- **Auto Sign-Up**: Automatically creates user accounts on first login (configurable)
|
||||
- **Protected Routes**: Middleware-protected pages requiring authentication
|
||||
- **Error Handling**: Comprehensive error states and user feedback
|
||||
- **TypeScript**: Full type safety with TypeScript
|
||||
|
||||
## Components
|
||||
|
||||
### 1. MagicLinkForm
|
||||
|
||||
A standalone form for Magic Link authentication.
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <MagicLinkForm />
|
||||
}
|
||||
```
|
||||
|
||||
### 2. OTPRequestForm
|
||||
|
||||
Request a one-time password to be sent via email.
|
||||
|
||||
```tsx
|
||||
import { OTPRequestForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form'
|
||||
|
||||
export default function OTPPage() {
|
||||
return <OTPRequestForm />
|
||||
}
|
||||
```
|
||||
|
||||
### 3. OTPVerifyForm
|
||||
|
||||
Verify the OTP code received via email.
|
||||
|
||||
```tsx
|
||||
import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return <OTPVerifyForm />
|
||||
}
|
||||
```
|
||||
|
||||
### 4. PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified form that supports both Magic Link and OTP authentication. Use the `method` prop to specify which authentication method to use.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <PasswordlessLoginForm method="magic-link" />
|
||||
}
|
||||
```
|
||||
|
||||
**Props:**
|
||||
- `method?: 'magic-link' | 'otp'` - Authentication method (default: `'magic-link'`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```tsx
|
||||
// Magic Link authentication
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
|
||||
// OTP authentication
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Ensure you have the following environment variables in your `.env.local`:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
### 2. Supabase Configuration
|
||||
|
||||
#### Configure Redirect URLs
|
||||
|
||||
In your Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **URL Configuration**
|
||||
2. Add your site URL (e.g., `http://localhost:3000` for development)
|
||||
3. Add redirect URLs:
|
||||
- `http://localhost:3000/auth/confirm`
|
||||
- `http://localhost:3000/protected`
|
||||
|
||||
#### Email Templates
|
||||
|
||||
For **Magic Link** (default):
|
||||
The default email template works out of the box. For PKCE flow, update the Magic Link email template:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
<p>Follow this link to login:</p>
|
||||
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
|
||||
```
|
||||
|
||||
For **OTP**:
|
||||
Update the Magic Link email template to show the OTP code:
|
||||
|
||||
```html
|
||||
<h2>One time login code</h2>
|
||||
<p>Please enter this code: {{ .Token }}</p>
|
||||
```
|
||||
|
||||
### 3. Middleware
|
||||
|
||||
Copy the included `middleware.ts` to your project root to protect routes:
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
export async function middleware(request: NextRequest) {
|
||||
// ... (see middleware.ts in this block)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Magic Link Login
|
||||
|
||||
```tsx
|
||||
// app/auth/login/page.tsx
|
||||
import { MagicLinkForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<MagicLinkForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### OTP Flow
|
||||
|
||||
```tsx
|
||||
// app/auth/otp/page.tsx
|
||||
import { OTPRequestForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form'
|
||||
|
||||
export default function OTPPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<OTPRequestForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// app/auth/verify-otp/page.tsx
|
||||
import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<OTPVerifyForm />
|
||||
</div>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Combined Passwordless Form (Recommended)
|
||||
|
||||
```tsx
|
||||
// app/auth/passwordless/page.tsx
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'
|
||||
|
||||
export default function PasswordlessPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<PasswordlessLoginForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Disable Auto Sign-Up
|
||||
|
||||
If you want to prevent automatic user creation, set `shouldCreateUser` to `false`:
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false, // Only allow existing users to sign in
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Redirect
|
||||
|
||||
Customize where users are redirected after authentication:
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/dashboard`, // Custom redirect
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### OTP Expiration
|
||||
|
||||
By default:
|
||||
|
||||
- OTPs can be requested once every 60 seconds
|
||||
- OTPs expire after 1 hour
|
||||
|
||||
Configure these in your Supabase Dashboard:
|
||||
**Authentication** > **Providers** > **Email** > **Email OTP Expiration**
|
||||
|
||||
## Protected Routes
|
||||
|
||||
Create protected pages that require authentication:
|
||||
|
||||
```tsx
|
||||
// app/protected/page.tsx
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function ProtectedPage() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/passwordless')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Protected Content</h1>
|
||||
<p>Welcome, {user.email}!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Rate Limiting**: Supabase enforces rate limits (1 request per 60 seconds by default)
|
||||
2. **Expiration**: Magic Links and OTPs expire after 1 hour
|
||||
3. **HTTPS Required**: Always use HTTPS in production
|
||||
4. **Environment Variables**: Never commit `.env` files to version control
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Magic Link not working
|
||||
|
||||
1. Check your email template includes the correct redirect URL
|
||||
2. Verify redirect URLs are configured in Supabase Dashboard
|
||||
3. Check email spam folder
|
||||
|
||||
### OTP not arriving
|
||||
|
||||
1. Verify email template is configured to show `{{ .Token }}`
|
||||
2. Check Supabase Auth email provider settings
|
||||
3. Check rate limiting (wait 60 seconds between requests)
|
||||
|
||||
### Authentication errors
|
||||
|
||||
1. Check browser console for detailed error messages
|
||||
2. Verify environment variables are set correctly
|
||||
3. Ensure Supabase client is initialized properly
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Supabase Passwordless Auth Docs](https://supabase.com/docs/guides/auth/auth-email-passwordless)
|
||||
- [Magic Link Documentation](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-magic-link)
|
||||
- [OTP Documentation](https://supabase.com/docs/guides/auth/auth-email-passwordless#with-otp)
|
||||
@@ -0,0 +1,468 @@
|
||||
# Passwordless Authentication - Usage Guide
|
||||
|
||||
This guide provides detailed instructions for implementing passwordless authentication in your Next.js application using Supabase Auth.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Authentication Methods](#authentication-methods)
|
||||
3. [Component API](#component-api)
|
||||
4. [Advanced Configuration](#advanced-configuration)
|
||||
5. [Best Practices](#best-practices)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copy the block to your project:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add passwordless-auth-nextjs
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
3. Configure Supabase redirect URLs in your dashboard:
|
||||
- `http://localhost:3000/auth/confirm` (development)
|
||||
- `https://yourdomain.com/auth/confirm` (production)
|
||||
|
||||
### Basic Implementation
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return <PasswordlessLoginForm />
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### 1. Magic Link
|
||||
|
||||
Magic Links are one-time use URLs sent to the user's email. When clicked, they automatically authenticate the user.
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- Seamless user experience (one click to login)
|
||||
- No need to remember or type codes
|
||||
- Works across devices
|
||||
|
||||
**Disadvantages:**
|
||||
|
||||
- Requires user to access their email
|
||||
- May be caught by spam filters
|
||||
- Links expire after 1 hour
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
import { MagicLinkForm } from '@/components/magic-link-form'
|
||||
|
||||
export default function MagicLinkLoginPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-md py-10">
|
||||
<MagicLinkForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Email Template Configuration:**
|
||||
|
||||
For implicit flow (default):
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
<p>Follow this link to login:</p>
|
||||
<p><a href="{{ .ConfirmationURL }}">Log In</a></p>
|
||||
```
|
||||
|
||||
For PKCE flow:
|
||||
|
||||
```html
|
||||
<h2>Magic Link</h2>
|
||||
<p>Follow this link to login:</p>
|
||||
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
|
||||
```
|
||||
|
||||
### 2. OTP (One-Time Password)
|
||||
|
||||
OTPs are 6-digit codes sent to the user's email that they manually enter.
|
||||
|
||||
**Advantages:**
|
||||
|
||||
- More control (user types the code)
|
||||
- Works better for mobile apps
|
||||
- Familiar to users
|
||||
|
||||
**Disadvantages:**
|
||||
|
||||
- Extra step (user must type code)
|
||||
- Possible typos
|
||||
- Codes expire after 1 hour
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```tsx
|
||||
// Step 1: Request OTP
|
||||
import { OTPRequestForm } from '@/components/otp-request-form'
|
||||
|
||||
export default function RequestOTPPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-md py-10">
|
||||
<OTPRequestForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Verify OTP
|
||||
import { OTPVerifyForm } from '@/components/otp-verify-form'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default function VerifyOTPPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<div className="container mx-auto max-w-md py-10">
|
||||
<OTPVerifyForm />
|
||||
</div>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Email Template Configuration:**
|
||||
|
||||
```html
|
||||
<h2>One time login code</h2>
|
||||
<p>Please enter this code: {{ .Token }}</p>
|
||||
<p>This code will expire in 1 hour.</p>
|
||||
```
|
||||
|
||||
### 3. PasswordlessLoginForm (Recommended)
|
||||
|
||||
A unified authentication form that supports both Magic Link and OTP. Use the `method` prop to specify which authentication method to use.
|
||||
|
||||
```tsx
|
||||
import { PasswordlessLoginForm } from '@/components/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-md py-10">
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**With OTP:**
|
||||
|
||||
```tsx
|
||||
<PasswordlessLoginForm method="otp" />
|
||||
```
|
||||
|
||||
## Component API
|
||||
|
||||
### MagicLinkForm
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface MagicLinkFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Events:**
|
||||
|
||||
- On success: Displays confirmation message
|
||||
- On error: Shows error message inline
|
||||
|
||||
**Example with custom styling:**
|
||||
|
||||
```tsx
|
||||
<MagicLinkForm className="rounded-xl shadow-2xl" />
|
||||
```
|
||||
|
||||
### OTPRequestForm
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface OTPRequestFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- On success: Redirects to `/auth/verify-otp?email={email}`
|
||||
- On error: Shows error message inline
|
||||
|
||||
### OTPVerifyForm
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface OTPVerifyFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Auto-formats OTP input (numbers only, max 6 digits)
|
||||
- Resend functionality
|
||||
- Email display from query parameter
|
||||
|
||||
**Example:**
|
||||
|
||||
```tsx
|
||||
// The email is passed via query parameter
|
||||
// URL: /auth/verify-otp?email=user@example.com
|
||||
<OTPVerifyForm />
|
||||
```
|
||||
|
||||
### PasswordlessLoginForm
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
method?: 'magic-link' | 'otp'
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Single form supporting both Magic Link and OTP
|
||||
- Method controlled via prop (defaults to `'magic-link'`)
|
||||
- Success state management
|
||||
- Automatic redirect for OTP verification
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Redirect URLs
|
||||
|
||||
Customize where users are redirected after authentication:
|
||||
|
||||
```typescript
|
||||
// In your component
|
||||
const handleLogin = async () => {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/dashboard`,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Auto Sign-Up
|
||||
|
||||
Prevent new user creation, only allow existing users:
|
||||
|
||||
```typescript
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom OTP Expiration
|
||||
|
||||
Configure in Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Email**
|
||||
2. Find **Email OTP Expiration**
|
||||
3. Set custom duration (max 24 hours)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Customize rate limiting in Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **Rate Limits**
|
||||
2. Adjust the rate limits for email authentication
|
||||
|
||||
Default: 1 request per 60 seconds per email address
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
Always handle errors gracefully:
|
||||
|
||||
```tsx
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||
if (error) throw error
|
||||
// Success handling
|
||||
} catch (error) {
|
||||
// Show user-friendly error message
|
||||
if (error.message.includes('rate limit')) {
|
||||
setError('Please wait a moment before requesting another code')
|
||||
} else {
|
||||
setError('An error occurred. Please try again.')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Loading States
|
||||
|
||||
Show loading indicators during API calls:
|
||||
|
||||
```tsx
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// API call
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <Button disabled={isLoading}>{isLoading ? 'Sending...' : 'Send Link'}</Button>
|
||||
```
|
||||
|
||||
### 3. Security
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Never expose API keys in client-side code
|
||||
- Implement rate limiting
|
||||
- Monitor authentication logs
|
||||
- Set appropriate token expiration times
|
||||
|
||||
### 4. User Experience
|
||||
|
||||
**For Magic Links:**
|
||||
|
||||
- Clear instructions: "Check your email for a magic link"
|
||||
- Mention spam folder
|
||||
- Option to resend
|
||||
|
||||
**For OTPs:**
|
||||
|
||||
- Auto-focus OTP input field
|
||||
- Auto-submit when 6 digits entered
|
||||
- Clear visual feedback
|
||||
- Resend option with countdown
|
||||
|
||||
### 5. Email Templates
|
||||
|
||||
Make your emails clear and branded:
|
||||
|
||||
```html
|
||||
<table style="width: 100%; max-width: 600px; margin: 0 auto;">
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<img src="your-logo.png" alt="Logo" style="height: 40px;" />
|
||||
<h1 style="color: #333; margin-top: 30px;">Your Login Code</h1>
|
||||
<p style="font-size: 16px; color: #666;">Enter this code to sign in to your account:</p>
|
||||
<div style="background: #f5f5f5; padding: 20px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px;"> {{ .Token }} </span>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #999;">
|
||||
This code will expire in 1 hour. If you didn't request this, please ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
### 6. Testing
|
||||
|
||||
Test various scenarios:
|
||||
|
||||
- Valid email addresses
|
||||
- Invalid email addresses
|
||||
- Expired tokens/OTPs
|
||||
- Multiple rapid requests (rate limiting)
|
||||
- Different browsers and devices
|
||||
- Email delivery times
|
||||
- Spam folder handling
|
||||
|
||||
### 7. Analytics
|
||||
|
||||
Track key metrics:
|
||||
|
||||
- Magic Link vs OTP usage
|
||||
- Success/failure rates
|
||||
- Time to complete authentication
|
||||
- Drop-off points in the flow
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Magic Link not working
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify redirect URLs in Supabase Dashboard
|
||||
2. Check email template configuration
|
||||
3. Ensure HTTPS in production
|
||||
4. Check browser console for errors
|
||||
|
||||
### Problem: OTP not received
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Check email provider settings in Supabase
|
||||
2. Verify email template shows `{{ .Token }}`
|
||||
3. Check spam folder
|
||||
4. Verify rate limiting hasn't been triggered
|
||||
|
||||
### Problem: "Invalid token" error
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Check if token has expired (default: 1 hour)
|
||||
2. Ensure user hasn't already used the token
|
||||
3. Verify system clock is synchronized
|
||||
4. Check for typos in OTP entry
|
||||
|
||||
### Problem: Redirect not working
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify `emailRedirectTo` URL is whitelisted
|
||||
2. Check middleware configuration
|
||||
3. Ensure URL is absolute, not relative
|
||||
4. Verify Next.js routing configuration
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Password-Based Auth
|
||||
|
||||
Replace password fields with email-only:
|
||||
|
||||
```diff
|
||||
- import { LoginForm } from '@/components/login-form'
|
||||
+ import { PasswordlessLoginForm } from '@/components/passwordless-login-form'
|
||||
|
||||
export default function LoginPage() {
|
||||
- return <LoginForm />
|
||||
+ return <PasswordlessLoginForm />
|
||||
}
|
||||
```
|
||||
|
||||
### From Other Passwordless Solutions
|
||||
|
||||
1. Replace API calls with Supabase methods
|
||||
2. Update email templates
|
||||
3. Configure redirect URLs
|
||||
4. Test thoroughly
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth)
|
||||
- [Next.js App Router Guide](https://nextjs.org/docs/app)
|
||||
- [Email Template Best Practices](https://supabase.com/docs/guides/auth/auth-email-templates)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/server'
|
||||
import { type EmailOtpType } from '@supabase/supabase-js'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const token_hash = searchParams.get('token_hash')
|
||||
const type = searchParams.get('type') as EmailOtpType | null
|
||||
const next = searchParams.get('next') ?? '/protected'
|
||||
|
||||
if (token_hash && type) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type,
|
||||
token_hash,
|
||||
})
|
||||
if (!error) {
|
||||
// Redirect user to specified redirect URL or default to protected page
|
||||
return NextResponse.redirect(new URL(next, request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect the user to an error page with some instructions
|
||||
return NextResponse.redirect(new URL('/auth/error', request.url))
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import { Link } from '@/registry/default/components/ui/link'
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Authentication Error</CardTitle>
|
||||
<CardDescription>There was an error authenticating your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The authentication link may have expired or is invalid. Please try requesting a new
|
||||
one.
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/auth/passwordless">Try again</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { MagicLinkForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form'
|
||||
|
||||
export default function MagicLinkPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<MagicLinkForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { OTPRequestForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form'
|
||||
|
||||
export default function OTPRequestPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<OTPRequestForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'
|
||||
|
||||
export default function PasswordlessLoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<PasswordlessLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
function OTPVerifyContent() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<OTPVerifyForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OTPVerifyPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<OTPVerifyContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
|
||||
export default async function ProtectedPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/passwordless')
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
'use server'
|
||||
const supabase = await createClient()
|
||||
await supabase.auth.signOut()
|
||||
redirect('/auth/passwordless')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Protected Page</CardTitle>
|
||||
<CardDescription>You are logged in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Email</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">User ID</p>
|
||||
<p className="text-sm text-muted-foreground">{user.id}</p>
|
||||
</div>
|
||||
<form action={handleSignOut}>
|
||||
<Button type="submit" variant="outline" className="w-full">
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
const handleMagicLinkRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const supabase = createClient()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsSuccess(false)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsSuccess(true)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Magic Link Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a magic link for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSuccess ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click the
|
||||
link in the email to sign in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleMagicLinkRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending magic link...' : 'Send magic link'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleOTPRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const supabase = createClient()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">OTP Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a one-time password for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleOTPRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending code...' : 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const searchParams = useSearchParams()
|
||||
const email = searchParams.get('email') || ''
|
||||
const [otp, setOTP] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const supabase = createClient()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: 'email',
|
||||
})
|
||||
if (error) throw error
|
||||
// Redirect to protected page after successful verification
|
||||
router.push('/protected')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
const supabase = createClient()
|
||||
setIsResending(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setError(null)
|
||||
// Show success message
|
||||
alert('A new verification code has been sent to your email.')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend code')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Verify Your Email</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the 6-digit code sent to{' '}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerifyOTP}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="otp">Verification Code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={otp}
|
||||
onChange={(e) => setOTP(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={isLoading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter the 6-digit code</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || otp.length !== 6}>
|
||||
{isLoading ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={isResending}
|
||||
className="text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isResending ? 'Sending...' : 'Resend'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
||||
type LoginMethod = 'magic-link' | 'otp'
|
||||
|
||||
interface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
method?: LoginMethod
|
||||
}
|
||||
|
||||
export function PasswordlessLoginForm({
|
||||
className,
|
||||
method = 'magic-link',
|
||||
...props
|
||||
}: PasswordlessLoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const supabase = createClient()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsMagicLinkSent(false)
|
||||
|
||||
try {
|
||||
if (method === 'magic-link') {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsMagicLinkSent(true)
|
||||
} else {
|
||||
// OTP method
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign up</CardTitle>
|
||||
<CardDescription>Sign in or create a new account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click
|
||||
the link in the email to sign in.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => {
|
||||
setIsMagicLinkSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Send another link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading
|
||||
? method === 'magic-link'
|
||||
? 'Sending magic link...'
|
||||
: 'Sending code...'
|
||||
: method === 'magic-link'
|
||||
? 'Send magic link'
|
||||
: 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({
|
||||
request,
|
||||
})
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
// Protect /protected route
|
||||
if (request.nextUrl.pathname.startsWith('/protected') && !user) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/auth/passwordless'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "passwordless-auth-nextjs",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for Next.js and Supabase",
|
||||
"description": "Passwordless authentication for Next.js using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": ["button", "card", "input", "label", "link"],
|
||||
"dependencies": ["@supabase/ssr@latest", "@supabase/supabase-js@latest"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/magic-link/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/magic-link/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/otp/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/verify-otp/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/verify-otp/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/passwordless/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/passwordless/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/confirm/route.ts",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/confirm/route.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/auth/error/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/auth/error/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/app/protected/page.tsx",
|
||||
"type": "registry:page",
|
||||
"target": "app/protected/page.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-nextjs/middleware.ts",
|
||||
"type": "registry:file",
|
||||
"target": "middleware.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'
|
||||
import { type EmailOtpType } from '@supabase/supabase-js'
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const requestUrl = new URL(request.url)
|
||||
const token_hash = requestUrl.searchParams.get('token_hash')
|
||||
const type = requestUrl.searchParams.get('type') as EmailOtpType | null
|
||||
const _next = requestUrl.searchParams.get('next')
|
||||
const next = _next?.startsWith('/') ? _next : '/protected'
|
||||
|
||||
if (token_hash && type) {
|
||||
const { supabase, headers } = createClient(request)
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type,
|
||||
token_hash,
|
||||
})
|
||||
if (!error) {
|
||||
return redirect(next, { headers })
|
||||
} else {
|
||||
return redirect(`/auth/error?error=${error?.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// redirect the user to an error page with some instructions
|
||||
return redirect(`/auth/error?error=No token hash or type`)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Authentication Error</CardTitle>
|
||||
<CardDescription>There was an error authenticating your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The authentication link may have expired or is invalid. Please try requesting a new
|
||||
one.
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/passwordless">Try again</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-react-router/components/passwordless-login-form'
|
||||
|
||||
export default function PasswordlessPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { type LoaderFunctionArgs, redirect, useLoaderData } from 'react-router'
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const { supabase } = createClient(request)
|
||||
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error || !data?.user) {
|
||||
return redirect('/passwordless')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export default function ProtectedPage() {
|
||||
let data = useLoaderData<typeof loader>()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Protected Page</CardTitle>
|
||||
<CardDescription>You are logged in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Email</p>
|
||||
<p className="text-sm text-muted-foreground">{data.user.email}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">User ID</p>
|
||||
<p className="text-sm text-muted-foreground">{data.user.id}</p>
|
||||
</div>
|
||||
<form method="post" action="/logout">
|
||||
<Button type="submit" variant="outline" className="w-full">
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-react-router/components/otp-verify-form'
|
||||
|
||||
export default function VerifyOTPPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<OTPVerifyForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleMagicLinkRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsSuccess(false)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsSuccess(true)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Magic Link Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a magic link for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSuccess ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click the
|
||||
link in the email to sign in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleMagicLinkRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending magic link...' : 'Send magic link'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleOTPRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
navigate(`/verify-otp?email=${encodeURIComponent(email)}`)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">OTP Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a one-time password for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleOTPRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending code...' : 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const email = searchParams.get('email') || ''
|
||||
const [otp, setOTP] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: 'email',
|
||||
})
|
||||
if (error) throw error
|
||||
// Redirect to protected page after successful verification
|
||||
navigate('/protected')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
setIsResending(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setError(null)
|
||||
// Show success message
|
||||
alert('A new verification code has been sent to your email.')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend code')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Verify Your Email</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the 6-digit code sent to{' '}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerifyOTP}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="otp">Verification Code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={otp}
|
||||
onChange={(e) => setOTP(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={isLoading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter the 6-digit code</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || otp.length !== 6}>
|
||||
{isLoading ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={isResending}
|
||||
className="text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isResending ? 'Sending...' : 'Resend'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react-router/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
type LoginMethod = 'magic-link' | 'otp'
|
||||
|
||||
interface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
method?: LoginMethod
|
||||
}
|
||||
|
||||
export function PasswordlessLoginForm({
|
||||
className,
|
||||
method = 'magic-link',
|
||||
...props
|
||||
}: PasswordlessLoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsMagicLinkSent(false)
|
||||
|
||||
try {
|
||||
if (method === 'magic-link') {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsMagicLinkSent(true)
|
||||
} else {
|
||||
// OTP method
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
navigate(`/verify-otp?email=${encodeURIComponent(email)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign up</CardTitle>
|
||||
<CardDescription>Sign in or create a new account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click
|
||||
the link in the email to sign in.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => {
|
||||
setIsMagicLinkSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Send another link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading
|
||||
? method === 'magic-link'
|
||||
? 'Sending magic link...'
|
||||
: 'Sending code...'
|
||||
: method === 'magic-link'
|
||||
? 'Send magic link'
|
||||
: 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "passwordless-auth-react-router",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React Router and Supabase",
|
||||
"description": "Passwordless authentication for React Router using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": ["button", "card", "input", "label"],
|
||||
"dependencies": [
|
||||
"@supabase/ssr@latest",
|
||||
"@react-router/dev@latest",
|
||||
"@react-router/fs-routes@latest"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.confirm.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.confirm.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/auth.error.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/auth.error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/passwordless.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/verify-otp.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react-router/app/routes/protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/protected.tsx"
|
||||
}
|
||||
]
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleMagicLinkRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsSuccess(false)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsSuccess(true)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Magic Link Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a magic link for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSuccess ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click the
|
||||
link in the email to sign in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleMagicLinkRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending magic link...' : 'Send magic link'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface OTPRequestFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
onOtpRequested?: (email: string) => void
|
||||
}
|
||||
|
||||
export function OTPRequestForm({ className, onOtpRequested, ...props }: OTPRequestFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleOTPRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Call callback to notify parent component
|
||||
if (onOtpRequested) {
|
||||
onOtpRequested(email)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">OTP Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a one-time password for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleOTPRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending code...' : 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface OTPVerifyFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
email: string
|
||||
onVerified?: () => void
|
||||
}
|
||||
|
||||
export function OTPVerifyForm({ className, email, onVerified, ...props }: OTPVerifyFormProps) {
|
||||
const [otp, setOTP] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: 'email',
|
||||
})
|
||||
if (error) throw error
|
||||
// Call callback to notify parent component
|
||||
if (onVerified) {
|
||||
onVerified()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
setIsResending(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setError(null)
|
||||
// Show success message
|
||||
alert('A new verification code has been sent to your email.')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend code')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Verify Your Email</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the 6-digit code sent to{' '}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerifyOTP}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="otp">Verification Code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={otp}
|
||||
onChange={(e) => setOTP(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={isLoading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter the 6-digit code</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || otp.length !== 6}>
|
||||
{isLoading ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={isResending}
|
||||
className="text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isResending ? 'Sending...' : 'Resend'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/react/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
type LoginMethod = 'magic-link' | 'otp'
|
||||
|
||||
interface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
method?: LoginMethod
|
||||
onOtpRequested?: (email: string) => void
|
||||
}
|
||||
|
||||
export function PasswordlessLoginForm({
|
||||
className,
|
||||
method = 'magic-link',
|
||||
onOtpRequested,
|
||||
...props
|
||||
}: PasswordlessLoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsMagicLinkSent(false)
|
||||
|
||||
try {
|
||||
if (method === 'magic-link') {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsMagicLinkSent(true)
|
||||
} else {
|
||||
// OTP method
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Call callback to notify parent component
|
||||
if (onOtpRequested) {
|
||||
onOtpRequested(email)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign up</CardTitle>
|
||||
<CardDescription>Sign in or create a new account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click
|
||||
the link in the email to sign in.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => {
|
||||
setIsMagicLinkSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Send another link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading
|
||||
? method === 'magic-link'
|
||||
? 'Sending magic link...'
|
||||
: 'Sending code...'
|
||||
: method === 'magic-link'
|
||||
? 'Send magic link'
|
||||
: 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "passwordless-auth-react",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for React and Supabase",
|
||||
"description": "Passwordless authentication for React using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods. Components only, no routing included.",
|
||||
"registryDependencies": ["button", "card", "input", "label"],
|
||||
"dependencies": ["@supabase/supabase-js@latest"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-react/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
}
|
||||
]
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function MagicLinkForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const supabase = createClient()
|
||||
|
||||
const handleMagicLinkRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsSuccess(false)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsSuccess(true)
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Magic Link Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a magic link for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSuccess ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click the
|
||||
link in the email to sign in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleMagicLinkRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending magic link...' : 'Send magic link'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPRequestForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleOTPRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
// Set this to false if you do not want the user to be automatically signed up
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
navigate({ to: '/verify-otp', search: { email } })
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">OTP Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email to receive a one-time password for passwordless login
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleOTPRequest}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Sending code...' : 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function OTPVerifyForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||
const search = useSearch({ strict: false })
|
||||
const email = (search as { email?: string })?.email || ''
|
||||
const [otp, setOTP] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
email,
|
||||
token: otp,
|
||||
type: 'email',
|
||||
})
|
||||
if (error) throw error
|
||||
// Redirect to protected page after successful verification
|
||||
navigate({ to: '/protected' })
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendOTP = async () => {
|
||||
setIsResending(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setError(null)
|
||||
// Show success message
|
||||
alert('A new verification code has been sent to your email.')
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resend code')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Verify Your Email</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the 6-digit code sent to{' '}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerifyOTP}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="otp">Verification Code</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
value={otp}
|
||||
onChange={(e) => setOTP(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={isLoading}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter the 6-digit code</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading || otp.length !== 6}>
|
||||
{isLoading ? 'Verifying...' : 'Verify Code'}
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
Didn't receive a code?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendOTP}
|
||||
disabled={isResending}
|
||||
className="text-primary underline-offset-4 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isResending ? 'Sending...' : 'Resend'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Input } from '@/registry/default/components/ui/input'
|
||||
import { Label } from '@/registry/default/components/ui/label'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
type LoginMethod = 'magic-link' | 'otp'
|
||||
|
||||
interface PasswordlessLoginFormProps extends React.ComponentPropsWithoutRef<'div'> {
|
||||
method?: LoginMethod
|
||||
}
|
||||
|
||||
export function PasswordlessLoginForm({
|
||||
className,
|
||||
method = 'magic-link',
|
||||
...props
|
||||
}: PasswordlessLoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsMagicLinkSent(false)
|
||||
|
||||
try {
|
||||
if (method === 'magic-link') {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
setIsMagicLinkSent(true)
|
||||
} else {
|
||||
// OTP method
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
shouldCreateUser: true,
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
// Navigate to OTP verification page
|
||||
navigate({ to: '/verify-otp', search: { email } })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Sign up</CardTitle>
|
||||
<CardDescription>Sign in or create a new account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<p className="font-semibold">Check your email!</p>
|
||||
<p className="mt-1">
|
||||
We've sent a magic link to <span className="font-medium">{email}</span>. Click
|
||||
the link in the email to sign in.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => {
|
||||
setIsMagicLinkSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Send another link
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading
|
||||
? method === 'magic-link'
|
||||
? 'Sending magic link...'
|
||||
: 'Sending code...'
|
||||
: method === 'magic-link'
|
||||
? 'Send magic link'
|
||||
: 'Send verification code'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'
|
||||
import type { Factor, User } from '@supabase/supabase-js'
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
type SSRSafeUser = User & {
|
||||
factors: (Factor & { factor_type: 'phone' | 'totp' })[]
|
||||
}
|
||||
|
||||
export const fetchUserServerFn: () => Promise<SSRSafeUser | null> = createServerFn({
|
||||
method: 'GET',
|
||||
}).handler(async () => {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data.user as SSRSafeUser
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "passwordless-auth-tanstack",
|
||||
"type": "registry:block",
|
||||
"title": "Passwordless Auth for TanStack and Supabase",
|
||||
"description": "Passwordless authentication for TanStack using Supabase Auth. Supports both Magic Link and OTP (One-Time Password) login methods.",
|
||||
"registryDependencies": ["button", "card", "input", "label"],
|
||||
"dependencies": ["@supabase/ssr@latest"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/passwordless-login-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/magic-link-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-request-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/components/otp-verify-form.tsx",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/passwordless.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/passwordless.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/error.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/error.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/_protected/protected.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/_protected/protected.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/auth/confirm.ts",
|
||||
"type": "registry:file",
|
||||
"target": "routes/auth/confirm.ts"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/routes/verify-otp.tsx",
|
||||
"type": "registry:file",
|
||||
"target": "routes/verify-otp.tsx"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/passwordless-auth-tanstack/lib/supabase/fetch-user-server-fn.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { fetchUserServerFn } from '@/registry/default/blocks/passwordless-auth-tanstack/lib/supabase/fetch-user-server-fn'
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_protected')({
|
||||
beforeLoad: async () => {
|
||||
const user = await fetchUserServerFn()
|
||||
if (!user) {
|
||||
throw redirect({
|
||||
to: '/passwordless',
|
||||
})
|
||||
}
|
||||
return { user }
|
||||
},
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/client'
|
||||
|
||||
export const Route = createFileRoute('/_protected/protected')({
|
||||
component: ProtectedPage,
|
||||
})
|
||||
|
||||
function ProtectedPage() {
|
||||
const router = useRouter()
|
||||
const { user } = Route.useRouteContext()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await supabase.auth.signOut()
|
||||
router.navigate({ to: '/passwordless' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Protected Page</CardTitle>
|
||||
<CardDescription>You are logged in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Email</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">User ID</p>
|
||||
<p className="text-sm text-muted-foreground">{user.id}</p>
|
||||
</div>
|
||||
<Button onClick={handleSignOut} variant="outline" className="w-full">
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'
|
||||
import { type EmailOtpType } from '@supabase/supabase-js'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { getWebRequest } from '@tanstack/react-start/server'
|
||||
|
||||
const confirmFn = createServerFn({ method: 'GET' })
|
||||
.validator((searchParams: unknown) => {
|
||||
if (
|
||||
searchParams &&
|
||||
typeof searchParams === 'object' &&
|
||||
'token_hash' in searchParams &&
|
||||
'type' in searchParams
|
||||
) {
|
||||
return searchParams
|
||||
}
|
||||
throw new Error('Invalid search params')
|
||||
})
|
||||
.handler(async (ctx) => {
|
||||
const request = getWebRequest()
|
||||
|
||||
if (!request) {
|
||||
throw redirect({ to: `/auth/error`, search: { error: 'No request' } })
|
||||
}
|
||||
|
||||
const searchParams = ctx.data
|
||||
const token_hash = searchParams['token_hash'] as string
|
||||
const type = searchParams['type'] as EmailOtpType | null
|
||||
const _next = searchParams['next'] as string | undefined
|
||||
const next = _next?.startsWith('/') ? _next : '/protected'
|
||||
|
||||
if (token_hash && type) {
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type,
|
||||
token_hash,
|
||||
})
|
||||
if (!error) {
|
||||
// redirect user to specified redirect URL or protected page
|
||||
throw redirect({ href: next })
|
||||
} else {
|
||||
// redirect the user to an error page with some instructions
|
||||
throw redirect({
|
||||
to: `/auth/error`,
|
||||
search: { error: error?.message },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// redirect the user to an error page with some instructions
|
||||
throw redirect({
|
||||
to: `/auth/error`,
|
||||
search: { error: 'No token hash or type' },
|
||||
})
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/auth/confirm')({
|
||||
preload: false,
|
||||
loader: (opts) => confirmFn({ data: opts.location.search }),
|
||||
})
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/registry/default/components/ui/card'
|
||||
import { Button } from '@/registry/default/components/ui/button'
|
||||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/auth/error')({
|
||||
component: AuthErrorPage,
|
||||
})
|
||||
|
||||
function AuthErrorPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Authentication Error</CardTitle>
|
||||
<CardDescription>There was an error authenticating your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The authentication link may have expired or is invalid. Please try requesting a new
|
||||
one.
|
||||
</p>
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/passwordless">Try again</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-tanstack/components/passwordless-login-form'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/passwordless')({
|
||||
component: PasswordlessPage,
|
||||
})
|
||||
|
||||
function PasswordlessPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<PasswordlessLoginForm method="magic-link" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { OTPVerifyForm } from '@/registry/default/blocks/passwordless-auth-tanstack/components/otp-verify-form'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/verify-otp')({
|
||||
component: VerifyOTPPage,
|
||||
validateSearch: (search: Record<string, unknown>): { email?: string } => {
|
||||
return {
|
||||
email: (search.email as string) || undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function VerifyOTPPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<OTPVerifyForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { MagicLinkForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/magic-link-form'
|
||||
|
||||
const MagicLinkAuthDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<MagicLinkForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MagicLinkAuthDemo
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OTPRequestForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/otp-request-form'
|
||||
|
||||
const OTPAuthDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<OTPRequestForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OTPAuthDemo
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PasswordlessLoginForm } from '@/registry/default/blocks/passwordless-auth-nextjs/components/passwordless-login-form'
|
||||
|
||||
const PasswordlessAuthDemo = () => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<PasswordlessLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordlessAuthDemo
|
||||
@@ -34,6 +34,39 @@ export const examples: RegistryItem[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'passwordless-auth-demo',
|
||||
type: 'registry:example',
|
||||
registryDependencies: [],
|
||||
files: [
|
||||
{
|
||||
path: 'registry/default/examples/passwordless-auth.tsx',
|
||||
type: 'registry:example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'magic-link-auth-demo',
|
||||
type: 'registry:example',
|
||||
registryDependencies: [],
|
||||
files: [
|
||||
{
|
||||
path: 'registry/default/examples/magic-link-auth.tsx',
|
||||
type: 'registry:example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'otp-auth-demo',
|
||||
type: 'registry:example',
|
||||
registryDependencies: [],
|
||||
files: [
|
||||
{
|
||||
path: 'registry/default/examples/otp-auth.tsx',
|
||||
type: 'registry:example',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'current-user-avatar-demo',
|
||||
type: 'registry:example',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<svg width="567" height="424" viewBox="0 0 567 424" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<<<<<<< Updated upstream
|
||||
<g filter="url(#filter0_d_626_214)">
|
||||
<path d="M212.929 143.934H96.1804C80.6168 143.934 68 156.55 68 172.114V289.589C68 305.153 80.6168 317.77 96.1804 317.77H212.929C228.492 317.77 241.109 305.153 241.109 289.589V172.114C241.109 156.55 228.492 143.934 212.929 143.934Z" fill="#202020"/>
|
||||
<path d="M212.929 144.434H96.1804C80.8929 144.434 68.5 156.827 68.5 172.114V289.589C68.5 304.877 80.8929 317.27 96.1804 317.27H212.929C228.216 317.27 240.609 304.877 240.609 289.589V172.114C240.609 156.827 228.216 144.434 212.929 144.434Z" stroke="#2E2E2E"/>
|
||||
@@ -32,22 +33,60 @@
|
||||
<path d="M132.721 192.571C153.535 184.719 178.16 200.169 180.917 222.706C181.174 224.802 180.835 226.936 179.915 228.819C176.123 236.568 169.234 242.645 161.14 245.189C166.93 233.497 166.091 219.522 159.071 208.713L158.723 208.186L158.715 208.175C152.572 199.557 143.169 193.486 132.721 192.571Z" fill="#282828" stroke="#454141"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_626_214" x="65.1239" y="141.057" width="180.779" height="181.506" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
=======
|
||||
<g filter="url(#filter0_d_915_1391)">
|
||||
<rect x="68" y="143.935" width="173.109" height="173.836" rx="28.1804" fill="#202020"/>
|
||||
<rect x="68.5" y="144.435" width="172.109" height="172.836" rx="27.6804" stroke="#2E2E2E"/>
|
||||
</g>
|
||||
<line x1="85.6701" y1="148" x2="85.6701" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="103.386" y1="148" x2="103.386" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="148.659" y1="148" x2="148.659" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="166.375" y1="148" x2="166.375" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="196.885" y1="148" x2="196.885" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="214.601" y1="148" x2="214.601" y2="315" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="231.57" y1="165.967" x2="69.1769" y2="165.967" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="231.57" y1="184.185" x2="69.1769" y2="184.185" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="231.57" y1="279.324" x2="69.1769" y2="279.324" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line x1="231.57" y1="297.542" x2="69.1769" y2="297.542" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line y1="-0.254572" x2="184.046" y2="-0.254572" transform="matrix(-0.604949 -0.796264 0.779556 -0.626333 232.177 294.665)" stroke="#282828" stroke-width="0.509144"/>
|
||||
<line y1="-0.254572" x2="208.019" y2="-0.254572" transform="matrix(-0.604949 -0.796264 0.779556 -0.626333 224.304 313.895)" stroke="#282828" stroke-width="0.509144"/>
|
||||
<path d="M179.869 214.752C180.32 215.335 180.73 215.873 181.092 216.357L180.916 216.489L181.506 217.292C181.711 217.577 181.915 217.864 182.115 218.153L182.294 218.028C182.695 218.608 183.087 219.196 183.465 219.794H183.466L183.28 219.912C183.656 220.505 184.02 221.107 184.369 221.718L184.558 221.608C184.909 222.221 185.245 222.842 185.567 223.471L185.374 223.571C185.694 224.196 185.999 224.829 186.287 225.471L186.486 225.381C186.776 226.024 187.048 226.675 187.304 227.335L187.101 227.414C187.354 228.068 187.589 228.729 187.806 229.398L188.013 229.331C188.23 230.001 188.428 230.679 188.605 231.365L188.396 231.42C188.571 232.097 188.727 232.781 188.861 233.472L189.075 233.43C189.209 234.12 189.322 234.817 189.413 235.521L189.197 235.55C189.287 236.241 189.355 236.94 189.399 237.644L189.617 237.631C189.662 238.33 189.683 239.035 189.682 239.747H189.464C189.463 240.096 189.457 240.447 189.444 240.8C189.433 241.124 189.417 241.448 189.397 241.77L189.615 241.783C189.575 242.436 189.517 243.085 189.441 243.727L189.225 243.703C189.149 244.349 189.056 244.989 188.946 245.623L189.161 245.659C189.05 246.303 188.92 246.942 188.774 247.573L188.562 247.524C188.416 248.157 188.254 248.783 188.074 249.401L188.284 249.462C188.102 250.089 187.904 250.71 187.688 251.322L187.481 251.25C187.266 251.862 187.034 252.465 186.787 253.06L186.989 253.144C186.739 253.747 186.472 254.341 186.19 254.926L185.994 254.831C185.713 255.415 185.415 255.989 185.103 256.555L185.295 256.66C184.98 257.231 184.649 257.791 184.304 258.342L184.118 258.226C183.774 258.775 183.415 259.313 183.042 259.841L183.221 259.967C182.845 260.499 182.455 261.02 182.051 261.529L181.879 261.393C181.477 261.901 181.062 262.397 180.633 262.881L180.796 263.025C180.364 263.512 179.92 263.988 179.462 264.451L179.306 264.297C178.851 264.756 178.383 265.204 177.903 265.638L178.05 265.8C177.568 266.236 177.073 266.659 176.567 267.068L176.43 266.899C175.927 267.306 175.414 267.7 174.89 268.079L175.018 268.256C174.49 268.637 173.953 269.004 173.404 269.356L173.286 269.172C172.743 269.521 172.188 269.855 171.625 270.175L171.733 270.365C171.168 270.686 170.593 270.99 170.009 271.28L169.912 271.085L169.037 271.504C168.727 271.647 168.414 271.787 168.099 271.922L168.184 272.123C167.552 272.393 166.91 272.645 166.261 272.879L166.187 272.673C165.544 272.904 164.894 273.118 164.235 273.312L164.296 273.521C163.637 273.716 162.971 273.892 162.298 274.049L162.249 273.836C161.585 273.99 160.913 274.125 160.235 274.241L160.271 274.457C159.596 274.573 158.914 274.669 158.226 274.746L158.202 274.528C157.525 274.603 156.842 274.659 156.154 274.695L156.165 274.914C155.481 274.95 154.793 274.966 154.1 274.962L154.102 274.742C153.76 274.74 153.418 274.733 153.074 274.721L152.055 274.672L152.041 274.888C151.354 274.845 150.672 274.783 149.997 274.701L150.023 274.483C149.345 274.401 148.672 274.299 148.006 274.179L147.967 274.392C147.29 274.27 146.62 274.13 145.958 273.97L146.01 273.758C145.346 273.597 144.69 273.419 144.042 273.221L143.978 273.43C143.321 273.231 142.672 273.011 142.032 272.775L142.108 272.571C141.789 272.453 141.471 272.331 141.156 272.204L140.255 271.826L140.167 272.025C139.564 271.763 138.97 271.485 138.386 271.19L138.485 270.995C137.902 270.701 137.328 270.392 136.765 270.067L136.654 270.256C136.086 269.928 135.526 269.586 134.979 269.228L135.099 269.045C134.552 268.688 134.017 268.317 133.492 267.931L133.361 268.107C132.833 267.719 132.315 267.316 131.81 266.899L131.949 266.73C131.446 266.316 130.955 265.887 130.476 265.446L130.326 265.607C129.844 265.163 129.374 264.705 128.917 264.235L129.075 264.083C128.621 263.615 128.18 263.135 127.752 262.643L127.586 262.787C127.156 262.292 126.739 261.785 126.337 261.267L126.511 261.134C126.111 260.619 125.725 260.094 125.354 259.557L125.173 259.681C124.8 259.143 124.443 258.593 124.1 258.033L124.287 257.919C123.947 257.364 123.622 256.799 123.312 256.224L123.119 256.328C122.809 255.752 122.513 255.166 122.234 254.571L122.434 254.478C122.158 253.89 121.897 253.292 121.653 252.686L121.45 252.767C121.206 252.16 120.979 251.545 120.768 250.923L120.975 250.853C120.766 250.238 120.574 249.615 120.399 248.985L120.188 249.043C120.014 248.413 119.856 247.777 119.716 247.134L119.93 247.088C119.791 246.453 119.671 245.813 119.567 245.167L119.352 245.2C119.249 244.556 119.161 243.906 119.094 243.251L119.312 243.229C119.246 242.584 119.197 241.934 119.166 241.279L118.946 241.289C118.915 240.638 118.903 239.983 118.908 239.324L119.127 239.325L119.148 238.346C119.161 237.992 119.178 237.638 119.201 237.286L118.981 237.272C119.018 236.708 119.068 236.147 119.131 235.591L119.184 235.144L119.401 235.172C119.49 234.466 119.6 233.766 119.729 233.074L119.514 233.034C119.646 232.331 119.798 231.635 119.971 230.947L120.184 231.001C120.357 230.311 120.551 229.63 120.764 228.958L120.554 228.891C120.77 228.21 121.005 227.537 121.261 226.875L121.466 226.954C121.721 226.292 121.996 225.639 122.289 224.997L122.089 224.906C122.316 224.408 122.555 223.915 122Line truncated
|
||||
<g filter="url(#filter1_d_915_1391)">
|
||||
<rect x="215.536" y="88.4706" width="284.576" height="284.576" rx="53.0232" fill="#1C1C1C" stroke="#343434" stroke-width="1.07125"/>
|
||||
<g clip-path="url(#clip0_915_1391)">
|
||||
<path d="M369.662 298.914C366.04 303.465 358.696 300.972 358.609 295.16L357.333 210.16H414.61C424.984 210.16 430.77 222.116 424.319 230.224L369.662 298.914Z" fill="url(#paint0_linear_915_1391)"/>
|
||||
<path d="M369.662 298.914C366.04 303.465 358.696 300.972 358.609 295.16L357.333 210.16H414.61C424.984 210.16 430.77 222.116 424.319 230.224L369.662 298.914Z" fill="url(#paint1_linear_915_1391)" fill-opacity="0.2"/>
|
||||
<path d="M346.367 162.141C349.989 157.589 357.333 160.083 357.42 165.895L357.98 250.895H301.42C291.045 250.895 285.259 238.938 291.71 230.831L346.367 162.141Z" fill="#3ECF8E"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M351.667 101.406C351.667 104.352 354.054 106.74 357 106.74C359.946 106.74 362.333 104.352 362.333 101.406C362.333 98.4607 359.946 96.0729 357 96.0729C354.054 96.0729 351.667 98.4607 351.667 101.406ZM155 144H156V71H155H154V144H155ZM176 50V51H336V50V49H176V50ZM357 71H356V101.406H357H358V71H357ZM336 50V51C347.046 51 356 59.9543 356 71H357H358C358 58.8497 348.15 49 336 49V50ZM155 71H156C156 59.9543 164.954 51 176 51V50V49C163.85 49 154 58.8497 154 71H155Z" fill="url(#paint2_linear_915_1391)"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_915_1391" x="65.1239" y="141.059" width="180.779" height="181.506" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
>>>>>>> Stashed changes
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="0.958705" dy="0.958705"/>
|
||||
<feGaussianBlur stdDeviation="1.91741"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
|
||||
<<<<<<< Updated upstream
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_626_214"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_626_214" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_626_214" x="203" y="79.9336" width="309.648" height="309.648" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
=======
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_915_1391"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_915_1391" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_915_1391" x="203" y="79.9349" width="309.647" height="309.647" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
>>>>>>> Stashed changes
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
|
||||
<<<<<<< Updated upstream
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_626_214"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_626_214" result="shape"/>
|
||||
</filter>
|
||||
@@ -63,5 +102,25 @@
|
||||
<stop stop-color="#1C1C1C"/>
|
||||
<stop offset="1" stop-color="#EDEDED"/>
|
||||
</linearGradient>
|
||||
=======
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_915_1391"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_915_1391" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_915_1391" x1="357.333" y1="229.006" x2="408.206" y2="250.388" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#249361"/>
|
||||
<stop offset="1" stop-color="#3ECF8E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_915_1391" x1="334.764" y1="198.172" x2="357.902" y2="241.821" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_915_1391" x1="153" y1="126.746" x2="391.581" y2="115.784" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1C1C1C"/>
|
||||
<stop offset="1" stop-color="#EDEDED"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_915_1391">
|
||||
<rect width="138.063" height="142.823" fill="white" transform="translate(288.969 159.523)"/>
|
||||
</clipPath>
|
||||
>>>>>>> Stashed changes
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 19 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg width="568" height="424" viewBox="0 0 568 424" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<<<<<<< Updated upstream
|
||||
<path d="M213.104 144.434H96.3562C81.0687 144.434 68.6758 156.827 68.6758 172.114V289.589C68.6758 304.877 81.0687 317.27 96.3562 317.27H213.104C228.392 317.27 240.785 304.877 240.785 289.589V172.114C240.785 156.827 228.392 144.434 213.104 144.434Z" fill="white" stroke="#EDEDED"/>
|
||||
<path d="M86.297 147L86.2969 315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<path d="M103.605 147V315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
@@ -31,12 +32,40 @@
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_626_241" x="203.176" y="79.9336" width="309.648" height="309.648" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
=======
|
||||
<rect x="68.6768" y="144.435" width="172.109" height="172.836" rx="27.6804" fill="white" stroke="#EDEDED"/>
|
||||
<line x1="86.2971" y1="147" x2="86.297" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="103.605" y1="147" x2="103.605" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="147.837" y1="147" x2="147.837" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="165.146" y1="147" x2="165.146" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="194.954" y1="147" x2="194.954" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="212.262" y1="147" x2="212.262" y2="315" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="228.835" y1="165.073" x2="70.1768" y2="165.073" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="228.835" y1="183.4" x2="70.1768" y2="183.4" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="228.835" y1="279.109" x2="70.1768" y2="279.109" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line x1="228.835" y1="297.436" x2="70.1768" y2="297.436" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line y1="-0.254572" x2="183.214" y2="-0.254572" transform="matrix(-0.593721 -0.804671 0.770508 -0.63743 229.428 294.543)" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<line y1="-0.254572" x2="207.078" y2="-0.254572" transform="matrix(-0.593721 -0.804671 0.770508 -0.63743 221.736 313.888)" stroke="#EDEDED" stroke-width="0.509144"/>
|
||||
<path d="M180.496 215.752C180.947 216.335 181.357 216.873 181.719 217.357L181.543 217.489L182.133 218.292C182.338 218.577 182.542 218.864 182.742 219.153L182.921 219.028C183.322 219.608 183.714 220.196 184.092 220.794H184.093L183.907 220.912C184.283 221.505 184.646 222.107 184.996 222.718L185.185 222.608C185.535 223.221 185.872 223.842 186.194 224.471L186.001 224.571C186.321 225.196 186.626 225.829 186.914 226.471L187.113 226.381C187.403 227.024 187.675 227.675 187.931 228.335L187.728 228.414C187.981 229.068 188.216 229.729 188.433 230.398L188.64 230.331C188.857 231.001 189.055 231.679 189.232 232.365L189.022 232.42C189.198 233.097 189.354 233.781 189.488 234.472L189.702 234.43C189.836 235.12 189.949 235.817 190.04 236.521L189.824 236.55C189.914 237.241 189.982 237.94 190.026 238.644L190.244 238.631C190.289 239.33 190.31 240.035 190.309 240.747H190.091C190.09 241.096 190.084 241.447 190.071 241.8C190.06 242.124 190.044 242.448 190.024 242.77L190.242 242.783C190.202 243.436 190.144 244.085 190.068 244.727L189.852 244.703C189.776 245.349 189.683 245.989 189.573 246.623L189.788 246.659C189.676 247.303 189.547 247.942 189.401 248.573L189.189 248.524C189.043 249.157 188.881 249.783 188.701 250.401L188.911 250.462C188.729 251.089 188.531 251.71 188.315 252.322L188.108 252.25C187.893 252.862 187.661 253.465 187.414 254.06L187.616 254.144C187.366 254.747 187.099 255.341 186.817 255.926L186.621 255.831C186.34 256.415 186.042 256.989 185.729 257.555L185.922 257.66C185.607 258.231 185.276 258.791 184.931 259.342L184.745 259.226C184.401 259.775 184.042 260.313 183.669 260.841L183.848 260.967C183.472 261.499 183.082 262.02 182.678 262.529L182.506 262.393C182.104 262.901 181.689 263.397 181.26 263.881L181.423 264.025C180.991 264.512 180.547 264.988 180.089 265.451L179.933 265.297C179.478 265.756 179.01 266.204 178.53 266.638L178.677 266.8C178.195 267.236 177.7 267.659 177.194 268.068L177.057 267.899C176.554 268.306 176.041 268.7 175.517 269.079L175.645 269.256C175.117 269.637 174.58 270.004 174.031 270.356L173.913 270.172C173.369 270.521 172.815 270.855 172.252 271.175L172.36 271.365C171.795 271.686 171.22 271.99 170.636 272.28L170.539 272.085L169.664 272.504C169.354 272.647 169.041 272.787 168.726 272.922L168.811 273.123C168.179 273.393 167.537 273.645 166.888 273.879L166.813 273.673C166.171 273.904 165.521 274.118 164.862 274.312L164.923 274.521C164.264 274.716 163.598 274.892 162.925 275.049L162.876 274.836C162.212 274.99 161.54 275.125 160.862 275.241L160.898 275.457C160.223 275.573 159.54 275.669 158.853 275.746L158.829 275.528C158.152 275.603 157.469 275.659 156.781 275.695L156.792 275.914C156.108 275.95 155.42 275.966 154.727 275.962L154.729 275.742C154.387 275.74 154.045 275.733 153.701 275.721L152.682 275.672L152.668 275.888C151.981 275.845 151.299 275.783 150.624 275.701L150.65 275.483C149.972 275.401 149.299 275.299 148.633 275.179L148.594 275.392C147.917 275.27 147.247 275.13 146.585 274.97L146.637 274.758C145.973 274.597 145.317 274.419 144.669 274.221L144.604 274.43C143.947 274.231 143.299 274.011 142.659 273.775L142.735 273.571C142.416 273.453 142.098 273.331 141.783 273.204L140.882 272.826L140.794 273.025C140.191 272.763 139.597 272.485 139.013 272.19L139.112 271.995C138.529 271.701 137.955 271.392 137.392 271.067L137.281 271.256C136.713 270.928 136.153 270.586 135.605 270.228L135.726 270.045C135.179 269.688 134.644 269.317 134.119 268.931L133.988 269.107C133.46 268.719 132.942 268.316 132.437 267.899L132.576 267.73C132.073 267.316 131.582 266.887 131.103 266.446L130.953 266.607C130.471 266.163 130.001 265.705 129.544 265.235L129.702 265.083C129.248 264.615 128.807 264.135 128.379 263.643L128.213 263.787C127.783 263.292 127.366 262.785 126.964 262.267L127.138 262.134C126.738 261.619 126.352 261.094 125.981 260.557L125.8 260.681C125.427 260.143 125.069 259.593 124.727 259.033L124.914 258.919C124.574 258.364 124.249 257.799 123.939 257.224L123.746 257.328C123.436 256.752 123.14 256.166 122.861 255.571L123.061 255.478C122.784 254.89 122.524 254.292 122.28 253.686L122.077 253.767C121.833 253.16 121.605 252.545 121.395 251.923L121.602 251.853C121.393 251.238 121.201 250.615 121.026 249.985L120.815 250.043C120.641 249.413 120.483 248.777 120.343 248.134L120.557 248.088C120.418 247.453 120.298 246.813 120.194 246.167L119.979 246.2C119.875 245.556 119.788 244.906 119.721 244.251L119.939 244.229C119.873 243.584 119.824 242.934 119.793 242.279L119.573 242.289C119.542 241.638 119.53 240.983 119.535 240.324L119.754 240.325L119.775 239.346C119.788 238.992 119.805 238.638 119.828 238.286L119.608 238.272C119.645 237.708 119.695 237.147 119.758 236.591L119.811 236.144L120.028 236.172C120.117 235.466 120.227 234.766 120.356 234.074L120.141 234.034C120.272 233.331 120.425 232.635 120.598 231.947L120.811 232.001C120.984 231.311 121.178 230.63 121.391 229.958L121.181 229.891C121.397 229.21 121.632 228.537 121.888 227.875L122.093 227.954C122.348 227.292 122.623 226.639 122.916 225.997L122.716 225.906C122.943 225.408 123.182 Line truncated
|
||||
<g filter="url(#filter0_d_915_1426)">
|
||||
<rect x="215.712" y="88.4706" width="284.576" height="284.576" rx="53.0232" fill="#FCFCFC" stroke="#DFDFDF" stroke-width="1.07125"/>
|
||||
<g clip-path="url(#clip0_915_1426)">
|
||||
<path d="M369.839 298.914C366.217 303.465 358.873 300.972 358.786 295.16L357.51 210.16H414.786C425.161 210.16 430.947 222.116 424.496 230.224L369.839 298.914Z" fill="url(#paint0_linear_915_1426)"/>
|
||||
<path d="M369.839 298.914C366.217 303.465 358.873 300.972 358.786 295.16L357.51 210.16H414.786C425.161 210.16 430.947 222.116 424.496 230.224L369.839 298.914Z" fill="url(#paint1_linear_915_1426)" fill-opacity="0.2"/>
|
||||
<path d="M346.544 162.141C350.166 157.589 357.51 160.083 357.597 165.895L358.156 250.895H301.596C291.222 250.895 285.436 238.938 291.887 230.831L346.544 162.141Z" fill="#3FCF8E"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M351.843 101.406C351.843 104.352 354.231 106.74 357.177 106.74C360.122 106.74 362.51 104.352 362.51 101.406C362.51 98.4607 360.122 96.0729 357.177 96.0729C354.231 96.0729 351.843 98.4607 351.843 101.406ZM155.177 145H156.177V71H155.177H154.177V145H155.177ZM176.177 50V51H336.177V50V49H176.177V50ZM357.177 71H356.177V101.406H357.177H358.177V71H357.177ZM336.177 50V51C347.222 51 356.177 59.9543 356.177 71H357.177H358.177C358.177 58.8497 348.327 49 336.177 49V50ZM155.177 71H156.177C156.177 59.9543 165.131 51 176.177 51V50V49C164.026 49 154.177 58.8497 154.177 71H155.177Z" fill="url(#paint2_linear_915_1426)"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_915_1426" x="203.177" y="79.9349" width="309.647" height="309.647" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
>>>>>>> Stashed changes
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
|
||||
<<<<<<< Updated upstream
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_626_241"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_626_241" result="shape"/>
|
||||
</filter>
|
||||
@@ -54,6 +83,25 @@
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_626_241">
|
||||
<rect width="84" height="85" fill="white" transform="translate(103.5 188)"/>
|
||||
=======
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_915_1426"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_915_1426" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_915_1426" x1="357.51" y1="229.006" x2="408.383" y2="250.388" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#249361"/>
|
||||
<stop offset="1" stop-color="#3ECF8E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_915_1426" x1="334.941" y1="198.172" x2="358.079" y2="241.821" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_915_1426" x1="134.177" y1="132" x2="391.983" y2="116.803" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FCFCFC"/>
|
||||
<stop offset="1" stop-color="#171717"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_915_1426">
|
||||
<rect width="138.063" height="142.823" fill="white" transform="translate(289.146 159.523)"/>
|
||||
>>>>>>> Stashed changes
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 18 KiB |
Reference in new issue
Block a user