diff --git a/package.json b/package.json index 016515f..5cce0c7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.1", + "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-slot": "^1.2.2", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.49.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43669f0..cfca979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@radix-ui/react-checkbox': + specifier: ^1.3.1 + version: 1.3.1(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-label': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.2 version: 1.2.2(@types/react@19.1.4)(react@19.1.0) @@ -380,6 +386,22 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-checkbox@1.3.1': + resolution: {integrity: sha512-xTaLKAO+XXMPK/BpVTSaAAhlefmvMSACjIhK9mGsImvX2ljcTDm8VGR1CuS1uYcNdR5J+oiOhoJZc5un6bh3VQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -389,6 +411,54 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.6': + resolution: {integrity: sha512-S/hv1mTlgcPX2gCTJrWuTjSXf7ER3Zf7zWGtOprxhIIY93Qin3n5VgNA0Ez9AgrK/lEtlYgzLd4f5x6AVar4Yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.2': + resolution: {integrity: sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.2': resolution: {integrity: sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==} peerDependencies: @@ -398,6 +468,51 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2475,12 +2590,64 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/react-checkbox@1.3.1(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.4(@types/react@19.1.4) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.4)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.1.4 + '@radix-ui/react-context@1.1.2(@types/react@19.1.4)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-label@2.1.6(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.4(@types/react@19.1.4) + + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.4(@types/react@19.1.4) + + '@radix-ui/react-primitive@2.1.2(@types/react-dom@19.1.4(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.2(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.4(@types/react@19.1.4) + '@radix-ui/react-slot@1.2.2(@types/react@19.1.4)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.0) @@ -2488,6 +2655,40 @@ snapshots: optionalDependencies: '@types/react': 19.1.4 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.4)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.4)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.4)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.4)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.4)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.4 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.11.0': {} diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..c593aac --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,138 @@ +'use server'; + +import 'server-only'; +import { encodedRedirect } from '@/utils/utils'; +import { createClient } from '@/utils/supabase/server'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +export const signUp = async (formData: FormData) => { + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const supabase = await createClient(); + const origin = (await headers()).get('origin'); + + if (!email || !password) { + return encodedRedirect( + 'error', + '/sign-up', + 'Email & password are required', + ); + } + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${origin}/auth/callback`, + }, + }); + + if (error) { + console.error(error.code + ': ' + error.message); + return encodedRedirect( + 'error', + '/sign-up', + 'Thanks for signing up! Please check your email for a verification link.', + ); + } else { + return encodedRedirect( + 'success', + '/sign-up', + 'Thanks for signing up! Please check your email for a verification link.', + ); + } +}; + +export const signIn = async (formData: FormData) => { + const email = formData.get('email') as string; + const password = formData.get('password') as string; + const supabase = await createClient(); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return encodedRedirect('error', '/sign-in', error.message); + } + + return redirect('/protected'); +}; + +export const forgotPassword = async (formData: FormData) => { + const email = formData.get('email') as string; + const supabase = await createClient(); + const origin = (await headers()).get('origin'); + const callbackUrl = formData.get('callbackUrl') as string; + + if (!email) { + return encodedRedirect('error', '/forgot-password', 'Email is required'); + } + + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, + }); + + if (error) { + console.error(error.message); + return encodedRedirect( + 'error', + '/forgot-password', + 'Could not reset password', + ); + } + + if (callbackUrl) { + return redirect(callbackUrl); + } + + return encodedRedirect( + 'success', + '/forgot-password', + 'Check your email for a link to reset your password.', + ); +}; + +export const resetPassword = async (formData: FormData) => { + const supabase = await createClient(); + const password = formData.get('password') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (!password || !confirmPassword) { + encodedRedirect( + 'error', + '/protected/reset-password', + 'Password and confirm password are required', + ); + } + + if (password !== confirmPassword) { + encodedRedirect( + 'error', + '/protected/reset-password', + 'Passwords do not match', + ); + } + + const { error } = await supabase.auth.updateUser({ + password: password, + }); + + if (error) { + encodedRedirect( + 'error', + '/protected/reset-password', + 'Password update failed', + ); + } + + encodedRedirect('success', '/protected/reset-password', 'Password updated'); +}; + +export const signOut = async () => { + const supabase = await createClient(); + await supabase.auth.signOut(); + return redirect('/sign-in'); +}; diff --git a/src/app/(auth-pages)/forgot-password/page.tsx b/src/app/(auth-pages)/forgot-password/page.tsx new file mode 100644 index 0000000..443cadf --- /dev/null +++ b/src/app/(auth-pages)/forgot-password/page.tsx @@ -0,0 +1,37 @@ +import Link from 'next/link'; +import { forgotPassword } from '@/actions/auth'; +import { FormMessage, type Message, SubmitButton } from '@/components/default'; +import { Input, Label } from '@/components/ui'; +import { SmtpMessage } from '@/app/(auth-pages)/smtp-message'; + +const ForgotPassword = async (props: { searchParams: Promise }) => { + const searchParams = await props.searchParams; + return ( + <> +
+
+

Reset Password

+

+ Already have an account?{' '} + + Sign in + +

+
+
+ + + + Reset Password + + +
+
+ + + ); +}; +export default ForgotPassword; diff --git a/src/app/(auth-pages)/layout.tsx b/src/app/(auth-pages)/layout.tsx new file mode 100644 index 0000000..ea6d1a2 --- /dev/null +++ b/src/app/(auth-pages)/layout.tsx @@ -0,0 +1,6 @@ +const Layout = async ({ children }: { children: React.ReactNode }) => { + return ( +
{children}
+ ); +}; +export default Layout; diff --git a/src/app/(auth-pages)/sign-in/page.tsx b/src/app/(auth-pages)/sign-in/page.tsx new file mode 100644 index 0000000..b61d3a6 --- /dev/null +++ b/src/app/(auth-pages)/sign-in/page.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link'; +import { signIn } from '@/actions/auth'; +import { FormMessage, type Message, SubmitButton } from '@/components/default'; +import { Input, Label } from '@/components/ui'; + +const Login = async (props: { searchParams: Promise }) => { + const searchParams = await props.searchParams; + return ( +
+

Sign in

+

+ Don't have an account?{' '} + + Sign up + +

+
+ + +
+ + + Forgot Password? + +
+ + + Sign in + + +
+
+ ); +}; +export default Login; diff --git a/src/app/(auth-pages)/sign-up/page.tsx b/src/app/(auth-pages)/sign-up/page.tsx new file mode 100644 index 0000000..952e509 --- /dev/null +++ b/src/app/(auth-pages)/sign-up/page.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link'; +import { signUp } from '@/actions/auth'; +import { FormMessage, type Message, SubmitButton } from '@/components/default'; +import { Input, Label } from '@/components/ui'; +import { SmtpMessage } from '@/app/(auth-pages)/smtp-message'; + +const SignUp = async (props: { searchParams: Promise }) => { + const searchParams = await props.searchParams; + if ('message' in searchParams) { + return ( +
+ +
+ ); + } else { + return ( + <> +
+

Sign up

+

+ Already have an account?{' '} + + Sign in + +

+
+ + + + + + Sign up + + +
+
+ + + ); + } +}; +export default SignUp; diff --git a/src/app/(auth-pages)/smtp-message.tsx b/src/app/(auth-pages)/smtp-message.tsx new file mode 100644 index 0000000..f63dbce --- /dev/null +++ b/src/app/(auth-pages)/smtp-message.tsx @@ -0,0 +1,25 @@ +import { ArrowUpRight, InfoIcon } from 'lucide-react'; +import Link from 'next/link'; + +export const SmtpMessage = () => { + return ( +
+ +
+ + Note: Emails are rate limited. Enable Custom SMTP to + increase the rate limit. + +
+ + Learn more + +
+
+
+ ); +}; diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts new file mode 100644 index 0000000..19e17f1 --- /dev/null +++ b/src/app/auth/callback/route.ts @@ -0,0 +1,24 @@ +import { createClient } from '@/utils/supabase/server'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + // The `/auth/callback` route is required for the server-side auth flow implemented + // by the SSR package. It exchanges an auth code for the user's session. + // https://supabase.com/docs/guides/auth/server-side/nextjs + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get('code'); + const origin = requestUrl.origin; + const redirectTo = requestUrl.searchParams.get('redirect_to')?.toString(); + + if (code) { + const supabase = await createClient(); + await supabase.auth.exchangeCodeForSession(code); + } + + if (redirectTo) { + return NextResponse.redirect(`${origin}${redirectTo}`); + } + + // URL to redirect to after sign up process completes + return NextResponse.redirect(`${origin}/protected`); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a992615..72acbaf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import '@/styles/globals.css'; import { Geist } from 'next/font/google'; import { cn } from '@/lib/utils'; import { ThemeProvider } from '@/components/context/theme'; +import Navigation from '@/components/navigation'; export const metadata: Metadata = { title: 'T3 Template with Supabase', @@ -44,7 +45,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { >
- + +
+ {children} +
diff --git a/src/app/page.tsx b/src/app/page.tsx index f2b8d37..4509f3b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,37 +1,14 @@ -import Link from 'next/link'; - -export default function HomePage() { +const HomePage = () => { return ( -
-
-

- Create T3 App -

-
- -

First Steps →

-
- Just the basics - Everything you need to know to set up your - database and authentication. -
- - -

Documentation →

-
- Learn more about Create T3 App, the libraries it uses, and how to - deploy it. -
- -
-
+
+
); -} +}; +export default HomePage; diff --git a/src/app/protected/page.tsx b/src/app/protected/page.tsx new file mode 100644 index 0000000..cf5e7bc --- /dev/null +++ b/src/app/protected/page.tsx @@ -0,0 +1,40 @@ +'use server'; + +import { FetchDataSteps } from '@/components/tutorial'; +import { createClient } from '@/utils/supabase/server'; +import { InfoIcon } from 'lucide-react'; +import { redirect } from 'next/navigation'; + +const ProtectedPage = async () => { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return redirect('sign-in'); + } + + return ( +
+
+
+ + This is a protected page that you can only see as an authenticated + user +
+
+
+

Your user details

+
+          {JSON.stringify(user, null, 2)}
+        
+
+
+

Next steps

+ +
+
+ ); +}; +export default ProtectedPage; diff --git a/src/app/protected/reset-password/page.tsx b/src/app/protected/reset-password/page.tsx new file mode 100644 index 0000000..09cf889 --- /dev/null +++ b/src/app/protected/reset-password/page.tsx @@ -0,0 +1,36 @@ +import { resetPassword } from "@/actions/auth"; +import { FormMessage, type Message, SubmitButton } from '@/components/default'; +import { Input, Label } from "@/components/ui"; + +const ResetPassword = async (props: { + searchParams: Promise; +}) => { + const searchParams = await props.searchParams; + return ( +
+

Reset password

+

+ Please enter your new password below. +

+ + + + + + Reset password + + + + ); +} +export default ResetPassword; diff --git a/src/components/default/form-message.tsx b/src/components/default/form-message.tsx new file mode 100644 index 0000000..42b9a7b --- /dev/null +++ b/src/components/default/form-message.tsx @@ -0,0 +1,24 @@ +export type Message = + | { success: string } + | { error: string } + | { message: string }; + +export const FormMessage = ({ message }: { message: Message }) => { + return ( +
+ {'success' in message && ( +
+ {message.success} +
+ )} + {'error' in message && ( +
+ {message.error} +
+ )} + {'message' in message && ( +
{message.message}
+ )} +
+ ); +}; diff --git a/src/components/default/index.tsx b/src/components/default/index.tsx new file mode 100644 index 0000000..3a200fb --- /dev/null +++ b/src/components/default/index.tsx @@ -0,0 +1,4 @@ +import { FormMessage, type Message } from '@/components/default/form-message'; +import { SubmitButton } from '@/components/default/submit-button'; + +export { FormMessage, type Message, SubmitButton }; diff --git a/src/components/default/submit-button.tsx b/src/components/default/submit-button.tsx new file mode 100644 index 0000000..b486071 --- /dev/null +++ b/src/components/default/submit-button.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { type ComponentProps } from 'react'; +import { useFormStatus } from 'react-dom'; + +type Props = ComponentProps & { + pendingText?: string; +}; + +export const SubmitButton = ({ + children, + pendingText = 'Submitting...', + ...props +}: Props) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; diff --git a/src/components/navigation/auth.tsx b/src/components/navigation/auth.tsx new file mode 100644 index 0000000..5a52b45 --- /dev/null +++ b/src/components/navigation/auth.tsx @@ -0,0 +1,34 @@ +'use server'; + +import Link from 'next/link'; +import { Button } from '@/components/ui'; +import { createClient } from '@/utils/supabase/server'; +import { signOut } from '@/actions/auth'; + +const NavigationAuth = async () => { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return user ? ( +
+ Hello, {user.email}! +
+ +
+
+ ) : ( +
+ + +
+ ); +}; +export default NavigationAuth; diff --git a/src/components/navigation/index.tsx b/src/components/navigation/index.tsx new file mode 100644 index 0000000..bde512a --- /dev/null +++ b/src/components/navigation/index.tsx @@ -0,0 +1,32 @@ +'use server'; + +import Link from 'next/link'; +import { Button } from '@/components/ui'; +import NavigationAuth from '@/components/navigation/auth'; + +const Navigation = () => { + return ( + + ); +}; +export default Navigation; diff --git a/src/components/tutorial/code-block.tsx b/src/components/tutorial/code-block.tsx new file mode 100644 index 0000000..acd1828 --- /dev/null +++ b/src/components/tutorial/code-block.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui'; + +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +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 ( +
+      
+      {code}
+    
+ ); +} diff --git a/src/components/tutorial/fetch-data-steps.tsx b/src/components/tutorial/fetch-data-steps.tsx new file mode 100644 index 0000000..8f7d828 --- /dev/null +++ b/src/components/tutorial/fetch-data-steps.tsx @@ -0,0 +1,96 @@ +import { TutorialStep, CodeBlock } from '@/components/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
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +const client = `'use client' + +import { createClient } from '@/utils/supabase/client' +import { useEffect, useState } from 'react' + +export default function Page() { + const [notes, setNotes] = useState(null) + const supabase = createClient() + + useEffect(() => { + const getData = async () => { + const { data } = await supabase.from('notes').select() + setNotes(data) + } + getData() + }, []) + + return
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +const FetchDataSteps = () => { + return ( +
    + +

    + Head over to the{' '} + + Table Editor + {' '} + 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{' '} + + SQL Editor + {' '} + and click RUN! +

    + +
    + + +

    + To create a Supabase client and query data from an Async Server + Component, create a new page.tsx file at{' '} + + /app/notes/page.tsx + {' '} + and add the following. +

    + +

    Alternatively, you can use a Client Component.

    + +
    + + +

    You're ready to launch your product to the world! 🚀

    +
    +
+ ); +}; +export default FetchDataSteps; diff --git a/src/components/tutorial/index.tsx b/src/components/tutorial/index.tsx new file mode 100644 index 0000000..5e81b3a --- /dev/null +++ b/src/components/tutorial/index.tsx @@ -0,0 +1,5 @@ +import { CodeBlock } from '@/components/tutorial/code-block'; +import FetchDataSteps from '@/components/tutorial/fetch-data-steps'; +import { TutorialStep } from '@/components/tutorial/tutorial-step'; + +export { CodeBlock, FetchDataSteps, TutorialStep }; diff --git a/src/components/tutorial/tutorial-step.tsx b/src/components/tutorial/tutorial-step.tsx new file mode 100644 index 0000000..efb30fb --- /dev/null +++ b/src/components/tutorial/tutorial-step.tsx @@ -0,0 +1,30 @@ +import { Checkbox } from '@/components/ui'; + +export const TutorialStep = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => { + return ( +
  • + + +
  • + ); +}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..8375444 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..fde2498 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx new file mode 100644 index 0000000..6965284 --- /dev/null +++ b/src/components/ui/index.tsx @@ -0,0 +1,7 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export { Badge, Button, Checkbox, Input, Label }; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..485626a --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..6bc5f71 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; + +import { cn } from '@/lib/utils'; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label };