mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Implement Supabase Auth with auth pages
This commit is contained in:
parent
a3a68aae77
commit
210a987a45
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["denoland.vscode-deno"]
|
||||
}
|
||||
24
.vscode/settings.json
vendored
Normal file
24
.vscode/settings.json
vendored
Normal 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
157
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
22
src/app/account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/auth/confirm/route.ts
Normal file
35
src/app/auth/confirm/route.ts
Normal 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)
|
||||
}
|
||||
21
src/app/auth/signout/route.ts
Normal file
21
src/app/auth/signout/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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
42
src/app/login/actions.ts
Normal 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
18
src/app/login/page.tsx
Normal 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
59
src/app/signup/actions.ts
Normal 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
18
src/app/signup/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
src/components/Account/AccountForm.tsx
Normal file
174
src/components/Account/AccountForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
src/components/Account/Avatar.tsx
Normal file
152
src/components/Account/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/Auth/LoginForm.tsx
Normal file
97
src/components/Auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
136
src/components/Auth/SignupForm.tsx
Normal file
136
src/components/Auth/SignupForm.tsx
Normal 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
20
src/middleware.ts
Normal 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
16
src/utils/auth.ts
Normal 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 };
|
||||
};
|
||||
9
src/utils/supabase/client.ts
Normal file
9
src/utils/supabase/client.ts
Normal 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!
|
||||
);
|
||||
}
|
||||
34
src/utils/supabase/middleware.ts
Normal file
34
src/utils/supabase/middleware.ts
Normal 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
|
||||
}
|
||||
31
src/utils/supabase/server.ts
Normal file
31
src/utils/supabase/server.ts
Normal 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
8
supabase/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
347
supabase/config.toml
Normal file
347
supabase/config.toml
Normal 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)"
|
||||
1036
supabase/migrations/20251008081035_remote_schema.sql
Normal file
1036
supabase/migrations/20251008081035_remote_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user