init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# Get these from your API settings: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,151 @@
# Supabase Next.js Auth & User Management Starter
This example will set you up for a very common situation: users can sign up or sign in and then update their account with public profile information, including a profile image.
This demonstrates how to use:
- User signups using Supabase [Auth](https://supabase.com/auth).
- Supabase [Auth Helpers for Next.js](https://supabase.com/docs/guides/auth/auth-helpers/nextjs).
- Supabase [pre-built Auth UI for React](https://supabase.com/docs/guides/auth/auth-helpers/auth-ui).
- User avatar images using Supabase [Storage](https://supabase.com/storage)
- Public profiles restricted with [Policies](https://supabase.com/docs/guides/auth#policies).
- Frontend using [Next.js](<[nextjs.org/](https://nextjs.org/)>).
## Technologies used
- Frontend:
- [Next.js](https://github.com/vercel/next.js) - a React framework for production.
- [Supabase.js](https://supabase.com/docs/library/getting-started) for user management and realtime data syncing.
- Supabase [Auth Helpers for Next.js](https://supabase.com/docs/guides/auth/auth-helpers/nextjs).
- Supabase [pre-built Auth UI for React](https://supabase.com/docs/guides/auth/auth-helpers/auth-ui).
- Backend:
- [supabase.com/dashboard](https://supabase.com/dashboard/): hosted Postgres database with restful API for usage with Supabase.js.
## Instant deploy
The Vercel deployment will guide you through creating a Supabase account and project. After installation of the Supabase integration, all relevant environment variables will be set up so that the project is usable immediately after deployment 🚀.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-user-management&project-name=supabase-nextjs-user-management&repository-name=supabase-nextjs-user-management&integration-ids=oac_VqOgBHqhEoFTPzGkPd7L0iH6&external-id=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-user-management)
### 1. Create new project
Sign up to Supabase - [https://supabase.com/dashboard](https://supabase.com/dashboard) and create a new project. Wait for your database to start.
### 2. Run "User Management" Quickstart
Once your database has started, head over to your project's `SQL Editor` and run the "User Management Starter" quickstart. On the `SQL editor` page, scroll down until you see `User Management Starter: Sets up a public Profiles table which you can access with your API`. Click that, then click `RUN` to execute that query and create a new `profiles` table. When that's finished, head over to the `Table Editor` and see your new `profiles` table.
### 3. Get the URL and Key
Go to the Project Settings (the cog icon), open the API tab, and find your API URL and `anon` key, you'll need these in the next step.
The `anon` key is your client-side API key. It allows "anonymous access" to your database, until the user has logged in. Once they have logged in, the keys will switch to the user's own login token. This enables row level security for your data. Read more about this [below](#postgres-row-level-security).
![image](https://user-images.githubusercontent.com/10214025/88916245-528c2680-d298-11ea-8a71-708f93e1ce4f.png)
**_NOTE_**: The `service_role` key has full access to your data, bypassing any security policies. These keys have to be kept secret and are meant to be used in server environments and never on a client or browser.
### 4. Env vars
Create a file in this folder `.env.local`
```
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
```
Populate this file with your URL and Key.
### 5. Run the application
Run the application: `npm run dev`. Open your browser to `https://localhost:3000/` and you are ready to go 🚀.
## Supabase details
### Postgres Row level security
This project uses very high-level Authorization using Postgres' Row Level Security.
When you start a Postgres database on Supabase, we populate it with an `auth` schema, and some helper functions.
When a user logs in, they are issued a JWT with the role `authenticated` and their UUID.
We can use these details to provide fine-grained control over what each user can and cannot do.
This is a trimmed-down schema, with the policies:
```sql
-- Create a table for public profiles
create table profiles (
id uuid references auth.users not null primary key,
updated_at timestamp with time zone,
username text unique,
full_name text,
avatar_url text,
website text,
constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
alter table profiles
enable row level security;
create policy "Public profiles are viewable by everyone." on profiles
for select using (true);
create policy "Users can insert their own profile." on profiles
for insert with check ((select auth.uid()) = id);
create policy "Users can update own profile." on profiles
for update using ((select auth.uid()) = id);
-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
-- Set up access controls for storage.
-- See https://supabase.com/docs/guides/storage#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars');
create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
create policy "Anyone can update their own avatar." on storage.objects
for update using ( auth.uid() = owner ) with check (bucket_id = 'avatars');
```
## More Supabase Examples & Resources
## Examples
These official examples are maintained by the Supabase team:
- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments)
- [Next.js Slack Clone](https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone)
- [Next.js 13 Data Fetching](https://github.com/supabase/supabase/tree/master/examples/caching/with-nextjs-13)
- [And more...](https://github.com/supabase/supabase/tree/master/examples)
## Other resources
- [[Docs] Next.js User Management Quickstart](https://supabase.com/docs/guides/getting-started/tutorials/with-nextjs)
- [[Egghead.io] Build a SaaS product with Next.js, Supabase and Stripe](https://egghead.io/courses/build-a-saas-product-with-next-js-supabase-and-stripe-61f2bc20)
- [[Blog] Fetching and caching Supabase data in Next.js 13 Server Components](https://supabase.com/blog/fetching-and-caching-supabase-data-in-next-js-server-components)
## Authors
- [Supabase](https://supabase.com)
Supabase is open source. We'd love for you to follow along and get involved at https://github.com/supabase/supabase

View File

@@ -0,0 +1,139 @@
'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 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)
}
} catch (error) {
alert('Error loading user data!')
} finally {
setLoading(false)
}
}, [user, supabase])
useEffect(() => {
getProfile()
}, [user, getProfile])
async function updateProfile({
username,
website,
avatar_url,
}: {
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
alert('Profile updated!')
} catch (error) {
alert('Error updating the data!')
} finally {
setLoading(false)
}
}
return (
<div className="form-widget">
<Avatar
uid={user?.id ?? null}
url={avatar_url}
size={150}
onUpload={(url) => {
setAvatarUrl(url)
updateProfile({ fullname, username, website, avatar_url: url })
}}
/>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="text" value={user?.email} disabled />
</div>
<div>
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
type="text"
value={fullname || ''}
onChange={(e) => setFullname(e.target.value)}
/>
</div>
<div>
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username || ''}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="website">Website</label>
<input
id="website"
type="url"
value={website || ''}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
<div>
<button
className="button primary block"
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
disabled={loading}
>
{loading ? 'Loading ...' : 'Update'}
</button>
</div>
<div>
<form action="/auth/signout" method="post">
<button className="button block" type="submit">
Sign out
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import React, { useEffect, useState } from 'react'
import { createClient } from '@/utils/supabase/client'
import Image from 'next/image'
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)
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
}
onUpload(filePath)
} catch (error) {
alert('Error uploading avatar!')
} finally {
setUploading(false)
}
}
return (
<div>
{avatarUrl ? (
<Image
width={size}
height={size}
src={avatarUrl}
alt="Avatar"
className="avatar image"
style={{ height: size, width: size }}
/>
) : (
<div className="avatar no-image" style={{ height: size, width: size }} />
)}
<div style={{ width: size }}>
<label className="button primary block" htmlFor="single">
{uploading ? 'Uploading ...' : 'Upload'}
</label>
<input
style={{
visibility: 'hidden',
position: 'absolute',
}}
type="file"
id="single"
accept="image/*"
onChange={uploadAvatar}
disabled={uploading}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import AccountForm from './account-form'
import { createClient } from '@/utils/supabase/server'
export default async function Account() {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return <AccountForm user={user} />
}

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 = createClient()
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
})
if (!error) {
redirectTo.searchParams.delete('next')
return NextResponse.redirect(redirectTo)
}
}
// return the user to an error page with some instructions
redirectTo.pathname = '/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 = 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

@@ -0,0 +1,50 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json }
| Json[]
export interface Database {
public: {
Tables: {
profiles: {
Row: {
id: string
updated_at: string | null
username: string | null
full_name: string | null
avatar_url: string | null
website: string | null
}
Insert: {
id: string
updated_at?: string | null
username?: string | null
full_name?: string | null
avatar_url?: string | null
website?: string | null
}
Update: {
id?: string
updated_at?: string | null
username?: string | null
full_name?: string | null
avatar_url?: string | null
website?: string | null
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
}
}

View File

@@ -0,0 +1,3 @@
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,372 @@
html,
body {
--custom-font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--custom-bg-color: #101010;
--custom-panel-color: #222;
--custom-box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.8);
--custom-color: #fff;
--custom-color-brand: #24b47e;
--custom-color-secondary: #666;
--custom-border: 1px solid #333;
--custom-border-radius: 5px;
--custom-spacing: 5px;
padding: 0;
margin: 0;
font-family: var(--custom-font-family);
background-color: var(--custom-bg-color);
}
* {
color: var(--custom-color);
font-family: var(--custom-font-family);
box-sizing: border-box;
}
html,
body,
#__next {
height: 100vh;
width: 100vw;
overflow-x: hidden;
}
/* Grid */
.container {
width: 90%;
margin-left: auto;
margin-right: auto;
}
.row {
position: relative;
width: 100%;
}
.row [class^="col"] {
float: left;
margin: 0.5rem 2%;
min-height: 0.125rem;
}
.col-1,
.col-2,
.col-3,
.col-4,
.col-5,
.col-6,
.col-7,
.col-8,
.col-9,
.col-10,
.col-11,
.col-12 {
width: 96%;
}
.col-1-sm {
width: 4.33%;
}
.col-2-sm {
width: 12.66%;
}
.col-3-sm {
width: 21%;
}
.col-4-sm {
width: 29.33%;
}
.col-5-sm {
width: 37.66%;
}
.col-6-sm {
width: 46%;
}
.col-7-sm {
width: 54.33%;
}
.col-8-sm {
width: 62.66%;
}
.col-9-sm {
width: 71%;
}
.col-10-sm {
width: 79.33%;
}
.col-11-sm {
width: 87.66%;
}
.col-12-sm {
width: 96%;
}
.row::after {
content: "";
display: table;
clear: both;
}
.hidden-sm {
display: none;
}
@media only screen and (min-width: 33.75em) {
/* 540px */
.container {
width: 80%;
}
}
@media only screen and (min-width: 45em) {
/* 720px */
.col-1 {
width: 4.33%;
}
.col-2 {
width: 12.66%;
}
.col-3 {
width: 21%;
}
.col-4 {
width: 29.33%;
}
.col-5 {
width: 37.66%;
}
.col-6 {
width: 46%;
}
.col-7 {
width: 54.33%;
}
.col-8 {
width: 62.66%;
}
.col-9 {
width: 71%;
}
.col-10 {
width: 79.33%;
}
.col-11 {
width: 87.66%;
}
.col-12 {
width: 96%;
}
.hidden-sm {
display: block;
}
}
@media only screen and (min-width: 60em) {
/* 960px */
.container {
width: 75%;
max-width: 60rem;
}
}
/* Forms */
label {
display: block;
margin: 5px 0;
color: var(--custom-color-secondary);
font-size: 0.8rem;
text-transform: uppercase;
}
input {
width: 100%;
border-radius: 5px;
border: var(--custom-border);
padding: 8px;
font-size: 0.9rem;
background-color: var(--custom-bg-color);
color: var(--custom-color);
}
input[disabled] {
color: var(--custom-color-secondary);
}
/* Utils */
.block {
display: block;
width: 100%;
}
.inline-block {
display: inline-block;
width: 100%;
}
.flex {
display: flex;
}
.flex.column {
flex-direction: column;
}
.flex.row {
flex-direction: row;
}
.flex.flex-1 {
flex: 1 1 0;
}
.flex-end {
justify-content: flex-end;
}
.flex-center {
justify-content: center;
}
.items-center {
align-items: center;
}
.text-sm {
font-size: 0.8rem;
font-weight: 300;
}
.text-right {
text-align: right;
}
.font-light {
font-weight: 300;
}
.opacity-half {
opacity: 50%;
}
/* Button */
button,
.button {
color: var(--custom-color);
border: var(--custom-border);
background-color: var(--custom-bg-color);
display: inline-block;
text-align: center;
border-radius: var(--custom-border-radius);
padding: 0.5rem 1rem;
cursor: pointer;
text-align: center;
font-size: 0.9rem;
text-transform: uppercase;
}
button.primary,
.button.primary {
background-color: var(--custom-color-brand);
border: 1px solid var(--custom-color-brand);
}
/* Widgets */
.card {
width: 100%;
display: block;
border: var(--custom-border);
border-radius: var(--custom-border-radius);
padding: var(--custom-spacing);
}
.avatar {
border-radius: var(--custom-border-radius);
overflow: hidden;
max-width: 100%;
}
.avatar.image {
object-fit: cover;
}
.avatar.no-image {
background-color: #333;
border: 1px solid rgb(200, 200, 200);
border-radius: 5px;
}
.footer {
position: absolute;
max-width: 100%;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-flow: row;
border-top: var(--custom-border);
background-color: var(--custom-bg-color);
}
.footer div {
padding: var(--custom-spacing);
display: flex;
align-items: center;
width: 100%;
}
.footer div > img {
height: 20px;
margin-left: 10px;
}
.footer > div:first-child {
display: none;
}
.footer > div:nth-child(2) {
justify-content: left;
}
@media only screen and (min-width: 60em) {
/* 960px */
.footer > div:first-child {
display: flex;
}
.footer > div:nth-child(2) {
justify-content: center;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.mainHeader {
width: 100%;
font-size: 1.3rem;
margin-bottom: 20px;
}
.avatarPlaceholder {
border: var(--custom-border);
border-radius: var(--custom-border-radius);
width: 35px;
height: 35px;
background-color: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.form-widget {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-widget > .button {
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: #444444;
text-transform: none !important;
transition: all 0.2s ease;
}
.form-widget .button:hover {
background-color: #2a2a2a;
}
.form-widget .button > .loader {
width: 17px;
animation: spin 1s linear infinite;
filter: invert(1);
}

View File

@@ -0,0 +1,18 @@
import './globals.css'
export const metadata = {
title: 'User Management',
description: 'Generated by create next app',
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div className="container" style={{ padding: '50px 0 100px 0' }}>
{children}
</div>
</body>
</html>
)
}

View File

@@ -0,0 +1,46 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const supabase = createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/account')
}
export async function signup(formData: FormData) {
const supabase = createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/account')
}

View File

@@ -0,0 +1,14 @@
import { login, signup } from './actions'
export default function LoginPage() {
return (
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
)
}

View File

@@ -0,0 +1,229 @@
.main {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 6rem;
min-height: 100vh;
}
.description {
display: inherit;
justify-content: inherit;
align-items: inherit;
font-size: 0.85rem;
max-width: var(--max-width);
width: 100%;
z-index: 2;
font-family: var(--font-mono);
}
.description a {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.description p {
position: relative;
margin: 0;
padding: 1rem;
background-color: rgba(var(--callout-rgb), 0.5);
border: 1px solid rgba(var(--callout-border-rgb), 0.3);
border-radius: var(--border-radius);
}
.code {
font-weight: 700;
font-family: var(--font-mono);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(25%, auto));
width: var(--max-width);
max-width: 100%;
}
.card {
padding: 1rem 1.2rem;
border-radius: var(--border-radius);
background: rgba(var(--card-rgb), 0);
border: 1px solid rgba(var(--card-border-rgb), 0);
transition: background 200ms, border 200ms;
}
.card span {
display: inline-block;
transition: transform 200ms;
}
.card h2 {
font-weight: 600;
margin-bottom: 0.7rem;
}
.card p {
margin: 0;
opacity: 0.6;
font-size: 0.9rem;
line-height: 1.5;
max-width: 30ch;
}
.center {
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 4rem 0;
}
.center::before {
background: var(--secondary-glow);
border-radius: 50%;
width: 480px;
height: 360px;
margin-left: -400px;
}
.center::after {
background: var(--primary-glow);
width: 240px;
height: 180px;
z-index: -1;
}
.center::before,
.center::after {
content: '';
left: 50%;
position: absolute;
filter: blur(45px);
transform: translateZ(0);
}
.logo {
position: relative;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
.card:hover {
background: rgba(var(--card-rgb), 0.1);
border: 1px solid rgba(var(--card-border-rgb), 0.15);
}
.card:hover span {
transform: translateX(4px);
}
}
@media (prefers-reduced-motion) {
.card:hover span {
transform: none;
}
}
/* Mobile */
@media (max-width: 700px) {
.content {
padding: 4rem;
}
.grid {
grid-template-columns: 1fr;
margin-bottom: 120px;
max-width: 320px;
text-align: center;
}
.card {
padding: 1rem 2.5rem;
}
.card h2 {
margin-bottom: 0.5rem;
}
.center {
padding: 8rem 0 6rem;
}
.center::before {
transform: none;
height: 300px;
}
.description {
font-size: 0.8rem;
}
.description a {
padding: 1rem;
}
.description p,
.description div {
display: flex;
justify-content: center;
position: fixed;
width: 100%;
}
.description p {
align-items: center;
inset: 0 0 auto;
padding: 2rem 1rem 1.4rem;
border-radius: 0;
border: none;
border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
background: linear-gradient(
to bottom,
rgba(var(--background-start-rgb), 1),
rgba(var(--callout-rgb), 0.5)
);
background-clip: padding-box;
backdrop-filter: blur(24px);
}
.description div {
align-items: flex-end;
pointer-events: none;
inset: auto 0 0;
padding: 2rem;
height: 200px;
background: linear-gradient(
to bottom,
transparent 0%,
rgb(var(--background-end-rgb)) 40%
);
z-index: 1;
}
}
/* Tablet and Smaller Desktop */
@media (min-width: 701px) and (max-width: 1120px) {
.grid {
grid-template-columns: repeat(2, 50%);
}
}
@media (prefers-color-scheme: dark) {
.vercelLogo {
filter: invert(1);
}
.logo {
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
}
}
@keyframes rotate {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export default function Home() {
return (
<div className="row">
<div className="col-12">
<h1 className="header">Supabase Auth + Storage</h1>
<p>
Experience our Auth and Storage through a simple profile management example. Create a user
profile and upload an avatar image. Fast, simple, secure.
</p>
</div>
<div className="col-6 form-widget">
<Link href="/login">Auth page</Link>
</div>
</div>
)
}

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)$).*)',
],
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "nextjs-user-management",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2",
"@types/node": "20.1.4",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"encoding": "^0.1.13",
"eslint": "8.40.0",
"eslint-config-next": "13.4.2",
"next": "14.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.0.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,3 @@
# Supabase
.branches
.temp

View File

@@ -0,0 +1,6 @@
<h2>Sign up confirmation</h2>
<p>Thank you for signing up! To complete the registration process and gain access to our platform, please click the link below to confirm your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email address</a></p>

View File

@@ -0,0 +1,4 @@
<h2>Magic Link</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>

View File

@@ -0,0 +1,77 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the working
# directory name when running `supabase init`.
project_id = "nextjs-user-management"
[api]
# 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 storage are always included.
schemas = []
# Extra schemas to add to the search_path of every request.
extra_search_path = ["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
[db]
# Port to use for the local database URL.
port = 54322
# 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 = 14
[studio]
# Port to use for Supabase Studio.
port = 54323
# 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]
# Port to use for the email testing server web interface.
port = 54324
smtp_port = 54325
pop3_port = 54326
[storage]
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one
# week).
jwt_expiry = 3600
# Allow/disallow new user signups to your project.
enable_signup = true
[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
[auth.email.template.confirmation]
subject = "Confirm Your Email"
content_path = "./supabase/auth/email/confirmation.html"
[auth.email.template.magic_link]
subject = "Your Magic Link"
content_path = "./supabase/auth/email/magic-link.html"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`.
[auth.external.apple]
enabled = false
client_id = ""
secret = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""

View File

@@ -0,0 +1,53 @@
-- Create a table for public profiles
create table profiles (
id uuid references auth.users not null primary key,
updated_at timestamp with time zone,
username text unique,
full_name text,
avatar_url text,
website text,
constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
alter table profiles
enable row level security;
create policy "Public profiles are viewable by everyone." on profiles
for select using (true);
create policy "Users can insert their own profile." on profiles
for insert with check (auth.uid() = id);
create policy "Users can update own profile." on profiles
for update using (auth.uid() = id);
-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
-- Set up access controls for storage.
-- See https://supabase.com/docs/guides/storage#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars');
create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
create policy "Anyone can update their own avatar." on storage.objects
for update using ( auth.uid() = owner ) with check (bucket_id = 'avatars');

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

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_ANON_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_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// 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 function createClient() {
const cookieStore = 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_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}