Compare commits

...

18 Commits

Author SHA1 Message Date
Gib
7d7ed00c22 Add middleware to block misbehaving clients 2025-06-11 09:31:19 -05:00
Gib
42b07ea2da Move to tabs over spaces! 2025-06-09 09:57:27 -05:00
Gib
476d6c91b4 Update context imports for simplicity and consistency 2025-06-09 07:42:13 -05:00
Gib
6a6c0934d5 Got Apple Auth working 2025-06-08 18:31:35 -05:00
Gib
c47c43dc92 Committing because I really need to restart my laptop or something :( 2025-06-08 16:14:26 -05:00
Gib
5c5e992e7d Cleaned up some stuff. Messing with sign in button components. 2025-06-08 13:32:17 -05:00
Gib
23efcdee80 Adding Sentry is basically done now. We have a nice lil component to help us get started to! 2025-06-07 14:30:25 -05:00
Gib
eebc022928 Add sentry to template. Got a small error but we are gonna fix it soon! 2025-06-07 11:44:54 -05:00
Gib
930dc0867d Sign in with microsoft now works but sign out when signed in via microsoft does not 2025-06-06 16:35:28 -05:00
Gib
35e019558f Cleanup. Stuff from yesterday idk 2025-06-06 08:43:18 -05:00
Gib
a776c5a30a Minor changes 2025-06-05 09:24:39 -05:00
Gib
3e0c23054a Committing to hopefully figure out why photos aren't working in next.js 2025-06-04 23:16:12 -05:00
Gib
f51e78ed2f Start adding apple auth 2025-06-04 17:00:19 -05:00
Gib
ab7559555e Microsoft Login is mostly working now 2025-06-04 16:28:06 -05:00
Gib
04dceb93bd Reaching a finishing point! 2025-06-04 14:33:39 -05:00
Gib
bfb6e9e648 Fix bug in auth provider 2025-06-04 10:36:02 -05:00
Gib
e2f291e707 Made more client components to make the rendering not so crazy 2025-06-04 10:28:37 -05:00
Gib
ef24642128 update auth. deal with migraine 2025-06-03 17:00:53 -05:00
93 changed files with 7369 additions and 3471 deletions

View File

@ -1,11 +1,3 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js" # When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly. # should be updated accordingly.
@ -13,14 +5,26 @@
# SERVERVAR="foo" # SERVERVAR="foo"
# NEXT_PUBLIC_CLIENTVAR="bar" # NEXT_PUBLIC_CLIENTVAR="bar"
# Server Variables ### Server Variables ###
NODE_ENV= # Next Variables # Default Values:
#NODE_ENV= # development
#SKIP_ENV_VALIDATION= # false
# Sentry Variables # Default Values:
SENTRY_AUTH_TOKEN=
#CI= # true
# Client Variables ### Client Variables ###
# Next Variables # Default Values:
#NEXT_PUBLIC_SITE_URL= # http://localhost:3000
# Supabase Variables
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Sentry Variables
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_URL= # https://sentry.gbrown.org
# Script Variables # Default Values ### Script Variables ### These variables are only needed for our scripts, so do not add these to env.js! ###
# generateTypes # Default Values:
SUPABASE_DB_PASSWORD= SUPABASE_DB_PASSWORD=
#SUPABASE_DB_PORT= # 5432 #SUPABASE_DB_PORT= # 5432
#SUPABASE_DB_USER= # postgres #SUPABASE_DB_USER= # postgres

2
.gitignore vendored
View File

@ -44,3 +44,5 @@ yarn-error.log*
# idea files # idea files
.idea .idea
# Sentry Config File
.env.sentry-build-plugin

View File

@ -1,5 +1,6 @@
{ {
"singleQuote": true, "singleQuote": true,
"jsxSingleQuote": true, "jsxSingleQuote": true,
"trailingComma": "all" "trailingComma": "all",
"useTabs": true
} }

View File

@ -1,5 +1,6 @@
import { FlatCompat } from '@eslint/eslintrc'; import { FlatCompat } from '@eslint/eslintrc';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: import.meta.dirname, baseDirectory: import.meta.dirname,
@ -16,6 +17,7 @@ export default tseslint.config(
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended,
], ],
rules: { rules: {
'@typescript-eslint/array-type': 'off', '@typescript-eslint/array-type': 'off',
@ -33,6 +35,9 @@ export default tseslint.config(
'error', 'error',
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
}, },
}, },
{ {

View File

@ -1,8 +1,8 @@
/** /* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * This is especially useful for Docker builds.
* for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@ -15,11 +15,53 @@ const config = {
}, },
], ],
}, },
serverExternalPackages: ['require-in-the-middle'],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
}, },
}, },
turbopack: {
rules: {
'*.svg': {
loaders: [
{
loader: '@svgr/webpack',
options: {
icon: true,
},
},
],
as: '*.js',
},
},
},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

View File

@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"check": "next lint && tsc --noEmit", "check": "next lint && tsc --noEmit",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"dev:slow": "next dev",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint", "lint": "next lint",
@ -16,15 +17,16 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.1.0",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^9.27.0",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -33,30 +35,36 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.57.0",
"sonner": "^2.0.4", "require-in-the-middle": "^7.5.2",
"zod": "^3.25.42" "sonner": "^2.0.5",
"zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^20.17.57", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^20.19.0",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.6",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-next": "^15.3.3", "eslint-config-next": "^15.3.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"import-in-the-middle": "^1.14.0",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12", "prettier-plugin-tailwindcss": "^0.6.12",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.2", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.33.0" "typescript-eslint": "^8.33.1"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
} }

2816
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- '@sentry/cli'
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- sharp - sharp
- unrs-resolver - unrs-resolver

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

19
public/icons/apple.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#000000">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#F35325" d="M1 1h6.5v6.5H1V1z"/><path fill="#81BC06" d="M8.5 1H15v6.5H8.5V1z"/><path fill="#05A6F0" d="M1 8.5h6.5V15H1V8.5z"/><path fill="#FFBA08" d="M8.5 8.5H15V15H8.5V8.5z"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@ -3,6 +3,7 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@ -15,6 +16,7 @@ const config = {
}, },
], ],
}, },
serverExternalPackages: ['require-in-the-middle'],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
@ -26,6 +28,40 @@ const config = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

View File

@ -3,6 +3,7 @@
* for Docker builds. * for Docker builds.
*/ */
import './src/env.js'; import './src/env.js';
import { withSentryConfig } from '@sentry/nextjs';
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const config = { const config = {
@ -15,11 +16,46 @@ const config = {
}, },
], ],
}, },
serverExternalPackages: ['require-in-the-middle'],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
}, },
}, },
//turbopack: {
//rules: {
//'*.svg': {
//loaders: ['@svgr/webpack'],
//as: '*.js',
//},
//},
//},
}; };
export default config; const sentryConfig = {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: 'gib',
project: 't3-supabase-template',
sentryUrl: process.env.SENTRY_URL,
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Capture React Component Names
reactComponentAnnotation: {
enabled: true,
},
};
export default withSentryConfig(config, sentryConfig);

15
sentry.server.config.ts Normal file
View File

@ -0,0 +1,15 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://0468176d5291bc2b914261147bfef117@sentry.gbrown.org/6',
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,5 +1,4 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js'; import { type EmailOtpType } from '@supabase/supabase-js';
@ -7,13 +6,23 @@ import { type NextRequest } from 'next/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export const GET = async (request: NextRequest) => { export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url); const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const token_hash = searchParams.get('token'); const token_hash = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null; const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to') ?? '/'; const redirectTo = searchParams.get('redirect_to') ?? '/';
const supabase = await createServerClient();
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
console.error('OAuth error:', error);
return redirect(`/sign-in?error=${encodeURIComponent(error.message)}`);
}
return redirect(redirectTo);
}
if (token_hash && type) { if (token_hash && type) {
const supabase = await createServerClient();
const { error } = await supabase.auth.verifyOtp({ const { error } = await supabase.auth.verifyOtp({
type, type,
token_hash, token_hash,
@ -23,11 +32,12 @@ export const GET = async (request: NextRequest) => {
return redirect('/'); return redirect('/');
if (type === 'recovery' || type === 'email_change') if (type === 'recovery' || type === 'email_change')
return redirect('/profile'); return redirect('/profile');
if (type === 'invite') if (type === 'invite') return redirect('/sign-up');
return redirect('/sign-up');
else return redirect(`/?Could not identify type ${type as string}`)
} }
else return redirect(`/?${error.message}`); return redirect(
`/?error=${encodeURIComponent(error?.message || 'Unknown error')}`,
);
} }
return redirect('/'); return redirect('/');
}; };

View File

@ -1,77 +0,0 @@
'use server';
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import { type EmailOtpType } from '@supabase/supabase-js';
import { type NextRequest, NextResponse } from 'next/server';
export const GET = async (request: NextRequest) => {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const token = searchParams.get('token');
const type = searchParams.get('type') as EmailOtpType | null;
const redirectTo = searchParams.get('redirect_to')?.toString();
const supabase = await createServerClient();
if (token && type) {
try {
if (type === 'signup') {
// Confirm email signup
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'signup',
});
if (error) {
console.error('Email confirmation error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Invalid or expired confirmation link`);
}
} else if (type === 'recovery') {
// Handle password recovery
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'recovery',
});
if (error) {
console.error('Password recovery error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Invalid or expired reset link`);
} else {
return NextResponse.redirect(`${origin}/reset-password`);
}
} else if (type === 'email_change') {
// Handle email change
const { error } = await supabase.auth.verifyOtp({
token_hash: token,
type: 'email_change',
});
if (error) {
console.error('Email change error:', error);
return NextResponse.redirect(`${origin}/profile?error=Invalid or expired email change link`);
}
}
} catch (error) {
console.error('Verification error:', error);
return NextResponse.redirect(`${origin}/sign-in?error=Verification failed`);
}
}
// Handle code-based flow (OAuth, etc.)
if (code) {
await supabase.auth.exchangeCodeForSession(code);
}
// Handle redirect
if (redirectTo) {
try {
new URL(redirectTo);
return NextResponse.redirect(redirectTo);
} catch {
return NextResponse.redirect(`${origin}${redirectTo}`);
}
}
return NextResponse.redirect(origin);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
const AuthSuccessPage = () => {
const { refreshUserData } = useAuth();
const router = useRouter();
useEffect(() => {
const handleAuthSuccess = async () => {
// Refresh the auth context to pick up the new session
await refreshUserData();
// Small delay to ensure state is updated
setTimeout(() => {
router.push('/');
}, 100);
};
handleAuthSuccess().catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
});
}, [refreshUserData, router]);
// Show loading while processing
return (
<div className='flex items-center justify-center min-h-screen'>
<div className='flex flex-col items-center space-y-4'>
<Loader2 className='h-8 w-8 animate-spin' />
<p>Completing sign in...</p>
</div>
</div>
);
};
export default AuthSuccessPage;

View File

@ -1,35 +1,129 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@/components/ui';
import Link from 'next/link'; import Link from 'next/link';
import { forgotPassword } from '@/lib/actions'; import { forgotPassword } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { useRouter } from 'next/navigation';
import { Input, Label } from '@/components/ui'; import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
const formSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
});
const ForgotPassword = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
},
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleForgotPassword = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('email', values.email);
const result = await forgotPassword(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result?.data ?? 'Check your email for a link to reset your password.',
);
form.reset();
router.push('');
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
const ForgotPassword = async (props: { searchParams: Promise<Message> }) => {
const searchParams = await props.searchParams;
return ( return (
<> <Card className='min-w-xs md:min-w-sm'>
<form <CardHeader>
className='flex-1 flex flex-col w-full gap-2 text-foreground <CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
[&>input]:mb-6 min-w-64 max-w-64 mx-auto' <CardDescription className='text-sm text-foreground'>
> Don&apos;t have an account?{' '}
<div> <Link className='font-medium underline' href='/sign-up'>
<h1 className='text-2xl font-medium'>Reset Password</h1> Sign up
<p className='text-sm text-secondary-foreground'>
Already have an account?{' '}
<Link className='text-primary underline' href='/sign-in'>
Sign in
</Link> </Link>
</p> </CardDescription>
</div> </CardHeader>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'> <CardContent>
<Label htmlFor='email'>Email</Label> <Form {...form}>
<Input name='email' placeholder='you@example.com' required /> <form
<SubmitButton formAction={forgotPassword}> onSubmit={form.handleSubmit(handleForgotPassword)}
className='flex flex-col min-w-64 space-y-6'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SubmitButton
disabled={isLoading}
pendingText='Resetting Password...'
>
Reset Password Reset Password
</SubmitButton> </SubmitButton>
<FormMessage message={searchParams} /> {statusMessage &&
</div> (statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
</form> </form>
</> </Form>
</CardContent>
</Card>
); );
}; };
export default ForgotPassword; export default ForgotPassword;

View File

@ -1,8 +1,13 @@
'use client'; 'use client';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AvatarUpload, ProfileForm, ResetPasswordForm } from '@/components/default/profile'; import {
AvatarUpload,
ProfileForm,
ResetPasswordForm,
SignOut,
} from '@/components/default/profile';
import { import {
Card, Card,
CardHeader, CardHeader,
@ -16,14 +21,20 @@ import { toast } from 'sonner';
import { type Result } from '@/lib/actions'; import { type Result } from '@/lib/actions';
const ProfilePage = () => { const ProfilePage = () => {
const { profile, isLoading, isAuthenticated, updateProfile, refreshUserData } = useAuth(); const {
profile,
isLoading,
isAuthenticated,
updateProfile,
refreshUserData,
} = useAuth();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
router.push('/sign-in'); router.push('/sign-in');
} }
}, [isLoading, isAuthenticated, router]) }, [isLoading, isAuthenticated, router]);
const handleAvatarUploaded = async (path: string) => { const handleAvatarUploaded = async (path: string) => {
await updateProfile({ avatar_url: path }); await updateProfile({ avatar_url: path });
@ -50,17 +61,17 @@ const ProfilePage = () => {
try { try {
const result = await resetPassword(formData); const result = await resetPassword(formData);
if (!result.success) { if (!result.success) {
toast.error(`Error resetting password: ${result.error}`) toast.error(`Error resetting password: ${result.error}`);
return {success: false, error: result.error}; return { success: false, error: result.error };
} }
return {success: true, data: null}; return { success: true, data: null };
} catch (error) { } catch (error) {
toast.error( toast.error(
`Error resetting password!: ${error as string ?? 'Unknown error'}` `Error resetting password!: ${(error as string) ?? 'Unknown error'}`,
); );
return {success: false, error: 'Unknown error'}; return { success: false, error: 'Unknown error' };
}
} }
};
// Show loading state while checking authentication // Show loading state while checking authentication
if (isLoading) { if (isLoading) {
@ -81,7 +92,7 @@ const ProfilePage = () => {
} }
return ( return (
<div className='max-w-3xl min-w-sm mx-auto p-4'> <div className='max-w-2xl min-w-sm mx-auto p-4'>
<Card className='mb-8'> <Card className='mb-8'>
<CardHeader className='pb-2'> <CardHeader className='pb-2'>
<CardTitle className='text-2xl'>Your Profile</CardTitle> <CardTitle className='text-2xl'>Your Profile</CardTitle>
@ -100,6 +111,8 @@ const ProfilePage = () => {
<ProfileForm onSubmit={handleProfileSubmit} /> <ProfileForm onSubmit={handleProfileSubmit} />
<Separator /> <Separator />
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} /> <ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
<Separator />
<SignOut />
</div> </div>
)} )}
</Card> </Card>

View File

@ -4,7 +4,6 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
Button,
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
@ -12,20 +11,21 @@ import {
CardTitle, CardTitle,
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
Label,
} from '@/components/ui'; } from '@/components/ui';
import Link from 'next/link'; import Link from 'next/link';
import { signIn } from '@/lib/actions'; import { signIn } from '@/lib/actions';
import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { StatusMessage, SubmitButton } from '@/components/default';
import { Separator } from '@/components/ui';
import { SignInWithMicrosoft } from '@/components/default/auth/SignInWithMicrosoft';
import { SignInWithApple } from '@/components/default/auth/SignInWithApple';
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ email: z.string().email({
@ -34,7 +34,7 @@ const formSchema = z.object({
password: z.string().min(8, { password: z.string().min(8, {
message: 'Password must be at least 8 characters.', message: 'Password must be at least 8 characters.',
}), }),
}) });
const Login = () => { const Login = () => {
const router = useRouter(); const router = useRouter();
@ -58,6 +58,7 @@ const Login = () => {
const handleSignIn = async (values: z.infer<typeof formSchema>) => { const handleSignIn = async (values: z.infer<typeof formSchema>) => {
try { try {
setStatusMessage('');
const formData = new FormData(); const formData = new FormData();
formData.append('email', values.email); formData.append('email', values.email);
formData.append('password', values.password); formData.append('password', values.password);
@ -67,22 +68,20 @@ const Login = () => {
form.reset(); form.reset();
router.push(''); router.push('');
} else { } else {
setStatusMessage(`Error: ${result.error}`) setStatusMessage(`Error: ${result.error}`);
} }
} catch (error) { } catch (error) {
setStatusMessage( setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}` `Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
); );
} }
}; };
return ( return (
<Card> <Card className='min-w-xs md:min-w-sm'>
<CardHeader> <CardHeader>
<CardTitle className='text-2xl font-medium'> <CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
Sign In <CardDescription className='text-foreground'>
</CardTitle>
<CardDescription className='text-sm text-foreground'>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link className='font-medium underline' href='/sign-up'> <Link className='font-medium underline' href='/sign-up'>
Sign up Sign up
@ -93,16 +92,20 @@ const Login = () => {
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleSignIn)} onSubmit={form.handleSubmit(handleSignIn)}
className='flex flex-col min-w-64 space-y-6' className='flex flex-col min-w-64 space-y-6 pb-4'
> >
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel className='text-lg'>Email</FormLabel>
<FormControl> <FormControl>
<Input type='email' placeholder='you@example.com' {...field} /> <Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -115,7 +118,7 @@ const Login = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='flex justify-between'> <div className='flex justify-between'>
<FormLabel>Password</FormLabel> <FormLabel className='text-lg'>Password</FormLabel>
<Link <Link
className='text-xs text-foreground underline text-right' className='text-xs text-foreground underline text-right'
href='/forgot-password' href='/forgot-password'
@ -124,32 +127,42 @@ const Login = () => {
</Link> </Link>
</div> </div>
<FormControl> <FormControl>
<Input type='password' placeholder='Your password' {...field} /> <Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{statusMessage && ( {statusMessage &&
<div (statusMessage.includes('Error') ||
className={`text-sm text-center ${ statusMessage.includes('error') ||
statusMessage.includes('Error') || statusMessage.includes('failed') statusMessage.includes('failed') ||
? 'text-destructive' statusMessage.includes('invalid') ? (
: 'text-green-800' <StatusMessage message={{ error: statusMessage }} />
}`} ) : (
> <StatusMessage message={{ message: statusMessage }} />
{statusMessage} ))}
</div>
)}
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}
pendingText='Signing In...' pendingText='Signing In...'
className='text-[1.0rem] cursor-pointer'
> >
Sign in Sign in
</SubmitButton> </SubmitButton>
</form> </form>
</Form> </Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft />
<SignInWithApple />
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,53 +1,210 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link'; import Link from 'next/link';
import { signUp } from '@/lib/actions'; import { signUp } from '@/lib/actions';
import { FormMessage, type Message, SubmitButton } from '@/components/default'; import { StatusMessage, SubmitButton } from '@/components/default';
import { Input, Label } from '@/components/ui'; import { useRouter } from 'next/navigation';
import { redirect } from 'next/navigation'; import { useAuth } from '@/components/context';
import { getUser } from '@/lib/actions'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Separator,
} from '@/components/ui';
import { useEffect, useState } from 'react';
import {
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const SignUp = async (props: { searchParams: Promise<Message> }) => { const formSchema = z
const searchParams = await props.searchParams; .object({
const user = await getUser(); name: z.string().min(2, {
if (user.success) redirect('/profile'); message: 'Name must be at least 2 characters.',
if ('message' in searchParams) { }),
return ( email: z.string().email({
<div message: 'Please enter a valid email address.',
className='w-full flex-1 flex items-center h-screen }),
sm:max-w-md justify-center gap-2 p-4' password: z.string().min(8, {
> message: 'Password must be at least 8 characters.',
<FormMessage message={searchParams} /> }),
</div> confirmPassword: z.string().min(8, {
message: 'Password must be at least 8 characters.',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const SignUp = () => {
const router = useRouter();
const { isAuthenticated, isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
mode: 'onChange',
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.push('/');
}
}, [isAuthenticated, router]);
const handleSignUp = async (values: z.infer<typeof formSchema>) => {
try {
setStatusMessage('');
const formData = new FormData();
formData.append('name', values.name);
formData.append('email', values.email);
formData.append('password', values.password);
const result = await signUp(formData);
if (result?.success) {
await refreshUserData();
setStatusMessage(
result.data ??
'Thanks for signing up! Please check your email for a verification link.',
); );
form.reset();
router.push('');
} else { } else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return ( return (
<form className='flex flex-col min-w-64 max-w-64 mx-auto'> <Card className='min-w-xs md:min-w-sm'>
<h1 className='text-2xl font-medium'>Sign up</h1> <CardHeader>
<p className='text-sm text text-foreground'> <CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
<CardDescription className='text-foreground'>
Already have an account?{' '} Already have an account?{' '}
<Link className='text-primary font-medium underline' href='/sign-in'> <Link className='text-primary font-medium underline' href='/sign-in'>
Sign in Sign in
</Link> </Link>
</p> </CardDescription>
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'> </CardHeader>
<Label htmlFor='name'>Name</Label> <CardContent>
<Input name='name' placeholder='Full Name' required /> <Form {...form}>
<Label htmlFor='email'>Email</Label> <form
<Input name='email' placeholder='you@example.com' required /> onSubmit={form.handleSubmit(handleSignUp)}
<Label htmlFor='password'>Password</Label> className='flex flex-col mx-auto space-y-4 mb-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Name</FormLabel>
<FormControl>
<Input type='text' placeholder='Full Name' {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Password</FormLabel>
<FormControl>
<Input <Input
type='password' type='password'
name='password'
placeholder='Your password' placeholder='Your password'
minLength={6} {...field}
required
/> />
<SubmitButton formAction={signUp} pendingText='Signing up...'> </FormControl>
Sign up <FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-lg'>Confirm Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{statusMessage &&
(statusMessage.includes('Error') ||
statusMessage.includes('error') ||
statusMessage.includes('failed') ||
statusMessage.includes('invalid') ? (
<StatusMessage message={{ error: statusMessage }} />
) : (
<StatusMessage message={{ success: statusMessage }} />
))}
<SubmitButton
className='text-[1.0rem] cursor-pointer'
disabled={isLoading}
pendingText='Signing Up...'
>
Sign Up
</SubmitButton> </SubmitButton>
<FormMessage message={searchParams} />
</div>
</form> </form>
</Form>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<SignInWithMicrosoft type='signUp' />
<SignInWithApple type='signUp' />
</CardContent>
</Card>
); );
}
}; };
export default SignUp; export default SignUp;

View File

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
class SentryExampleAPIError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleAPIError';
}
}
// A faulty API route to test Sentry's error monitoring
export function GET() {
throw new SentryExampleAPIError(
'This error is raised on the backend called by the example page.',
);
return NextResponse.json({ data: 'Testing Sentry Error...' });
}

79
src/app/global-error.tsx Normal file
View File

@ -0,0 +1,79 @@
'use client';
import '@/styles/globals.css';
import { cn } from '@/lib/utils';
import { AuthProvider, ThemeProvider } from '@/components/context';
import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer';
import { Button, Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
import { Geist } from 'next/font/google';
const geist = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
type GlobalErrorProps = {
error: Error & { digest?: string };
reset?: () => void;
};
const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body
className={cn('bg-background text-foreground font-sans antialiased')}
>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation />
<div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'
>
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</div>
</div>
<Footer />
</main>
<Toaster />
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
return (
<html lang='en'>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
{reset !== undefined && (
<Button onClick={() => reset()}>Try again</Button>
)}
</body>
</html>
);
};
export default GlobalError;

View File

@ -1,22 +1,24 @@
import { type Metadata } from 'next'; import type { Metadata } from 'next';
import '@/styles/globals.css'; import '@/styles/globals.css';
import { Geist } from 'next/font/google'; import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ThemeProvider } from '@/components/context/theme'; import { AuthProvider, ThemeProvider } from '@/components/context';
import { AuthProvider } from '@/components/context/auth'
import Navigation from '@/components/default/navigation'; import Navigation from '@/components/default/navigation';
import Footer from '@/components/default/footer'; import Footer from '@/components/default/footer';
import { Toaster } from '@/components/ui'; import { Toaster } from '@/components/ui';
import * as Sentry from '@sentry/nextjs';
export const metadata: Metadata = { export const generateMetadata = (): Metadata => {
return {
title: { title: {
template: '%s | T3 Template', template: '%s | T3 Template',
default: 'T3 Template with Supabase', default: 'T3 Template with Supabase',
}, },
description: 'Created by Gib with T3!', description: 'Created by Gib with T3!',
applicationName: 'T3 Template', applicationName: 'T3 Template',
keywords: 'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo', keywords:
authors: [{name: 'Gib', url: 'https://gbrown.org'}], 'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown', creator: 'Gib Brown',
publisher: 'Gib Brown', publisher: 'Gib Brown',
formatDetection: { formatDetection: {
@ -40,72 +42,233 @@ export const metadata: Metadata = {
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' }, { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{ url: '/favicon-16x16.png', type: 'image/png', sizes: '16x16'}, { url: '/favicon-16x16.png', type: 'image/png', sizes: '16x16' },
{ url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32'}, { url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' },
{ url: '/favicon-96x96.png', type: 'image/png', sizes: '96x96'}, { url: '/favicon.png', type: 'image/png', sizes: '96x96' },
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any', media: '(prefers-color-scheme: dark)' }, {
{ url: '/favicon-16x16.png', type: 'image/png', sizes: '16x16', media: '(prefers-color-scheme: dark)' }, url: '/favicon.ico',
{ url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32', media: '(prefers-color-scheme: dark)' }, type: 'image/x-icon',
{ url: '/favicon-96x96.png', type: 'image/png', sizes: '96x96', media: '(prefers-color-scheme: dark)' }, sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-16x16.png',
type: 'image/png',
sizes: '16x16',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-32x32.png',
type: 'image/png',
sizes: '32x32',
media: '(prefers-color-scheme: dark)',
},
{
url: '/favicon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36'}, { url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48'}, { url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72'}, { url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96'}, { url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{ url: '/appicon/icon-144x144.png', type: 'image/png', sizes: '144x144'}, {
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192'}, url: '/appicon/icon-144x144.png',
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36', media: '(prefers-color-scheme: dark)' }, type: 'image/png',
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48', media: '(prefers-color-scheme: dark)' }, sizes: '144x144',
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72', media: '(prefers-color-scheme: dark)' }, },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96', media: '(prefers-color-scheme: dark)' }, { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ url: '/appicon/icon-144x144.png', type: 'image/png', sizes: '144x144', media: '(prefers-color-scheme: dark)' }, {
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192', media: '(prefers-color-scheme: dark)' }, url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
], ],
shortcut: [ shortcut: [
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36'}, { url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48'}, { url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72'}, { url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96'}, { url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96' },
{ url: '/appicon/icon-144x144.png', type: 'image/png', sizes: '144x144'}, {
{ url: '/appicon/icon-192x192.png', type: 'image/png', sizes: '192x192'}, url: '/appicon/icon-144x144.png',
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36', media: '(prefers-color-scheme: dark)' }, type: 'image/png',
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48', media: '(prefers-color-scheme: dark)' }, sizes: '144x144',
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72', media: '(prefers-color-scheme: dark)' }, },
{ url: '/appicon/icon-96x96.png', type: 'image/png', sizes: '96x96', media: '(prefers-color-scheme: dark)' }, { url: '/appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ url: '/appicon/icon-144x144.png', type: 'image/png', sizes: '144x144', media: '(prefers-color-scheme: dark)' }, {
{ url: '/appicon/icon.png', type: 'image/png', sizes: '192x192', media: '(prefers-color-scheme: dark)' }, url: '/appicon/icon-36x36.png',
type: 'image/png',
sizes: '36x36',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-48x48.png',
type: 'image/png',
sizes: '48x48',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-96x96.png',
type: 'image/png',
sizes: '96x96',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: '/appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
], ],
apple: [ apple: [
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' }, { url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' },
{ url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' }, { url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' },
{ url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' }, { url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
{ url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76' }, { url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76' },
{ url: 'appicon/icon-114x114.png', type: 'image/png', sizes: '114x114' }, {
{ url: 'appicon/icon-120x120.png', type: 'image/png', sizes: '120x120' }, url: 'appicon/icon-114x114.png',
{ url: 'appicon/icon-144x144.png', type: 'image/png', sizes: '144x144' }, type: 'image/png',
{ url: 'appicon/icon-152x152.png', type: 'image/png', sizes: '152x152' }, sizes: '114x114',
{ url: 'appicon/icon-180x180.png', type: 'image/png', sizes: '180x180' }, },
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
},
{ url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' }, { url: 'appicon/icon.png', type: 'image/png', sizes: '192x192' },
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57', media: '(prefers-color-scheme: dark)' }, {
{ url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60', media: '(prefers-color-scheme: dark)' }, url: 'appicon/icon-57x57.png',
{ url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72', media: '(prefers-color-scheme: dark)' }, type: 'image/png',
{ url: 'appicon/icon-76x76.png', type: 'image/png', sizes: '76x76', media: '(prefers-color-scheme: dark)' }, sizes: '57x57',
{ url: 'appicon/icon-114x114.png', type: 'image/png', sizes: '114x114', media: '(prefers-color-scheme: dark)' }, media: '(prefers-color-scheme: dark)',
{ url: 'appicon/icon-120x120.png', type: 'image/png', sizes: '120x120', media: '(prefers-color-scheme: dark)' }, },
{ url: 'appicon/icon-144x144.png', type: 'image/png', sizes: '144x144', media: '(prefers-color-scheme: dark)' }, {
{ url: 'appicon/icon-152x152.png', type: 'image/png', sizes: '152x152', media: '(prefers-color-scheme: dark)' }, url: 'appicon/icon-60x60.png',
{ url: 'appicon/icon-180x180.png', type: 'image/png', sizes: '180x180', media: '(prefers-color-scheme: dark)' }, type: 'image/png',
{ url: 'appicon/icon.png', type: 'image/png', sizes: '192x192', media: '(prefers-color-scheme: dark)' }, sizes: '60x60',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-72x72.png',
type: 'image/png',
sizes: '72x72',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-76x76.png',
type: 'image/png',
sizes: '76x76',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-114x114.png',
type: 'image/png',
sizes: '114x114',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-120x120.png',
type: 'image/png',
sizes: '120x120',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-144x144.png',
type: 'image/png',
sizes: '144x144',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-152x152.png',
type: 'image/png',
sizes: '152x152',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon-180x180.png',
type: 'image/png',
sizes: '180x180',
media: '(prefers-color-scheme: dark)',
},
{
url: 'appicon/icon.png',
type: 'image/png',
sizes: '192x192',
media: '(prefers-color-scheme: dark)',
},
], ],
other: [ other: [
{ {
rel: 'apple-touch-icon-precomposed', rel: 'apple-touch-icon-precomposed',
url: '/appicon/icon-precomposed.png', url: '/appicon/icon-precomposed.png',
type: 'image/png', type: 'image/png',
sizes: '180x180' sizes: '180x180',
}, },
], ],
}, },
other: {
...Sentry.getTraceData(),
},
twitter: { twitter: {
card: 'app', card: 'app',
title: 'T3 Template', title: 'T3 Template',
@ -172,6 +335,7 @@ export const metadata: Metadata = {
richPin: true, richPin: true,
}, },
category: 'technology', category: 'technology',
};
}; };
const geist = Geist({ const geist = Geist({
@ -195,7 +359,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<main className='min-h-screen flex flex-col items-center'> <main className='min-h-screen flex flex-col items-center'>
<div className='flex-1 w-full flex flex-col gap-20 items-center'> <div className='flex-1 w-full flex flex-col gap-20 items-center'>
<Navigation /> <Navigation />
<div className='flex flex-col gap-20 max-w-5xl p-5 w-full'> <div
className='flex flex-col gap-20 max-w-5xl
p-5 w-full items-center'
>
{children} {children}
</div> </div>
</div> </div>

View File

@ -4,14 +4,61 @@ import { FetchDataSteps } from '@/components/default/tutorial';
import { InfoIcon } from 'lucide-react'; import { InfoIcon } from 'lucide-react';
import { getUser } from '@/lib/actions'; import { getUser } from '@/lib/actions';
import type { User } from '@/utils/supabase'; import type { User } from '@/utils/supabase';
import { TestSentryCard } from '@/components/default/sentry';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import {
SignInSignUp,
SignInWithApple,
SignInWithMicrosoft,
} from '@/components/default/auth';
const HomePage = async () => { const HomePage = async () => {
const response = await getUser(); const response = await getUser();
if (!response.success || !response.data) { if (!response.success || !response.data) {
return ( return (
<main className='w-full items-center justify-center'> <main className='w-full items-center justify-center'>
<div className='flex p-5 items-center justify-center'> <div className='flex flex-col p-5 items-center justify-center space-y-6'>
<h1>Make sure you can sign in!</h1> <Card className='md:min-w-2xl'>
<CardHeader className='flex flex-col items-center'>
<CardTitle className='text-3xl'>
Welcome to the T3 Supabase Template!
</CardTitle>
<CardDescription className='text-[1.0rem] mb-2'>
A great place to start is by creating a new user account &
ensuring you can sign up! If you already have an account, go
ahead and sign in!
</CardDescription>
<SignInSignUp
className='flex gap-4 w-full justify-center'
signInSize='xl'
signUpSize='xl'
/>
<div className='flex items-center w-full gap-4'>
<Separator className='flex-1 bg-accent py-0.5' />
<span className='text-sm text-muted-foreground'>or</span>
<Separator className='flex-1 bg-accent py-0.5' />
</div>
<div className='flex gap-4'>
<SignInWithMicrosoft buttonSize='lg' />
<SignInWithApple buttonSize='lg' />
</div>
</CardHeader>
<Separator className='bg-accent' />
<CardContent className='flex flex-col px-5 py-2 items-center justify-center'>
<CardTitle className='text-lg mb-6 w-2/3 text-center'>
You can also test out your connection to Sentry if you want to
start there!
</CardTitle>
<TestSentryCard />
</CardContent>
</Card>
</div> </div>
</main> </main>
); );
@ -30,14 +77,15 @@ const HomePage = async () => {
</div> </div>
</div> </div>
<div className='flex flex-col gap-2 items-start'> <div className='flex flex-col gap-2 items-start'>
<h2 className='font-bold text-2xl mb-4'>Your user details</h2> <h2 className='font-bold text-3xl mb-4'>Your user details</h2>
<pre <pre
className='text-xs font-mono p-3 rounded className='text-sm font-mono p-3 rounded
border max-h-32 overflow-auto' border max-h-50 overflow-auto'
> >
{JSON.stringify(user, null, 2)} {JSON.stringify(user, null, 2)}
</pre> </pre>
</div> </div>
<TestSentryCard />
<div> <div>
<h2 className='font-bold text-2xl mb-4'>Next steps</h2> <h2 className='font-bold text-2xl mb-4'>Next steps</h2>
<FetchDataSteps /> <FetchDataSteps />

View File

@ -0,0 +1,195 @@
'use client';
import React, {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import {
getProfile,
getSignedUrl,
getUser,
updateProfile as updateProfileAction,
} from '@/lib/hooks';
import { type User, type Profile, createClient } from '@/utils/supabase';
import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
refreshUserData: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const fetchingRef = useRef(false);
const fetchUserData = useCallback(
async (showLoading = true) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
// Only show loading for initial load or manual refresh
if (showLoading) {
setIsLoading(true);
}
const userResponse = await getUser();
const profileResponse = await getProfile();
if (!userResponse.success || !profileResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
} else {
setAvatarUrl(null);
}
} else {
setAvatarUrl(null);
}
} catch (error) {
console.error(
'Auth fetch error: ',
error instanceof Error
? `${error.message}`
: 'Failed to load user data!',
);
if (!isInitialized) {
toast.error('Failed to load user data!');
}
} finally {
if (showLoading) {
setIsLoading(false);
}
setIsInitialized(true);
fetchingRef.current = false;
}
},
[isInitialized],
);
useEffect(() => {
const supabase = createClient();
// Initial fetch with loading
fetchUserData(true).catch((error) => {
console.error('💥 Initial fetch error:', error);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
console.log('Auth state change:', event); // Debug log
if (event === 'SIGNED_IN') {
// Background refresh without loading state
await fetchUserData(false);
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
} else if (event === 'TOKEN_REFRESHED') {
// Silent refresh - don't show loading
await fetchUserData(false);
}
});
return () => {
subscription.unsubscribe();
};
}, [fetchUserData]);
const updateProfile = useCallback(
async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
const result = await updateProfileAction(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile:', error);
toast.error(
error instanceof Error ? error.message : 'Failed to update profile',
);
return { success: false, error };
}
},
[],
);
const refreshUserData = useCallback(async () => {
await fetchUserData(true); // Manual refresh shows loading
}, [fetchUserData]);
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,
refreshUserData,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,68 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui';
export const ThemeProvider = ({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
export interface ThemeToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: number;
}
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant='outline' size='icon' {...props}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
<span className='sr-only'>Toggle theme</span>
</Button>
);
};

View File

@ -1,176 +0,0 @@
'use client';
import React, {
type ReactNode,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import {
getProfile,
getSignedUrl,
getUser,
updateProfile as updateProfileAction,
} from '@/lib/actions';
import {
type User,
type Profile,
createClient,
} from '@/utils/supabase';
import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => Promise<{ success: boolean; data?: Profile; error?: unknown }>;
refreshUserData: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUserData = async () => {
try {
setIsLoading(true);
// Get user data
const userResponse = await getUser();
if (!userResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
// Get profile data
const profileResponse = await getProfile();
if (!profileResponse.success) {
setProfile(null);
setAvatarUrl(null);
return;
}
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
} catch (error) {
toast.error('Failed to load user data');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const supabase = createClient();
fetchUserData().catch((error) => {
console.error('💥 Initial fetch error:', error);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
await fetchUserData();
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
}
});
return () => {
subscription.unsubscribe();
};
}, []);
const updateProfile = async (data: {
full_name?: string;
email?: string;
avatar_url?: string;
}) => {
try {
setIsLoading(true);
const result = await updateProfileAction(data);
if (!result.success) {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
console.error('Error updating profile:', error);
toast.error(error instanceof Error ? error.message : 'Failed to update profile');
return { success: false, error };
} finally {
setIsLoading(false);
}
};
const refreshUserData = async () => {
console.log('Manual refresh triggered');
await fetchUserData();
};
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,
refreshUserData,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,2 @@
export { AuthProvider, useAuth } from './Auth';
export { ThemeProvider, ThemeToggle } from './Theme';

View File

@ -1,68 +0,0 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui';
export const ThemeProvider = ({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
export interface ThemeToggleProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: number;
}
export const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant='outline' size='icon' {...props}>
<span style={{ height: `${size}rem`, width: `${size}rem` }} />
</Button>
);
}
const toggleTheme = () => {
if (resolvedTheme === 'dark') setTheme('light');
else setTheme('dark');
};
return (
<Button
variant='outline'
size='icon'
className='cursor-pointer'
onClick={toggleTheme}
{...props}
>
<Sun
style={{ height: `${size}rem`, width: `${size}rem` }}
className='rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0'
/>
<Moon
style={{ height: `${size}rem`, width: `${size}rem` }}
className='absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100'
/>
<span className='sr-only'>Toggle theme</span>
</Button>
);
};

View File

@ -0,0 +1,25 @@
export type Message =
| { success: string }
| { error: string }
| { message: string };
export const StatusMessage = ({ message }: { message: Message }) => {
return (
<div
className='flex flex-col gap-2 w-full max-w-md
text-sm bg-accent rounded-md p-2 px-4'
>
{'success' in message && (
<div className='dark:text-green-500 text-green-700'>
{message.success}
</div>
)}
{'error' in message && (
<div className='text-destructive'>{message.error}</div>
)}
{'message' in message && (
<div className='text-foreground'>{message.message}</div>
)}
</div>
);
};

View File

@ -0,0 +1,39 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
type Props = ComponentProps<typeof Button> & {
disabled?: boolean;
pendingText?: string;
};
export const SubmitButton = ({
children,
disabled = false,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button
className='cursor-pointer'
type='submit'
aria-disabled={pending}
disabled={disabled}
{...props}
>
{pending || disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{pendingText}
</>
) : (
children
)}
</Button>
);
};

View File

@ -0,0 +1,33 @@
'use server';
import Link from 'next/link';
import { Button, type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInSignUpProps = {
className?: ComponentProps<'div'>['className'];
signInSize?: VariantProps<typeof buttonVariants>['size'];
signUpSize?: VariantProps<typeof buttonVariants>['size'];
signInVariant?: VariantProps<typeof buttonVariants>['variant'];
signUpVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInSignUp = async ({
className = 'flex gap-2',
signInSize = 'default',
signUpSize = 'sm',
signInVariant = 'outline',
signUpVariant = 'default',
}: SignInSignUpProps) => {
return (
<div className={className}>
<Button asChild size={signInSize} variant={signInVariant}>
<Link href='/sign-in'>Sign In</Link>
</Button>
<Button asChild size={signUpSize} variant={signUpVariant}>
<Link href='/sign-up'>Sign Up</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,77 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithAppleProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithApple = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithAppleProps) => {
const router = useRouter();
const { isLoading, refreshUserData } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithApple = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithApple();
if (result?.success && result.data) {
// Redirect to Apple OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
} finally {
setIsSigningIn(false);
await refreshUserData();
router.push('');
}
};
return (
<form onSubmit={handleSignInWithApple} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/apple.svg'
alt='Apple logo'
className='invert-75 dark:invert-25'
width={22}
height={22}
/>
<p className='text-[1.0rem]'>Sign In with Apple</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,70 @@
'use client';
import { signInWithMicrosoft } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useState } from 'react';
import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
buttonSize?: VariantProps<typeof buttonVariants>['size'];
buttonVariant?: VariantProps<typeof buttonVariants>['variant'];
};
export const SignInWithMicrosoft = ({
className = 'my-4',
buttonSize = 'default',
buttonVariant = 'default',
}: SignInWithMicrosoftProps) => {
const { isLoading } = useAuth();
const [statusMessage, setStatusMessage] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false);
const handleSignInWithMicrosoft = async (e: React.FormEvent) => {
e.preventDefault();
try {
setStatusMessage('');
setIsSigningIn(true);
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
} else {
setStatusMessage(`Error: ${result.error}`);
}
} catch (error) {
setStatusMessage(
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
);
}
};
return (
<form onSubmit={handleSignInWithMicrosoft} className={className}>
<SubmitButton
size={buttonSize}
variant={buttonVariant}
className='w-full cursor-pointer'
disabled={isLoading || isSigningIn}
pendingText='Redirecting...'
type='submit'
>
<div className='flex items-center gap-2'>
<Image
src='/icons/microsoft.svg'
alt='Microsoft logo'
width={20}
height={20}
/>
<p className='text-[1.0rem]'>Sign In with Microsoft</p>
</div>
</SubmitButton>
{statusMessage && <StatusMessage message={{ error: statusMessage }} />}
</form>
);
};

View File

@ -0,0 +1,3 @@
export * from './SignInSignUp';
export * from './SignInWithApple';
export * from './SignInWithMicrosoft';

View File

@ -1,6 +1,6 @@
'use server'; 'use server';
const FooterTest = () => { const Footer = () => {
return ( return (
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'> <footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
<p> <p>
@ -17,4 +17,4 @@ const FooterTest = () => {
</footer> </footer>
); );
}; };
export default FooterTest; export default Footer;

View File

@ -1,24 +0,0 @@
export type Message =
| { success: string }
| { error: string }
| { message: string };
export const FormMessage = ({ message }: { message: Message }) => {
return (
<div className='flex flex-col gap-2 w-full max-w-md text-sm'>
{'success' in message && (
<div className='text-foreground border-l-2 border-foreground px-4'>
{message.success}
</div>
)}
{'error' in message && (
<div className='text-destructive-foreground border-l-2 border-destructive-foreground px-4'>
{message.error}
</div>
)}
{'message' in message && (
<div className='text-foreground border-l-2 px-4'>{message.message}</div>
)}
</div>
);
};

View File

@ -1,4 +1,5 @@
import { FormMessage, type Message } from '@/components/default/form-message'; export {
import { SubmitButton } from '@/components/default/submit-button'; StatusMessage,
type Message,
export { FormMessage, type Message, SubmitButton }; } from '@/components/default/StatusMessage';
export { SubmitButton } from '@/components/default/SubmitButton';

View File

@ -12,7 +12,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui'; } from '@/components/ui';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions'; import { signOut } from '@/lib/actions';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
@ -31,7 +31,8 @@ const AvatarDropdown = () => {
const getInitials = (name: string | null | undefined): string => { const getInitials = (name: string | null | undefined): string => {
if (!name) return ''; if (!name) return '';
return name.split(' ') return name
.split(' ')
.map((n) => n[0]) .map((n) => n[0])
.join('') .join('')
.toUpperCase(); .toUpperCase();
@ -42,12 +43,19 @@ const AvatarDropdown = () => {
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Avatar className='cursor-pointer'> <Avatar className='cursor-pointer'>
{avatarUrl ? ( {avatarUrl ? (
<AvatarImage src={avatarUrl} alt={getInitials(profile?.full_name)} width={64} height={64} /> <AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={64}
height={64}
/>
) : ( ) : (
<AvatarFallback className='text-sm'> <AvatarFallback className='text-sm'>
{profile?.full_name {profile?.full_name ? (
? getInitials(profile.full_name) getInitials(profile.full_name)
: <User size={32} />} ) : (
<User size={32} />
)}
</AvatarFallback> </AvatarFallback>
)} )}
</Avatar> </Avatar>
@ -56,14 +64,20 @@ const AvatarDropdown = () => {
<DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel> <DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href='/profile' className='w-full justify-center cursor-pointer'> <Link
href='/profile'
className='w-full justify-center cursor-pointer'
>
Edit profile Edit profile
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className='h-[2px]' /> <DropdownMenuSeparator className='h-[2px]' />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<button onClick={handleSignOut} className='w-full justify-center cursor-pointer'> <button
Log out onClick={handleSignOut}
className='w-full justify-center cursor-pointer'
>
Sign Out
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@ -1,9 +1,8 @@
'use server'; 'use server';
import Link from 'next/link';
import { Button } from '@/components/ui';
import { getProfile } from '@/lib/actions'; import { getProfile } from '@/lib/actions';
import AvatarDropdown from './AvatarDropdown'; import AvatarDropdown from './AvatarDropdown';
import { SignInSignUp } from '@/components/default/auth';
const NavigationAuth = async () => { const NavigationAuth = async () => {
try { try {
@ -13,17 +12,11 @@ const NavigationAuth = async () => {
<AvatarDropdown /> <AvatarDropdown />
</div> </div>
) : ( ) : (
<div className='flex gap-2'> <SignInSignUp />
<Button asChild size='default' variant={'outline'}>
<Link href='/sign-in'>Sign in</Link>
</Button>
<Button asChild size='sm' variant={'default'}>
<Link href='/sign-up'>Sign up</Link>
</Button>
</div>
); );
} catch (error) { } catch (error) {
console.error('Error getting profile:', error); console.error(`Error getting profile: ${error as string}`);
return <SignInSignUp />;
} }
}; };
export default NavigationAuth; export default NavigationAuth;

View File

@ -3,7 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import NavigationAuth from './auth'; import NavigationAuth from './auth';
import { ThemeToggle } from '@/components/context/theme'; import { ThemeToggle } from '@/components/context';
import Image from 'next/image'; import Image from 'next/image';
const Navigation = () => { const Navigation = () => {
@ -18,7 +18,7 @@ const Navigation = () => {
> >
<div className='flex gap-5 items-center font-semibold'> <div className='flex gap-5 items-center font-semibold'>
<Link className='flex flex-row my-auto gap-2' href='/'> <Link className='flex flex-row my-auto gap-2' href='/'>
<Image src='/icons/favicon-96x96.png' alt='T3 Logo' width={50} height={50} /> <Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
<h1 className='my-auto text-2xl'>T3 Supabase Template</h1> <h1 className='my-auto text-2xl'>T3 Supabase Template</h1>
</Link> </Link>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>

View File

@ -1,6 +1,11 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload'; import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { Avatar, AvatarFallback, AvatarImage, CardContent } from '@/components/ui'; import {
Avatar,
AvatarFallback,
AvatarImage,
CardContent,
} from '@/components/ui';
import { Loader2, Pencil, Upload, User } from 'lucide-react'; import { Loader2, Pencil, Upload, User } from 'lucide-react';
type AvatarUploadProps = { type AvatarUploadProps = {
@ -28,16 +33,17 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
maxHeight: 500, maxHeight: 500,
quality: 0.8, quality: 0.8,
}, },
prevPath: profile?.avatar_url, replace: { replace: true, path: profile?.avatar_url ?? file.name },
}); });
if (result.success && result.path) { if (result.success && result.data) {
await onAvatarUploaded(result.path); await onAvatarUploaded(result.data);
} }
}; };
const getInitials = (name: string | null | undefined): string => { const getInitials = (name: string | null | undefined): string => {
if (!name) return ''; if (!name) return '';
return name.split(' ') return name
.split(' ')
.map((n) => n[0]) .map((n) => n[0])
.join('') .join('')
.toUpperCase(); .toUpperCase();
@ -45,7 +51,6 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
return ( return (
<CardContent> <CardContent>
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<div <div
className='relative group cursor-pointer mb-4' className='relative group cursor-pointer mb-4'
@ -53,12 +58,19 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
> >
<Avatar className='h-32 w-32'> <Avatar className='h-32 w-32'>
{avatarUrl ? ( {avatarUrl ? (
<AvatarImage src={avatarUrl} alt={getInitials(profile?.full_name)} width={128} height={128} /> <AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={128}
height={128}
/>
) : ( ) : (
<AvatarFallback className='text-4xl'> <AvatarFallback className='text-4xl'>
{profile?.full_name {profile?.full_name ? (
? getInitials(profile.full_name) getInitials(profile.full_name)
: <User size={32} />} ) : (
<User size={32} />
)}
</AvatarFallback> </AvatarFallback>
)} )}
</Avatar> </Avatar>

View File

@ -2,7 +2,6 @@ import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
Button,
CardContent, CardContent,
Form, Form,
FormControl, FormControl,
@ -13,9 +12,9 @@ import {
FormMessage, FormMessage,
Input, Input,
} from '@/components/ui'; } from '@/components/ui';
import { Loader2 } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { SubmitButton } from '@/components/default';
const formSchema = z.object({ const formSchema = z.object({
full_name: z.string().min(5, { full_name: z.string().min(5, {
@ -28,7 +27,7 @@ type ProfileFormProps = {
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>; onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
}; };
export const ProfileForm = ({onSubmit}: ProfileFormProps) => { export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
const { profile, isLoading } = useAuth(); const { profile, isLoading } = useAuth();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -90,19 +89,12 @@ export const ProfileForm = ({onSubmit}: ProfileFormProps) => {
/> />
<div className='flex justify-center'> <div className='flex justify-center'>
<Button type='submit' disabled={isLoading}> <SubmitButton disabled={isLoading} pendingText='Saving...'>
{isLoading ? ( Save Changes
<> </SubmitButton>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
); );
} };

View File

@ -18,7 +18,7 @@ import {
import { SubmitButton } from '@/components/default'; import { SubmitButton } from '@/components/default';
import { useState } from 'react'; import { useState } from 'react';
import { type Result } from '@/lib/actions'; import { type Result } from '@/lib/actions';
import { FormMessage as Pw } from '@/components/default'; import { StatusMessage } from '@/components/default';
const formSchema = z const formSchema = z
.object({ .object({
@ -69,7 +69,7 @@ export const ResetPasswordForm = ({
} }
} catch (error) { } catch (error) {
setStatusMessage( setStatusMessage(
error instanceof Error ? error.message : 'Password was not updated!' error instanceof Error ? error.message : 'Password was not updated!',
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -85,7 +85,6 @@ export const ResetPasswordForm = ({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(handleUpdatePassword)} onSubmit={form.handleSubmit(handleUpdatePassword)}
@ -123,17 +122,15 @@ export const ResetPasswordForm = ({
</FormItem> </FormItem>
)} )}
/> />
{statusMessage && ( {statusMessage &&
<div (statusMessage.includes('Error') ||
className={`text-sm text-center ${ statusMessage.includes('error') ||
statusMessage.includes('Error') || statusMessage.includes('failed') statusMessage.includes('failed') ||
? 'text-destructive' statusMessage.includes('invalid') ? (
: 'text-green-600' <StatusMessage message={{ error: statusMessage }} />
}`} ) : (
> <StatusMessage message={{ message: statusMessage }} />
{statusMessage} ))}
</div>
)}
<div className='flex justify-center'> <div className='flex justify-center'>
<SubmitButton <SubmitButton
disabled={isLoading} disabled={isLoading}

View File

@ -0,0 +1,34 @@
'use client';
import { CardHeader } from '@/components/ui';
import { SubmitButton } from '@/components/default';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/context';
import { signOut } from '@/lib/actions';
export const SignOut = () => {
const { isLoading, refreshUserData } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
const result = await signOut();
if (result?.success) {
await refreshUserData();
router.push('/sign-in');
}
};
return (
<div className='flex justify-center'>
<CardHeader className='md:w-5/6 w-full'>
<SubmitButton
className='text-[1.0rem] font-semibold cursor-pointer
hover:bg-red-700/60 dark:hover:bg-red-300/80'
disabled={isLoading}
onClick={handleSignOut}
>
Sign Out
</SubmitButton>
</CardHeader>
</div>
);
};

View File

@ -1,3 +1,4 @@
export * from './AvatarUpload'; export * from './AvatarUpload';
export * from './ProfileForm'; export * from './ProfileForm';
export * from './ResetPasswordForm'; export * from './ResetPasswordForm';
export * from './SignOut';

View File

@ -0,0 +1,126 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useState, useEffect } from 'react';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from '@/components/ui';
import Link from 'next/link';
import { CheckCircle, MessageCircleWarning } from 'lucide-react';
class SentryExampleFrontendError extends Error {
constructor(message: string | undefined) {
super(message);
this.name = 'SentryExampleFrontendError';
}
}
export const TestSentryCard = () => {
const [hasSentError, setHasSentError] = useState(false);
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const checkConnectivity = async () => {
console.log('Checking Sentry SDK connectivity...');
const result = await Sentry.diagnoseSdkConnectivity();
setIsConnected(result !== 'sentry-unreachable');
};
checkConnectivity().catch((error) => {
console.error('Error trying to connect to Sentry: ', error);
});
}, []);
const createError = async () => {
await Sentry.startSpan(
{
name: 'Example Frontend Span',
op: 'test',
},
async () => {
const res = await fetch('/api/sentry/example');
if (!res.ok) {
setHasSentError(true);
throw new SentryExampleFrontendError(
'This error is raised in our TestSentry component on the main page.',
);
}
},
);
};
return (
<Card>
<CardHeader>
<div className='flex flex-row my-auto space-x-4'>
<svg
height='40'
width='40'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z'
fill='currentcolor'
/>
</svg>
<CardTitle className='text-3xl my-auto'>Test Sentry</CardTitle>
</div>
<CardDescription className='text-[1.0rem]'>
Click the button below & view the sample error on{' '}
<Link
href={`${process.env.NEXT_PUBLIC_SENTRY_URL}`}
className='text-accent-foreground underline hover:text-primary'
>
the Sentry website
</Link>
. Navigate to the {"'"}Issues{"'"} page & you should see the sample
error!
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-row gap-4 my-auto'>
<Button
type='button'
onClick={createError}
className='cursor-pointer text-md my-auto py-6'
>
<span>Throw Sample Error</span>
</Button>
{hasSentError ? (
<div className='rounded-md bg-green-500/80 dark:bg-green-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<CheckCircle size={30} className='my-auto' />
<p className='text-lg'>Sample error was sent to Sentry!</p>
</div>
) : !isConnected ? (
<div className='rounded-md bg-red-600/50 dark:bg-red-500/50 py-2 px-4 flex flex-row gap-2 my-auto'>
<MessageCircleWarning size={40} className='my-auto' />
<p>
Wait! The Sentry SDK is not able to reach Sentry right now -
this may be due to an adblocker. For more information, see{' '}
<Link
href='https://docs.sentry.io/platforms/javascript/guides/nextjs/troubleshooting/#the-sdk-is-not-sending-any-data'
className='text-accent-foreground underline hover:text-primary'
>
the troubleshooting guide.
</Link>
</p>
</div>
) : (
<div className='success_placeholder' />
)}
</div>
<Separator className='my-4 bg-accent' />
<p className='description'>
Warning! Sometimes Adblockers will prevent errors from being sent to
Sentry.
</p>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1 @@
export { TestSentryCard } from './TestSentry';

View File

@ -1,33 +0,0 @@
'use client';
import { Button } from '@/components/ui';
import { type ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
type Props = ComponentProps<typeof Button> & {
disabled?: boolean;
pendingText?: string;
};
export const SubmitButton = ({
children,
disabled = false,
pendingText = 'Submitting...',
...props
}: Props) => {
const { pending } = useFormStatus();
return (
<Button type='submit' aria-disabled={pending} disabled={disabled} {...props}>
{pending || disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{pendingText}
</>
) : (
children
)}
</Button>
);
};

View File

@ -0,0 +1,61 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui';
const CopyIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'></path>
</svg>
);
const CheckIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<polyline points='20 6 9 17 4 12'></polyline>
</svg>
);
export function CodeBlock({ code }: { code: string }) {
const [icon, setIcon] = useState(CopyIcon);
const copy = async () => {
await navigator?.clipboard?.writeText(code);
setIcon(CheckIcon);
setTimeout(() => setIcon(CopyIcon), 2000);
};
return (
<pre className='bg-muted rounded-md p-6 my-6 relative'>
<Button
size='icon'
onClick={copy}
variant={'outline'}
className='absolute right-2 top-2'
>
{icon}
</Button>
<code className='text-xs p-3'>{code}</code>
</pre>
);
}

View File

@ -0,0 +1,95 @@
import { TutorialStep, CodeBlock } from '@/components/default/tutorial';
const create = `create table notes (
id bigserial primary key,
title text
);
insert into notes(title)
values
('Today I created a Supabase project.'),
('I added some data and queried it from Next.js.'),
('It was awesome!');
`.trim();
const server = `import { createClient } from '@/utils/supabase/server'
export default async function Page() {
const supabase = await createClient()
const { data: notes } = await supabase.from('notes').select()
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
const client = `'use client'
import { createClient } from '@/utils/supabase/client'
import { useEffect, useState } from 'react'
export default function Page() {
const [notes, setNotes] = useState<any[] | null>(null)
const supabase = createClient()
useEffect(() => {
const getData = async () => {
const { data } = await supabase.from('notes').select()
setNotes(data)
}
getData()
}, [])
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
export const FetchDataSteps = () => {
return (
<ol className='flex flex-col gap-6'>
<TutorialStep title='Create some tables and insert some data'>
<p>
Head over to the{' '}
<a
href='https://supabase.com/dashboard/project/_/editor'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
Table Editor
</a>{' '}
for your Supabase project to create a table and insert some example
data. If you&apos;re stuck for creativity, you can copy and paste the
following into the{' '}
<a
href='https://supabase.com/dashboard/project/_/sql/new'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
SQL Editor
</a>{' '}
and click RUN!
</p>
<CodeBlock code={create} />
</TutorialStep>
<TutorialStep title='Query Supabase data from Next.js'>
<p>
To create a Supabase client and query data from an Async Server
Component, create a new page.tsx file at{' '}
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
/app/notes/page.tsx
</span>{' '}
and add the following.
</p>
<CodeBlock code={server} />
<p>Alternatively, you can use a Client Component.</p>
<CodeBlock code={client} />
</TutorialStep>
<TutorialStep title='Build in a weekend and scale to millions!'>
<p>You&apos;re ready to launch your product to the world! 🚀</p>
</TutorialStep>
</ol>
);
};

View File

@ -0,0 +1,30 @@
import { Checkbox } from '@/components/ui';
export const TutorialStep = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => {
return (
<li className='relative'>
<Checkbox
id={title}
name={title}
className={`absolute top-[3px] mr-2 peer`}
/>
<label
htmlFor={title}
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
>
<span className='ml-8'>{title}</span>
<div
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
>
{children}
</div>
</label>
</li>
);
};

View File

@ -1,61 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui';
const CopyIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'></path>
</svg>
);
const CheckIcon = () => (
<svg
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<polyline points='20 6 9 17 4 12'></polyline>
</svg>
);
export function CodeBlock({ code }: { code: string }) {
const [icon, setIcon] = useState(CopyIcon);
const copy = async () => {
await navigator?.clipboard?.writeText(code);
setIcon(CheckIcon);
setTimeout(() => setIcon(CopyIcon), 2000);
};
return (
<pre className='bg-muted rounded-md p-6 my-6 relative'>
<Button
size='icon'
onClick={copy}
variant={'outline'}
className='absolute right-2 top-2'
>
{icon}
</Button>
<code className='text-xs p-3'>{code}</code>
</pre>
);
}

View File

@ -1,95 +0,0 @@
import { TutorialStep, CodeBlock } from '@/components/default/tutorial';
const create = `create table notes (
id bigserial primary key,
title text
);
insert into notes(title)
values
('Today I created a Supabase project.'),
('I added some data and queried it from Next.js.'),
('It was awesome!');
`.trim();
const server = `import { createClient } from '@/utils/supabase/server'
export default async function Page() {
const supabase = await createClient()
const { data: notes } = await supabase.from('notes').select()
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
const client = `'use client'
import { createClient } from '@/utils/supabase/client'
import { useEffect, useState } from 'react'
export default function Page() {
const [notes, setNotes] = useState<any[] | null>(null)
const supabase = createClient()
useEffect(() => {
const getData = async () => {
const { data } = await supabase.from('notes').select()
setNotes(data)
}
getData()
}, [])
return <pre>{JSON.stringify(notes, null, 2)}</pre>
}
`.trim();
export const FetchDataSteps = () => {
return (
<ol className='flex flex-col gap-6'>
<TutorialStep title='Create some tables and insert some data'>
<p>
Head over to the{' '}
<a
href='https://supabase.com/dashboard/project/_/editor'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
Table Editor
</a>{' '}
for your Supabase project to create a table and insert some example
data. If you&apos;re stuck for creativity, you can copy and paste the
following into the{' '}
<a
href='https://supabase.com/dashboard/project/_/sql/new'
className='font-bold hover:underline text-foreground/80'
target='_blank'
rel='noreferrer'
>
SQL Editor
</a>{' '}
and click RUN!
</p>
<CodeBlock code={create} />
</TutorialStep>
<TutorialStep title='Query Supabase data from Next.js'>
<p>
To create a Supabase client and query data from an Async Server
Component, create a new page.tsx file at{' '}
<span className='relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-xs font-medium text-secondary-foreground border'>
/app/notes/page.tsx
</span>{' '}
and add the following.
</p>
<CodeBlock code={server} />
<p>Alternatively, you can use a Client Component.</p>
<CodeBlock code={client} />
</TutorialStep>
<TutorialStep title='Build in a weekend and scale to millions!'>
<p>You&apos;re ready to launch your product to the world! 🚀</p>
</TutorialStep>
</ol>
);
};

View File

@ -1,3 +1,3 @@
export { CodeBlock } from './code-block'; export { CodeBlock } from './CodeBlock';
export { FetchDataSteps } from './fetch-data-steps'; export { FetchDataSteps } from './FetchDataSteps';
export { TutorialStep } from './tutorial-step'; export { TutorialStep } from './TutorialStep';

View File

@ -1,30 +0,0 @@
import { Checkbox } from '@/components/ui';
export const TutorialStep = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => {
return (
<li className='relative'>
<Checkbox
id={title}
name={title}
className={`absolute top-[3px] mr-2 peer`}
/>
<label
htmlFor={title}
className={`relative text-base text-foreground peer-checked:line-through font-medium`}
>
<span className='ml-8'>{title}</span>
<div
className={`ml-8 text-sm peer-checked:line-through font-normal text-muted-foreground`}
>
{children}
</div>
</label>
</li>
);
};

View File

@ -25,6 +25,8 @@ const buttonVariants = cva(
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-12 rounded-md px-8 has-[>svg]:px-6',
xxl: 'h-14 rounded-md px-10 has-[>svg]:px-8',
icon: 'size-9', icon: 'size-9',
smicon: 'size-6', smicon: 'size-6',
}, },

View File

@ -7,7 +7,11 @@ export const env = createEnv({
* This way you can ensure the app isn't built with invalid env vars. * This way you can ensure the app isn't built with invalid env vars.
*/ */
server: { server: {
NODE_ENV: z.enum(['development', 'test', 'production']), NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
SENTRY_AUTH_TOKEN: z.string().min(1),
CI: z.enum(['true', 'false']).default('false'),
}, },
/** /**
@ -18,6 +22,12 @@ export const env = createEnv({
client: { client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(), NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
NEXT_PUBLIC_SENTRY_DSN: z.string().min(1),
NEXT_PUBLIC_SENTRY_URL: z
.string()
.url()
.default('https://sentry.gbrown.org'),
}, },
/** /**
@ -26,9 +36,14 @@ export const env = createEnv({
*/ */
runtimeEnv: { runtimeEnv: {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
CI: process.env.CI,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
}, },
/** /**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@ -0,0 +1,36 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!,
// Adds request headers and IP for users, for more info visit:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for tracing.
// We recommend adjusting this value in production
// Learn more at
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
tracesSampleRate: 1.0,
// Replay may only be enabled for the client-side
integrations: [Sentry.replayIntegration()],
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
// Learn more at
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});
// This export will instrument router navigations, and is only relevant if you enable tracing.
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

10
src/instrumentation.ts Normal file
View File

@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
import type { Instrumentation } from 'next';
export const register = async () => {
await import('../sentry.server.config');
};
export const onRequestError: Instrumentation.onRequestError = (...args) => {
Sentry.captureRequestError(...args);
};

View File

@ -1,14 +1,14 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { encodedRedirect } from '@/utils/utils';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { User } from '@/utils/supabase'; import type { User } from '@/utils/supabase';
import type { Result } from './index'; import type { Result } from '.';
export const signUp = async (formData: FormData) => { export const signUp = async (
formData: FormData,
): Promise<Result<string | null>> => {
const name = formData.get('name') as string; const name = formData.get('name') as string;
const email = formData.get('email') as string; const email = formData.get('email') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
@ -16,11 +16,7 @@ export const signUp = async (formData: FormData) => {
const origin = (await headers()).get('origin'); const origin = (await headers()).get('origin');
if (!email || !password) { if (!email || !password) {
return encodedRedirect( return { success: false, error: 'Email and password are required' };
'error',
'/sign-up',
'Email & password are required',
);
} }
const { error } = await supabase.auth.signUp({ const { error } = await supabase.auth.signUp({
@ -35,20 +31,18 @@ export const signUp = async (formData: FormData) => {
}, },
}, },
}); });
if (error) { if (error) {
return encodedRedirect('error', '/sign-up', error.message); return { success: false, error: error.message };
} else { } else {
return encodedRedirect( return {
'success', success: true,
'/sign-up', data: 'Thanks for signing up! Please check your email for a verification link.',
'Thanks for signing up! Please check your email for a verification link.', };
);
} }
}; };
export const signIn = async ( export const signIn = async (formData: FormData): Promise<Result<null>> => {
formData: FormData,
): Promise<Result<null>> => {
const email = formData.get('email') as string; const email = formData.get('email') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
const supabase = await createServerClient(); const supabase = await createServerClient();
@ -64,14 +58,42 @@ export const signIn = async (
} }
}; };
export const forgotPassword = async (formData: FormData) => { export const signInWithMicrosoft = async (): Promise<Result<string>> => {
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'openid, profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const signInWithApple = async (): Promise<Result<string>> => {
const supabase = await createServerClient();
const origin = process.env.BASE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const forgotPassword = async (
formData: FormData,
): Promise<Result<string | null>> => {
const email = formData.get('email') as string; const email = formData.get('email') as string;
const supabase = await createServerClient(); const supabase = await createServerClient();
const origin = (await headers()).get('origin'); const origin = (await headers()).get('origin');
const callbackUrl = formData.get('callbackUrl') as string;
if (!email) { if (!email) {
return encodedRedirect('error', '/forgot-password', 'Email is required'); return { success: false, error: 'Email is required' };
} }
const { error } = await supabase.auth.resetPasswordForEmail(email, { const { error } = await supabase.auth.resetPasswordForEmail(email, {
@ -79,30 +101,24 @@ export const forgotPassword = async (formData: FormData) => {
}); });
if (error) { if (error) {
return encodedRedirect( return { success: false, error: 'Could not reset password' };
'error',
'/forgot-password',
'Could not reset password',
);
} }
return {
if (callbackUrl) { success: true,
return redirect(callbackUrl); data: 'Check your email for a link to reset your password.',
} };
return encodedRedirect(
'success',
'/forgot-password',
'Check your email for a link to reset your password.',
);
}; };
export const resetPassword = async (
export const resetPassword = async (formData: FormData): Promise<Result<null>> => { formData: FormData,
): Promise<Result<null>> => {
const password = formData.get('password') as string; const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string; const confirmPassword = formData.get('confirmPassword') as string;
if (!password || !confirmPassword) { if (!password || !confirmPassword) {
return { success: false, error: 'Password and confirm password are required!' }; return {
success: false,
error: 'Password and confirm password are required!',
};
} }
const supabase = await createServerClient(); const supabase = await createServerClient();
if (password !== confirmPassword) { if (password !== confirmPassword) {
@ -112,7 +128,10 @@ export const resetPassword = async (formData: FormData): Promise<Result<null>> =
password, password,
}); });
if (error) { if (error) {
return { success: false, error: `Password update failed: ${error.message}` }; return {
success: false,
error: `Password update failed: ${error.message}`,
};
} }
return { success: true, data: null }; return { success: true, data: null };
}; };
@ -120,7 +139,7 @@ export const resetPassword = async (formData: FormData): Promise<Result<null>> =
export const signOut = async (): Promise<Result<null>> => { export const signOut = async (): Promise<Result<null>> => {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { error } = await supabase.auth.signOut(); const { error } = await supabase.auth.signOut();
if (error) return { success: false, error: error.message } if (error) return { success: false, error: error.message };
return { success: true, data: null }; return { success: true, data: null };
}; };

View File

@ -3,7 +3,7 @@
import 'server-only'; import 'server-only';
import { createServerClient, type Profile } from '@/utils/supabase'; import { createServerClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/actions'; import { getUser } from '@/lib/actions';
import type { Result } from './index'; import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => { export const getProfile = async (): Promise<Result<Profile>> => {
try { try {

52
src/lib/actions/storage.ts Normal file → Executable file
View File

@ -1,7 +1,7 @@
'use server'; 'use server';
import 'server-only'; import 'server-only';
import { createServerClient } from '@/utils/supabase'; import { createServerClient } from '@/utils/supabase';
import type { Result } from './index'; import type { Result } from '.';
export type GetStorageProps = { export type GetStorageProps = {
bucket: string; bucket: string;
@ -23,29 +23,27 @@ export type UploadStorageProps = {
file: File; file: File;
options?: { options?: {
cacheControl?: string; cacheControl?: string;
upsert?: boolean;
contentType?: string; contentType?: string;
}; };
}; };
export type ReplaceStorageProps = { export type ReplaceStorageProps = {
bucket: string; bucket: string;
prevPath: string; path: string;
file: File; file: File;
options?: { options?: {
cacheControl?: string; cacheControl?: string;
upsert?: boolean;
contentType?: string; contentType?: string;
}; };
}; };
export type resizeImageProps = { export type resizeImageProps = {
file: File, file: File;
options?: { options?: {
maxWidth?: number, maxWidth?: number;
maxHeight?: number, maxHeight?: number;
quality?: number, quality?: number;
} };
}; };
export const getSignedUrl = async ({ export const getSignedUrl = async ({
@ -77,7 +75,7 @@ export const getSignedUrl = async ({
: 'Unknown error getting signed URL', : 'Unknown error getting signed URL',
}; };
} }
} };
export const getPublicUrl = async ({ export const getPublicUrl = async ({
bucket, bucket,
@ -87,9 +85,7 @@ export const getPublicUrl = async ({
}: GetStorageProps): Promise<Result<string>> => { }: GetStorageProps): Promise<Result<string>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const { data } = supabase.storage const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
.from(bucket)
.getPublicUrl(url, {
download, download,
transform, transform,
}); });
@ -106,7 +102,7 @@ export const getPublicUrl = async ({
: 'Unknown error getting public URL', : 'Unknown error getting public URL',
}; };
} }
} };
export const uploadFile = async ({ export const uploadFile = async ({
bucket, bucket,
@ -131,25 +127,19 @@ export const uploadFile = async ({
error instanceof Error ? error.message : 'Unknown error uploading file', error instanceof Error ? error.message : 'Unknown error uploading file',
}; };
} }
} };
export const replaceFile = async ({ export const replaceFile = async ({
bucket, bucket,
prevPath, path,
file, file,
options = {}, options = {},
}: ReplaceStorageProps): Promise<Result<string>> => { }: ReplaceStorageProps): Promise<Result<string>> => {
try { try {
options.upsert = true;
const supabase = await createServerClient(); const supabase = await createServerClient();
//const deleteFileData = await deleteFile({
//bucket,
//path: [...prevPath],
//});
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
//.update(path, file, options); .update(path, file, { ...options, upsert: true });
.update(prevPath, file, options);
if (error) throw error; if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload'); if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path }; return { success: true, data: data.path };
@ -184,7 +174,7 @@ export const deleteFile = async ({
error instanceof Error ? error.message : 'Unknown error deleting file', error instanceof Error ? error.message : 'Unknown error deleting file',
}; };
} }
} };
// Add a helper to list files in a bucket // Add a helper to list files in a bucket
export const listFiles = async ({ export const listFiles = async ({
@ -218,17 +208,13 @@ export const listFiles = async ({
error instanceof Error ? error.message : 'Unknown error listing files', error instanceof Error ? error.message : 'Unknown error listing files',
}; };
} }
} };
export const resizeImage = async ({ export const resizeImage = async ({
file, file,
options = {}, options = {},
}: resizeImageProps): Promise<File> => { }: resizeImageProps): Promise<File> => {
const { const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
} = options;
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file); reader.readAsDataURL(file);
@ -240,11 +226,11 @@ export const resizeImage = async ({
let height = img.height; let height = img.height;
if (width > height) { if (width > height) {
if (width > maxWidth) { if (width > maxWidth) {
height = Math.round((height * maxWidth / width)); height = Math.round((height * maxWidth) / width);
width = maxWidth; width = maxWidth;
} }
} else if (height > maxHeight) { } else if (height > maxHeight) {
width = Math.round((width * maxHeight / height)); width = Math.round((width * maxHeight) / height);
height = maxHeight; height = maxHeight;
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -262,7 +248,7 @@ export const resizeImage = async ({
resolve(resizedFile); resolve(resizedFile);
}, },
'image/jpeg', 'image/jpeg',
quality quality,
); );
}; };
}; };

148
src/lib/hooks/auth.ts Normal file
View File

@ -0,0 +1,148 @@
'use client';
import { createClient } from '@/utils/supabase';
import type { User } from '@/utils/supabase';
import type { Result } from '.';
export const signUp = async (
formData: FormData,
): Promise<Result<string | null>> => {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
if (!email || !password) {
return { success: false, error: 'Email and password are required' };
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`,
data: {
full_name: name,
email,
provider: 'email',
},
},
});
if (error) {
return { success: false, error: error.message };
} else {
return {
success: true,
data: 'Thanks for signing up! Please check your email for a verification link.',
};
}
};
export const signIn = async (formData: FormData): Promise<Result<null>> => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { success: false, error: error.message };
} else {
return { success: true, data: null };
}
};
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
const supabase = createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'openid, profile email offline_access',
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const signInWithApple = async (): Promise<Result<string>> => {
const supabase = createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid, profile email offline_access',
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
};
export const forgotPassword = async (
formData: FormData,
): Promise<Result<string | null>> => {
const email = formData.get('email') as string;
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
if (!email) {
return { success: false, error: 'Email is required' };
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/reset-password`,
});
if (error) {
return { success: false, error: 'Could not reset password' };
}
return {
success: true,
data: 'Check your email for a link to reset your password.',
};
};
export const resetPassword = async (
formData: FormData,
): Promise<Result<null>> => {
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!password || !confirmPassword) {
return {
success: false,
error: 'Password and confirm password are required!',
};
}
const supabase = createClient();
if (password !== confirmPassword) {
return { success: false, error: 'Passwords do not match!' };
}
const { error } = await supabase.auth.updateUser({
password,
});
if (error) {
return {
success: false,
error: `Password update failed: ${error.message}`,
};
}
return { success: true, data: null };
};
export const signOut = async (): Promise<Result<null>> => {
const supabase = createClient();
const { error } = await supabase.auth.signOut();
if (error) return { success: false, error: error.message };
return { success: true, data: null };
};
export const getUser = async (): Promise<Result<User>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
return { success: true, data: data.user };
} catch (error) {
return { success: false, error: 'Could not get user!' };
}
};

8
src/lib/hooks/index.ts Normal file → Executable file
View File

@ -1,2 +1,8 @@
export * from './resizeImage'; export * from './auth';
export * from './public';
export * from './storage';
export * from './useFileUpload'; export * from './useFileUpload';
export type Result<T> =
| { success: true; data: T }
| { success: false; error: string };

79
src/lib/hooks/public.ts Normal file
View File

@ -0,0 +1,79 @@
'use client';
import { createClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/hooks';
import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => {
try {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.data.id)
.single();
if (error) throw error;
return { success: true, data: data as Profile };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting profile',
};
}
};
type updateProfileProps = {
full_name?: string;
email?: string;
avatar_url?: string;
};
export const updateProfile = async ({
full_name,
email,
avatar_url,
}: updateProfileProps): Promise<Result<Profile>> => {
try {
if (
full_name === undefined &&
email === undefined &&
avatar_url === undefined
)
throw new Error('No profile data provided');
const userResponse = await getUser();
if (!userResponse.success || userResponse.data === undefined)
throw new Error('User not found');
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.update({
...(full_name !== undefined && { full_name }),
...(email !== undefined && { email }),
...(avatar_url !== undefined && { avatar_url }),
})
.eq('id', userResponse.data.id)
.select()
.single();
if (error) throw error;
return {
success: true,
data: data as Profile,
};
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error updating profile',
};
}
};

View File

@ -1,59 +0,0 @@
'use client'
export type resizeImageProps = {
file: File,
options?: {
maxWidth?: number,
maxHeight?: number,
quality?: number,
}
};
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const {
maxWidth = 800,
maxHeight = 800,
quality = 0.8,
} = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth / width));
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight / height));
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality
);
};
};
});
};

259
src/lib/hooks/storage.ts Normal file
View File

@ -0,0 +1,259 @@
'use client';
import { createClient } from '@/utils/supabase';
import type { Result } from '.';
export type GetStorageProps = {
bucket: string;
url: string;
seconds?: number;
transform?: {
width?: number;
height?: number;
quality?: number;
format?: 'origin';
resize?: 'cover' | 'contain' | 'fill';
};
download?: boolean | string;
};
export type UploadStorageProps = {
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type ReplaceStorageProps = {
bucket: string;
path: string;
file: File;
options?: {
cacheControl?: string;
contentType?: string;
};
};
export type resizeImageProps = {
file: File;
options?: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
};
};
export const getSignedUrl = async ({
bucket,
url,
seconds = 3600,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(url, seconds, {
download,
transform,
});
if (error) throw error;
if (!data?.signedUrl) throw new Error('No signed URL returned');
return { success: true, data: data.signedUrl };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting signed URL',
};
}
};
export const getPublicUrl = async ({
bucket,
url,
transform = {},
download = false,
}: GetStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
download,
transform,
});
if (!data?.publicUrl) throw new Error('No public URL returned');
return { success: true, data: data.publicUrl };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting public URL',
};
}
};
export const uploadFile = async ({
bucket,
path,
file,
options = {},
}: UploadStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, options);
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error uploading file',
};
}
};
export const replaceFile = async ({
bucket,
path,
file,
options = {},
}: ReplaceStorageProps): Promise<Result<string>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.update(path, file, {
...options,
upsert: true,
});
if (error) throw error;
if (!data?.path) throw new Error('No path returned from upload');
return { success: true, data: data.path };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error replacing file',
};
}
};
// Add a helper to delete files
export const deleteFile = async ({
bucket,
path,
}: {
bucket: string;
path: string[];
}): Promise<Result<null>> => {
try {
const supabase = createClient();
const { error } = await supabase.storage.from(bucket).remove(path);
if (error) throw error;
return { success: true, data: null };
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error deleting file',
};
}
};
// Add a helper to list files in a bucket
export const listFiles = async ({
bucket,
path = '',
options = {},
}: {
bucket: string;
path?: string;
options?: {
limit?: number;
offset?: number;
sortBy?: { column: string; order: 'asc' | 'desc' };
};
}): Promise<Result<Array<{ name: string; id: string; metadata: unknown }>>> => {
try {
const supabase = createClient();
const { data, error } = await supabase.storage
.from(bucket)
.list(path, options);
if (error) throw error;
if (!data) throw new Error('No data returned from list operation');
return { success: true, data };
} catch (error) {
console.error('Could not list files!', error);
return {
success: false,
error:
error instanceof Error ? error.message : 'Unknown error listing files',
};
}
};
export const resizeImage = async ({
file,
options = {},
}: resizeImageProps): Promise<File> => {
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((width * maxHeight) / height);
height = maxHeight;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (!blob) return;
const resizedFile = new File([blob], file.name, {
type: 'imgage/jpeg',
lastModified: Date.now(),
});
resolve(resizedFile);
},
'image/jpeg',
quality,
);
};
};
});
};

View File

@ -1,10 +1,13 @@
'use client' 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { deleteFile, replaceFile, uploadFile } from '@/lib/actions'; import { replaceFile, uploadFile } from '@/lib/hooks';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuth } from '@/components/context/auth'; import { useAuth } from '@/components/context';
import { resizeImage } from '@/lib/hooks'; import { resizeImage } from '@/lib/hooks';
import type { Result } from '.';
export type Replace = { replace: true; path: string } | false;
export type uploadToStorageProps = { export type uploadToStorageProps = {
file: File; file: File;
@ -15,7 +18,7 @@ export type uploadToStorageProps = {
maxHeight?: number; maxHeight?: number;
quality?: number; quality?: number;
}; };
prevPath?: string | null; replace?: Replace;
}; };
export const useFileUpload = () => { export const useFileUpload = () => {
@ -28,35 +31,25 @@ export const useFileUpload = () => {
bucket, bucket,
resize = false, resize = false,
options = {}, options = {},
prevPath = null, replace = false,
}: uploadToStorageProps) => { }: uploadToStorageProps): Promise<Result<string>> => {
try { try {
if (!isAuthenticated) throw new Error('User is not authenticated'); if (!isAuthenticated) throw new Error('User is not authenticated');
setIsUploading(true); setIsUploading(true);
if (prevPath !== null) { if (replace) {
const deleteResult = await deleteFile({
bucket,
path: [...prevPath],
});
if (!deleteResult.success) {
console.error('Error deleting file:', deleteResult.error);
throw new Error(deleteResult.error || `Failed to delete ${prevPath}`);
} else console.log('Delete sucessful!')
console.log('Deleted file path: ', deleteResult.data)
const updateResult = await replaceFile({ const updateResult = await replaceFile({
bucket, bucket,
prevPath: prevPath, path: replace.path,
file, file,
options: { options: {
contentType: file.type, contentType: file.type,
}, },
}); });
if (!updateResult.success) { if (!updateResult.success) {
console.error('Error updating file:', updateResult.error); return { success: false, error: updateResult.error };
} else { } else {
console.log('We used the new update function hopefully it worked!'); return { success: true, data: updateResult.data };
return { success: true, path: updateResult.data };
} }
} }
@ -74,7 +67,6 @@ export const useFileUpload = () => {
path: fileName, path: fileName,
file: fileToUpload, file: fileToUpload,
options: { options: {
upsert: true,
contentType: file.type, contentType: file.type,
}, },
}); });
@ -83,15 +75,21 @@ export const useFileUpload = () => {
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`); throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
} }
return { success: true, path: uploadResult.data }; return { success: true, data: uploadResult.data };
} catch (error) { } catch (error) {
console.error(`Error uploading to ${bucket}:`, error);
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
: `Failed to upload to ${bucket}`, : `Failed to upload to ${bucket}`,
); );
return { success: false, error }; return {
success: false,
error: `Error: ${
error instanceof Error
? error.message
: `Failed to upload to ${bucket}`
}`,
};
} finally { } finally {
setIsUploading(false); setIsUploading(false);
// Clear the input value so the same file can be selected again // Clear the input value so the same file can be selected again

View File

@ -1,7 +1,119 @@
import { type NextRequest } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware'; import { updateSession } from '@/utils/supabase/middleware';
// In-memory store for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
if (realIP) {
return realIP;
}
return request.ip ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
// Clean up the attempts record
ipAttempts.delete(ip);
// Auto-unban after duration (in production, use a proper scheduler)
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
export const middleware = async (request: NextRequest) => { export const middleware = async (request: NextRequest) => {
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
return new NextResponse('Access denied.', { status: 403 });
}
// Check for suspicious activity
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
if (isSuspiciousPath || isSuspiciousMethod) {
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned', { status: 403 });
}
// Return 404 to not reveal the blocking mechanism
return new NextResponse('Not Found', { status: 404 });
}
return await updateSession(request); return await updateSession(request);
}; };
@ -12,9 +124,10 @@ export const config = {
* - _next/static (static files) * - _next/static (static files)
* - _next/image (image optimization files) * - _next/image (image optimization files)
* - favicon.ico (favicon file) * - favicon.ico (favicon file)
* - /monitoring-tunnel (Sentry monitoring)
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp * - images - .svg, .png, .jpg, .jpeg, .gif, .webp
* Feel free to modify this pattern to include more paths. * Feel free to modify this pattern to include more paths.
*/ */
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
], ],
}; };

View File

@ -2,14 +2,13 @@
// https://deno.land/manual/getting_started/setup_your_environment // https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc. // This enables autocomplete, go to definition, etc.
import { serve } from "https://deno.land/std@0.177.1/http/server.ts" import { serve } from 'https://deno.land/std@0.177.1/http/server.ts';
serve(async () => { serve(async () => {
return new Response( return new Response(`"Hello from Edge Functions!"`, {
`"Hello from Edge Functions!"`, headers: { 'Content-Type': 'application/json' },
{ headers: { "Content-Type": "application/json" } }, });
) });
})
// To invoke: // To invoke:
// curl 'http://localhost:<KONG_HTTP_PORT>/functions/v1/hello' \ // curl 'http://localhost:<KONG_HTTP_PORT>/functions/v1/hello' \

View File

@ -1,78 +1,78 @@
import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' import { serve } from 'https://deno.land/std@0.131.0/http/server.ts';
import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts';
console.log('main function started') console.log('main function started');
const JWT_SECRET = Deno.env.get('JWT_SECRET') const JWT_SECRET = Deno.env.get('JWT_SECRET');
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true';
function getAuthToken(req: Request) { function getAuthToken(req: Request) {
const authHeader = req.headers.get('authorization') const authHeader = req.headers.get('authorization');
if (!authHeader) { if (!authHeader) {
throw new Error('Missing authorization header') throw new Error('Missing authorization header');
} }
const [bearer, token] = authHeader.split(' ') const [bearer, token] = authHeader.split(' ');
if (bearer !== 'Bearer') { if (bearer !== 'Bearer') {
throw new Error(`Auth header is not 'Bearer {token}'`) throw new Error(`Auth header is not 'Bearer {token}'`);
} }
return token return token;
} }
async function verifyJWT(jwt: string): Promise<boolean> { async function verifyJWT(jwt: string): Promise<boolean> {
const encoder = new TextEncoder() const encoder = new TextEncoder();
const secretKey = encoder.encode(JWT_SECRET) const secretKey = encoder.encode(JWT_SECRET);
try { try {
await jose.jwtVerify(jwt, secretKey) await jose.jwtVerify(jwt, secretKey);
} catch (err) { } catch (err) {
console.error(err) console.error(err);
return false return false;
} }
return true return true;
} }
serve(async (req: Request) => { serve(async (req: Request) => {
if (req.method !== 'OPTIONS' && VERIFY_JWT) { if (req.method !== 'OPTIONS' && VERIFY_JWT) {
try { try {
const token = getAuthToken(req) const token = getAuthToken(req);
const isValidJWT = await verifyJWT(token) const isValidJWT = await verifyJWT(token);
if (!isValidJWT) { if (!isValidJWT) {
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e);
return new Response(JSON.stringify({ msg: e.toString() }), { return new Response(JSON.stringify({ msg: e.toString() }), {
status: 401, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
} }
} }
const url = new URL(req.url) const url = new URL(req.url);
const { pathname } = url const { pathname } = url;
const path_parts = pathname.split('/') const path_parts = pathname.split('/');
const service_name = path_parts[1] const service_name = path_parts[1];
if (!service_name || service_name === '') { if (!service_name || service_name === '') {
const error = { msg: 'missing function name in request' } const error = { msg: 'missing function name in request' };
return new Response(JSON.stringify(error), { return new Response(JSON.stringify(error), {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
} }
const servicePath = `/home/deno/functions/${service_name}` const servicePath = `/home/deno/functions/${service_name}`;
console.error(`serving the request with ${servicePath}`) console.error(`serving the request with ${servicePath}`);
const memoryLimitMb = 150 const memoryLimitMb = 150;
const workerTimeoutMs = 1 * 60 * 1000 const workerTimeoutMs = 1 * 60 * 1000;
const noModuleCache = false const noModuleCache = false;
const importMapPath = null const importMapPath = null;
const envVarsObj = Deno.env.toObject() const envVarsObj = Deno.env.toObject();
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
try { try {
const worker = await EdgeRuntime.userWorkers.create({ const worker = await EdgeRuntime.userWorkers.create({
@ -82,13 +82,13 @@ serve(async (req: Request) => {
noModuleCache, noModuleCache,
importMapPath, importMapPath,
envVars, envVars,
}) });
return await worker.fetch(req) return await worker.fetch(req);
} catch (e) { } catch (e) {
const error = { msg: e.toString() } const error = { msg: e.toString() };
return new Response(JSON.stringify(error), { return new Response(JSON.stringify(error), {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
} }
}) });

View File

@ -2,9 +2,9 @@ import { createServerClient } from '@supabase/ssr';
import { type NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import type { Database } from '@/utils/supabase/types'; import type { Database } from '@/utils/supabase/types';
export const updateSession = async (request: NextRequest) => { export const updateSession = async (
// This `try/catch` block is only here for the interactive tutorial. request: NextRequest,
// Feel free to remove once you have Supabase connected. ): Promise<NextResponse> => {
try { try {
// Create an unmodified response // Create an unmodified response
let response = NextResponse.next({ let response = NextResponse.next({
@ -45,15 +45,8 @@ export const updateSession = async (request: NextRequest) => {
return NextResponse.redirect(new URL('/sign-in', request.url)); return NextResponse.redirect(new URL('/sign-in', request.url));
} }
//if (request.nextUrl.pathname === '/' && !user.error) {
//return NextResponse.redirect(new URL('/protected', request.url));
//}
return response; return response;
} catch (e) { } catch (e) {
// If you are here, a Supabase client could not be created!
// This is likely because you have not set up environment variables.
// Check out http://localhost:3000 for Next Steps.
return NextResponse.next({ return NextResponse.next({
request: { request: {
headers: request.headers, headers: request.headers,

View File

@ -1,4 +1,4 @@
'use server' 'use server';
import 'server-only'; import 'server-only';
import { createServerClient as CreateServerClient } from '@supabase/ssr'; import { createServerClient as CreateServerClient } from '@supabase/ssr';

View File

@ -1,16 +0,0 @@
import { redirect } from 'next/navigation';
/**
* Redirects to a specified path with an encoded message as a query parameter.
* @param {('error' | 'success')} type - The type of message, either 'error' or 'success'.
* @param {string} path - The path to redirect to.
* @param {string} message - The message to be encoded and added as a query parameter.
* @returns {never} This function doesn't return as it triggers a redirect.
*/
export function encodedRedirect(
type: 'error' | 'success',
path: string,
message: string,
) {
return redirect(`${path}?${type}=${encodeURIComponent(message)}`);
}