├── .env.example ├── .gitignore ├── .prettierrc ├── README.md ├── app ├── (carbon) │ ├── api-keys │ │ └── page.tsx │ ├── carbon-connect │ │ └── page.tsx │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ ├── onboarding │ │ └── page.tsx │ ├── organization │ │ └── page.tsx │ ├── page.tsx │ ├── reset-password │ │ └── page.tsx │ └── settings │ │ └── page.tsx ├── (paigo) │ ├── billing │ │ └── page.tsx │ ├── checkout │ │ └── page.tsx │ └── layout.tsx ├── auth │ ├── callback │ │ └── route.ts │ └── confirm │ │ └── route.ts ├── favicon.ico ├── globals.css ├── opengraph-image.png └── twitter-image.png ├── components.json ├── components ├── Billing.tsx ├── Checkout.tsx ├── Code.tsx ├── CreateAPIKeys.tsx ├── CreateOrg.tsx ├── DeleteAPIKey.tsx ├── Footer.tsx ├── ManageAPIKeys.tsx ├── ManageSelfInvites.tsx ├── Navbar.tsx ├── NextLogo.tsx ├── Onboarding.tsx ├── OrgCreationSuccess.tsx ├── OrgSelector.tsx ├── Organization.tsx ├── SecondaryNavLinks.tsx ├── SecondaryNavbar.tsx ├── Step.tsx ├── SupabaseLogo.tsx ├── Syncer.tsx ├── UsageDashboard.tsx ├── UserInvite.tsx ├── UserNav.tsx └── ui │ ├── Button.tsx │ ├── Card.tsx │ ├── DataTable.tsx │ ├── Form.tsx │ ├── Loader.tsx │ ├── Pagination.tsx │ ├── Table.tsx │ ├── avatar.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── select.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── hooks ├── useOrganizationMember.ts ├── useSyncAPIKeys.ts ├── useSyncAuth.ts ├── useSyncInvites.ts └── useSyncOrgs.ts ├── lib └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── logo-carbon.png ├── store ├── useAPIKeysStore.ts ├── useAuthStore.ts ├── useInvitesStore.ts └── useOrgsStore.ts ├── tailwind.config.ts ├── tsconfig.json ├── types ├── common.ts └── supabase.ts └── utils ├── auth.ts ├── carbon.ts └── supabase ├── client.ts ├── middleware.ts ├── server.ts └── user.ts /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL= 2 | 3 | # Update these with your Supabase details from your project settings > API 4 | # https://app.supabase.com/project/_/settings/api 5 | NEXT_PUBLIC_SUPABASE_URL= 6 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 7 | 8 | HMAC_KEY= 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "plugins": ["prettier-plugin-tailwindcss"] 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # User Management Dashboard 2 | 3 | This is a sample project that allwos users management of organizational roles, API key distribution, and more, with a robust authentication system powered by Supabase. 4 | 5 | Tech Stack: 6 | 7 | - Supabase 8 | - Next.js 9 | - TailwindCSS 10 | 11 | ## Features 12 | 13 | - **Supabase Authentication**: Secure and straightforward user authentication. 14 | - **Database Management**: Utilize Supabase for backend operations including database management with Row Level Security (RLS). 15 | - **Authorization and Access Control**: Relies on Supabase's RLS policies to guarantee authorized access to data. 16 | - **Dynamic UI with TailwindCSS and shadCN UI**: Build responsive and modern user interfaces. 17 | - **Comprehensive Role Management**: Efficiently manage user roles within organizations which can be extended to have granular access control. 18 | 19 | ## Getting Started 20 | 21 | ### Prerequisites 22 | 23 | - Node.js installed on your system. 24 | - A Supabase account and project for backend services. 25 | 26 | ### Setup 27 | 28 | 1. **Clone the repository** 29 | 30 | ```bash 31 | git clone git@github.com:toughyear/supabase-user-management-dashboard.git 32 | cd carbon-customer-portal 33 | ``` 34 | 35 | 2. **Install dependencies** 36 | 37 | ```bash 38 | npm install 39 | ``` 40 | 41 | 3. **Configure environment variables** 42 | 43 | Create a `.env.local` file at the root of your project and add the following lines: 44 | 45 | ```plaintext 46 | NEXT_PUBLIC_SUPABASE_URL=YourSupabaseURL 47 | NEXT_PUBLIC_SUPABASE_ANON_KEY=YourSupabaseAnonKey 48 | ``` 49 | 50 | Replace `YourSupabaseURL` and `YourSupabaseAnonKey` with your actual Supabase project details. 51 | 52 | 4. **Database setup** 53 | You can use the `supabase.sql` file to create the necessary tables and policies in your Supabase project. This is not added yet, but will be added soon. To understand the schema and operations, refer to the Database Schema and Operations section below. 54 | 55 | ### Running the project 56 | 57 | - **Development mode** 58 | 59 | ```bash 60 | npm run dev 61 | ``` 62 | 63 | - **Production build** 64 | 65 | ```bash 66 | npm run build 67 | npm start 68 | ``` 69 | 70 | ## Code Structure 71 | 72 | - **App**: Project relies on the latest `app` directory paradigm of Nextjs to create file-based routing. 73 | - **Components**: Reusable UI components are located in the `components` directory. This is where we install ShadCN UI components to inside the `components/ui` directory. 74 | - **Utils**: The `utils` directory contains utility functions and custom hooks for interacting with Supabase and handling application auth logic. 75 | - **Types**: The `types` directory contains custom TypeScript types and interfaces used throughout the project such as `User`, `Organization`, `Role`, that come from the DB schema. 76 | - **Stores**: The `stores` directory contains the global state management using Zustand. 77 | - **Hooks**: The `hooks` directory contains custom hooks for handling global state and other UI logic. Some hooks sync the DB data with the global state to always have the latest data. 78 | 79 | ## Database Schema and Operations 80 | 81 | ### Schema Overview 82 | 83 | - **Organizations**: Track organizations with details including creation and admin information. 84 | - **Roles**: Define user roles within organizations. 85 | - **UserOrgRoles**: Associate users with organizations and their roles. 86 | - **APIKeys**: Manage API keys for secure access to services. 87 | - **Users**: Special table that comes from and managed by Supabase. Stores user details including email, name, and other information. 88 | 89 | ### Workflow 90 | 91 | 1. **User Registration**: Utilizes Supabase Auth for secure signup and login. 92 | 2. **Organization Management**: Users can create organizations, with entries automatically managed in the `Organizations` table. 93 | 3. **Role Assignment**: Users are assigned roles within their organizations, facilitated by updates to the `UserOrgRoles` table. 94 | 95 | ## UI Flow 96 | 97 | Guides the user through the process of logging in or signing up, joining or creating an organization, inviting other users, and managing API keys. The UI dynamically updates based on the user's interaction and organization's state. 98 | 99 | ## Contribution 100 | 101 | We welcome contributions! Please read our CONTRIBUTING.md for guidelines on how to make this project better. 102 | 103 | ## License 104 | 105 | This project is licensed under the MIT License - see the LICENSE file for details. 106 | 107 | --- 108 | -------------------------------------------------------------------------------- /app/(carbon)/api-keys/page.tsx: -------------------------------------------------------------------------------- 1 | import ManageAPIKeys from '@/components/ManageAPIKeys'; 2 | import { useServerOrganizationMember } from '@/hooks/useOrganizationMember'; 3 | import { authenticatePage } from '@/utils/auth'; 4 | import { redirect } from 'next/navigation'; 5 | import React from 'react'; 6 | 7 | async function APIKeys() { 8 | const [user, secret] = await authenticatePage() 9 | const organizationMember = await useServerOrganizationMember(secret) 10 | 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | 18 | export default APIKeys; 19 | -------------------------------------------------------------------------------- /app/(carbon)/carbon-connect/page.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/Card"; 2 | import { Form } from "@/components/ui/Form"; 3 | import { useServerOrganizationMember } from "@/hooks/useOrganizationMember"; 4 | import { authenticatePage } from "@/utils/auth"; 5 | import { useForm } from "react-hook-form"; 6 | import { z } from "zod"; 7 | 8 | // const formSchema = z.object({ 9 | // username: z.string().min(2).max(50), 10 | // }) 11 | 12 | async function CarbonConnect() { 13 | const [user, secret] = await authenticatePage() 14 | const organizationMember = await useServerOrganizationMember(secret, false) 15 | 16 | // const form = useForm() 17 | 18 | return ( 19 |
20 |

21 | Carbon Connect 22 |

23 |

24 | Customize the appearance of your Carbon Connect component. 25 |

26 | 27 | 28 | 29 | Profile Information 30 | 31 | 32 | {/*
33 |
*/} 34 |
35 |
36 | 37 | 38 | 39 | White Labeling 40 | 41 | 42 | 43 | 44 | 45 |
46 | ); 47 | } 48 | 49 | export default CarbonConnect; -------------------------------------------------------------------------------- /app/(carbon)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GeistSans } from 'geist/font/sans'; 2 | import '../globals.css'; 3 | import Navbar from '@/components/Navbar'; 4 | import { Toaster } from '@/components/ui/toaster'; 5 | import SecondaryNavbar from '@/components/SecondaryNavbar'; 6 | import Syncer from '@/components/Syncer'; 7 | 8 | const defaultUrl = process.env.VERCEL_URL 9 | ? `https://${process.env.VERCEL_URL}` 10 | : 'http://localhost:3000'; 11 | 12 | export const metadata = { 13 | metadataBase: new URL(defaultUrl), 14 | title: 'CarbonAI: Customer Portal', 15 | description: 'Admin console for CarbonAI customers.', 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | {children} 32 |
33 | 34 |
35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/(carbon)/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, Suspense, useEffect } from 'react'; 4 | import { createClient } from '@/utils/supabase/client'; 5 | import { AlertCircle } from 'lucide-react'; 6 | import { useRouter } from 'next/navigation'; 7 | import { useSearchParams } from 'next/navigation'; 8 | import { useAuthStore } from '@/store/useAuthStore'; 9 | import Loader from '@/components/ui/Loader'; 10 | 11 | const supabase = createClient(); 12 | 13 | enum LoginPageForm { 14 | LOGIN = "LOGIN", 15 | SIGNUP = "SIGNUP", 16 | PASSWORD_RESET = "PASSWORD_RESET", 17 | } 18 | 19 | enum AuthState { 20 | Idle = "IDLE", 21 | SigningIn = "SIGNING_IN", 22 | SigningUp = "SIGNING_UP", 23 | SendingEmailForReset = "SENDING_EMAIL_FOR_RESET", 24 | } 25 | 26 | export default function Login() { 27 | const router = useRouter(); 28 | const [email, setEmail] = useState(''); 29 | const [password, setPassword] = useState(''); 30 | 31 | const [loginPageForm, setLoginPageForm] = useState(LoginPageForm.LOGIN) 32 | const [authState, setAuthState] = useState(AuthState.Idle); 33 | 34 | const { user, loading } = useAuthStore(); 35 | 36 | const signIn = async () => { 37 | setAuthState(AuthState.SigningIn); 38 | const { error } = await supabase.auth.signInWithPassword( 39 | { 40 | email, 41 | password, 42 | } 43 | ); 44 | setAuthState(AuthState.Idle); 45 | 46 | if (error) { 47 | router.push("/login?message=Could not authenticate user") 48 | } else { 49 | router.refresh() 50 | } 51 | }; 52 | 53 | const signUp = async () => { 54 | setAuthState(AuthState.SigningUp); 55 | const { error } = await supabase.auth.signUp( 56 | { 57 | email, 58 | password, 59 | options: { 60 | emailRedirectTo: `${window.location.origin}/auth/callback`, 61 | }, 62 | } 63 | ); 64 | setAuthState(AuthState.Idle); 65 | 66 | return router.push( 67 | error 68 | ? "/login?message=Could not authenticate user." 69 | : "/login?message=Check your email to continue sign up." 70 | ); 71 | }; 72 | 73 | const sendEmailForReset = async () => { 74 | setAuthState(AuthState.SendingEmailForReset); 75 | const { error } = await supabase.auth.resetPasswordForEmail( 76 | email, 77 | { 78 | redirectTo: `${window.location.origin}/reset-password` 79 | } 80 | ); 81 | setAuthState(AuthState.Idle) 82 | 83 | return router.push( 84 | error 85 | ? "/login?message=Could not send email for password reset." 86 | : "/login?message=Check your email to continue with password reset." 87 | ) 88 | } 89 | 90 | useEffect(() => { 91 | if (user) { 92 | router.push('/api-keys'); 93 | } 94 | }, [user]) 95 | 96 | if (user || loading) { 97 | return ( 98 |
99 | 100 |
101 | ); 102 | } 103 | 104 | return ( 105 | Loading...}> 106 |
107 | {/* Suspense wrapper added for useSearchParams */} 108 | Loading...
}> 109 | 110 |
111 | { 112 | loginPageForm === LoginPageForm.LOGIN && 113 | 122 | } 123 | { 124 | loginPageForm === LoginPageForm.SIGNUP && 125 | 134 | } 135 | { 136 | loginPageForm === LoginPageForm.PASSWORD_RESET && 137 | 144 | } 145 | 146 | 147 | ); 148 | } 149 | 150 | const SearchParamsComponent = () => { 151 | const searchParams = useSearchParams(); 152 | const message = searchParams.get('message'); 153 | return message ? ( 154 |

155 | {message} 156 |

157 | ) : null; 158 | } 159 | 160 | const LoginForm = ({ 161 | email, 162 | setEmail, 163 | password, 164 | setPassword, 165 | authState, 166 | signIn, 167 | setLoginPageForm, 168 | }: { 169 | email: string; 170 | setEmail: (email: string) => void; 171 | password: string; 172 | setPassword: (password: string) => void; 173 | authState: AuthState; 174 | signIn: () => Promise; 175 | setLoginPageForm: (loginPageForm: LoginPageForm) => void; 176 | }) => { 177 | return ( 178 |
179 | 182 | setEmail(e.target.value)} 188 | required 189 | /> 190 | 193 | setPassword(e.target.value)} 200 | required 201 | /> 202 | 212 |
213 | 214 | 222 |

or

223 | 224 | 232 |
233 |
234 | ); 235 | } 236 | 237 | const SignupForm = ( 238 | { 239 | email, 240 | setEmail, 241 | password, 242 | setPassword, 243 | authState, 244 | signUp, 245 | setLoginPageForm, 246 | }: { 247 | email: string; 248 | setEmail: (email: string) => void; 249 | password: string; 250 | setPassword: (password: string) => void; 251 | authState: AuthState; 252 | signUp: () => Promise; 253 | setLoginPageForm: (loginPageForm: LoginPageForm) => void; 254 | } 255 | ) => { 256 | return ( 257 |
258 | 261 | setEmail(e.target.value)} 267 | required 268 | /> 269 | 272 | setPassword(e.target.value)} 279 | required 280 | /> 281 | 291 |
292 | 300 |
301 |
302 | ); 303 | } 304 | 305 | const SendEmailForResetForm = ( 306 | { 307 | email, 308 | setEmail, 309 | authState, 310 | sendEmailForPasswordReset, 311 | setLoginPageForm, 312 | 313 | } : { 314 | email: string; 315 | setEmail: (email: string) => void; 316 | authState: AuthState; 317 | sendEmailForPasswordReset: () => Promise; 318 | setLoginPageForm: (loginPageForm: LoginPageForm) => void; 319 | } 320 | ) => { 321 | return ( 322 |
323 | 326 | setEmail(e.target.value)} 332 | required 333 | /> 334 | 335 | 345 |
346 | 354 |
355 |
356 | ); 357 | } 358 | -------------------------------------------------------------------------------- /app/(carbon)/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { authenticatePage } from "@/utils/auth"; 3 | import { useServerOrganizationMember } from "@/hooks/useOrganizationMember"; 4 | import Onboarding from "@/components/Onboarding"; 5 | import { redirect } from "next/navigation"; 6 | 7 | const OnboardingPage = async () => { 8 | const [user, secret] = await authenticatePage() 9 | const organizationMember = await useServerOrganizationMember(secret, false) 10 | if (organizationMember) { 11 | redirect("/api-keys") 12 | } 13 | 14 | return 15 | } 16 | 17 | export default OnboardingPage; -------------------------------------------------------------------------------- /app/(carbon)/organization/page.tsx: -------------------------------------------------------------------------------- 1 | import Organization from "@/components/Organization" 2 | import { useServerOrganizationMember } from "@/hooks/useOrganizationMember" 3 | import { authenticatePage } from "@/utils/auth" 4 | 5 | const OrganizationPage = async () => { 6 | const [user, secret] = await authenticatePage() 7 | const organizationMember = await useServerOrganizationMember(secret) 8 | 9 | return ; 10 | } 11 | 12 | export default OrganizationPage -------------------------------------------------------------------------------- /app/(carbon)/page.tsx: -------------------------------------------------------------------------------- 1 | import CreateOrg from '@/components/CreateOrg'; 2 | import { useServerOrganizationMember } from '@/hooks/useOrganizationMember'; 3 | import { authenticatePage } from '@/utils/auth'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | export default async function Index() { 7 | const [user, secret] = await authenticatePage() 8 | const organizationMember = await useServerOrganizationMember(secret, false) 9 | 10 | if (organizationMember) { 11 | redirect("/api-keys") 12 | } 13 | 14 | return ( 15 |
16 | {!organizationMember && } 17 | 18 | {/* TODO */} 19 | {/* 20 | */} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/(carbon)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useToast } from "@/components/ui/use-toast"; 4 | import { createClient } from "@/utils/supabase/client"; 5 | import { useState } from "react"; 6 | 7 | const supabase = createClient(); 8 | 9 | const ResetPassword = () => { 10 | const [password, setPassword] = useState(""); 11 | const [isLoading, setIsLoading] = useState(false); 12 | 13 | const { toast } = useToast(); 14 | 15 | const updatePassword = async () => { 16 | setIsLoading(true) 17 | const { error } = await supabase.auth.updateUser({ password }) 18 | setIsLoading(false) 19 | 20 | if (error) { 21 | toast({ description: error.message }) 22 | } else { 23 | toast({ description: "Password updated." }) 24 | } 25 | } 26 | 27 | return ( 28 |
29 | 32 | setPassword(e.target.value)} 39 | required 40 | /> 41 | 42 | 54 |
55 | ); 56 | } 57 | 58 | export default ResetPassword -------------------------------------------------------------------------------- /app/(carbon)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import UserInvite from '@/components/UserInvite'; 2 | import { authenticatePage } from '@/utils/auth'; 3 | import React from 'react'; 4 | 5 | async function Settings() { 6 | await authenticatePage(); 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | export default Settings; 16 | -------------------------------------------------------------------------------- /app/(paigo)/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import Billing from '@/components/Billing'; 2 | import { useServerOrganizationMember } from '@/hooks/useOrganizationMember'; 3 | import { authenticatePage } from '@/utils/auth'; 4 | 5 | async function BillingPage() { 6 | const [user, secret] = await authenticatePage() 7 | const organizationMember = await useServerOrganizationMember(secret) 8 | 9 | return ; 10 | } 11 | 12 | export default BillingPage; 13 | -------------------------------------------------------------------------------- /app/(paigo)/checkout/page.tsx: -------------------------------------------------------------------------------- 1 | import Checkout from "@/components/Checkout"; 2 | import { useServerOrganizationMember } from "@/hooks/useOrganizationMember"; 3 | import { authenticatePage } from "@/utils/auth"; 4 | 5 | const CheckoutPage = async () => { 6 | const [user, secret] = await authenticatePage() 7 | const organizationMember = await useServerOrganizationMember(secret) 8 | 9 | return ; 10 | } 11 | 12 | export default CheckoutPage; -------------------------------------------------------------------------------- /app/(paigo)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GeistSans } from 'geist/font/sans'; 2 | import '../globals.css'; 3 | import Navbar from '@/components/Navbar'; 4 | import { Toaster } from '@/components/ui/toaster'; 5 | import SecondaryNavbar from '@/components/SecondaryNavbar'; 6 | import Syncer from '@/components/Syncer'; 7 | 8 | const defaultUrl = process.env.VERCEL_URL 9 | ? `https://${process.env.VERCEL_URL}` 10 | : 'http://localhost:3000'; 11 | 12 | export const metadata = { 13 | metadataBase: new URL(defaultUrl), 14 | title: 'CarbonAI: Customer Portal', 15 | description: 'Admin console for CarbonAI customers.', 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | {children} 32 |
33 | 34 |
35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextResponse } from "next/server"; 3 | import { cookies } from "next/headers"; 4 | 5 | export async function GET(request: Request) { 6 | // The `/auth/callback` route is required for the server-side auth flow implemented 7 | // by the Auth Helpers package. It exchanges an auth code for the user's session. 8 | // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange 9 | const requestUrl = new URL(request.url); 10 | const code = requestUrl.searchParams.get("code"); 11 | 12 | if (code) { 13 | const cookieStore = cookies(); 14 | const supabase = createClient(cookieStore); 15 | await supabase.auth.exchangeCodeForSession(code); 16 | } 17 | 18 | // URL to redirect to after sign in process completes 19 | return NextResponse.redirect(requestUrl.origin); 20 | } 21 | -------------------------------------------------------------------------------- /app/auth/confirm/route.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from '@supabase/ssr' 2 | import { type EmailOtpType } from '@supabase/supabase-js' 3 | import { cookies } from 'next/headers' 4 | import { NextRequest, NextResponse } from 'next/server' 5 | 6 | export async function GET(request: NextRequest) { 7 | const { searchParams } = new URL(request.url) 8 | const token_hash = searchParams.get('token_hash') 9 | const type = searchParams.get('type') as EmailOtpType | null 10 | const next = searchParams.get('next') ?? '/' 11 | const redirectTo = request.nextUrl.clone() 12 | redirectTo.pathname = next 13 | 14 | if (token_hash && type) { 15 | const cookieStore = cookies() 16 | const supabase = createServerClient( 17 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 19 | { 20 | cookies: { 21 | get(name: string) { 22 | return cookieStore.get(name)?.value 23 | }, 24 | set(name: string, value: string, options: CookieOptions) { 25 | cookieStore.set({ name, value, ...options }) 26 | }, 27 | remove(name: string, options: CookieOptions) { 28 | cookieStore.delete({ name, ...options }) 29 | }, 30 | }, 31 | } 32 | ) 33 | 34 | const { error } = await supabase.auth.verifyOtp({ 35 | type, 36 | token_hash, 37 | }) 38 | if (!error) { 39 | return NextResponse.redirect(redirectTo) 40 | } 41 | } 42 | 43 | // return the user to an error page with some instructions 44 | redirectTo.pathname = '/auth/auth-code-error' 45 | return NextResponse.redirect(redirectTo) 46 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubbleai/supabase-user-management-dashboard/72228323d786136947a832ccdb728b853e901aac/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Kalam:wght@300;400&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | .animate-in { 8 | animation: animateIn 0.3s ease 0.15s both; 9 | } 10 | 11 | @keyframes animateIn { 12 | from { 13 | opacity: 0; 14 | transform: translateY(10px); 15 | } 16 | to { 17 | opacity: 1; 18 | transform: translateY(0); 19 | } 20 | } 21 | 22 | @layer base { 23 | :root { 24 | --background: 0 0% 100%; 25 | --foreground: 240 10% 3.9%; 26 | 27 | --card: 0 0% 100%; 28 | --card-foreground: 240 10% 3.9%; 29 | 30 | --popover: 0 0% 100%; 31 | --popover-foreground: 240 10% 3.9%; 32 | 33 | --primary: 240 5.9% 10%; 34 | --primary-foreground: 0 0% 98%; 35 | 36 | --secondary: 240 4.8% 95.9%; 37 | --secondary-foreground: 240 5.9% 10%; 38 | 39 | --muted: 240 4.8% 95.9%; 40 | --muted-foreground: 240 3.8% 46.1%; 41 | 42 | --accent: 240 4.8% 95.9%; 43 | --accent-foreground: 240 5.9% 10%; 44 | 45 | --destructive: 0 84.2% 60.2%; 46 | --destructive-foreground: 0 0% 98%; 47 | 48 | --border: 240 5.9% 90%; 49 | --input: 240 5.9% 90%; 50 | --ring: 240 10% 3.9%; 51 | 52 | --radius: 0.5rem; 53 | } 54 | 55 | .dark { 56 | --background: 240 10% 3.9%; 57 | --foreground: 0 0% 98%; 58 | 59 | --card: 240 10% 3.9%; 60 | --card-foreground: 0 0% 98%; 61 | 62 | --popover: 240 10% 3.9%; 63 | --popover-foreground: 0 0% 98%; 64 | 65 | --primary: 0 0% 98%; 66 | --primary-foreground: 240 5.9% 10%; 67 | 68 | --secondary: 240 3.7% 15.9%; 69 | --secondary-foreground: 0 0% 98%; 70 | 71 | --muted: 240 3.7% 15.9%; 72 | --muted-foreground: 240 5% 64.9%; 73 | 74 | --accent: 240 3.7% 15.9%; 75 | --accent-foreground: 0 0% 98%; 76 | 77 | --destructive: 0 62.8% 30.6%; 78 | --destructive-foreground: 0 0% 98%; 79 | 80 | --border: 240 3.7% 15.9%; 81 | --input: 240 3.7% 15.9%; 82 | --ring: 240 4.9% 83.9%; 83 | } 84 | } 85 | 86 | @layer base { 87 | * { 88 | @apply border-border; 89 | } 90 | body { 91 | @apply bg-background text-foreground; 92 | } 93 | } 94 | 95 | @keyframes fade { 96 | 0%, 97 | 39%, 98 | 100% { 99 | opacity: 0.14; 100 | } 101 | 40% { 102 | opacity: 1; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubbleai/supabase-user-management-dashboard/72228323d786136947a832ccdb728b853e901aac/app/opengraph-image.png -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hubbleai/supabase-user-management-dashboard/72228323d786136947a832ccdb728b853e901aac/app/twitter-image.png -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/Billing.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react"; 4 | import { OrganizationMember } from "@/hooks/useOrganizationMember" 5 | import { requestCarbon } from "@/utils/carbon"; 6 | import { useToast } from "./ui/use-toast"; 7 | import Loader from "./ui/Loader"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | type GetPaigoTokenResponse = { 11 | access_token: string | null 12 | } 13 | 14 | const Billing = ( 15 | props: { 16 | secret: string, 17 | organizationMember: OrganizationMember, 18 | }, 19 | ) => { 20 | const [token, setToken] = useState("") 21 | 22 | const router = useRouter(); 23 | const { toast } = useToast(); 24 | 25 | const getPaigoToken = async () => { 26 | const response = await requestCarbon(props.secret, "GET", "/billing/paigo") 27 | if (response.status !== 200) { 28 | toast({ description: "An error occured. Please try again." }) 29 | } else { 30 | const deserializedResponse: GetPaigoTokenResponse = await response.json() 31 | if (!deserializedResponse.access_token) { 32 | router.push("/checkout") 33 | } else { 34 | setToken(deserializedResponse.access_token) 35 | } 36 | } 37 | } 38 | 39 | useEffect(() => { 40 | getPaigoToken(); 41 | }, []) 42 | 43 | if (!token) { 44 | return ( 45 |
46 | 47 |
48 | ); 49 | } 50 | 51 | return ( 52 |
53 |