Implement Supabase Auth with auth pages

This commit is contained in:
Jared Schoeny 2025-10-08 16:04:55 -10:00
parent a3a68aae77
commit 210a987a45
24 changed files with 2463 additions and 6 deletions

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["denoland.vscode-deno"]
}

24
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,24 @@
{
"deno.enablePaths": [
"supabase/functions"
],
"deno.lint": true,
"deno.unstable": [
"bare-node-builtins",
"byonm",
"sloppy-imports",
"unsafe-proto",
"webgpu",
"broadcast-channel",
"worker-options",
"cron",
"kv",
"ffi",
"fs",
"http",
"net"
],
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
}
}

157
package-lock.json generated
View File

@ -12,6 +12,8 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"embla-carousel-react": "8.6.0",
"next": "15.5.4",
"react": "19.1.0",
@ -733,6 +735,92 @@
"node": ">= 10"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.74.0.tgz",
"integrity": "sha512-EJYDxYhBCOS40VJvfQ5zSjo8Ku7JbTICLTcmXt4xHMQZt4IumpRfHg11exXI9uZ6G7fhsQlNgbzDhFN4Ni9NnA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.74.0.tgz",
"integrity": "sha512-VqWYa981t7xtIFVf7LRb9meklHckbH/tqwaML5P3LgvlaZHpoSPjMCNLcquuLYiJLxnh2rio7IxLh+VlvRvSWw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.74.0.tgz",
"integrity": "sha512-9Ypa2eS0Ib/YQClE+BhDSjx7OKjYEF6LAGjTB8X4HucdboGEwR0LZKctNfw6V0PPIAVjjzZxIlNBXGv0ypIkHw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.74.0.tgz",
"integrity": "sha512-K5VqpA4/7RO1u1nyD5ICFKzWKu58bIDcPxHY0aFA7MyWkFd0pzi/XYXeoSsAifnD9p72gPIpgxVXCQZKJg1ktQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/ssr": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.74.0.tgz",
"integrity": "sha512-o0cTQdMqHh4ERDLtjUp1/KGPbQoNwKRxUh6f8+KQyjC5DSmiw/r+jgFe/WHh067aW+WU8nA9Ytw9ag7OhzxEkQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "2.6.15"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.74.0.tgz",
"integrity": "sha512-IEMM/V6gKdP+N/X31KDIczVzghDpiPWFGLNjS8Rus71KvV6y6ueLrrE/JGCHDrU+9pq5copF3iCa0YQh+9Lq9Q==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.74.0",
"@supabase/functions-js": "2.74.0",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "2.74.0",
"@supabase/realtime-js": "2.74.0",
"@supabase/storage-js": "2.74.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1083,12 +1171,17 @@
"version": "20.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
"integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
@ -1114,6 +1207,15 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -1370,6 +1472,15 @@
"node": ">=16"
}
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3807,6 +3918,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@ -3851,7 +3968,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
@ -3986,6 +4102,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4002,6 +4134,27 @@
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@ -12,6 +12,8 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"embla-carousel-react": "8.6.0",
"next": "15.5.4",
"react": "19.1.0",
@ -27,8 +29,8 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"patch-package": "^8.0.0",
"tailwindcss": "^4",
"typescript": "^5",
"patch-package": "^8.0.0"
"typescript": "^5"
}
}

22
src/app/account/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import AccountForm from "@/components/Account/AccountForm";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export default async function AccountPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="mx-auto max-w-screen-lg px-6 py-10">
<h1 className="text-3xl font-bold tracking-tight">Your account</h1>
<p className="mt-2 text-[15px] text-foreground/80">Manage profile details and avatar.</p>
<div className="mt-8 card p-6">
<AccountForm user={user} />
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
// Creating a handler to a GET request to route /auth/confirm
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 = '/account'
// Create redirect link without the secret token
const redirectTo = request.nextUrl.clone()
redirectTo.pathname = next
redirectTo.searchParams.delete('token_hash')
redirectTo.searchParams.delete('type')
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
})
if (!error) {
redirectTo.searchParams.delete('next')
return NextResponse.redirect(redirectTo)
}
}
// return the user to login with an error message
redirectTo.pathname = '/login'
redirectTo.searchParams.set('error', 'EMAIL_CONFIRMATION_ERROR')
return NextResponse.redirect(redirectTo)
}

View File

@ -0,0 +1,21 @@
import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
import { type NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const supabase = await createClient();
// Check if a user's logged in
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
await supabase.auth.signOut();
}
revalidatePath("/", "layout");
return NextResponse.redirect(new URL("/login", req.url), {
status: 302,
});
}

View File

@ -28,14 +28,14 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
>
<BaseRomProvider>
<div className="fixed inset-0 -z-10">
<div className="aurora" />
</div>
<Header />
<main>{children}</main>
<main className="flex-1 flex flex-col">{children}</main>
<Footer />
</BaseRomProvider>
</body>

42
src/app/login/actions.ts Normal file
View File

@ -0,0 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { AuthError } from '@supabase/supabase-js'
import { createClient } from '@/utils/supabase/server'
function getErrorMessage(error: AuthError): string {
const code = (error.code)?.toLowerCase()
switch (code) {
case 'invalid_credentials':
return 'Incorrect email or password.';
case 'email_not_confirmed':
return 'Please verify your email before logging in.';
case 'over_request_rate_limit':
return 'Too many attempts. Please wait a minute and try again.';
case 'user_banned':
return 'This account is currently banned.';
}
return error.message || 'Unable to log in. Please try again later.';
}
export type AuthActionState = { error?: string | null }
export async function login(state: AuthActionState, payload: FormData) {
const supabase = await createClient()
const data = {
email: payload.get('email') as string,
password: payload.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
return { error: getErrorMessage(error) }
}
revalidatePath('/', 'layout')
redirect('/account')
}

18
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import LoginForm from "@/components/Auth/LoginForm";
export default function LoginPage() {
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm text-foreground/70">Log in to manage your account and submissions.</p>
<div className="mt-6">
<LoginForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
New here? <a className="text-[var(--accent)] hover:underline" href="/signup">Create an account</a>
</p>
</div>
</div>
)
}

59
src/app/signup/actions.ts Normal file
View File

@ -0,0 +1,59 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { AuthError } from '@supabase/supabase-js'
import { createClient } from '@/utils/supabase/server'
import { validateEmail, validatePassword } from '@/utils/auth'
function getErrorMessage(error: AuthError): string {
const code = (error.code)?.toLowerCase()
switch (code) {
case 'signup_disabled':
case 'email_provider_disabled':
return 'Signups are currently disabled.';
case 'weak_password':
return 'Password must be at least 6 characters long and contain at least one uppercase letter, one lowercase letter, and one number.';
case 'email_exists':
case 'user_already_exists':
case 'user_already_exists_identity':
return 'An account with this email already exists.';
case 'over_request_rate_limit':
case 'over_email_send_rate_limit':
return 'Too many attempts. Please wait a minute and try again.';
}
return error.message || 'Unable to sign up. Please try again later.';
}
export interface AuthActionState {
error?: string | null;
}
export async function signup(state: AuthActionState, payload: FormData) {
const supabase = await createClient()
const data = {
email: payload.get('email') as string,
password: payload.get('password') as string,
}
const { error: emailError } = validateEmail(data.email);
if (emailError) {
return { error: emailError };
}
const { error: passwordError } = validatePassword(data.password);
if (passwordError) {
return { error: passwordError };
}
const { error } = await supabase.auth.signUp(data)
if (error) {
return { error: getErrorMessage(error) };
}
revalidatePath('/', 'layout');
redirect('/account');
}

18
src/app/signup/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import SignupForm from "@/components/Auth/SignupForm";
export default function SignupPage() {
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Create your account</h1>
<p className="mt-1 text-sm text-foreground/70">Sign up to submit hacks and manage your profile.</p>
<div className="mt-6">
<SignupForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
Already have an account? <a className="text-[var(--accent)] hover:underline" href="/login">Log in</a>
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,174 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import { type User } from '@supabase/supabase-js'
import Avatar from './Avatar'
export default function AccountForm({ user }: { user: User | null }) {
const supabase = createClient()
const [loading, setLoading] = useState(true)
const [fullname, setFullname] = useState<string | null>(null)
const [username, setUsername] = useState<string | null>(null)
const [website, setWebsite] = useState<string | null>(null)
const [avatar_url, setAvatarUrl] = useState<string | null>(null)
const [initialProfile, setInitialProfile] = useState<{
fullname: string | null
username: string | null
website: string | null
avatar_url: string | null
}>({ fullname: null, username: null, website: null, avatar_url: null })
const getProfile = useCallback(async () => {
try {
setLoading(true)
const { data, error, status } = await supabase
.from('profiles')
.select(`full_name, username, website, avatar_url`)
.eq('id', user?.id)
.single()
if (error && status !== 406) {
console.log(error)
throw error
}
if (data) {
setFullname(data.full_name)
setUsername(data.username)
setWebsite(data.website)
setAvatarUrl(data.avatar_url)
setInitialProfile({
fullname: data.full_name,
username: data.username,
website: data.website,
avatar_url: data.avatar_url,
})
}
} catch (error) {
alert('Error loading user data!')
} finally {
setLoading(false)
}
}, [user, supabase])
useEffect(() => {
getProfile()
}, [user, getProfile])
async function updateProfile({
username,
website,
avatar_url,
fullname,
}: {
username: string | null
fullname: string | null
website: string | null
avatar_url: string | null
}) {
try {
setLoading(true)
const { error } = await supabase.from('profiles').upsert({
id: user?.id as string,
full_name: fullname,
username,
website,
avatar_url,
updated_at: new Date().toISOString(),
})
if (error) throw error
setInitialProfile({ fullname, username, website, avatar_url })
alert('Profile updated!')
} catch (error) {
alert('Error updating the data!')
} finally {
setLoading(false)
}
}
const normalize = (v: string | null | undefined) => v ?? ''
const hasChanges =
normalize(fullname) !== normalize(initialProfile.fullname) ||
normalize(username) !== normalize(initialProfile.username) ||
normalize(website) !== normalize(initialProfile.website) ||
normalize(avatar_url) !== normalize(initialProfile.avatar_url)
return (
<div className="grid gap-8">
<div className="flex items-center gap-6">
<Avatar
uid={user?.id ?? null}
url={avatar_url}
size={120}
onUpload={(url) => {
setAvatarUrl(url)
updateProfile({ fullname, username, website, avatar_url: url })
}}
/>
<div className="text-sm text-foreground/70">
<div className="text-xl font-semibold text-foreground">{fullname || <span className="italic text-foreground/80">No name</span>}</div>
<div className="text-sm text-foreground/70">{username ? `@${username}` : <span className="italic text-foreground/60">No username</span>}</div>
<div className="mt-3">Update your profile details and avatar.</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2 sm:col-span-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input id="email" type="text" value={user?.email || ''} disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm text-foreground/70 ring-1 ring-inset ring-[var(--border)]" />
</div>
<div className="grid gap-2">
<label htmlFor="fullName" className="text-sm text-foreground/80">Name</label>
<input
id="fullName"
type="text"
value={fullname || ''}
onChange={(e) => setFullname(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="grid gap-2">
<label htmlFor="username" className="text-sm text-foreground/80">Username</label>
<input
id="username"
type="text"
value={username || ''}
onChange={(e) => setUsername(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="grid gap-2 sm:col-span-2">
<label htmlFor="website" className="text-sm text-foreground/80">Website</label>
<input
id="website"
type="url"
value={website || ''}
onChange={(e) => setWebsite(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
/>
</div>
<div className="sm:col-span-2 flex flex-col justify-center items-center gap-4 mt-4 sm:flex-row sm:justify-end">
<button
className="shine-wrap btn-premium h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
disabled={loading || !hasChanges}
>
<span>{loading ? 'Saving...' : 'Update profile'}</span>
</button>
<form action="/auth/signout" method="post">
<button className="inline-flex h-14 min-w-48 sm:h-11 sm:min-w-[7.5rem] items-center justify-center rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 px-4 text-sm font-medium text-red-600/90 dark:text-red-400/80 transition-colors hover:bg-red-600/5 dark:hover:bg-red-400/10" type="submit">
Sign out
</button>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import Image from 'next/image'
import { FaGamepad } from 'react-icons/fa'
const PLACEHOLDER_BG_CLASSES = [
'bg-rose-500/70 dark:bg-rose-500/20 shadow-lg shadow-rose-500/50 dark:shadow-rose-500/10',
'bg-sky-500/70 dark:bg-sky-500/20 shadow-lg shadow-sky-500/50 dark:shadow-sky-500/10',
'bg-amber-500/70 dark:bg-amber-500/20 shadow-lg shadow-amber-500/50 dark:shadow-amber-500/10',
'bg-emerald-500/70 dark:bg-emerald-500/20 shadow-lg shadow-emerald-500/50 dark:shadow-emerald-500/10',
'bg-violet-500/70 dark:bg-violet-500/20 shadow-lg shadow-violet-500/50 dark:shadow-violet-500/10',
'bg-pink-500/70 dark:bg-pink-500/20 shadow-lg shadow-pink-500/50 dark:shadow-pink-500/10',
'bg-indigo-500/70 dark:bg-indigo-500/20 shadow-lg shadow-indigo-500/50 dark:shadow-indigo-500/10',
'bg-cyan-500/70 dark:bg-cyan-500/20 shadow-lg shadow-cyan-500/50 dark:shadow-cyan-500/10',
]
function hashStringToNumber(input: string): number {
let hash = 0;
const numChars = Math.min(input.length, 3);
for (let i = 0; i < numChars; i++) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
function getPlaceholderBgClass(seed: string | null): string {
if (!seed) return PLACEHOLDER_BG_CLASSES[0]
const index = hashStringToNumber(seed) % PLACEHOLDER_BG_CLASSES.length
return PLACEHOLDER_BG_CLASSES[index]
}
export default function Avatar({
uid,
url,
size,
onUpload,
}: {
uid: string | null
url: string | null
size: number
onUpload?: (url: string) => void
}) {
const supabase = createClient()
const [avatarUrl, setAvatarUrl] = useState<string | null>(url)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const placeholderBgClass = getPlaceholderBgClass(uid)
const isEditable = Boolean(onUpload)
useEffect(() => {
async function downloadImage(path: string) {
try {
const { data, error } = await supabase.storage.from('avatars').download(path)
if (error) {
throw error
}
const url = URL.createObjectURL(data)
setAvatarUrl(url)
} catch (error) {
console.log('Error downloading image: ', error)
}
}
if (url) downloadImage(url)
}, [url, supabase])
const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (event) => {
try {
setUploading(true)
if (!event.target.files || event.target.files.length === 0) {
throw new Error('You must select an image to upload.')
}
const file = event.target.files[0]
const fileExt = file.name.split('.').pop()
const filePath = `${uid}-${Math.random()}.${fileExt}`
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
if (uploadError) {
throw uploadError
}
if (onUpload) onUpload(filePath)
} catch (error) {
alert('Error uploading avatar!')
} finally {
setUploading(false)
}
}
return (
<div
className={isEditable ? "relative group cursor-pointer" : "relative"}
style={{ height: size, width: size }}
onClick={isEditable ? () => fileInputRef.current?.click() : undefined}
role={isEditable ? "button" : undefined}
tabIndex={isEditable ? 0 : undefined}
onKeyDown={isEditable ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
fileInputRef.current?.click()
}
} : undefined}
aria-label={isEditable ? "Change avatar" : "User avatar"}
>
{avatarUrl ? (
<Image
width={size}
height={size}
src={avatarUrl}
alt="Avatar"
className="avatar image rounded-full object-cover"
style={{ height: size, width: size }}
/>
) : (
<div
className={`avatar no-image flex items-center justify-center rounded-full ${placeholderBgClass} text-white/90 dark:text-white/60`}
style={{ height: size, width: size }}
aria-label="No avatar placeholder"
>
<FaGamepad
className="opacity-70"
size={Math.floor(size * 0.6)}
aria-hidden="true"
/>
</div>
)}
{isEditable && (
<div className="absolute inset-0 rounded-full bg-black/50 text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center select-none">
{uploading ? 'Uploading…' : 'Change'}
</div>
)}
{isEditable && (
<input
ref={fileInputRef}
style={{ visibility: 'hidden', position: 'absolute' }}
type="file"
accept="image/*"
onChange={uploadAvatar}
disabled={uploading}
/>
)}
</div>
)
}

View File

@ -0,0 +1,97 @@
"use client";
import React, { useActionState} from "react";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { AuthActionState, login } from "@/app/login/actions";
import { useSearchParams } from "next/navigation";
export default function LoginForm() {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [showPassword, setShowPassword] = React.useState(false);
const searchParams = useSearchParams();
const urlError = searchParams.get("error");
const [state, formAction] = useActionState<AuthActionState, FormData>(login, { error: null });
const errorMessage = urlError === "EMAIL_CONFIRMATION_ERROR" ?
"Email verification failed. Try again or request a new link." :
state?.error || null;
const emailValid = /.+@.+\..+/.test(email);
const passwordValid = password.length > 1;
const isValid = emailValid && passwordValid;
return (
<form className="grid gap-5 group">
{(errorMessage) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{errorMessage}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
email && !emailValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm text-foreground/80">Password</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
password && !passwordValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <FiEyeOff className="h-4 w-4" /> : <FiEye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex flex-col items-center gap-3">
{!isValid && (email || password) ? (
<span className="text-xs text-red-500/70 italic h-3 group-has-focus:invisible">Please enter a valid email and password.</span>
) : (
<div className="h-3" />
)}
<button
type="submit"
formAction={formAction}
disabled={!isValid}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Log in</span>
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import React, { useActionState, useEffect } from "react";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { AuthActionState, signup } from "@/app/signup/actions";
import { validateEmail, validatePassword } from "@/utils/auth";
export default function SignupForm() {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [showPassword, setShowPassword] = React.useState(false);
const [emailError, setEmailError] = React.useState<string | null>(null);
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const [state, formAction] = useActionState<AuthActionState, FormData>(signup, { error: null });
const passwordsMatch = password === confirm;
const isValid = !emailError && !passwordError && passwordsMatch;
useEffect(() => {
const { error } = validateEmail(email);
setEmailError(error);
}, [email]);
useEffect(() => {
const { error } = validatePassword(password);
setPasswordError(error);
}, [password]);
return (
<form className="grid gap-5 group">
{(state?.error) && (
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
{state?.error}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
email && emailError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
{email && emailError && (
<span className="text-xs text-red-500/70">{emailError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm text-foreground/80">Password</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
password && passwordError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <FiEyeOff className="h-4 w-4" /> : <FiEye className="h-4 w-4" />}
</button>
</div>
{password && passwordError && (
<span className="text-xs text-red-500/70">{passwordError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="confirm" className="text-sm text-foreground/80">Confirm password</label>
<div className="relative">
<input
id="confirm"
name="confirm"
type={showPassword ? "text" : "password"}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Re-enter password"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
confirm && !passwordsMatch ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <FiEyeOff className="h-4 w-4" /> : <FiEye className="h-4 w-4" />}
</button>
</div>
{confirm && !passwordsMatch && (
<span className="text-xs text-red-500/70">Passwords do not match.</span>
)}
</div>
<div className="flex flex-col items-center gap-3">
<button
type="submit"
formAction={formAction}
disabled={!isValid}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Sign up</span>
</button>
</div>
</form>
);
}

20
src/middleware.ts Normal file
View File

@ -0,0 +1,20 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
// update user's auth session
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

16
src/utils/auth.ts Normal file
View File

@ -0,0 +1,16 @@
export const validatePassword = (password: string): { error: string | null } => {
if (password.length < 6) {
return { error: 'Password must be at least 6 characters long.' };
}
if (!/^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?:[^\s])+$/.test(password)) {
return { error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number.' };
}
return { error: null };
};
export const validateEmail = (email: string): { error: string | null } => {
if (!/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/.test(email)) {
return { error: 'Invalid email address.' };
}
return { error: null };
};

View File

@ -0,0 +1,9 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
// Create a supabase client on the browser with project's credentials
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}

View File

@ -0,0 +1,34 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_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)
)
},
},
}
)
// refreshing the auth token
await supabase.auth.getUser()
return supabaseResponse
}

View File

@ -0,0 +1,31 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
// Create a server's supabase client with newly configured cookie,
// which could be used to maintain user's session
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_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.
}
},
},
}
);
}

8
supabase/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

347
supabase/config.toml Normal file
View File

@ -0,0 +1,347 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "pokemon-romhack-platform"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"

File diff suppressed because it is too large Load Diff