Compare commits
20 Commits
dc7cec8539
...
main
Author | SHA1 | Date | |
---|---|---|---|
7d7ed00c22 | |||
42b07ea2da | |||
476d6c91b4 | |||
6a6c0934d5 | |||
c47c43dc92 | |||
5c5e992e7d | |||
23efcdee80 | |||
eebc022928 | |||
930dc0867d | |||
35e019558f | |||
a776c5a30a | |||
3e0c23054a | |||
f51e78ed2f | |||
ab7559555e | |||
04dceb93bd | |||
bfb6e9e648 | |||
e2f291e707 | |||
ef24642128 | |||
a9e7d8e126 | |||
f49488123b |
28
.env.example
@ -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"
|
||||
# should be updated accordingly.
|
||||
|
||||
@ -13,14 +5,26 @@
|
||||
# SERVERVAR="foo"
|
||||
# NEXT_PUBLIC_CLIENTVAR="bar"
|
||||
|
||||
# Server Variables
|
||||
NODE_ENV=
|
||||
### Server Variables ###
|
||||
# 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_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_PORT= # 5432
|
||||
#SUPABASE_DB_USER= # postgres
|
||||
|
2
.gitignore
vendored
@ -44,3 +44,5 @@ yarn-error.log*
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"useTabs": true
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
@ -16,6 +17,7 @@ export default tseslint.config(
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
@ -33,6 +35,9 @@ export default tseslint.config(
|
||||
'error',
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
|
||||
* This is especially useful for Docker builds.
|
||||
*/
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
@ -15,11 +15,53 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
serverExternalPackages: ['require-in-the-middle'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
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);
|
||||
|
28
package.json
@ -7,6 +7,7 @@
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev --turbo",
|
||||
"dev:slow": "next dev",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
@ -16,15 +17,16 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@sentry/nextjs": "^9.27.0",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"@supabase/supabase-js": "^2.50.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -33,30 +35,36 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"sonner": "^2.0.4",
|
||||
"zod": "^3.25.42"
|
||||
"react-hook-form": "^7.57.0",
|
||||
"require-in-the-middle": "^7.5.2",
|
||||
"sonner": "^2.0.5",
|
||||
"zod": "^3.25.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@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-dom": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.28.0",
|
||||
"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",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.33.0"
|
||||
"typescript-eslint": "^8.33.1"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||
}
|
||||
|
2816
pnpm-lock.yaml
generated
@ -1,4 +1,5 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@sentry/cli'
|
||||
- '@tailwindcss/oxide'
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
BIN
public/appicon/icon-114x114.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/appicon/icon-120x120.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/appicon/icon-144x144.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
public/appicon/icon-152x152.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
public/appicon/icon-180x180.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/appicon/icon-36x36.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
public/appicon/icon-48x48.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
public/appicon/icon-57x57.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
public/appicon/icon-60x60.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
public/appicon/icon-72x72.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
public/appicon/icon-76x76.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
public/appicon/icon-96x96.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/appicon/icon-precomposed.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/appicon/icon.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.png
Normal file
After Width: | Height: | Size: 13 KiB |
19
public/icons/apple.svg
Normal 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 |
2
public/icons/microsoft.svg
Normal 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 |
@ -3,6 +3,7 @@
|
||||
* for Docker builds.
|
||||
*/
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
@ -15,6 +16,7 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
serverExternalPackages: ['require-in-the-middle'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
@ -26,6 +28,40 @@ const config = {
|
||||
eslint: {
|
||||
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);
|
||||
|
@ -3,6 +3,7 @@
|
||||
* for Docker builds.
|
||||
*/
|
||||
import './src/env.js';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
@ -15,11 +16,46 @@ const config = {
|
||||
},
|
||||
],
|
||||
},
|
||||
serverExternalPackages: ['require-in-the-middle'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
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
@ -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,
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
'use server';
|
||||
|
||||
import 'server-only';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import { type EmailOtpType } from '@supabase/supabase-js';
|
||||
@ -7,13 +6,23 @@ import { type NextRequest } from 'next/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
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 type = searchParams.get('type') as EmailOtpType | null;
|
||||
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) {
|
||||
const supabase = await createServerClient();
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type,
|
||||
token_hash,
|
||||
@ -23,11 +32,12 @@ export const GET = async (request: NextRequest) => {
|
||||
return redirect('/');
|
||||
if (type === 'recovery' || type === 'email_change')
|
||||
return redirect('/profile');
|
||||
if (type === 'invite')
|
||||
return redirect('/sign-up');
|
||||
else return redirect(`/?Could not identify type ${type as string}`)
|
||||
if (type === 'invite') return redirect('/sign-up');
|
||||
}
|
||||
else return redirect(`/?${error.message}`);
|
||||
return redirect(
|
||||
`/?error=${encodeURIComponent(error?.message || 'Unknown error')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
39
src/app/(auth-pages)/auth/success/page.tsx
Normal 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;
|
@ -1,37 +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 { forgotPassword } from '@/lib/actions';
|
||||
import { FormMessage, type Message, SubmitButton } from '@/components/default';
|
||||
import { Input, Label } from '@/components/ui';
|
||||
import { SmtpMessage } from '@/app/(auth-pages)/smtp-message';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 (
|
||||
<>
|
||||
<form
|
||||
className='flex-1 flex flex-col w-full gap-2 text-foreground
|
||||
[&>input]:mb-6 min-w-64 max-w-64 mx-auto'
|
||||
>
|
||||
<div>
|
||||
<h1 className='text-2xl font-medium'>Reset Password</h1>
|
||||
<p className='text-sm text-secondary-foreground'>
|
||||
Already have an account?{' '}
|
||||
<Link className='text-primary underline' href='/sign-in'>
|
||||
Sign in
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-medium'>Reset Password</CardTitle>
|
||||
<CardDescription className='text-sm text-foreground'>
|
||||
Don't have an account?{' '}
|
||||
<Link className='font-medium underline' href='/sign-up'>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input name='email' placeholder='you@example.com' required />
|
||||
<SubmitButton formAction={forgotPassword}>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
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
|
||||
</SubmitButton>
|
||||
<FormMessage message={searchParams} />
|
||||
</div>
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ success: statusMessage }} />
|
||||
))}
|
||||
</form>
|
||||
<SmtpMessage />
|
||||
</>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default ForgotPassword;
|
||||
|
@ -1,8 +1,13 @@
|
||||
'use client';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { AvatarUpload, ProfileForm, ResetPasswordForm } from '@/components/default/profile';
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileForm,
|
||||
ResetPasswordForm,
|
||||
SignOut,
|
||||
} from '@/components/default/profile';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@ -16,14 +21,20 @@ import { toast } from 'sonner';
|
||||
import { type Result } from '@/lib/actions';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { profile, isLoading, isAuthenticated, updateProfile, refreshUserData } = useAuth();
|
||||
const {
|
||||
profile,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
updateProfile,
|
||||
refreshUserData,
|
||||
} = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/sign-in');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
|
||||
const handleAvatarUploaded = async (path: string) => {
|
||||
await updateProfile({ avatar_url: path });
|
||||
@ -50,17 +61,17 @@ const ProfilePage = () => {
|
||||
try {
|
||||
const result = await resetPassword(formData);
|
||||
if (!result.success) {
|
||||
toast.error(`Error resetting password: ${result.error}`)
|
||||
return {success: false, error: result.error};
|
||||
toast.error(`Error resetting password: ${result.error}`);
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return {success: true, data: null};
|
||||
return { success: true, data: null };
|
||||
} catch (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
|
||||
if (isLoading) {
|
||||
@ -81,7 +92,7 @@ const ProfilePage = () => {
|
||||
}
|
||||
|
||||
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'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-2xl'>Your Profile</CardTitle>
|
||||
@ -100,6 +111,8 @@ const ProfilePage = () => {
|
||||
<ProfileForm onSubmit={handleProfileSubmit} />
|
||||
<Separator />
|
||||
<ResetPasswordForm onSubmit={handleResetPasswordSubmit} />
|
||||
<Separator />
|
||||
<SignOut />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
@ -4,7 +4,6 @@ import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
@ -12,20 +11,21 @@ import {
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Label,
|
||||
} from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
import { signIn } from '@/lib/actions';
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
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({
|
||||
email: z.string().email({
|
||||
@ -34,7 +34,7 @@ const formSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: 'Password must be at least 8 characters.',
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
const Login = () => {
|
||||
const router = useRouter();
|
||||
@ -58,6 +58,7 @@ const Login = () => {
|
||||
|
||||
const handleSignIn = async (values: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
setStatusMessage('');
|
||||
const formData = new FormData();
|
||||
formData.append('email', values.email);
|
||||
formData.append('password', values.password);
|
||||
@ -67,22 +68,20 @@ const Login = () => {
|
||||
form.reset();
|
||||
router.push('');
|
||||
} else {
|
||||
setStatusMessage(`Error: ${result.error}`)
|
||||
setStatusMessage(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-medium'>
|
||||
Sign In
|
||||
</CardTitle>
|
||||
<CardDescription className='text-sm text-foreground'>
|
||||
<CardTitle className='text-3xl font-medium'>Sign In</CardTitle>
|
||||
<CardDescription className='text-foreground'>
|
||||
Don't have an account?{' '}
|
||||
<Link className='font-medium underline' href='/sign-up'>
|
||||
Sign up
|
||||
@ -93,16 +92,20 @@ const Login = () => {
|
||||
<Form {...form}>
|
||||
<form
|
||||
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
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel className='text-lg'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='email' placeholder='you@example.com' {...field} />
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='you@example.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -115,7 +118,7 @@ const Login = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className='flex justify-between'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel className='text-lg'>Password</FormLabel>
|
||||
<Link
|
||||
className='text-xs text-foreground underline text-right'
|
||||
href='/forgot-password'
|
||||
@ -124,32 +127,42 @@ const Login = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input type='password' placeholder='Your password' {...field} />
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Your password'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage && (
|
||||
<div
|
||||
className={`text-sm text-center ${
|
||||
statusMessage.includes('Error') || statusMessage.includes('failed')
|
||||
? 'text-destructive'
|
||||
: 'text-green-800'
|
||||
}`}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
pendingText='Signing In...'
|
||||
className='text-[1.0rem] cursor-pointer'
|
||||
>
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
@ -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 { signUp } from '@/lib/actions';
|
||||
import { FormMessage, type Message, SubmitButton } from '@/components/default';
|
||||
import { Input, Label } from '@/components/ui';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUser } from '@/lib/actions';
|
||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/context';
|
||||
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 searchParams = await props.searchParams;
|
||||
const user = await getUser();
|
||||
if (user.success) redirect('/profile');
|
||||
if ('message' in searchParams) {
|
||||
return (
|
||||
<div
|
||||
className='w-full flex-1 flex items-center h-screen
|
||||
sm:max-w-md justify-center gap-2 p-4'
|
||||
>
|
||||
<FormMessage message={searchParams} />
|
||||
</div>
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
}),
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address.',
|
||||
}),
|
||||
password: z.string().min(8, {
|
||||
message: 'Password must be at least 8 characters.',
|
||||
}),
|
||||
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 {
|
||||
setStatusMessage(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Error: ${error instanceof Error ? error.message : 'Could not sign in!'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className='flex flex-col min-w-64 max-w-64 mx-auto'>
|
||||
<h1 className='text-2xl font-medium'>Sign up</h1>
|
||||
<p className='text-sm text text-foreground'>
|
||||
<Card className='min-w-xs md:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-3xl font-medium'>Sign Up</CardTitle>
|
||||
<CardDescription className='text-foreground'>
|
||||
Already have an account?{' '}
|
||||
<Link className='text-primary font-medium underline' href='/sign-in'>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<div className='flex flex-col gap-2 [&>input]:mb-3 mt-8'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Input name='name' placeholder='Full Name' required />
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
<Input name='email' placeholder='you@example.com' required />
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSignUp)}
|
||||
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
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder='Your password'
|
||||
minLength={6}
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
<SubmitButton formAction={signUp} pendingText='Signing up...'>
|
||||
Sign up
|
||||
</FormControl>
|
||||
<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>
|
||||
<FormMessage message={searchParams} />
|
||||
</div>
|
||||
</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;
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { ArrowUpRight, InfoIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const SmtpMessage = () => {
|
||||
return (
|
||||
<div className='bg-muted/50 px-5 py-3 border rounded-md flex gap-4'>
|
||||
<InfoIcon size={16} className='mt-0.5' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<small className='text-sm text-secondary-foreground'>
|
||||
<strong> Note:</strong> Emails are rate limited. Enable Custom SMTP to
|
||||
increase the rate limit.
|
||||
</small>
|
||||
<div>
|
||||
<Link
|
||||
href='https://supabase.com/docs/guides/auth/auth-smtp'
|
||||
target='_blank'
|
||||
className='text-primary/50 hover:text-primary flex items-center text-sm gap-1'
|
||||
>
|
||||
Learn more <ArrowUpRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
16
src/app/(sentry)/api/sentry/example/route.ts
Normal 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
@ -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;
|
@ -1,32 +1,341 @@
|
||||
import { type Metadata } from 'next';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
import { Geist } from 'next/font/google';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThemeProvider } from '@/components/context/theme';
|
||||
import { AuthProvider } from '@/components/context/auth'
|
||||
import { AuthProvider, ThemeProvider } from '@/components/context';
|
||||
import Navigation from '@/components/default/navigation';
|
||||
import Footer from '@/components/default/footer';
|
||||
import { Toaster } from '@/components/ui';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'T3 Template with Supabase',
|
||||
description: 'Generated by create-t3-app',
|
||||
icons: [
|
||||
export const generateMetadata = (): Metadata => {
|
||||
return {
|
||||
title: {
|
||||
template: '%s | T3 Template',
|
||||
default: 'T3 Template with Supabase',
|
||||
},
|
||||
description: 'Created by Gib with T3!',
|
||||
applicationName: 'T3 Template',
|
||||
keywords:
|
||||
'T3 Template, Next.js, Supabase, Tailwind, TypeScript, React, T3, Gib, Theo',
|
||||
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
|
||||
creator: 'Gib Brown',
|
||||
publisher: 'Gib Brown',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
nocache: false,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
noimageindex: false,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
|
||||
{ url: '/favicon-16x16.png', type: 'image/png', sizes: '16x16' },
|
||||
{ url: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' },
|
||||
{ url: '/favicon.png', type: 'image/png', sizes: '96x96' },
|
||||
{
|
||||
rel: 'icon',
|
||||
url: '/images/favicon.ico',
|
||||
url: '/favicon.ico',
|
||||
type: 'image/x-icon',
|
||||
sizes: 'any',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
url: '/favicon-16x16.png',
|
||||
type: 'image/png',
|
||||
sizes: '16x16',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
{
|
||||
url: '/favicon-32x32.png',
|
||||
type: 'image/png',
|
||||
sizes: '32x32',
|
||||
url: '/images/favicon.png',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
url: '/images/appicon.png',
|
||||
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-48x48.png', type: 'image/png', sizes: '48x48' },
|
||||
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
|
||||
{ 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-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: [
|
||||
{ url: '/appicon/icon-36x36.png', type: 'image/png', sizes: '36x36' },
|
||||
{ url: '/appicon/icon-48x48.png', type: 'image/png', sizes: '48x48' },
|
||||
{ url: '/appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
|
||||
{ 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-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: [
|
||||
{ url: 'appicon/icon-57x57.png', type: 'image/png', sizes: '57x57' },
|
||||
{ url: 'appicon/icon-60x60.png', type: 'image/png', sizes: '60x60' },
|
||||
{ url: 'appicon/icon-72x72.png', type: 'image/png', sizes: '72x72' },
|
||||
{ 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-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-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-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: [
|
||||
{
|
||||
rel: 'apple-touch-icon-precomposed',
|
||||
url: '/appicon/icon-precomposed.png',
|
||||
type: 'image/png',
|
||||
sizes: '180x180',
|
||||
},
|
||||
],
|
||||
},
|
||||
other: {
|
||||
...Sentry.getTraceData(),
|
||||
},
|
||||
twitter: {
|
||||
card: 'app',
|
||||
title: 'T3 Template',
|
||||
description: 'Created by Gib with T3!',
|
||||
siteId: '',
|
||||
creator: '@cs_gib',
|
||||
creatorId: '',
|
||||
images: {
|
||||
url: 'https://git.gbrown.org/gib/T3-Template/raw/main/public/icons/apple/icon.png',
|
||||
alt: 'T3 Template',
|
||||
},
|
||||
app: {
|
||||
name: 'T3 Template',
|
||||
id: {
|
||||
iphone: '',
|
||||
ipad: '',
|
||||
googleplay: '',
|
||||
},
|
||||
url: {
|
||||
iphone: '',
|
||||
ipad: '',
|
||||
googleplay: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: 'google',
|
||||
yandex: 'yandex',
|
||||
yahoo: 'yahoo',
|
||||
},
|
||||
itunes: {
|
||||
appId: '',
|
||||
appArgument: '',
|
||||
},
|
||||
appleWebApp: {
|
||||
title: 'T3 Template',
|
||||
statusBarStyle: 'black-translucent',
|
||||
startupImage: [
|
||||
'/icons/apple/splash-768x1004.png',
|
||||
{
|
||||
url: '/icons/apple/splash-1536x2008.png',
|
||||
media: '(device-width: 768px) and (device-height: 1024px)',
|
||||
},
|
||||
],
|
||||
},
|
||||
appLinks: {
|
||||
ios: {
|
||||
url: 'https://t3-template.gbrown.org/ios',
|
||||
app_store_id: 't3_template',
|
||||
},
|
||||
android: {
|
||||
package: 'org.gbrown.android/t3-template',
|
||||
app_name: 'app_t3_template',
|
||||
},
|
||||
web: {
|
||||
url: 'https://t3-template.gbrown.org/web',
|
||||
should_fallback: true,
|
||||
},
|
||||
},
|
||||
facebook: {
|
||||
appId: '',
|
||||
},
|
||||
pinterest: {
|
||||
richPin: true,
|
||||
},
|
||||
category: 'technology',
|
||||
};
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
@ -50,7 +359,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
<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'>
|
||||
<div
|
||||
className='flex flex-col gap-20 max-w-5xl
|
||||
p-5 w-full items-center'
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,14 +4,61 @@ import { FetchDataSteps } from '@/components/default/tutorial';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { getUser } from '@/lib/actions';
|
||||
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 response = await getUser();
|
||||
if (!response.success || !response.data) {
|
||||
return (
|
||||
<main className='w-full items-center justify-center'>
|
||||
<div className='flex p-5 items-center justify-center'>
|
||||
<h1>Make sure you can sign in!</h1>
|
||||
<div className='flex flex-col p-5 items-center justify-center space-y-6'>
|
||||
<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>
|
||||
</main>
|
||||
);
|
||||
@ -30,14 +77,15 @@ const HomePage = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
className='text-xs font-mono p-3 rounded
|
||||
border max-h-32 overflow-auto'
|
||||
className='text-sm font-mono p-3 rounded
|
||||
border max-h-50 overflow-auto'
|
||||
>
|
||||
{JSON.stringify(user, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<TestSentryCard />
|
||||
<div>
|
||||
<h2 className='font-bold text-2xl mb-4'>Next steps</h2>
|
||||
<FetchDataSteps />
|
||||
|
195
src/components/context/Auth.tsx
Normal 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;
|
||||
};
|
68
src/components/context/Theme.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
2
src/components/context/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { AuthProvider, useAuth } from './Auth';
|
||||
export { ThemeProvider, ThemeToggle } from './Theme';
|
@ -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>
|
||||
);
|
||||
};
|
25
src/components/default/StatusMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
39
src/components/default/SubmitButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
src/components/default/auth/SignInSignUp.tsx
Normal 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>
|
||||
);
|
||||
};
|
77
src/components/default/auth/SignInWithApple.tsx
Normal 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>
|
||||
);
|
||||
};
|
70
src/components/default/auth/SignInWithMicrosoft.tsx
Normal 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>
|
||||
);
|
||||
};
|
3
src/components/default/auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './SignInSignUp';
|
||||
export * from './SignInWithApple';
|
||||
export * from './SignInWithMicrosoft';
|
@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
|
||||
const FooterTest = () => {
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className='w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16'>
|
||||
<p>
|
||||
@ -17,4 +17,4 @@ const FooterTest = () => {
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
export default FooterTest;
|
||||
export default Footer;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import { FormMessage, type Message } from '@/components/default/form-message';
|
||||
import { SubmitButton } from '@/components/default/submit-button';
|
||||
|
||||
export { FormMessage, type Message, SubmitButton };
|
||||
export {
|
||||
StatusMessage,
|
||||
type Message,
|
||||
} from '@/components/default/StatusMessage';
|
||||
export { SubmitButton } from '@/components/default/SubmitButton';
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signOut } from '@/lib/actions';
|
||||
import { User } from 'lucide-react';
|
||||
@ -31,7 +31,8 @@ const AvatarDropdown = () => {
|
||||
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return '';
|
||||
return name.split(' ')
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
@ -42,12 +43,19 @@ const AvatarDropdown = () => {
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar className='cursor-pointer'>
|
||||
{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'>
|
||||
{profile?.full_name
|
||||
? getInitials(profile.full_name)
|
||||
: <User size={32} />}
|
||||
{profile?.full_name ? (
|
||||
getInitials(profile.full_name)
|
||||
) : (
|
||||
<User size={32} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
@ -56,14 +64,20 @@ const AvatarDropdown = () => {
|
||||
<DropdownMenuLabel>{profile?.full_name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href='/profile' className='w-full justify-center cursor-pointer'>
|
||||
<Link
|
||||
href='/profile'
|
||||
className='w-full justify-center cursor-pointer'
|
||||
>
|
||||
Edit profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className='h-[2px]' />
|
||||
<DropdownMenuItem asChild>
|
||||
<button onClick={handleSignOut} className='w-full justify-center cursor-pointer'>
|
||||
Log out
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className='w-full justify-center cursor-pointer'
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
@ -1,9 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui';
|
||||
import { getProfile } from '@/lib/actions';
|
||||
import AvatarDropdown from './AvatarDropdown';
|
||||
import { SignInSignUp } from '@/components/default/auth';
|
||||
|
||||
const NavigationAuth = async () => {
|
||||
try {
|
||||
@ -13,17 +12,11 @@ const NavigationAuth = async () => {
|
||||
<AvatarDropdown />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2'>
|
||||
<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>
|
||||
<SignInSignUp />
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error getting profile:', error);
|
||||
console.error(`Error getting profile: ${error as string}`);
|
||||
return <SignInSignUp />;
|
||||
}
|
||||
};
|
||||
export default NavigationAuth;
|
||||
|
@ -3,7 +3,8 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui';
|
||||
import NavigationAuth from './auth';
|
||||
import { ThemeToggle } from '@/components/context/theme';
|
||||
import { ThemeToggle } from '@/components/context';
|
||||
import Image from 'next/image';
|
||||
|
||||
const Navigation = () => {
|
||||
return (
|
||||
@ -16,7 +17,10 @@ const Navigation = () => {
|
||||
items-center p-3 px-5 text-sm'
|
||||
>
|
||||
<div className='flex gap-5 items-center font-semibold'>
|
||||
<Link href={'/'}>T3 Supabase Template</Link>
|
||||
<Link className='flex flex-row my-auto gap-2' href='/'>
|
||||
<Image src='/favicon.png' alt='T3 Logo' width={50} height={50} />
|
||||
<h1 className='my-auto text-2xl'>T3 Supabase Template</h1>
|
||||
</Link>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild>
|
||||
<Link href='https://git.gbrown.org/gib/T3-Template'>
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { Avatar, AvatarFallback, AvatarImage, CardContent } from '@/components/ui';
|
||||
import { useAuth } from '@/components/context';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
CardContent,
|
||||
} from '@/components/ui';
|
||||
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
||||
|
||||
type AvatarUploadProps = {
|
||||
@ -28,16 +33,17 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
maxHeight: 500,
|
||||
quality: 0.8,
|
||||
},
|
||||
prevPath: profile?.avatar_url,
|
||||
replace: { replace: true, path: profile?.avatar_url ?? file.name },
|
||||
});
|
||||
if (result.success && result.path) {
|
||||
await onAvatarUploaded(result.path);
|
||||
if (result.success && result.data) {
|
||||
await onAvatarUploaded(result.data);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return '';
|
||||
return name.split(' ')
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
@ -45,7 +51,6 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
|
||||
return (
|
||||
<CardContent>
|
||||
|
||||
<div className='flex flex-col items-center'>
|
||||
<div
|
||||
className='relative group cursor-pointer mb-4'
|
||||
@ -53,12 +58,19 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||
>
|
||||
<Avatar className='h-32 w-32'>
|
||||
{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'>
|
||||
{profile?.full_name
|
||||
? getInitials(profile.full_name)
|
||||
: <User size={32} />}
|
||||
{profile?.full_name ? (
|
||||
getInitials(profile.full_name)
|
||||
) : (
|
||||
<User size={32} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
|
@ -2,7 +2,6 @@ import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Button,
|
||||
CardContent,
|
||||
Form,
|
||||
FormControl,
|
||||
@ -13,9 +12,9 @@ import {
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@/components/ui';
|
||||
import { Loader2 } from 'lucide-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({
|
||||
full_name: z.string().min(5, {
|
||||
@ -28,7 +27,7 @@ type ProfileFormProps = {
|
||||
onSubmit: (values: z.infer<typeof formSchema>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ProfileForm = ({onSubmit}: ProfileFormProps) => {
|
||||
export const ProfileForm = ({ onSubmit }: ProfileFormProps) => {
|
||||
const { profile, isLoading } = useAuth();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@ -90,19 +89,12 @@ export const ProfileForm = ({onSubmit}: ProfileFormProps) => {
|
||||
/>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button type='submit' disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
<SubmitButton disabled={isLoading} pendingText='Saving...'>
|
||||
Save Changes
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
import { SubmitButton } from '@/components/default';
|
||||
import { useState } from 'react';
|
||||
import { type Result } from '@/lib/actions';
|
||||
import { FormMessage as Pw } from '@/components/default';
|
||||
import { StatusMessage } from '@/components/default';
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
@ -69,7 +69,7 @@ export const ResetPasswordForm = ({
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
error instanceof Error ? error.message : 'Password was not updated!'
|
||||
error instanceof Error ? error.message : 'Password was not updated!',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -85,7 +85,6 @@ export const ResetPasswordForm = ({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleUpdatePassword)}
|
||||
@ -123,17 +122,15 @@ export const ResetPasswordForm = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{statusMessage && (
|
||||
<div
|
||||
className={`text-sm text-center ${
|
||||
statusMessage.includes('Error') || statusMessage.includes('failed')
|
||||
? 'text-destructive'
|
||||
: 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
{statusMessage &&
|
||||
(statusMessage.includes('Error') ||
|
||||
statusMessage.includes('error') ||
|
||||
statusMessage.includes('failed') ||
|
||||
statusMessage.includes('invalid') ? (
|
||||
<StatusMessage message={{ error: statusMessage }} />
|
||||
) : (
|
||||
<StatusMessage message={{ message: statusMessage }} />
|
||||
))}
|
||||
<div className='flex justify-center'>
|
||||
<SubmitButton
|
||||
disabled={isLoading}
|
||||
|
34
src/components/default/profile/SignOut.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export * from './AvatarUpload';
|
||||
export * from './ProfileForm';
|
||||
export * from './ResetPasswordForm';
|
||||
export * from './SignOut';
|
||||
|
126
src/components/default/sentry/TestSentry.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
src/components/default/sentry/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { TestSentryCard } from './TestSentry';
|
@ -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>
|
||||
);
|
||||
};
|
61
src/components/default/tutorial/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
95
src/components/default/tutorial/FetchDataSteps.tsx
Normal 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'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're ready to launch your product to the world! 🚀</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
};
|
30
src/components/default/tutorial/TutorialStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'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're ready to launch your product to the world! 🚀</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
};
|
@ -1,3 +1,3 @@
|
||||
export { CodeBlock } from './code-block';
|
||||
export { FetchDataSteps } from './fetch-data-steps';
|
||||
export { TutorialStep } from './tutorial-step';
|
||||
export { CodeBlock } from './CodeBlock';
|
||||
export { FetchDataSteps } from './FetchDataSteps';
|
||||
export { TutorialStep } from './TutorialStep';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -25,6 +25,8 @@ const buttonVariants = cva(
|
||||
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',
|
||||
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',
|
||||
smicon: 'size-6',
|
||||
},
|
||||
|
17
src/env.js
@ -7,7 +7,11 @@ export const env = createEnv({
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
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: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
|
||||
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: {
|
||||
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_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
|
||||
|
36
src/instrumentation-client.ts
Normal 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
@ -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);
|
||||
};
|
@ -1,14 +1,14 @@
|
||||
'use server';
|
||||
|
||||
import 'server-only';
|
||||
import { encodedRedirect } from '@/utils/utils';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
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 email = formData.get('email') 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');
|
||||
|
||||
if (!email || !password) {
|
||||
return encodedRedirect(
|
||||
'error',
|
||||
'/sign-up',
|
||||
'Email & password are required',
|
||||
);
|
||||
return { success: false, error: 'Email and password are required' };
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signUp({
|
||||
@ -35,20 +31,18 @@ export const signUp = async (formData: FormData) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return encodedRedirect('error', '/sign-up', error.message);
|
||||
return { success: false, error: error.message };
|
||||
} else {
|
||||
return encodedRedirect(
|
||||
'success',
|
||||
'/sign-up',
|
||||
'Thanks for signing up! Please check your email for a verification link.',
|
||||
);
|
||||
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>> => {
|
||||
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 = 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 supabase = await createServerClient();
|
||||
const origin = (await headers()).get('origin');
|
||||
const callbackUrl = formData.get('callbackUrl') as string;
|
||||
|
||||
if (!email) {
|
||||
return encodedRedirect('error', '/forgot-password', 'Email is required');
|
||||
return { success: false, error: 'Email is required' };
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
@ -79,30 +101,24 @@ export const forgotPassword = async (formData: FormData) => {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return encodedRedirect(
|
||||
'error',
|
||||
'/forgot-password',
|
||||
'Could not reset password',
|
||||
);
|
||||
return { success: false, error: 'Could not reset password' };
|
||||
}
|
||||
|
||||
if (callbackUrl) {
|
||||
return redirect(callbackUrl);
|
||||
}
|
||||
|
||||
return encodedRedirect(
|
||||
'success',
|
||||
'/forgot-password',
|
||||
'Check your email for a link to reset your password.',
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: 'Check your email for a link to reset your password.',
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const resetPassword = async (formData: FormData): Promise<Result<null>> => {
|
||||
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!' };
|
||||
return {
|
||||
success: false,
|
||||
error: 'Password and confirm password are required!',
|
||||
};
|
||||
}
|
||||
const supabase = await createServerClient();
|
||||
if (password !== confirmPassword) {
|
||||
@ -112,7 +128,10 @@ export const resetPassword = async (formData: FormData): Promise<Result<null>> =
|
||||
password,
|
||||
});
|
||||
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 };
|
||||
};
|
||||
@ -120,7 +139,7 @@ export const resetPassword = async (formData: FormData): Promise<Result<null>> =
|
||||
export const signOut = async (): Promise<Result<null>> => {
|
||||
const supabase = await createServerClient();
|
||||
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 };
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
import 'server-only';
|
||||
import { createServerClient, type Profile } from '@/utils/supabase';
|
||||
import { getUser } from '@/lib/actions';
|
||||
import type { Result } from './index';
|
||||
import type { Result } from '.';
|
||||
|
||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
||||
try {
|
||||
|
48
src/lib/actions/storage.ts
Normal file → Executable file
@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
import 'server-only';
|
||||
import { createServerClient } from '@/utils/supabase';
|
||||
import type { Result } from './index';
|
||||
import type { Result } from '.';
|
||||
|
||||
export type GetStorageProps = {
|
||||
bucket: string;
|
||||
@ -23,30 +23,27 @@ export type UploadStorageProps = {
|
||||
file: File;
|
||||
options?: {
|
||||
cacheControl?: string;
|
||||
upsert?: boolean;
|
||||
contentType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReplaceStorageProps = {
|
||||
bucket: string;
|
||||
prevPath: string;
|
||||
path: string;
|
||||
file: File;
|
||||
options?: {
|
||||
cacheControl?: string;
|
||||
upsert?: boolean;
|
||||
contentType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type resizeImageProps = {
|
||||
file: File,
|
||||
file: File;
|
||||
options?: {
|
||||
maxWidth?: number,
|
||||
maxHeight?: number,
|
||||
quality?: number,
|
||||
}
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const getSignedUrl = async ({
|
||||
@ -78,7 +75,7 @@ export const getSignedUrl = async ({
|
||||
: 'Unknown error getting signed URL',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getPublicUrl = async ({
|
||||
bucket,
|
||||
@ -88,9 +85,7 @@ export const getPublicUrl = async ({
|
||||
}: GetStorageProps): Promise<Result<string>> => {
|
||||
try {
|
||||
const supabase = await createServerClient();
|
||||
const { data } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(url, {
|
||||
const { data } = supabase.storage.from(bucket).getPublicUrl(url, {
|
||||
download,
|
||||
transform,
|
||||
});
|
||||
@ -107,7 +102,7 @@ export const getPublicUrl = async ({
|
||||
: 'Unknown error getting public URL',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadFile = async ({
|
||||
bucket,
|
||||
@ -132,11 +127,10 @@ export const uploadFile = async ({
|
||||
error instanceof Error ? error.message : 'Unknown error uploading file',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceFile = async ({
|
||||
bucket,
|
||||
prevPath,
|
||||
path,
|
||||
file,
|
||||
options = {},
|
||||
@ -145,13 +139,9 @@ export const replaceFile = async ({
|
||||
const supabase = await createServerClient();
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.update(path, file, options);
|
||||
.update(path, file, { ...options, upsert: true });
|
||||
if (error) throw error;
|
||||
if (!data?.path) throw new Error('No path returned from upload');
|
||||
const deleteFileData = await deleteFile({
|
||||
bucket,
|
||||
path: [...prevPath],
|
||||
});
|
||||
return { success: true, data: data.path };
|
||||
} catch (error) {
|
||||
return {
|
||||
@ -184,7 +174,7 @@ export const deleteFile = async ({
|
||||
error instanceof Error ? error.message : 'Unknown error deleting file',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add a helper to list files in a bucket
|
||||
export const listFiles = async ({
|
||||
@ -218,17 +208,13 @@ export const listFiles = async ({
|
||||
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;
|
||||
const { maxWidth = 800, maxHeight = 800, quality = 0.8 } = options;
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
@ -240,11 +226,11 @@ export const resizeImage = async ({
|
||||
let height = img.height;
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((height * maxWidth / width));
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
}
|
||||
} else if (height > maxHeight) {
|
||||
width = Math.round((width * maxHeight / height));
|
||||
width = Math.round((width * maxHeight) / height);
|
||||
height = maxHeight;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
@ -262,7 +248,7 @@ export const resizeImage = async ({
|
||||
resolve(resizedFile);
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
quality,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
148
src/lib/hooks/auth.ts
Normal 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
@ -1,2 +1,8 @@
|
||||
export * from './resizeImage';
|
||||
export * from './auth';
|
||||
export * from './public';
|
||||
export * from './storage';
|
||||
export * from './useFileUpload';
|
||||
|
||||
export type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
79
src/lib/hooks/public.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
};
|
@ -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
@ -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,
|
||||
);
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { deleteFile, uploadFile } from '@/lib/actions';
|
||||
import { replaceFile, uploadFile } from '@/lib/hooks';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/components/context/auth';
|
||||
import { useAuth } from '@/components/context';
|
||||
import { resizeImage } from '@/lib/hooks';
|
||||
import type { Result } from '.';
|
||||
|
||||
export type Replace = { replace: true; path: string } | false;
|
||||
|
||||
export type uploadToStorageProps = {
|
||||
file: File;
|
||||
@ -15,7 +18,7 @@ export type uploadToStorageProps = {
|
||||
maxHeight?: number;
|
||||
quality?: number;
|
||||
};
|
||||
prevPath?: string | null;
|
||||
replace?: Replace;
|
||||
};
|
||||
|
||||
export const useFileUpload = () => {
|
||||
@ -28,21 +31,26 @@ export const useFileUpload = () => {
|
||||
bucket,
|
||||
resize = false,
|
||||
options = {},
|
||||
prevPath = null,
|
||||
}: uploadToStorageProps) => {
|
||||
replace = false,
|
||||
}: uploadToStorageProps): Promise<Result<string>> => {
|
||||
try {
|
||||
if (!isAuthenticated) throw new Error('User is not authenticated');
|
||||
|
||||
setIsUploading(true);
|
||||
if (prevPath !== null) {
|
||||
const deleteResult = await deleteFile({
|
||||
if (replace) {
|
||||
const updateResult = await replaceFile({
|
||||
bucket,
|
||||
path: [...prevPath],
|
||||
path: replace.path,
|
||||
file,
|
||||
options: {
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
if (!deleteResult.success) {
|
||||
console.error('Error deleting file:', deleteResult.error);
|
||||
throw new Error(deleteResult.error || `Failed to delete ${prevPath}`);
|
||||
} else console.log('Delete sucessful!')
|
||||
if (!updateResult.success) {
|
||||
return { success: false, error: updateResult.error };
|
||||
} else {
|
||||
return { success: true, data: updateResult.data };
|
||||
}
|
||||
}
|
||||
|
||||
let fileToUpload = file;
|
||||
@ -59,7 +67,6 @@ export const useFileUpload = () => {
|
||||
path: fileName,
|
||||
file: fileToUpload,
|
||||
options: {
|
||||
upsert: true,
|
||||
contentType: file.type,
|
||||
},
|
||||
});
|
||||
@ -68,15 +75,21 @@ export const useFileUpload = () => {
|
||||
throw new Error(uploadResult.error || `Failed to upload to ${bucket}`);
|
||||
}
|
||||
|
||||
return { success: true, path: uploadResult.data };
|
||||
return { success: true, data: uploadResult.data };
|
||||
} catch (error) {
|
||||
console.error(`Error uploading to ${bucket}:`, error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `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 {
|
||||
setIsUploading(false);
|
||||
// Clear the input value so the same file can be selected again
|
||||
|
@ -1,7 +1,119 @@
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@ -12,9 +124,10 @@ export const config = {
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - /monitoring-tunnel (Sentry monitoring)
|
||||
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
|
||||
* 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)$).*)',
|
||||
],
|
||||
};
|
||||
|
@ -58,6 +58,12 @@ create policy "Avatar images are publicly accessible." on storage.objects
|
||||
create policy "Anyone can upload an avatar." on storage.objects
|
||||
for insert with check (bucket_id = 'avatars');
|
||||
|
||||
create policy "Anyone can update an avatar." on storage.objects
|
||||
for update using (bucket_id = 'avatars');
|
||||
|
||||
create policy "Anyone can delete an avatar." on storage.objects
|
||||
for delete using (bucket_id = 'avatars');
|
||||
|
||||
-- -- Create a table for public statuses
|
||||
-- CREATE TABLE statuses (
|
||||
-- id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
|
@ -2,14 +2,13 @@
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// 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 () => {
|
||||
return new Response(
|
||||
`"Hello from Edge Functions!"`,
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
)
|
||||
})
|
||||
return new Response(`"Hello from Edge Functions!"`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// To invoke:
|
||||
// curl 'http://localhost:<KONG_HTTP_PORT>/functions/v1/hello' \
|
||||
|
@ -1,78 +1,78 @@
|
||||
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 { 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';
|
||||
|
||||
console.log('main function started')
|
||||
console.log('main function started');
|
||||
|
||||
const JWT_SECRET = Deno.env.get('JWT_SECRET')
|
||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
|
||||
const JWT_SECRET = Deno.env.get('JWT_SECRET');
|
||||
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true';
|
||||
|
||||
function getAuthToken(req: Request) {
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const authHeader = req.headers.get('authorization');
|
||||
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') {
|
||||
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> {
|
||||
const encoder = new TextEncoder()
|
||||
const secretKey = encoder.encode(JWT_SECRET)
|
||||
const encoder = new TextEncoder();
|
||||
const secretKey = encoder.encode(JWT_SECRET);
|
||||
try {
|
||||
await jose.jwtVerify(jwt, secretKey)
|
||||
await jose.jwtVerify(jwt, secretKey);
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return false
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
serve(async (req: Request) => {
|
||||
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
|
||||
try {
|
||||
const token = getAuthToken(req)
|
||||
const isValidJWT = await verifyJWT(token)
|
||||
const token = getAuthToken(req);
|
||||
const isValidJWT = await verifyJWT(token);
|
||||
|
||||
if (!isValidJWT) {
|
||||
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(e);
|
||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(req.url)
|
||||
const { pathname } = url
|
||||
const path_parts = pathname.split('/')
|
||||
const service_name = path_parts[1]
|
||||
const url = new URL(req.url);
|
||||
const { pathname } = url;
|
||||
const path_parts = pathname.split('/');
|
||||
const service_name = path_parts[1];
|
||||
|
||||
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), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const servicePath = `/home/deno/functions/${service_name}`
|
||||
console.error(`serving the request with ${servicePath}`)
|
||||
const servicePath = `/home/deno/functions/${service_name}`;
|
||||
console.error(`serving the request with ${servicePath}`);
|
||||
|
||||
const memoryLimitMb = 150
|
||||
const workerTimeoutMs = 1 * 60 * 1000
|
||||
const noModuleCache = false
|
||||
const importMapPath = null
|
||||
const envVarsObj = Deno.env.toObject()
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
|
||||
const memoryLimitMb = 150;
|
||||
const workerTimeoutMs = 1 * 60 * 1000;
|
||||
const noModuleCache = false;
|
||||
const importMapPath = null;
|
||||
const envVarsObj = Deno.env.toObject();
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
||||
|
||||
try {
|
||||
const worker = await EdgeRuntime.userWorkers.create({
|
||||
@ -82,13 +82,13 @@ serve(async (req: Request) => {
|
||||
noModuleCache,
|
||||
importMapPath,
|
||||
envVars,
|
||||
})
|
||||
return await worker.fetch(req)
|
||||
});
|
||||
return await worker.fetch(req);
|
||||
} catch (e) {
|
||||
const error = { msg: e.toString() }
|
||||
const error = { msg: e.toString() };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
@ -2,9 +2,9 @@ import { createServerClient } from '@supabase/ssr';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import type { Database } from '@/utils/supabase/types';
|
||||
|
||||
export const updateSession = async (request: NextRequest) => {
|
||||
// This `try/catch` block is only here for the interactive tutorial.
|
||||
// Feel free to remove once you have Supabase connected.
|
||||
export const updateSession = async (
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse> => {
|
||||
try {
|
||||
// Create an unmodified response
|
||||
let response = NextResponse.next({
|
||||
@ -45,15 +45,8 @@ export const updateSession = async (request: NextRequest) => {
|
||||
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;
|
||||
} 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({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
|
@ -1,4 +1,4 @@
|
||||
'use server'
|
||||
'use server';
|
||||
|
||||
import 'server-only';
|
||||
import { createServerClient as CreateServerClient } from '@supabase/ssr';
|
||||
|
@ -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)}`);
|
||||
}
|