Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a NextJS example using Supabase Auth with getContext #8469

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/framework-nextjs-supabase-auth/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SUPABASE_DATABASE_URL=<SUPABASE_DATABASE_URL>
NEXT_PUBLIC_SUPABASE_URL=<SUPABASE_URL>
SUPABASE_SERVICE_KEY=<SUPABASE_SERVICE_KEY>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<SUPABASE_ANON_KEY>
20 changes: 20 additions & 0 deletions examples/framework-nextjs-supabase-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Keystone in NextJS with Supabase and Supabase Auth

This is an example of using KeystoneJS with NextJS and Supabase Auth.

## Features

- KeystoneJS
- NextJS
- Supabase
- Supabase Auth

## Supabase Auth

This example uses Supabase auth to create and login the users. There is a `beforeOperation` hook on the Keystone user list to update the Supabase `app_metadata` for that user whenever a user is updated in Keystone.

# Information and Requirements

This repo using `pnpm` to get start install pnpm (https://pnpm.io/installation) and run `pnpm install` then `pnpm dev` to start. Once started go to http://localhost:3000. Use seed data or supabase to setup different users with their correct supabase `id`.

You will need to set your `DATABASE_URL` environment variable to your supabase postgres DB URL and the following will need to be set for Supabase: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, and `SUPABASE_SERVICE_KEY` - make sure you keep your `SUPABASE_SERVICE_KEY` private as this gives Super User access to your Supabase account.
24 changes: 24 additions & 0 deletions examples/framework-nextjs-supabase-auth/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { Header } from '../components/Header';
import SupabaseListener from '../components/SupabaseListener';
import SupabaseProvider from '../components/SupabaseProvider';
import { getKeystoneSessionContext } from '../keystone/context';

export default async function Layout({ children }: { children: React.ReactNode }) {
const context = await getKeystoneSessionContext();
const session = context.session;
return (
<html lang="en">
<body>
<div className="min-h-screen bg-gray-100">
<SupabaseProvider>
<Header user={session} />
<SupabaseListener serverAccessToken={session?.access_token} />

<main>{children}</main>
</SupabaseProvider>
</div>
</body>
</html>
);
}
31 changes: 31 additions & 0 deletions examples/framework-nextjs-supabase-auth/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { getKeystoneSessionContext } from '../keystone/context';

export default async function Page() {
const context = await getKeystoneSessionContext();
const posts = await context.query.Post.findMany({
query: 'id name content',
});

return (
<div>
<p>Open the console to see the output.</p>

<div>
<p>
<strong>Users fetched from the server (in app directory)</strong>
</p>
<ol>
{posts.map(p => {
return (
<li key={p.id}>
<span>{p.name} </span>
<span>(content: {p.content})</span>
</li>
);
})}
</ol>
</div>
</div>
);
}
77 changes: 77 additions & 0 deletions examples/framework-nextjs-supabase-auth/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';
import React, { useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useSupabase } from './SupabaseProvider';

export function Header({ user }: { user: { name: string } | null }) {
const router = useRouter();
const { supabase } = useSupabase();
const emailRef = useRef<HTMLInputElement | null>(null);
const passwordRef = useRef<HTMLInputElement | null>(null);

// We are using the supabase client to manage users
const login = async () => {
if (emailRef.current && passwordRef.current) {
const email = emailRef.current.value;
const password = passwordRef.current.value;

const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
console.log('error', error);
}
if (data?.user?.email) {
router.refresh();
}
}
};

const logout = async () => {
console.log('logout');
const { error } = await supabase.auth.signOut();
if (!error) {
router.refresh();
} else {
console.log('error', error);
}
};

if (!user) {
return (
<>
<div
style={{
height: '2rem',
display: 'flex',
gap: '1em',
alignItems: 'flex-end',
}}
>
<label>
email: <input name="email" type="email" ref={emailRef} placeholder="[email protected]" />
</label>
<label>
password:{' '}
<input name="password" type="password" ref={passwordRef} placeholder="passw0rd" />
</label>
<button onClick={login}>login</button>
</div>
</>
);
}

return (
<div
style={{
height: '2rem',
display: 'flex',
justifyContent: 'space-between',
}}
>
<div>Hello, {user.name}!</div>
<button onClick={logout}>logout</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useSupabase } from './SupabaseProvider';

export default function SupabaseListener({ serverAccessToken }: { serverAccessToken?: string }) {
const { supabase } = useSupabase();
const router = useRouter();

useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (session?.access_token !== serverAccessToken) {
router.refresh();
}
});

return () => {
subscription.unsubscribe();
};
}, [serverAccessToken, router, supabase]);

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import React from 'react';
import { createContext, useContext, useState } from 'react';
import type { SupabaseClient } from '@supabase/auth-helpers-nextjs';
import { createClient } from '../utils/supabase-browser';

type SupabaseContext = {
supabase: SupabaseClient<any>;
};

const Context = createContext<SupabaseContext | undefined>(undefined);

export default function SupabaseProvider({ children }: { children: React.ReactNode }) {
const [supabase] = useState(() => createClient());

return (
<Context.Provider value={{ supabase }}>
<>{children}</>
</Context.Provider>
);
}

export const useSupabase = () => {
let context = useContext(Context);
if (context === undefined) {
throw new Error('useSupabase must be used inside SupabaseProvider');
} else {
return context;
}
};
18 changes: 18 additions & 0 deletions examples/framework-nextjs-supabase-auth/keystone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import dotenv from 'dotenv';
import { config } from '@keystone-6/core';
import { lists } from './keystone/schema';
import { TypeInfo } from '.keystone/types';

dotenv.config();

export default config<TypeInfo>({
db: {
provider: 'postgresql',
url:
process.env.SUPABASE_DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/postgres',

// WARNING: this is only needed for our monorepo examples, dont do this
prismaClientPath: 'node_modules/.myprisma/client',
},
lists,
});
23 changes: 23 additions & 0 deletions examples/framework-nextjs-supabase-auth/keystone/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getContext } from '@keystone-6/core/context';
import { createServerComponentSupabaseClient } from '@supabase/auth-helpers-nextjs';
import config from '../keystone';
import { Context } from '.keystone/types';
import * as PrismaModule from '.myprisma/client';

// Making sure multiple prisma clients are not created during hot reloading
export const keystoneContext: Context =
(globalThis as any).keystoneContext || getContext(config, PrismaModule);

if (process.env.NODE_ENV !== 'production') (globalThis as any).keystoneContext = keystoneContext;

export async function getKeystoneSessionContext() {
// This is how you would do session context in pages directory
//const { data: rawSession } = await createServerSupabaseClient({req,res}).auth.getSession();
//return keystoneContext.withSession(rawSession.session?.user);
const { headers, cookies } = require('next/headers');
const { data: rawSession } = await createServerComponentSupabaseClient({
headers,
cookies,
}).auth.getSession();
return keystoneContext.withSession(rawSession.session?.user);
}
29 changes: 29 additions & 0 deletions examples/framework-nextjs-supabase-auth/keystone/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { list } from '@keystone-6/core';
import { allowAll, allOperations } from '@keystone-6/core/access';
import { text, timestamp } from '@keystone-6/core/fields';
import type { Lists } from '.keystone/types';

const permissions = {
authenticatedUser: ({ session }: any) => !!session,
};

export const lists: Lists = {
Post: list({
// readonly for demo purpose
access: {
operation: {
// Only Supabase Users can create, update, and delete
...allOperations<Lists.Post.TypeInfo>(permissions.authenticatedUser),
// override the deny and allow only query
query: allowAll,
},
},
fields: {
name: text({ validation: { isRequired: true } }),
content: text(),
createdAt: timestamp({
defaultValue: { kind: 'now' },
}),
},
}),
};
6 changes: 6 additions & 0 deletions examples/framework-nextjs-supabase-auth/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
13 changes: 13 additions & 0 deletions examples/framework-nextjs-supabase-auth/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// you don't need this if you're building something outside of the Keystone repo
const withPreconstruct = require('@preconstruct/next');

module.exports = withPreconstruct({
webpack(config) {
config.externals = [...config.externals, '.myprisma/client'];
return config;
},
experimental: {
appDir: true,
serverComponentsExternalPackages: ['graphql'],
},
});
32 changes: 32 additions & 0 deletions examples/framework-nextjs-supabase-auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@keystone-6/example-framework-nextjs-supabase-auth",
"scripts": {
"build": "keystone build --no-ui && next build",
"build:keystone": "keystone build",
"build:next": "next build",
"dev": "next dev",
"postinstall": "keystone postinstall",
"start:keystone": "keystone start",
"start": "next start"
},
"dependencies": {
"@keystone-6/core": "^5.0.0",
"@preconstruct/next": "^4.0.0",
"@prisma/client": "^4.15.0",
"@supabase/auth-helpers-nextjs": "^0.6.0",
"@supabase/supabase-js": "^2.17.0",
"dotenv": "^16.0.3",
"graphql": "^16.6.0",
"next": "^13.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^18.11.14",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"prisma": "^4.15.0",
"typescript": "~5.0.0"
},
"repository": "https://github.com/keystonejs/keystone/tree/main/examples/framework-nextjs-supabase-auth"
}
Binary file not shown.
Loading