B2B-88: add starter kit structure and elements
This commit is contained in:
14
packages/supabase/README.md
Normal file
14
packages/supabase/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Supabase - @kit/supabase
|
||||
|
||||
This package is responsible for managing the Supabase client and various utilities related to Supabase.
|
||||
|
||||
Make sure the app installs the `@kit/supabase` package before using it.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-app",
|
||||
"dependencies": {
|
||||
"@kit/supabase": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
3
packages/supabase/eslint.config.mjs
Normal file
3
packages/supabase/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
17
packages/supabase/node_modules/.bin/next
generated
vendored
Executable file
17
packages/supabase/node_modules/.bin/next
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../next/dist/bin/next" "$@"
|
||||
fi
|
||||
1
packages/supabase/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/eslint
|
||||
1
packages/supabase/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/prettier
|
||||
1
packages/supabase/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/typescript
|
||||
1
packages/supabase/node_modules/@supabase/ssr
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@supabase/ssr
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@supabase+ssr@0.6.1_@supabase+supabase-js@2.49.4/node_modules/@supabase/ssr
|
||||
1
packages/supabase/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js
|
||||
1
packages/supabase/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@tanstack/react-query
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@tanstack+react-query@5.76.1_react@19.1.0/node_modules/@tanstack/react-query
|
||||
1
packages/supabase/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/supabase/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/supabase/node_modules/next
generated
vendored
Symbolic link
1
packages/supabase/node_modules/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next
|
||||
1
packages/supabase/node_modules/react
generated
vendored
Symbolic link
1
packages/supabase/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/supabase/node_modules/server-only
generated
vendored
Symbolic link
1
packages/supabase/node_modules/server-only
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/server-only@0.0.1/node_modules/server-only
|
||||
1
packages/supabase/node_modules/zod
generated
vendored
Symbolic link
1
packages/supabase/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
43
packages/supabase/package.json
Normal file
43
packages/supabase/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@kit/supabase",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
"./server-client": "./src/clients/server-client.ts",
|
||||
"./server-admin-client": "./src/clients/server-admin-client.ts",
|
||||
"./middleware-client": "./src/clients/middleware-client.ts",
|
||||
"./browser-client": "./src/clients/browser-client.ts",
|
||||
"./check-requires-mfa": "./src/check-requires-mfa.ts",
|
||||
"./require-user": "./src/require-user.ts",
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
"./database": "./src/database.types.ts",
|
||||
"./auth": "./src/auth.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "2.49.4",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"@types/react": "19.1.4",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
292
packages/supabase/src/auth-callback.service.ts
Normal file
292
packages/supabase/src/auth-callback.service.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import 'server-only';
|
||||
|
||||
import {
|
||||
AuthError,
|
||||
type EmailOtpType,
|
||||
SupabaseClient,
|
||||
} from '@supabase/supabase-js';
|
||||
|
||||
/**
|
||||
* @name createAuthCallbackService
|
||||
* @description Creates an instance of the AuthCallbackService
|
||||
* @param client
|
||||
*/
|
||||
export function createAuthCallbackService(client: SupabaseClient) {
|
||||
return new AuthCallbackService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name AuthCallbackService
|
||||
* @description Service for handling auth callbacks in Supabase
|
||||
*/
|
||||
class AuthCallbackService {
|
||||
constructor(private readonly client: SupabaseClient) {}
|
||||
|
||||
/**
|
||||
* @name verifyTokenHash
|
||||
* @description Verifies the token hash and type and redirects the user to the next page
|
||||
* This should be used when using a token hash to verify the user's email
|
||||
* @param request
|
||||
* @param params
|
||||
*/
|
||||
async verifyTokenHash(
|
||||
request: Request,
|
||||
params: {
|
||||
joinTeamPath: string;
|
||||
redirectPath: string;
|
||||
errorPath?: string;
|
||||
},
|
||||
): Promise<URL> {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
const host = request.headers.get('host');
|
||||
|
||||
// set the host to the request host since outside of Vercel it gets set as "localhost" or "0.0.0.0"
|
||||
this.adjustUrlHostForLocalDevelopment(url, host);
|
||||
|
||||
url.pathname = params.redirectPath;
|
||||
|
||||
const token_hash = searchParams.get('token_hash');
|
||||
const type = searchParams.get('type') as EmailOtpType | null;
|
||||
const callbackParam =
|
||||
searchParams.get('next') ?? searchParams.get('callback');
|
||||
|
||||
let nextPath: string | null = null;
|
||||
const callbackUrl = callbackParam ? new URL(callbackParam) : null;
|
||||
|
||||
// if we have a callback url, we check if it has a next path
|
||||
if (callbackUrl) {
|
||||
// if we have a callback url, we check if it has a next path
|
||||
const callbackNextPath = callbackUrl.searchParams.get('next');
|
||||
|
||||
// if we have a next path in the callback url, we use that
|
||||
if (callbackNextPath) {
|
||||
nextPath = callbackNextPath;
|
||||
} else {
|
||||
nextPath = callbackUrl.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
const inviteToken = callbackUrl?.searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
// remove the query params from the url
|
||||
searchParams.delete('token_hash');
|
||||
searchParams.delete('type');
|
||||
searchParams.delete('next');
|
||||
|
||||
// if we have a next path, we redirect to that path
|
||||
if (nextPath) {
|
||||
url.pathname = nextPath;
|
||||
}
|
||||
|
||||
// if we have an invite token, we append it to the redirect url
|
||||
if (inviteToken) {
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
url.pathname = params.joinTeamPath;
|
||||
searchParams.set('invite_token', inviteToken);
|
||||
|
||||
const emailParam = callbackUrl?.searchParams.get('email');
|
||||
|
||||
if (emailParam) {
|
||||
searchParams.set('email', emailParam);
|
||||
}
|
||||
}
|
||||
|
||||
if (token_hash && type) {
|
||||
const { error } = await this.client.auth.verifyOtp({
|
||||
type,
|
||||
token_hash,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
url.searchParams.set('code', error.code);
|
||||
}
|
||||
|
||||
const errorMessage = getAuthErrorMessage({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
|
||||
url.searchParams.set('error', errorMessage);
|
||||
}
|
||||
|
||||
// return the user to an error page with some instructions
|
||||
url.pathname = errorPath;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name exchangeCodeForSession
|
||||
* @description Exchanges the auth code for a session and redirects the user to the next page or an error page
|
||||
* @param request
|
||||
* @param params
|
||||
*/
|
||||
async exchangeCodeForSession(
|
||||
request: Request,
|
||||
params: {
|
||||
joinTeamPath: string;
|
||||
redirectPath: string;
|
||||
errorPath?: string;
|
||||
},
|
||||
): Promise<{
|
||||
nextPath: string;
|
||||
}> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const searchParams = requestUrl.searchParams;
|
||||
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const nextUrlPathFromParams = searchParams.get('next');
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
||||
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
if (inviteToken) {
|
||||
const emailParam = searchParams.get('email');
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: emailParam ?? '',
|
||||
});
|
||||
|
||||
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
if (authCode) {
|
||||
try {
|
||||
const { error } =
|
||||
await this.client.auth.exchangeCodeForSession(authCode);
|
||||
|
||||
// if we have an error, we redirect to the error page
|
||||
if (error) {
|
||||
return onError({
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
path: errorPath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while exchanging code for session`,
|
||||
);
|
||||
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return onError({
|
||||
code: (error as AuthError)?.code,
|
||||
error: message as string,
|
||||
path: errorPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onError({
|
||||
error,
|
||||
path: errorPath,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
nextPath: nextUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
url.host = host as string;
|
||||
url.port = '';
|
||||
}
|
||||
}
|
||||
|
||||
private isLocalhost(host: string | null) {
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
host.includes('localhost:') ||
|
||||
host.includes('0.0.0.0:') ||
|
||||
host.includes('127.0.0.1:')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onError({
|
||||
error,
|
||||
path,
|
||||
code,
|
||||
}: {
|
||||
error: string;
|
||||
path: string;
|
||||
code?: string;
|
||||
}) {
|
||||
const errorMessage = getAuthErrorMessage({ error, code });
|
||||
|
||||
console.error(
|
||||
{
|
||||
error,
|
||||
name: `auth.callback`,
|
||||
},
|
||||
`An error occurred while signing user in`,
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
error: errorMessage,
|
||||
code: code ?? '',
|
||||
});
|
||||
|
||||
const nextPath = `${path}?${searchParams.toString()}`;
|
||||
|
||||
return {
|
||||
nextPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given error message indicates a verifier error.
|
||||
* We check for this specific error because it's highly likely that the
|
||||
* user is trying to sign in using a different browser than the one they
|
||||
* used to request the sign in link. This is a common mistake, so we
|
||||
* want to provide a helpful error message.
|
||||
*/
|
||||
function isVerifierError(error: string) {
|
||||
return error.includes('both auth code and code verifier should be non-empty');
|
||||
}
|
||||
|
||||
function getAuthErrorMessage(params: { error: string; code?: string }) {
|
||||
// this error arises when the user tries to sign in with an expired email link
|
||||
if (params.code) {
|
||||
if (params.code === 'otp_expired') {
|
||||
return 'auth:errors.otp_expired';
|
||||
}
|
||||
}
|
||||
|
||||
// this error arises when the user is trying to sign in with a different
|
||||
// browser than the one they used to request the sign in link
|
||||
if (isVerifierError(params.error)) {
|
||||
return 'auth:errors.codeVerifierMismatch';
|
||||
}
|
||||
|
||||
// fallback to the default error message
|
||||
return `auth:authenticationErrorAlertBody`;
|
||||
}
|
||||
1
packages/supabase/src/auth.ts
Normal file
1
packages/supabase/src/auth.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth-callback.service';
|
||||
31
packages/supabase/src/check-requires-mfa.ts
Normal file
31
packages/supabase/src/check-requires-mfa.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const ASSURANCE_LEVEL_2 = 'aal2';
|
||||
|
||||
/**
|
||||
* @name checkRequiresMultiFactorAuthentication
|
||||
* @description Checks if the current session requires multi-factor authentication.
|
||||
* We do it by checking that the next assurance level is AAL2 and that the current assurance level is not AAL2.
|
||||
* @param client
|
||||
*/
|
||||
export async function checkRequiresMultiFactorAuthentication(
|
||||
client: SupabaseClient,
|
||||
) {
|
||||
// Suppress the getSession warning. Remove when the issue is fixed.
|
||||
// https://github.com/supabase/auth-js/issues/873
|
||||
// @ts-expect-error: suppressGetSessionWarning is not part of the public API
|
||||
client.auth.suppressGetSessionWarning = true;
|
||||
|
||||
const assuranceLevel = await client.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
// @ts-expect-error: suppressGetSessionWarning is not part of the public API
|
||||
client.auth.suppressGetSessionWarning = false;
|
||||
|
||||
if (assuranceLevel.error) {
|
||||
throw new Error(assuranceLevel.error.message);
|
||||
}
|
||||
|
||||
const { nextLevel, currentLevel } = assuranceLevel.data;
|
||||
|
||||
return nextLevel === ASSURANCE_LEVEL_2 && nextLevel !== currentLevel;
|
||||
}
|
||||
14
packages/supabase/src/clients/browser-client.ts
Normal file
14
packages/supabase/src/clients/browser-client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createBrowserClient } from '@supabase/ssr';
|
||||
|
||||
import { Database } from '../database.types';
|
||||
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
|
||||
/**
|
||||
* @name getSupabaseBrowserClient
|
||||
* @description Get a Supabase client for use in the Browser
|
||||
*/
|
||||
export function getSupabaseBrowserClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createBrowserClient<GenericSchema>(keys.url, keys.anonKey);
|
||||
}
|
||||
38
packages/supabase/src/clients/middleware-client.ts
Normal file
38
packages/supabase/src/clients/middleware-client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'server-only';
|
||||
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
|
||||
import { Database } from '../database.types';
|
||||
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
|
||||
/**
|
||||
* Creates a middleware client for Supabase.
|
||||
*
|
||||
* @param {NextRequest} request - The Next.js request object.
|
||||
* @param {NextResponse} response - The Next.js response object.
|
||||
*/
|
||||
export function createMiddlewareClient<GenericSchema = Database>(
|
||||
request: NextRequest,
|
||||
response: NextResponse,
|
||||
) {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value),
|
||||
);
|
||||
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
response.cookies.set(name, value, options),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
28
packages/supabase/src/clients/server-admin-client.ts
Normal file
28
packages/supabase/src/clients/server-admin-client.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '../database.types';
|
||||
import {
|
||||
getServiceRoleKey,
|
||||
warnServiceRoleKeyUsage,
|
||||
} from '../get-service-role-key';
|
||||
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
|
||||
/**
|
||||
* @name getSupabaseServerAdminClient
|
||||
* @description Get a Supabase client for use in the Server with admin access to the database.
|
||||
*/
|
||||
export function getSupabaseServerAdminClient<GenericSchema = Database>() {
|
||||
warnServiceRoleKeyUsage();
|
||||
|
||||
const url = getSupabaseClientKeys().url;
|
||||
|
||||
return createClient<GenericSchema>(url, getServiceRoleKey(), {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
39
packages/supabase/src/clients/server-client.ts
Normal file
39
packages/supabase/src/clients/server-client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
|
||||
import { Database } from '../database.types';
|
||||
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
|
||||
/**
|
||||
* @name getSupabaseServerClient
|
||||
* @description Creates a Supabase client for use in the Server.
|
||||
*/
|
||||
export function getSupabaseServerClient<GenericSchema = Database>() {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
cookies: {
|
||||
async getAll() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
async setAll(cookiesToSet) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
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.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
1442
packages/supabase/src/database.types.ts
Normal file
1442
packages/supabase/src/database.types.ts
Normal file
File diff suppressed because it is too large
Load Diff
33
packages/supabase/src/get-service-role-key.ts
Normal file
33
packages/supabase/src/get-service-role-key.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'server-only';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const message =
|
||||
'Invalid Supabase Service Role Key. Please add the environment variable SUPABASE_SERVICE_ROLE_KEY.';
|
||||
|
||||
/**
|
||||
* @name getServiceRoleKey
|
||||
* @description Get the Supabase Service Role Key.
|
||||
* ONLY USE IN SERVER-SIDE CODE. DO NOT EXPOSE THIS TO CLIENT-SIDE CODE.
|
||||
*/
|
||||
export function getServiceRoleKey() {
|
||||
return z
|
||||
.string({
|
||||
required_error: message,
|
||||
})
|
||||
.min(1, {
|
||||
message: message,
|
||||
})
|
||||
.parse(process.env.SUPABASE_SERVICE_ROLE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a warning message if the Supabase Service Role is being used.
|
||||
*/
|
||||
export function warnServiceRoleKeyUsage() {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(
|
||||
`[Dev Only] This is a simple warning to let you know you are using the Supabase Service Role. Make sure it's the right call.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
packages/supabase/src/get-supabase-client-keys.ts
Normal file
24
packages/supabase/src/get-supabase-client-keys.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Returns and validates the Supabase client keys from the environment.
|
||||
*/
|
||||
export function getSupabaseClientKeys() {
|
||||
return z
|
||||
.object({
|
||||
url: z.string({
|
||||
description: `This is the URL of your hosted Supabase instance. Please provide the variable NEXT_PUBLIC_SUPABASE_URL.`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`,
|
||||
}),
|
||||
anonKey: z
|
||||
.string({
|
||||
description: `This is the anon key provided by Supabase. It is a public key used client-side. Please provide the variable NEXT_PUBLIC_SUPABASE_ANON_KEY.`,
|
||||
required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_ANON_KEY`,
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
url: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
});
|
||||
}
|
||||
82
packages/supabase/src/hooks/use-auth-change-listener.ts
Normal file
82
packages/supabase/src/hooks/use-auth-change-listener.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import type { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
/**
|
||||
* @name PRIVATE_PATH_PREFIXES
|
||||
* @description A list of private path prefixes
|
||||
*/
|
||||
const PRIVATE_PATH_PREFIXES = ['/home', '/admin', '/join', '/update-password'];
|
||||
|
||||
/**
|
||||
* @name AUTH_PATHS
|
||||
* @description A list of auth paths
|
||||
*/
|
||||
const AUTH_PATHS = ['/auth'];
|
||||
|
||||
/**
|
||||
* @name useAuthChangeListener
|
||||
* @param privatePathPrefixes - A list of private path prefixes
|
||||
* @param appHomePath - The path to redirect to when the user is signed out
|
||||
* @param onEvent - Callback function to be called when an auth event occurs
|
||||
*/
|
||||
export function useAuthChangeListener({
|
||||
privatePathPrefixes = PRIVATE_PATH_PREFIXES,
|
||||
appHomePath,
|
||||
onEvent,
|
||||
}: {
|
||||
appHomePath: string;
|
||||
privatePathPrefixes?: string[];
|
||||
onEvent?: (event: AuthChangeEvent, user: Session | null) => void;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const pathName = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// keep this running for the whole session unless the component was unmounted
|
||||
const listener = client.auth.onAuthStateChange((event, user) => {
|
||||
if (onEvent) {
|
||||
onEvent(event, user);
|
||||
}
|
||||
|
||||
// log user out if user is falsy
|
||||
// and if the current path is a private route
|
||||
const shouldRedirectUser =
|
||||
!user && isPrivateRoute(pathName, privatePathPrefixes);
|
||||
|
||||
if (shouldRedirectUser) {
|
||||
// send user away when signed out
|
||||
window.location.assign('/');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// revalidate user session when user signs in or out
|
||||
if (event === 'SIGNED_OUT') {
|
||||
// sometimes Supabase sends SIGNED_OUT event
|
||||
// but in the auth path, so we ignore it
|
||||
if (AUTH_PATHS.some((path) => pathName.startsWith(path))) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
// destroy listener on un-mounts
|
||||
return () => listener.data.subscription.unsubscribe();
|
||||
}, [client.auth, pathName, appHomePath, privatePathPrefixes, onEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given path is a private route.
|
||||
*/
|
||||
function isPrivateRoute(path: string, privatePathPrefixes: string[]) {
|
||||
return privatePathPrefixes.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
25
packages/supabase/src/hooks/use-fetch-mfa-factors.ts
Normal file
25
packages/supabase/src/hooks/use-fetch-mfa-factors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
import { useFactorsMutationKey } from './use-user-factors-mutation-key';
|
||||
|
||||
export function useFetchAuthFactors(userId: string) {
|
||||
const client = useSupabase();
|
||||
const queryKey = useFactorsMutationKey(userId);
|
||||
|
||||
const queryFn = async () => {
|
||||
const { data, error } = await client.auth.mfa.listFactors();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
42
packages/supabase/src/hooks/use-request-reset-password.ts
Normal file
42
packages/supabase/src/hooks/use-request-reset-password.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
interface RequestPasswordResetMutationParams {
|
||||
email: string;
|
||||
redirectTo: string;
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useRequestResetPassword
|
||||
* @description Requests a password reset for a user. This function will
|
||||
* trigger a password reset email to be sent to the user's email address.
|
||||
* After the user clicks the link in the email, they will be redirected to
|
||||
* /password-reset where their password can be updated.
|
||||
*/
|
||||
export function useRequestResetPassword() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'reset-password'];
|
||||
|
||||
const mutationFn = async (params: RequestPasswordResetMutationParams) => {
|
||||
const { error, data } = await client.auth.resetPasswordForEmail(
|
||||
params.email,
|
||||
{
|
||||
redirectTo: params.redirectTo,
|
||||
captchaToken: params.captchaToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SignInWithPasswordCredentials } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useSignInWithEmailPassword() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'sign-in-with-email-password'];
|
||||
|
||||
const mutationFn = async (credentials: SignInWithPasswordCredentials) => {
|
||||
const response = await client.auth.signInWithPassword(credentials);
|
||||
|
||||
if (response.error) {
|
||||
throw response.error.message;
|
||||
}
|
||||
|
||||
const user = response.data?.user;
|
||||
const identities = user?.identities ?? [];
|
||||
|
||||
// if the user has no identities, it means that the email is taken
|
||||
if (identities.length === 0) {
|
||||
throw new Error('User already registered');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({ mutationKey, mutationFn });
|
||||
}
|
||||
43
packages/supabase/src/hooks/use-sign-in-with-otp.ts
Normal file
43
packages/supabase/src/hooks/use-sign-in-with-otp.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SignInWithPasswordlessCredentials } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useSignInWithOtp() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'sign-in-with-otp'];
|
||||
|
||||
const mutationFn = async (credentials: SignInWithPasswordlessCredentials) => {
|
||||
const result = await client.auth.signInWithOtp(credentials);
|
||||
|
||||
if (result.error) {
|
||||
if (shouldIgnoreError(result.error.message)) {
|
||||
console.warn(
|
||||
`Ignoring error during development: ${result.error.message}`,
|
||||
);
|
||||
|
||||
return {} as never;
|
||||
}
|
||||
|
||||
throw result.error.message;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
|
||||
export default useSignInWithOtp;
|
||||
|
||||
function shouldIgnoreError(error: string) {
|
||||
return isSmsProviderNotSetupError(error);
|
||||
}
|
||||
|
||||
function isSmsProviderNotSetupError(error: string) {
|
||||
return error.includes(`sms Provider could not be found`);
|
||||
}
|
||||
25
packages/supabase/src/hooks/use-sign-in-with-provider.ts
Normal file
25
packages/supabase/src/hooks/use-sign-in-with-provider.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SignInWithOAuthCredentials } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useSignInWithProvider() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'sign-in-with-provider'];
|
||||
|
||||
const mutationFn = async (credentials: SignInWithOAuthCredentials) => {
|
||||
const response = await client.auth.signInWithOAuth(credentials);
|
||||
|
||||
if (response.error) {
|
||||
throw response.error.message;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
13
packages/supabase/src/hooks/use-sign-out.ts
Normal file
13
packages/supabase/src/hooks/use-sign-out.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useSignOut() {
|
||||
const client = useSupabase();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
return client.auth.signOut();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
emailRedirectTo: string;
|
||||
captchaToken?: string;
|
||||
}
|
||||
|
||||
export function useSignUpWithEmailAndPassword() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['auth', 'sign-up-with-email-password'];
|
||||
|
||||
const mutationFn = async (params: Credentials) => {
|
||||
const { emailRedirectTo, captchaToken, ...credentials } = params;
|
||||
|
||||
const response = await client.auth.signUp({
|
||||
...credentials,
|
||||
options: {
|
||||
emailRedirectTo,
|
||||
captchaToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error.message;
|
||||
}
|
||||
|
||||
const user = response.data?.user;
|
||||
const identities = user?.identities ?? [];
|
||||
|
||||
// if the user has no identities, it means that the email is taken
|
||||
if (identities.length === 0) {
|
||||
throw new Error('User already registered');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
8
packages/supabase/src/hooks/use-supabase.ts
Normal file
8
packages/supabase/src/hooks/use-supabase.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { getSupabaseBrowserClient } from '../clients/browser-client';
|
||||
import { Database } from '../database.types';
|
||||
|
||||
export function useSupabase<Db = Database>() {
|
||||
return useMemo(() => getSupabaseBrowserClient<Db>(), []);
|
||||
}
|
||||
31
packages/supabase/src/hooks/use-update-user-mutation.ts
Normal file
31
packages/supabase/src/hooks/use-update-user-mutation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { UserAttributes } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
type Params = UserAttributes & { redirectTo: string };
|
||||
|
||||
export function useUpdateUser() {
|
||||
const client = useSupabase();
|
||||
const mutationKey = ['supabase:user'];
|
||||
|
||||
const mutationFn = async (attributes: Params) => {
|
||||
const { redirectTo, ...params } = attributes;
|
||||
|
||||
const response = await client.auth.updateUser(params, {
|
||||
emailRedirectTo: redirectTo,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function useFactorsMutationKey(userId: string) {
|
||||
return ['mfa-factors', userId];
|
||||
}
|
||||
35
packages/supabase/src/hooks/use-user.ts
Normal file
35
packages/supabase/src/hooks/use-user.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
const queryKey = ['supabase:user'];
|
||||
|
||||
export function useUser(initialData?: User | null) {
|
||||
const client = useSupabase();
|
||||
|
||||
const queryFn = async () => {
|
||||
const response = await client.auth.getUser();
|
||||
|
||||
// this is most likely a session error or the user is not logged in
|
||||
if (response.error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.data?.user) {
|
||||
return response.data.user;
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Unexpected result format'));
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryFn,
|
||||
queryKey,
|
||||
initialData,
|
||||
refetchInterval: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
26
packages/supabase/src/hooks/use-verify-otp.ts
Normal file
26
packages/supabase/src/hooks/use-verify-otp.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VerifyOtpParams } from '@supabase/supabase-js';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useSupabase } from './use-supabase';
|
||||
|
||||
export function useVerifyOtp() {
|
||||
const client = useSupabase();
|
||||
|
||||
const mutationKey = ['verify-otp'];
|
||||
|
||||
const mutationFn = async (params: VerifyOtpParams) => {
|
||||
const { data, error } = await client.auth.verifyOtp(params);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
mutationKey,
|
||||
});
|
||||
}
|
||||
69
packages/supabase/src/require-user.ts
Normal file
69
packages/supabase/src/require-user.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { SupabaseClient, User } from '@supabase/supabase-js';
|
||||
|
||||
import { checkRequiresMultiFactorAuthentication } from './check-requires-mfa';
|
||||
|
||||
const MULTI_FACTOR_AUTH_VERIFY_PATH = '/auth/verify';
|
||||
const SIGN_IN_PATH = '/auth/sign-in';
|
||||
|
||||
/**
|
||||
* @name requireUser
|
||||
* @description Require a session to be present in the request
|
||||
* @param client
|
||||
*/
|
||||
export async function requireUser(client: SupabaseClient): Promise<
|
||||
| {
|
||||
error: null;
|
||||
data: User;
|
||||
}
|
||||
| (
|
||||
| {
|
||||
error: AuthenticationError;
|
||||
data: null;
|
||||
redirectTo: string;
|
||||
}
|
||||
| {
|
||||
error: MultiFactorAuthError;
|
||||
data: null;
|
||||
redirectTo: string;
|
||||
}
|
||||
)
|
||||
> {
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (!data.user || error) {
|
||||
return {
|
||||
data: null,
|
||||
error: new AuthenticationError(),
|
||||
redirectTo: SIGN_IN_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
const requiresMfa = await checkRequiresMultiFactorAuthentication(client);
|
||||
|
||||
// If the user requires multi-factor authentication,
|
||||
// redirect them to the page where they can verify their identity.
|
||||
if (requiresMfa) {
|
||||
return {
|
||||
data: null,
|
||||
error: new MultiFactorAuthError(),
|
||||
redirectTo: MULTI_FACTOR_AUTH_VERIFY_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
data: data.user,
|
||||
};
|
||||
}
|
||||
|
||||
class AuthenticationError extends Error {
|
||||
constructor() {
|
||||
super(`Authentication required`);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiFactorAuthError extends Error {
|
||||
constructor() {
|
||||
super(`Multi-factor authentication required`);
|
||||
}
|
||||
}
|
||||
8
packages/supabase/tsconfig.json
Normal file
8
packages/supabase/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user