├── .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 | {/* */}
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 |
180 | Email
181 |
182 |
setEmail(e.target.value)}
188 | required
189 | />
190 |
191 | Password
192 |
193 |
setPassword(e.target.value)}
200 | required
201 | />
202 |
208 | {authState === AuthState.SigningIn
209 | ? 'Signing In...'
210 | : 'Sign In'}
211 |
212 |
213 |
214 |
setLoginPageForm(LoginPageForm.SIGNUP)}
217 | disabled={authState !== AuthState.Idle}
218 | className="w-fit focus:outline-none justify-center text-zinc-500 hover:text-zinc-700 text-sm underline"
219 | >
220 | Create an account
221 |
222 |
or
223 |
224 |
setLoginPageForm(LoginPageForm.PASSWORD_RESET)}
227 | disabled={authState !== AuthState.Idle}
228 | className="w-fit focus:outline-none justify-center text-zinc-500 hover:text-zinc-700 text-sm underline"
229 | >
230 | Reset your password
231 |
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 |
259 | Email
260 |
261 |
setEmail(e.target.value)}
267 | required
268 | />
269 |
270 | Password
271 |
272 |
setPassword(e.target.value)}
279 | required
280 | />
281 |
287 | {authState === AuthState.SigningUp
288 | ? 'Signing Up...'
289 | : 'Sign Up'}
290 |
291 |
292 | setLoginPageForm(LoginPageForm.LOGIN)}
295 | disabled={authState !== AuthState.Idle}
296 | className="w-fit focus:outline-none justify-center text-zinc-500 hover:text-zinc-700 text-sm underline"
297 | >
298 | Already have an account?
299 |
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 |
324 | Please enter the email address for your account.
325 |
326 |
setEmail(e.target.value)}
332 | required
333 | />
334 |
335 |
341 | {authState === AuthState.SendingEmailForReset
342 | ? 'Sending Reset Email'
343 | : 'Reset Password'}
344 |
345 |
346 | setLoginPageForm(LoginPageForm.LOGIN)}
349 | disabled={authState !== AuthState.Idle}
350 | className="w-fit focus:outline-none justify-center text-zinc-500 hover:text-zinc-700 text-sm underline"
351 | >
352 | Go back
353 |
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 |
30 | Please enter a new password.
31 |
32 | setPassword(e.target.value)}
39 | required
40 | />
41 |
42 |
48 | {
49 | isLoading
50 | ? 'Updating'
51 | : 'Update'
52 | }
53 |
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 |
59 |
60 | );
61 | };
62 |
63 | export default Billing;
--------------------------------------------------------------------------------
/components/Checkout.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useSearchParams } from 'next/navigation';
4 | import { OrganizationMember } from "@/hooks/useOrganizationMember";
5 | import { requestCarbon } from '@/utils/carbon';
6 | import { useToast } from './ui/use-toast';
7 | import { useEffect, useState } from 'react';
8 | import Loader from './ui/Loader';
9 |
10 | type GetPaigoCheckoutTokenResponse = {
11 | access_token: string
12 | }
13 |
14 | const Checkout = (
15 | props: {
16 | secret: string,
17 | organizationMember: OrganizationMember,
18 | },
19 | ) => {
20 | const [paigoParams, setPaigoParams] = useState(new URLSearchParams())
21 |
22 | const queries = useSearchParams();
23 | const planId = queries.get("planId")
24 | const planName = queries.get("planName")
25 | const priceText = queries.get("priceText")
26 |
27 | const { toast } = useToast();
28 |
29 | const getPaigoCheckoutToken = async () => {
30 | const response = await requestCarbon(props.secret, "GET", "/billing/paigo/checkout")
31 | if (response.status !== 200) {
32 | toast({ description: "An error occured. Please try again." })
33 | return
34 | }
35 |
36 | const deserializedResponse: GetPaigoCheckoutTokenResponse = await response.json()
37 | if (!planId) {
38 | setPaigoParams(new URLSearchParams({
39 | token: deserializedResponse.access_token
40 | }))
41 | } else {
42 | setPaigoParams(new URLSearchParams({
43 | token: deserializedResponse.access_token,
44 | offeringId: planId,
45 | planName: planName || "",
46 | priceText: priceText || "",
47 | name: props.organizationMember.organization.name,
48 | email: props.organizationMember.email,
49 | customerId: String(props.organizationMember.organization_id),
50 | }))
51 | }
52 | }
53 |
54 | useEffect(() => {
55 | getPaigoCheckoutToken();
56 | }, [planId])
57 |
58 | if (!paigoParams.toString()) {
59 | return (
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | console.log(paigoParams.toString())
67 |
68 | return (
69 |
70 | {
71 | !planId && (
72 |
78 | )
79 | }
80 | {
81 | planId && (
82 | <
83 | iframe
84 | src={`https://console.paigo.tech/embedded/public/b5087098-ab1e-4ce4-a442-e7931580fa19?${paigoParams.toString()}`}
85 | width="100%"
86 | height="100%"
87 | title="Carbon Dev Onboarding"
88 | />
89 | )
90 | }
91 |
92 | );
93 | };
94 |
95 | export default Checkout
--------------------------------------------------------------------------------
/components/Code.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | const CopyIcon = () => (
6 |
17 |
18 |
19 |
20 | );
21 |
22 | const CheckIcon = () => (
23 |
34 |
35 |
36 | );
37 |
38 | export default function Code({ code }: { code: string }) {
39 | const [icon, setIcon] = useState(CopyIcon);
40 |
41 | const copy = async () => {
42 | await navigator?.clipboard?.writeText(code);
43 | setIcon(CheckIcon);
44 | setTimeout(() => setIcon(CopyIcon), 2000);
45 | };
46 |
47 | return (
48 |
49 |
53 | {icon}
54 |
55 | {code}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/components/CreateAPIKeys.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { Button } from '@/components/ui/Button';
5 | import {
6 | Dialog,
7 | DialogContent,
8 | DialogDescription,
9 | DialogFooter,
10 | DialogHeader,
11 | DialogTitle,
12 | DialogTrigger,
13 | } from '@/components/ui/dialog';
14 | import { Input } from '@/components/ui/input';
15 | import { Label } from '@/components/ui/label';
16 | import { useAuthStore } from '@/store/useAuthStore';
17 | import { useToast } from '@/components/ui/use-toast';
18 | import Loader from '@/components/ui/Loader';
19 | import { OrganizationMember } from '@/hooks/useOrganizationMember';
20 | import { requestCarbon } from '@/utils/carbon';
21 | import { useRouter } from 'next/navigation';
22 |
23 | // TODO move this type into a dedicated file
24 | export type APIKey = {
25 | id: number;
26 | token_hash: string;
27 | description?: string;
28 | customer_id: number;
29 | customer_email: string;
30 | organization_id: number;
31 | organization_name: string,
32 | is_hashed: boolean;
33 | expires_at: Date,
34 | created_at: Date,
35 | updated_at: Date,
36 | }
37 |
38 | const CreateAPIKeys = (
39 | props: {
40 | organizationMember: OrganizationMember,
41 | secret: string,
42 | setNewKey: (newKey: APIKey) => void,
43 | getAPIKeys: () => Promise,
44 | },
45 | ) => {
46 | const [label, setLabel] = useState('My New Key');
47 | const [isLoading, setIsLoading] = useState(false);
48 | const [isDialogOpen, setIsDialogOpen] = useState(false);
49 |
50 | const router = useRouter();
51 | const { user } = useAuthStore();
52 | const { toast } = useToast();
53 |
54 | const createAPIKey = async () => {
55 | if (!user) {
56 | router.push("/login");
57 | return;
58 | }
59 | if (!label) {
60 | toast({ description: "Please label the new api key." });
61 | return;
62 | }
63 |
64 | setIsLoading(true);
65 | const response = await requestCarbon(
66 | props.secret,
67 | "POST",
68 | "/customer/api_key",
69 | {
70 | user_id: props.organizationMember.id,
71 | org_id: props.organizationMember.organization_id,
72 | description: label,
73 | },
74 | )
75 |
76 | if (response.status !== 200) {
77 | toast({
78 | description: 'API Key Creation Failed',
79 | });
80 | } else {
81 | await props.getAPIKeys()
82 | const newKey: APIKey = await response.json()
83 |
84 | toast({
85 | description: 'New API Key Created',
86 | });
87 | props.setNewKey(newKey);
88 | setIsDialogOpen(false); // Close the dialog on success
89 | }
90 | setIsLoading(false);
91 | };
92 |
93 | return (
94 |
95 |
96 | {
97 | props.organizationMember.organization_admin && (
98 |
99 | setIsDialogOpen(true)}
102 | >
103 | Create New API Key
104 |
105 |
106 | )
107 | }
108 |
109 |
110 |
111 | Create New Key
112 |
113 | Anyone with this key will be able to access Carbon
114 | APIs on behalf of your organization. You can
115 | delete the key at any time.
116 |
117 |
118 |
119 |
120 |
121 | Label
122 |
123 | setLabel(e.target.value)}
127 | className="col-span-3"
128 | />
129 |
130 |
131 |
132 |
137 | {isLoading && }
138 | {isLoading ? 'Creating...' : 'Create API Key'}
139 |
140 |
141 |
142 |
143 |
144 | );
145 | }
146 |
147 | export default CreateAPIKeys;
148 |
--------------------------------------------------------------------------------
/components/CreateOrg.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState } from 'react';
4 | import { createClient as createBrowserClient } from '@/utils/supabase/client';
5 | import { useToast } from '@/components/ui/use-toast';
6 | import { Status } from '@/types/common';
7 | import Loader from '@/components/ui/Loader';
8 | import { useRouter } from 'next/navigation';
9 | import { MoveRight } from 'lucide-react';
10 | import { motion } from 'framer-motion';
11 | import { useInvitesStore } from '@/store/useInvitesStore';
12 | import { User } from '@supabase/supabase-js';
13 | import { requestCarbon } from '@/utils/carbon';
14 |
15 | const CreateOrg = (
16 | props: {
17 | user: User,
18 | secret: string,
19 | },
20 | ) => {
21 | // Global state
22 | // const { invites } = useInvitesStore();
23 |
24 | // Local state
25 | const [organizationName, setOrganizationName] = useState('');
26 | const [status, setStatus] = useState(Status.Idle);
27 |
28 | // utitlity hooks
29 | const { toast } = useToast();
30 | const router = useRouter();
31 |
32 | const createOrganization = async (organizationName: string, user: User) => {
33 | if (!user.email) {
34 | toast({
35 | description: "Something went wrong. Please contact support."
36 | })
37 | setStatus(Status.Error)
38 | return
39 | }
40 |
41 | // The endpoint /organization/create takes no form of authentication.
42 | // Instead it queries supabase to make sure the user actually exists.
43 | // For that reason, null is passed in for the parameter secret.
44 | const response = await requestCarbon(
45 | null,
46 | "POST",
47 | "/organization/create",
48 | {
49 | organization_name: organizationName,
50 | organization_owner: {
51 | email: user.email,
52 | supabase_id: user.id,
53 | secret: props.secret,
54 | },
55 | }
56 | )
57 |
58 | if (response.status !== 200) {
59 | toast({
60 | description: "Something went wrong. Please contact support."
61 | })
62 | setStatus(Status.Error)
63 | } else {
64 | toast({
65 | description: "Organization Created."
66 | })
67 | setStatus(Status.Success)
68 | }
69 | };
70 |
71 | // assign self as admin of org
72 | // const createAdminRoleForNewOrg = async (userId: string) => {
73 | // const supabase = createBrowserClient();
74 |
75 | // // Fetch the latest organization created by the user
76 | // const { data: orgs, error: orgsError } = await supabase
77 | // .from('organizations')
78 | // .select('org_id,org_name')
79 | // .eq('created_by', userId)
80 | // .order('created_at', { ascending: false })
81 | // .limit(1);
82 |
83 | // if (orgsError || !orgs.length) {
84 | // toast({
85 | // description: 'Failed to find a new org for the user',
86 | // });
87 | // return;
88 | // }
89 |
90 | // const orgId = orgs[0].org_id;
91 | // const orgName = orgs[0].org_name;
92 |
93 | // // Check if the user already has a role in the selected organization
94 | // const { data: roles, error: rolesError } = await supabase
95 | // .from('user_org_roles')
96 | // .select('role_id')
97 | // .eq('user_id', userId)
98 | // .eq('org_id', orgId);
99 |
100 | // if (rolesError) {
101 | // toast({
102 | // description: 'Error checking existing roles',
103 | // });
104 | // return;
105 | // }
106 |
107 | // if (roles.length === 0) {
108 | // // User does not have a role in this organization, proceed to assign
109 | // const { error } = await supabase.from('user_org_roles').insert([
110 | // {
111 | // user_id: userId,
112 | // org_id: orgId,
113 | // role_id: 'admin',
114 | // },
115 | // ]);
116 |
117 | // if (error) {
118 | // toast({
119 | // description: 'Failed to assign admin role',
120 | // });
121 | // setStatus(Status.Idle);
122 | // } else {
123 | // toast({
124 | // description: `You are now admin of ${orgName}`,
125 | // });
126 | // }
127 | // } else {
128 | // toast({
129 | // description: 'User already has a role in org: ' + orgName,
130 | // });
131 | // setStatus(Status.Idle);
132 | // }
133 | // };
134 |
135 | const handleOrganizationCreation = async (organizationName: string) => {
136 | if (status === Status.Loading) {
137 | return
138 | }
139 | setStatus(Status.Loading);
140 |
141 | await createOrganization(organizationName, props.user);
142 | // await createAdminRoleForNewOrg(props.userId);
143 | setStatus(Status.Success);
144 | router.push('/api-keys');
145 | };
146 |
147 | // if (invites.length > 0) {
148 | // console.log("ZERO INVITES")
149 | // return null;
150 | // }
151 |
152 | return (
153 |
154 |
155 | Create an Organization
156 |
157 |
158 | Create the display name of your organization. You can change this
159 | name later in the settings.
160 |
161 | setOrganizationName(e.target.value)}
166 | autoFocus
167 | className="text-md my-4 rounded-lg border border-zinc-200 bg-zinc-100 p-2 transition-colors duration-300 ease-in-out focus:border-zinc-400 focus:outline-none"
168 | />
169 | handleOrganizationCreation(organizationName)}
171 | disabled={!organizationName}
172 | className=" flex items-center justify-center rounded-lg border bg-[#00A87A] px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-[#00A87A]/85 disabled:cursor-not-allowed"
173 | >
174 | {status === Status.Loading && (
175 |
176 | )}
177 | Create Organization
178 |
183 |
184 |
185 |
186 |
187 | );
188 | }
189 |
190 | export default CreateOrg
191 |
--------------------------------------------------------------------------------
/components/DeleteAPIKey.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from "react";
4 | import { useAuthStore } from '@/store/useAuthStore';
5 | import { useToast } from "./ui/use-toast";
6 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
7 | import { APIKey } from "./CreateAPIKeys";
8 | import { Button } from "./ui/Button";
9 | import Loader from "./ui/Loader";
10 | import { Trash } from "lucide-react";
11 | import { requestCarbon } from "@/utils/carbon";
12 |
13 | function DeleteAPIKeys(
14 | props: {
15 | apiKey: APIKey,
16 | getAPIKeys: () => Promise,
17 | secret: string,
18 | newKey: APIKey | null,
19 | setNewKey: (newKey: null) => void,
20 | }
21 | ) {
22 | const [isLoading, setIsLoading] = useState(false);
23 | const [isDialogOpen, setIsDialogOpen] = useState(false);
24 |
25 | const { user } = useAuthStore();
26 | const { toast } = useToast();
27 |
28 | const deleteAPIKey = async () => {
29 | if (!user ) {
30 | return;
31 | }
32 |
33 | setIsLoading(true);
34 | const response = await requestCarbon(
35 | props.secret,
36 | "POST",
37 | "/customer/api_key/delete",
38 | {
39 | "api_key_ids": [props.apiKey.id],
40 | }
41 | )
42 |
43 | if (response.status !== 200) {
44 | toast({
45 | description: 'API Key Deletion Failed',
46 | });
47 | } else {
48 | if (props.newKey && props.apiKey.id === props.newKey.id) {
49 | props.setNewKey(null);
50 | }
51 |
52 | toast({
53 | description: "API Key Deleted.",
54 | });
55 | setIsDialogOpen(false);
56 | await props.getAPIKeys()
57 | }
58 | setIsLoading(false);
59 | }
60 |
61 | return (
62 |
63 |
64 |
65 | setIsDialogOpen(true)}
69 | >
70 |
71 |
72 |
73 |
74 |
75 | Delete API Key
76 |
77 | Are you sure you want to delete "{props.apiKey.description}"? This API key will not be usable after deletion.
78 |
79 |
80 |
81 | deleteAPIKey()}
84 | disabled={isLoading}
85 | >
86 | {isLoading && }
87 | {isLoading ? 'Deleting...' : 'Delete API Key'}
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
96 | export default DeleteAPIKeys;
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function Footer() {
4 | return (
5 |
18 | );
19 | }
20 |
21 | export default Footer;
22 |
--------------------------------------------------------------------------------
/components/ManageAPIKeys.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useEffect, useState } from 'react';
4 | import CreateAPIKeys, { APIKey } from '@/components/CreateAPIKeys';
5 | import { OrganizationMember } from '@/hooks/useOrganizationMember';
6 | import { User } from '@supabase/supabase-js';
7 | import DeleteAPIKey from './DeleteAPIKey';
8 | import { requestCarbon } from '@/utils/carbon';
9 | import { useToast } from './ui/use-toast';
10 | import {
11 | IoEyeOffOutline,
12 | IoEyeOutline,
13 | IoCloseCircleOutline,
14 | IoCopyOutline,
15 | } from "react-icons/io5";
16 | import { Button } from './ui/Button';
17 |
18 | type ListAPIKeysResponse = {
19 | data: APIKey[];
20 | count: number;
21 | }
22 |
23 | const isPastDate = (date: Date) => date <= new Date()
24 |
25 | function ManageAPIKeys(
26 | props: {
27 | user: User,
28 | organizationMember: OrganizationMember,
29 | secret: string,
30 | }
31 | ) {
32 | const [newKey, setNewKey] = useState(null);
33 | const [apiKeys, setAPIKeys] = useState([]);
34 |
35 | const { toast } = useToast();
36 |
37 | const getAPIKeys = async () => {
38 | // TODO implement pages for api keys
39 | // Organizations are limited to 100 keys
40 | const response = await requestCarbon(props.secret, "GET", "/customer/api_key?limit=100")
41 | if (response.status !== 200) {
42 | toast({ description: "An error occured. Please try again." })
43 | } else {
44 | const deserializedResponse: ListAPIKeysResponse = await response.json()
45 | setAPIKeys(deserializedResponse.data)
46 | }
47 | }
48 |
49 | useEffect(() => {
50 | getAPIKeys();
51 | }, [])
52 |
53 | return (
54 |
55 |
56 |
57 |
Manage API Keys
58 |
Create or manage your API keys here.
59 |
60 |
61 |
67 |
68 |
69 | {newKey &&
}
70 |
71 |
72 |
Label
73 |
Status
74 |
Secret
75 |
76 |
77 | {apiKeys.map((apiKey) => (
78 |
79 |
83 |
{apiKey.description}
84 |
85 | {isPastDate(apiKey.expires_at) ? (
86 | Expired
87 | ) : (
88 | Active
89 | )}
90 |
91 |
92 |
93 |
94 | {
95 | props.organizationMember.organization_admin && (
96 |
103 | )
104 | }
105 |
106 |
107 |
108 | ))}
109 |
110 | );
111 | }
112 |
113 | const NewAPIKeyCard = (
114 | props: {
115 | newKey: APIKey,
116 | setNewKey: (newKey: null) => void,
117 | },
118 | ) => {
119 | const [isVisible, setIsVisible] = useState(false);
120 |
121 | const { toast } = useToast();
122 |
123 | const copyToClipboard = () => {
124 | navigator.clipboard.writeText(props.newKey?.token_hash || "")
125 | toast({ description: "Copied to clipboard." })
126 | }
127 |
128 | return (
129 |
130 |
131 |
New API Key
132 | props.setNewKey(null)}
136 | >
137 |
138 |
139 |
140 |
141 |
142 |
143 | Please save this key since it will not be shown again.
144 |
145 |
146 |
147 |
148 |
153 |
154 |
155 | setIsVisible(!isVisible)}
159 | >
160 | {isVisible && }
161 | {!isVisible && }
162 |
163 |
164 |
165 |
166 |
167 |
168 | );
169 | }
170 |
171 | const NewApiKey = (
172 | props: {
173 | value: string,
174 | isVisible: boolean,
175 | }
176 | ) => {
177 | const displayedValue = props.isVisible
178 | ? props.value
179 | : "*".repeat(props.value.length)
180 |
181 | const component = (
182 |
183 | {displayedValue}
184 |
185 | )
186 |
187 | return props.isVisible
188 | ? component
189 | : {component}
;
190 | }
191 |
192 | const ListedApiKey = (props: { value: string }) => {
193 | return (
194 |
195 | {props.value.slice(0, 2)}
196 | {"*".repeat(props.value.length - 4)}
197 | {props.value.slice(props.value.length - 2)}
198 |
199 | );
200 | }
201 |
202 | export default ManageAPIKeys;
203 |
--------------------------------------------------------------------------------
/components/ManageSelfInvites.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useInvitesStore } from '@/store/useInvitesStore';
4 | import { createClient } from '@/utils/supabase/client';
5 | import React from 'react';
6 | import { Button } from '@/components/ui/Button';
7 | import { Invite, InviteStatus } from '@/types/supabase';
8 | import { useToast } from '@/components/ui/use-toast';
9 | import { useAuthStore } from '@/store/useAuthStore';
10 |
11 | function ManageSelfInvites() {
12 | const { invites } = useInvitesStore();
13 | const { toast } = useToast();
14 | const { user } = useAuthStore();
15 |
16 | // Filtered invites: email matches user email & status is pending
17 | const filteredInvites = invites.filter(
18 | (invite: Invite) =>
19 | invite.recipient_email === user?.email &&
20 | invite.status === InviteStatus.Pending
21 | );
22 |
23 | const acceptInvite = async (invite: Invite) => {
24 | if (!user) {
25 | toast({ description: 'User not authenticated' });
26 | return;
27 | }
28 |
29 | const supabase = createClient();
30 |
31 | // Step 1: Update the invite status to 'accepted'
32 | const { error: updateError } = await supabase
33 | .from('invites')
34 | .update({ status: InviteStatus.Accepted })
35 | .match({ invite_id: invite.invite_id });
36 |
37 | if (updateError) {
38 | console.error(
39 | 'Failed to update invite status:',
40 | updateError.message
41 | );
42 | toast({ description: 'Failed to accept invite.' });
43 | return;
44 | }
45 |
46 | // Step 2: Insert a row in user_org_roles table
47 | const { error: insertError } = await supabase
48 | .from('user_org_roles')
49 | .insert([
50 | {
51 | user_id: user.id,
52 | org_id: invite.org_id,
53 | role_id: invite.role_id,
54 | },
55 | ]);
56 |
57 | if (insertError) {
58 | console.error(
59 | 'Failed to insert user into org:',
60 | insertError.message
61 | );
62 | toast({ description: 'Failed to accept invite.' });
63 | return;
64 | }
65 |
66 | // If everything is successful
67 | toast({
68 | title: 'Invite accepted successfully.',
69 | description: 'You have been added to the organization.',
70 | });
71 | };
72 |
73 | const rejectInvite = async (invite: Invite) => {
74 | if (!user) {
75 | toast({ description: 'User not authenticated' });
76 | return;
77 | }
78 |
79 | const supabase = createClient();
80 |
81 | // Step 1: Update the invite status to 'declined'
82 | const { error: updateError } = await supabase
83 | .from('invites')
84 | .update({ status: InviteStatus.Declined })
85 | .match({ invite_id: invite.invite_id });
86 |
87 | if (updateError) {
88 | console.error(
89 | 'Failed to update invite status:',
90 | updateError.message
91 | );
92 | toast({ description: 'Failed to decline invite.' });
93 | return;
94 | }
95 |
96 | // If everything is successful
97 | toast({
98 | title: 'Invite declined.',
99 | description: 'You have declined the invite.',
100 | });
101 | };
102 |
103 | if (!filteredInvites.length) {
104 | return null;
105 | }
106 |
107 | return (
108 |
109 |
110 | Manage Invites
111 |
112 |
113 | Accept or reject invites to join organizations
114 |
115 |
116 | {filteredInvites.length > 0 ? (
117 | filteredInvites.map((invite) => (
118 |
122 |
123 |
124 | {invite.org_name ?? 'Organization'}
125 |
126 |
127 | {invite.role_id}
128 |
129 |
130 |
131 | acceptInvite(invite)}
135 | >
136 | Accept
137 |
138 | rejectInvite(invite)}
141 | >
142 | Decline
143 |
144 |
145 |
146 | ))
147 | ) : (
148 |
149 | No pending invites.
150 |
151 | )}
152 |
153 | );
154 | }
155 |
156 | export default ManageSelfInvites;
157 |
--------------------------------------------------------------------------------
/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import OrgSelector from '@/components/OrgSelector';
3 | import UserNav from '@/components/UserNav';
4 | import { getUserOnServer } from '@/utils/supabase/user';
5 |
6 | async function Navbar() {
7 | const user_data = await getUserOnServer();
8 | const [user, secret] = user_data
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | export default Navbar;
24 |
--------------------------------------------------------------------------------
/components/NextLogo.tsx:
--------------------------------------------------------------------------------
1 | export default function NextLogo() {
2 | return (
3 |
10 |
14 |
18 |
22 |
26 |
32 |
36 |
40 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/components/Onboarding.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 | import { zodResolver } from "@hookform/resolvers/zod"
7 | import { useForm } from "react-hook-form"
8 | import { z } from "zod"
9 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/Form";
10 | import { Input } from "@/components/ui/input";
11 | import { Button } from "@/components/ui/Button";
12 | import { requestCarbon } from "@/utils/carbon";
13 | import { User } from "@supabase/supabase-js";
14 | import { useRouter } from "next/navigation";
15 |
16 | const formSchema = z.object({
17 | firstName: z.string().min(1, { message: "This field is required." }).max(64),
18 | lastName: z.string().min(1, { message: "This field is required." }).max(64),
19 | password: z.string().min(6, { message: "Must be at least 6 characters." }).max(64),
20 | })
21 |
22 | const supabase = createClient()
23 |
24 | const Onboarding = (
25 | props: {
26 | user: User,
27 | secret: string,
28 | }
29 | ) => {
30 | const [isLoading, setIsLoading] = useState(false)
31 |
32 | const router = useRouter();
33 | const { toast } = useToast()
34 | const form = useForm>({
35 | resolver: zodResolver(formSchema),
36 | defaultValues: {
37 | firstName: "",
38 | lastName: "",
39 | password: "",
40 | },
41 | });
42 |
43 | const onSubmit = async (values: z.infer) => {
44 | setIsLoading(true)
45 | const { error } = await supabase.auth.updateUser({ password: values.password })
46 | const response = await requestCarbon(
47 | null,
48 | "POST",
49 | "/customer/onboard",
50 | {
51 | email: props.user.email,
52 | first_name: values.firstName,
53 | last_name: values.lastName,
54 | supabase_id: props.user.id,
55 | secret: props.secret,
56 | },
57 | )
58 | setIsLoading(false)
59 |
60 | if (error || response.status !== 200){
61 | toast({ description: "An error occured."})
62 | } else {
63 | router.push("/api-keys")
64 | }
65 | }
66 |
67 | return (
68 |
69 |
70 |
71 |
You've successfully accepted the invitation! Please create your account.
72 |
121 |
122 |
123 | )
124 |
125 | }
126 |
127 | export default Onboarding
--------------------------------------------------------------------------------
/components/OrgCreationSuccess.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import { useSearchParams } from 'next/navigation';
5 | import { CheckCircle } from 'lucide-react';
6 | import { Organization } from '@/hooks/useOrganizationMember';
7 |
8 | function OrgCreationSuccess(props: { organization: Organization }) {
9 | const searchParams = useSearchParams();
10 | const isOrganizationNew = searchParams.get('orgCreated');
11 |
12 | if (!isOrganizationNew) {
13 | return null;
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 | Success
21 |
22 |
23 | 🎉
24 |
25 | {props.organization.name}
26 | {' '}
27 | is now active on of Carbon. You can now manage your
28 | organization, invite members and create API keys here.
29 |
30 |
31 | );
32 | }
33 |
34 | export default OrgCreationSuccess;
35 |
--------------------------------------------------------------------------------
/components/OrgSelector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from 'next/image';
4 | import { Check, ChevronsUpDown } from 'lucide-react';
5 | import { cn } from '@/lib/utils';
6 | import { Button } from '@/components/ui/Button';
7 | import {
8 | Command,
9 | CommandEmpty,
10 | CommandGroup,
11 | CommandInput,
12 | CommandItem,
13 | } from '@/components/ui/command';
14 | import {
15 | Popover,
16 | PopoverContent,
17 | PopoverTrigger,
18 | } from '@/components/ui/popover';
19 | import { useState } from 'react';
20 | import { useOrgsStore } from '@/store/useOrgsStore';
21 |
22 | function OrgSelector() {
23 | const [open, setOpen] = useState(false);
24 |
25 | const { activeOrg, orgs, loading, setActiveOrg } = useOrgsStore();
26 |
27 | if (loading || !orgs.length) {
28 | return (
29 |
35 | );
36 | }
37 | return (
38 |
39 |
45 |
46 |
47 |
48 |
49 |
50 |
56 | {activeOrg?.org_name ?? 'No Org Found'}
57 |
58 |
59 | Pro
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | No org found.
69 |
70 | {orgs.map((org) => (
71 | {
75 | // get org from orgs based on org_id
76 | const newOrg = orgs.find(
77 | (org) =>
78 | org.org_id === currentValue
79 | );
80 |
81 | // set active org
82 | if (newOrg) setActiveOrg(newOrg);
83 | setOpen(false);
84 | }}
85 | >
86 |
94 | {org.org_name}
95 |
96 | ))}
97 |
98 |
99 |
100 |
101 |
102 |
103 | );
104 | }
105 |
106 | export default OrgSelector;
107 |
--------------------------------------------------------------------------------
/components/Organization.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { requestCarbon } from "@/utils/carbon";
4 | import { Fragment, useEffect, useState } from "react";
5 | import { useToast } from "./ui/use-toast";
6 | import { ColumnDef, Row } from "@tanstack/react-table"
7 | import { DataTable } from "./ui/DataTable";
8 | import { Button } from "./ui/Button";
9 | import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "./ui/Pagination";
10 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
11 | import { Input } from "./ui/input";
12 | import Loader from "./ui/Loader";
13 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu";
14 | import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
15 | import { MoreHorizontal } from "lucide-react";
16 | import { IoCheckmarkCircleOutline } from "react-icons/io5";
17 | import { OrganizationMember } from "@/hooks/useOrganizationMember";
18 |
19 | type RowProps = {
20 | // These props describe the organization user to be displayed in the row
21 | id: number;
22 | email: string;
23 | name: string;
24 | isAdmin: boolean;
25 | canReceiveAlerts: boolean;
26 |
27 | // These props are used to make requests to Carbon BE
28 | secret: string;
29 | organizationMember: OrganizationMember;
30 | getOrganizationMembers: () => Promise;
31 | }
32 |
33 | const columns: ColumnDef[] = [
34 | {
35 | accessorKey: "email",
36 | header: "Email",
37 | },
38 | {
39 | accessorKey: "name",
40 | header: "Name",
41 | },
42 | {
43 | accessorKey: "isAdmin",
44 | header: "Admin",
45 | cell: ({ row }) => (row.original.isAdmin && ),
46 | },
47 | {
48 | accessorKey: "alerts",
49 | header: "Alerts",
50 | cell: ({ row }) => (row.original.canReceiveAlerts && ),
51 | },
52 | {
53 | id: "actions",
54 | enableHiding: false,
55 | cell: ({ row }) => (
56 | // Disable actions for the current user's row so they dont
57 | // delete or demote themselves.
58 | row.original.organizationMember.organization_admin &&
59 |
60 | ),
61 | }
62 | ]
63 |
64 | type ListAPIKeysResponse = {
65 | data: OrganizationMember[];
66 | count: number;
67 | }
68 |
69 | const Organization = (
70 | props: {
71 | secret: string,
72 | organizationMember: OrganizationMember,
73 | },
74 | ) => {
75 | const [isLoadingMembers, setIsLoadingMembers] = useState(false);
76 | const [organizationMembers, setOrganizationMembers] = useState([]);
77 | const [currentPage, setCurrentPage] = useState(1);
78 | const [lastPage, setLastPage] = useState(1);
79 | const [limit, setLimit] = useState(10);
80 |
81 | const { toast } = useToast();
82 |
83 | const getOrganizationMembers = async () => {
84 | setIsLoadingMembers(true);
85 | const offset = (currentPage - 1) * limit;
86 | const response = await requestCarbon(props.secret, "GET", `/customer/list?limit=${limit}&offset=${offset}`);
87 | setIsLoadingMembers(false);
88 |
89 | if (response.status !== 200) {
90 | toast({ description: "An error occured. Please try again." });
91 | } else {
92 | const deserializedResponse: ListAPIKeysResponse = await response.json();
93 | const tabulatedOrganizationMember: RowProps[] = deserializedResponse.data.map((organizationMember) => {
94 | return {
95 | id: organizationMember.id,
96 | email: organizationMember.email + (organizationMember.is_onboarded ? "" : ` [Invited]`),
97 | name: (organizationMember.first_name || "") + " " + (organizationMember.last_name || ""),
98 | isAdmin: organizationMember.organization_admin,
99 | canReceiveAlerts: organizationMember.can_receive_alerts,
100 | secret: props.secret,
101 | organizationMember: props.organizationMember,
102 | getOrganizationMembers,
103 | };
104 | });
105 |
106 | setOrganizationMembers(tabulatedOrganizationMember);
107 | setLastPage(deserializedResponse.count / limit);
108 | }
109 | };
110 |
111 | useEffect(() => {
112 | getOrganizationMembers();
113 | }, [currentPage]);
114 |
115 | return (
116 |
117 |
118 |
119 |
Manage Users
120 |
Invite users and manage user permissions.
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | Users
129 |
130 |
131 |
136 |
137 |
138 |
144 |
145 | );
146 | };
147 |
148 | const InviteButton = (
149 | props: {
150 | secret: string,
151 | organizationMember: OrganizationMember,
152 | getOrganizationMembers: () => Promise,
153 | },
154 | ) => {
155 | const [isInviting, setIsInviting] = useState(false);
156 | const [isLoadingInvite, setIsLoadingInvite] = useState(false);
157 | const [email, setEmail] = useState("");
158 |
159 | const { toast } = useToast();
160 |
161 | const inviteTeamMember = async () => {
162 | setIsLoadingInvite(true)
163 | const response = await requestCarbon(
164 | props.secret,
165 | "POST",
166 | "/customer/invite",
167 | { email },
168 | )
169 | setIsLoadingInvite(false)
170 |
171 | setIsInviting(false)
172 | setEmail("")
173 | if (response.status !== 200){
174 | toast({ description: "An error occured."})
175 | } else {
176 | await props.getOrganizationMembers()
177 | toast({ description: "An invitation has been sent."})
178 | }
179 | }
180 |
181 | return (
182 |
183 | {
184 | props.organizationMember.organization_admin && (
185 |
186 | setIsInviting(true)}>Invite
187 |
188 | )
189 | }
190 |
191 |
192 |
193 | Invite a Team Member
194 |
195 |
196 | setEmail(e.target.value)}
200 | placeholder="Email"
201 | />
202 |
203 |
204 |
209 | {isLoadingInvite && }
210 | {isLoadingInvite ? 'Inviting...' : 'Invite'}
211 |
212 |
213 |
214 |
215 | );
216 | }
217 |
218 | const range = (size: number, startAt = 0, max?: number) => {
219 | let actualSize = size;
220 | if (max && startAt + size > max) {
221 | actualSize = max + 1 - startAt;
222 | }
223 | return Array.from({length: actualSize}, (_, index) => index + startAt);
224 | }
225 |
226 | const OrganizationMembersTable = (
227 | props: {
228 | data: RowProps[],
229 | currentPage: number,
230 | setCurrentPage: (pageNumber: number) => void,
231 | lastPage: number,
232 | },
233 | ) => {
234 | const pageRange = range(5, Math.min(1, props.currentPage), props.lastPage);
235 |
236 | return (
237 |
238 |
239 |
240 |
241 | {
242 | props.currentPage > 1 && (
243 |
244 | props.setCurrentPage(props.currentPage - 1)} />
245 |
246 | )
247 | }
248 | {
249 | pageRange.map((value) => {
250 | let className = ""
251 | if (value === props.currentPage) {
252 | className += "underline hover:bg-transparent"
253 | }
254 |
255 | return (
256 |
257 | props.setCurrentPage(value)}
260 | >
261 | {value}
262 |
263 |
264 | );
265 | })
266 | }
267 | {
268 | Boolean(pageRange.length) && pageRange[pageRange.length - 1] !== props.lastPage && (
269 |
270 |
271 |
272 | )
273 | }
274 | {
275 | props.currentPage < props.lastPage && (
276 |
277 | props.setCurrentPage(props.currentPage + 1)}/>
278 |
279 | )
280 | }
281 |
282 |
283 |
284 | );
285 | }
286 |
287 | const OrganizationMemberActions = (
288 | props: {
289 | row: Row,
290 | }
291 | ) => {
292 | const [isDeleting, setIsDeleting] = useState(false);
293 | const [isLoadingUpdate, setIsLoadingUpdate] = useState(false);
294 | const [isLoadingDeletion, setIsLoadingDeletion] = useState(false);
295 |
296 | const { toast } = useToast()
297 |
298 | const updateOrganizationMember = async (property: string) => {
299 | setIsLoadingUpdate(true);
300 | const response = await requestCarbon(
301 | props.row.original.secret,
302 | "POST",
303 | "/customer/update",
304 | {
305 | id: props.row.original.id,
306 | organization_admin: property === "admin" ? !props.row.original.isAdmin : props.row.original.isAdmin,
307 | can_receive_alerts: property === "alerts" ? !props.row.original.canReceiveAlerts : props.row.original.canReceiveAlerts,
308 | },
309 | )
310 | setIsLoadingUpdate(false);
311 |
312 | if (response.status !== 200){
313 | toast({ description: "An error occured."})
314 | } else {
315 | await props.row.original.getOrganizationMembers()
316 | toast({ description: "Updated member"})
317 | }
318 | };
319 |
320 | const deleteOrganizationMember = async () => {
321 | setIsLoadingDeletion(true);
322 | const response = await requestCarbon(
323 | props.row.original.secret,
324 | "POST",
325 | "/customer/delete",
326 | {
327 | ids: [props.row.original.id]
328 | },
329 | );
330 | setIsLoadingDeletion(false);
331 |
332 | setIsDeleting(false);
333 | if (response.status !== 200){
334 | toast({ description: "An error occured."});
335 | } else {
336 | await props.row.original.getOrganizationMembers();
337 | toast({ description: "Deleted organization member"});
338 | }
339 | };
340 |
341 | return (
342 |
343 |
344 |
345 |
346 | Delete Organization Member?
347 |
348 | Are you sure you want to delete this organization member?
349 | This action cannot be undone.
350 |
351 |
352 |
353 |
354 | setIsDeleting(false)}
357 | >
358 | Cancel
359 |
360 |
361 |
367 | {isLoadingDeletion && }
368 | {isLoadingDeletion ? 'Deleting...' : 'Delete'}
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 | {
383 | props.row.original.organizationMember.id !== props.row.original.id && (
384 | updateOrganizationMember("admin")}
386 | >
387 | { props.row.original.isAdmin ? "Remove" : "Make" } Admin
388 |
389 | )
390 | }
391 |
392 | updateOrganizationMember("alerts")}
394 | >
395 | { props.row.original.canReceiveAlerts ? "Disable" : "Enable" } Alerts
396 |
397 |
398 | {
399 | props.row.original.organizationMember.id !== props.row.original.id && (
400 | setIsDeleting(true)}
403 | >
404 | Delete
405 |
406 | )
407 | }
408 |
409 |
410 |
411 | );
412 | }
413 |
414 | export default Organization;
--------------------------------------------------------------------------------
/components/SecondaryNavLinks.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useOrgsStore } from '@/store/useOrgsStore';
4 | import Link from 'next/link';
5 | import { usePathname } from 'next/navigation';
6 | import React from 'react';
7 |
8 | const navLinks = [
9 | { name: 'Home', path: '/' },
10 | { name: 'API Keys', path: '/api-keys' },
11 | { name: 'Billing', path: '/billing' },
12 | { name: 'Settings', path: '/settings' },
13 | ];
14 |
15 | function NavLink({
16 | name,
17 | path,
18 | isActive,
19 | }: {
20 | name: string;
21 | path: string;
22 | isActive: boolean;
23 | }) {
24 | return (
25 |
29 | {name}
30 |
31 | );
32 | }
33 |
34 | function SecondaryNavLinks() {
35 | const pathname = usePathname();
36 | const { activeOrg } = useOrgsStore();
37 |
38 | if (!activeOrg) return null;
39 |
40 | return (
41 |
42 | {navLinks.map(({ name, path }) => (
43 |
49 | ))}
50 |
51 | );
52 | }
53 |
54 | export default SecondaryNavLinks;
55 |
--------------------------------------------------------------------------------
/components/SecondaryNavbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React from 'react';
4 | import SecondaryNavLinks from './SecondaryNavLinks';
5 | import { useAuthStore } from '@/store/useAuthStore';
6 | import { useOrgsStore } from '@/store/useOrgsStore';
7 |
8 | function SecondaryNavbar() {
9 | const { user } = useAuthStore();
10 |
11 | const { activeOrg } = useOrgsStore();
12 | if (!user) return null;
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default SecondaryNavbar;
27 |
--------------------------------------------------------------------------------
/components/Step.tsx:
--------------------------------------------------------------------------------
1 | export default function Step({
2 | title,
3 | children,
4 | }: {
5 | title: string;
6 | children: React.ReactNode;
7 | }) {
8 | return (
9 |
10 |
11 |
15 | {title}
16 |
17 |
20 | {children}
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/SupabaseLogo.tsx:
--------------------------------------------------------------------------------
1 | export default function SupabaseLogo() {
2 | return (
3 |
11 |
12 |
13 |
17 |
22 |
26 |
27 |
31 |
35 |
39 |
43 |
47 |
51 |
55 |
59 |
60 |
61 |
69 |
70 |
71 |
72 |
80 |
81 |
82 |
83 |
84 |
90 |
91 |
92 |
98 |
99 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/components/Syncer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import useSyncAPIKeys from '@/hooks/useSyncAPIKeys';
3 | import useSyncAuth from '@/hooks/useSyncAuth';
4 | import useSyncInvites from '@/hooks/useSyncInvites';
5 | import useSyncOrgs from '@/hooks/useSyncOrgs';
6 |
7 | function Syncer() {
8 | useSyncAuth();
9 | useSyncOrgs();
10 | useSyncInvites();
11 | useSyncAPIKeys();
12 |
13 | return null;
14 | }
15 |
16 | export default Syncer;
17 |
--------------------------------------------------------------------------------
/components/UsageDashboard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useOrgsStore } from '@/store/useOrgsStore';
3 | import React from 'react';
4 | import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts';
5 |
6 | const data = [
7 | {
8 | name: 'Jan',
9 | total: Math.floor(Math.random() * 5000) + 1000,
10 | },
11 | {
12 | name: 'Feb',
13 | total: Math.floor(Math.random() * 5000) + 1000,
14 | },
15 | {
16 | name: 'Mar',
17 | total: Math.floor(Math.random() * 5000) + 1000,
18 | },
19 | {
20 | name: 'Apr',
21 | total: Math.floor(Math.random() * 5000) + 1000,
22 | },
23 | {
24 | name: 'May',
25 | total: Math.floor(Math.random() * 5000) + 1000,
26 | },
27 | {
28 | name: 'Jun',
29 | total: Math.floor(Math.random() * 5000) + 1000,
30 | },
31 | {
32 | name: 'Jul',
33 | total: Math.floor(Math.random() * 5000) + 1000,
34 | },
35 | {
36 | name: 'Aug',
37 | total: Math.floor(Math.random() * 5000) + 1000,
38 | },
39 | {
40 | name: 'Sep',
41 | total: Math.floor(Math.random() * 5000) + 1000,
42 | },
43 | {
44 | name: 'Oct',
45 | total: Math.floor(Math.random() * 5000) + 1000,
46 | },
47 | {
48 | name: 'Nov',
49 | total: Math.floor(Math.random() * 5000) + 1000,
50 | },
51 | {
52 | name: 'Dec',
53 | total: Math.floor(Math.random() * 5000) + 1000,
54 | },
55 | ];
56 |
57 | function UsageDashboard() {
58 | const { activeOrg } = useOrgsStore();
59 |
60 | if (!activeOrg) {
61 | return null;
62 | }
63 |
64 | return (
65 |
66 |
API Usage
67 |
68 |
69 |
70 |
77 | `$${value}`}
83 | />
84 |
90 |
91 |
92 |
93 |
94 | );
95 | }
96 |
97 | export default UsageDashboard;
98 |
--------------------------------------------------------------------------------
/components/UserInvite.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useState } from 'react';
4 | import { createClient as createBrowserClient } from '@/utils/supabase/client';
5 | import { useToast } from '@/components/ui/use-toast';
6 | import { InviteStatus, RoleId } from '@/types/supabase';
7 | import Loader from '@/components/ui/Loader';
8 | import { useOrgsStore } from '@/store/useOrgsStore';
9 | import { Button } from '@/components/ui/Button';
10 | import { PlusCircle } from 'lucide-react';
11 | import {
12 | Select,
13 | SelectContent,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from '@/components/ui/select';
18 |
19 | interface InviteRequest {
20 | recipient_email: string;
21 | role_id: RoleId;
22 | }
23 |
24 | const UserInvite = () => {
25 | const [invites, setInvites] = useState([
26 | { recipient_email: '', role_id: RoleId.member },
27 | ]);
28 | const [loading, setLoading] = useState(false);
29 |
30 | const { activeOrg } = useOrgsStore();
31 | const { toast } = useToast();
32 |
33 | const sendInvites = async () => {
34 | setLoading(true);
35 |
36 | // Filter out invalid emails before sending
37 | const validInvites = invites.filter((invite) =>
38 | checkIfEmailIsValid(invite.recipient_email)
39 | );
40 | if (validInvites.length !== invites.length) {
41 | toast({
42 | description:
43 | 'One or more emails are invalid. Please correct them before creating invites.',
44 | });
45 | setLoading(false);
46 | return;
47 | }
48 |
49 | const supabase = createBrowserClient();
50 | const { error } = await supabase.from('invites').insert(
51 | validInvites.map((invite) => ({
52 | recipient_email: invite.recipient_email,
53 | role_id: invite.role_id,
54 | org_id: activeOrg?.org_id,
55 | org_name: activeOrg?.org_name,
56 | status: InviteStatus.Pending,
57 | }))
58 | );
59 |
60 | if (error) {
61 | toast({ description: 'Failed to create invites.' });
62 | } else {
63 | toast({
64 | title: 'Invites created successfully.',
65 |
66 | description: 'Invited users can now login.',
67 | });
68 | setInvites([{ recipient_email: '', role_id: RoleId.member }]); // Reset form
69 | }
70 | setLoading(false);
71 | };
72 |
73 | // Function to handle the addition of new invite fields
74 | const addInviteField = () => {
75 | setInvites([
76 | ...invites,
77 | { recipient_email: '', role_id: RoleId.member },
78 | ]);
79 | };
80 |
81 | return (
82 |
83 |
Members
84 |
85 | Manage and invite Team Members
86 |
87 |
88 |
89 | Invite new members by email address to
90 |
91 | {' '}
92 | {activeOrg?.org_name}
93 |
94 |
95 |
96 |
97 |
Email Address
98 |
Role
99 | {invites.map((invite, index) => (
100 |
101 | {
106 | const newInvites = [...invites];
107 | newInvites[index].recipient_email =
108 | e.target.value;
109 | setInvites(newInvites);
110 | }}
111 | className="mb-4 w-full rounded-lg border border-zinc-200 p-2 focus:border-zinc-400 focus:outline-none"
112 | />
113 | {
115 | const newInvites = [...invites];
116 | newInvites[index].role_id = value as RoleId;
117 | setInvites(newInvites);
118 | }}
119 | >
120 |
121 |
122 |
123 |
124 | Admin
125 |
126 | Member
127 |
128 |
129 |
130 |
131 | ))}
132 |
133 | Add Another Invite{' '}
134 |
135 |
136 |
137 |
138 |
143 | {loading && }
144 | {loading ? 'Creating Invites...' : 'Create Invites'}
145 |
146 |
147 |
148 | );
149 | };
150 |
151 | // Helper function to validate email format
152 | const checkIfEmailIsValid = (email: string): boolean => {
153 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
154 | return emailRegex.test(email);
155 | };
156 |
157 | export default UserInvite;
158 |
--------------------------------------------------------------------------------
/components/UserNav.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Avatar, AvatarFallback } from '@/components/ui/avatar';
4 | import { Button } from '@/components/ui/Button';
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuGroup,
9 | DropdownMenuItem,
10 | DropdownMenuLabel,
11 | DropdownMenuSeparator,
12 | DropdownMenuTrigger,
13 | } from '@/components/ui/dropdown-menu';
14 | import Link from 'next/link';
15 | import { IoBookOutline } from "react-icons/io5";
16 | import { IoMdHelpCircleOutline } from "react-icons/io";
17 | import { createClient } from '@/utils/supabase/client';
18 | import { useRouter } from 'next/navigation';
19 | import { useAuthStore } from '@/store/useAuthStore';
20 | import { useEffect, useState } from 'react';
21 | import { requestCarbon } from '@/utils/carbon';
22 |
23 | const supabase = createClient();
24 |
25 | type isPaigoEligibleResponse = {
26 | is_eligible: boolean
27 | }
28 |
29 | export default function UserNav(props: {
30 | secret: string | null
31 | }) {
32 | const [showBilling, setShowBilling] = useState(false);
33 |
34 | const router = useRouter();
35 | const { user } = useAuthStore();
36 |
37 | const isPaigoEligible = async () => {
38 | if (props.secret) {
39 | const response = await requestCarbon(
40 | props.secret,
41 | "GET",
42 | "/billing/paigo/is_eligible",
43 | )
44 | if (response.status == 200) {
45 | const deserializedResponse: isPaigoEligibleResponse = await response.json()
46 | setShowBilling(deserializedResponse.is_eligible)
47 | }
48 | }
49 | }
50 |
51 | const signOut = async () => {
52 | await supabase.auth.signOut();
53 | router.push('/login');
54 | };
55 |
56 | useEffect(() => {
57 | isPaigoEligible()
58 | }, [props.secret])
59 |
60 | if (!user) {
61 | return null;
62 | }
63 |
64 | return (
65 |
66 | Help
67 | Docs
68 |
69 |
70 |
74 |
75 |
76 | {user?.email?.charAt(0).toUpperCase()}
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | {user?.email?.split('@')[0]}
86 |
87 |
88 | {user?.email}
89 |
90 |
91 |
92 |
93 |
94 |
95 | router.push("/api-keys")}>
96 | API Keys
97 |
98 |
99 |
100 |
101 | router.push("/organization")}>
102 | Organization
103 |
104 |
105 |
106 | {
107 | showBilling && (
108 |
109 | router.push("/billing")}>
110 | Billing
111 |
112 |
113 | )
114 | }
115 |
116 | {/*
117 | router.push("/carbon-connect")}>
118 | Carbon Connect
119 |
120 | */}
121 |
122 |
123 |
124 |
125 | Log out
126 |
127 |
128 |
129 |
130 | );
131 | }
132 |
--------------------------------------------------------------------------------
/components/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
--------------------------------------------------------------------------------
/components/ui/Card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
--------------------------------------------------------------------------------
/components/ui/DataTable.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | ColumnDef,
5 | flexRender,
6 | getCoreRowModel,
7 | useReactTable,
8 | } from "@tanstack/react-table"
9 |
10 | import {
11 | Table,
12 | TableBody,
13 | TableCell,
14 | TableHead,
15 | TableHeader,
16 | TableRow,
17 | } from "@/components/ui/Table"
18 |
19 | interface DataTableProps {
20 | columns: ColumnDef[]
21 | data: TData[]
22 | }
23 |
24 | export function DataTable({
25 | columns,
26 | data,
27 | }: DataTableProps) {
28 | const table = useReactTable({
29 | data,
30 | columns,
31 | getCoreRowModel: getCoreRowModel(),
32 | })
33 |
34 | return (
35 |
36 |
37 |
38 | {table.getHeaderGroups().map((headerGroup) => (
39 |
40 | {headerGroup.headers.map((header) => {
41 | return (
42 |
43 | {header.isPlaceholder
44 | ? null
45 | : flexRender(
46 | header.column.columnDef.header,
47 | header.getContext()
48 | )}
49 |
50 | )
51 | })}
52 |
53 | ))}
54 |
55 |
56 | {table.getRowModel().rows?.length ? (
57 | table.getRowModel().rows.map((row) => (
58 |
62 | {row.getVisibleCells().map((cell) => (
63 |
64 | {flexRender(cell.column.columnDef.cell, cell.getContext())}
65 |
66 | ))}
67 |
68 | ))
69 | ) : (
70 |
71 |
72 | No results.
73 |
74 |
75 | )}
76 |
77 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/components/ui/Form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/components/ui/Loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface LoaderProps {
4 | number?: number; // Number of bars
5 | size?: number; // Customizable size
6 | color?: string; // Customizable color
7 | className?: string; // Customizable className
8 | }
9 |
10 | const Loader: React.FC = ({
11 | number = 12,
12 | size = 20,
13 | color = 'bg-gray-500',
14 | className = '',
15 | }) => {
16 | const bars = Array.from({ length: number }).map((_, index) => (
17 |
27 | ));
28 |
29 | return (
30 |
37 | {bars}
38 |
39 | );
40 | };
41 |
42 | export default Loader;
43 |
--------------------------------------------------------------------------------
/components/ui/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { ButtonProps, buttonVariants } from "@/components/ui/Button"
6 |
7 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
8 |
14 | )
15 | Pagination.displayName = "Pagination"
16 |
17 | const PaginationContent = React.forwardRef<
18 | HTMLUListElement,
19 | React.ComponentProps<"ul">
20 | >(({ className, ...props }, ref) => (
21 |
26 | ))
27 | PaginationContent.displayName = "PaginationContent"
28 |
29 | const PaginationItem = React.forwardRef<
30 | HTMLLIElement,
31 | React.ComponentProps<"li">
32 | >(({ className, ...props }, ref) => (
33 |
34 | ))
35 | PaginationItem.displayName = "PaginationItem"
36 |
37 | type PaginationLinkProps = {
38 | isActive?: boolean
39 | } & Pick &
40 | React.ComponentProps<"a">
41 |
42 | const PaginationLink = ({
43 | className,
44 | isActive,
45 | size = "icon",
46 | ...props
47 | }: PaginationLinkProps) => (
48 |
59 | )
60 | PaginationLink.displayName = "PaginationLink"
61 |
62 | const PaginationPrevious = ({
63 | className,
64 | ...props
65 | }: React.ComponentProps) => (
66 |
72 |
73 | Previous
74 |
75 | )
76 | PaginationPrevious.displayName = "PaginationPrevious"
77 |
78 | const PaginationNext = ({
79 | className,
80 | ...props
81 | }: React.ComponentProps) => (
82 |
88 | Next
89 |
90 |
91 | )
92 | PaginationNext.displayName = "PaginationNext"
93 |
94 | const PaginationEllipsis = ({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"span">) => (
98 |
103 |
104 | More pages
105 |
106 | )
107 | PaginationEllipsis.displayName = "PaginationEllipsis"
108 |
109 | export {
110 | Pagination,
111 | PaginationContent,
112 | PaginationEllipsis,
113 | PaginationItem,
114 | PaginationLink,
115 | PaginationNext,
116 | PaginationPrevious,
117 | }
--------------------------------------------------------------------------------
/components/ui/Table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { type DialogProps } from "@radix-ui/react-dialog"
5 | import { Command as CommandPrimitive } from "cmdk"
6 | import { Search } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | Command.displayName = CommandPrimitive.displayName
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ))
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ))
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ))
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ))
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ))
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | )
142 | }
143 | CommandShortcut.displayName = "CommandShortcut"
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | }
156 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ToastPrimitives from "@radix-ui/react-toast"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { X } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/hooks/useOrganizationMember.ts:
--------------------------------------------------------------------------------
1 | // import { useCallback, useEffect, useState } from "react";
2 | import { requestCarbon } from "@/utils/carbon";
3 | import { redirect } from "next/navigation";
4 |
5 | // TODO create types for properties using any
6 | // TODO move these types into a dedicated file
7 | export type Organization = {
8 | id: number;
9 | name: string;
10 | nickname?: string;
11 | remove_branding: boolean;
12 | custom_branding?: any;
13 | custom_limits?: any;
14 | aggregate_file_size: any;
15 | aggregate_num_characters: any;
16 | aggregate_num_tokens: any;
17 | aggregate_num_embeddings: any;
18 | period_ends_at?: Date;
19 | cancel_at_period_end?: boolean;
20 | }
21 |
22 | export type OrganizationMember = {
23 | id: number;
24 | email: string;
25 | first_name?: string;
26 | last_name?: string;
27 | organization_id: number;
28 | organization: Organization;
29 | organization_admin: boolean;
30 | can_receive_alerts: boolean;
31 | is_onboarded: boolean;
32 | created_at: Date;
33 | updated: Date;
34 | }
35 |
36 | // NOTE may need to move this to another file
37 | // export function useClientOrganizationMember(supabaseId: string) {
38 | // const [organizationMember, setOrganizationMember] = useState(null);
39 |
40 | // const getOrganizationMember = useCallback(async () => {
41 | // const response = await fetch("localhost:8000/customer/me", {
42 | // method: "GET",
43 | // headers: {
44 | // "Authorization": `Bearer ${supabaseId}`,
45 | // },
46 | // });
47 | // const deserializedResponse = await response.json();
48 | // setOrganizationMember(deserializedResponse);
49 | // }, [supabaseId]);
50 |
51 | // useEffect(() => {
52 | // getOrganizationMember();
53 | // }, [getOrganizationMember]);
54 |
55 | // return organizationMember;
56 | // }
57 |
58 | export async function useServerOrganizationMember(secret: string, isRedirectAllowed?: true): Promise;
59 | export async function useServerOrganizationMember(secret: string, isRedirectAllowed: false): Promise;
60 | export async function useServerOrganizationMember(secret: string, isRedirectAllowed?: boolean): Promise {
61 | const response = await requestCarbon(secret, "GET", "/customer/me")
62 | if (response.status == 200) {
63 | const organizationMember: OrganizationMember = await response.json();
64 | return organizationMember;
65 | } else {
66 | return isRedirectAllowed ?? true ? redirect('/') : null
67 | }
68 | }
--------------------------------------------------------------------------------
/hooks/useSyncAPIKeys.ts:
--------------------------------------------------------------------------------
1 | // useSyncAPIKeys.ts
2 | 'use client';
3 |
4 | import { useEffect } from 'react';
5 | import { createClient } from '@/utils/supabase/client';
6 | import { useAPIKeysStore } from '@/store/useAPIKeysStore';
7 |
8 | const supabase = createClient();
9 |
10 | const useSyncAPIKeys = () => {
11 | const { setAPIKeys, setLoading } = useAPIKeysStore();
12 |
13 | const fetchAndUpdateAPIKeys = async () => {
14 | let { data: apiKeys, error } = await supabase
15 | .from('api_keys')
16 | .select('*');
17 |
18 | if (error) {
19 | console.error('Error fetching API Keys:', error);
20 | return;
21 | }
22 |
23 | if (apiKeys) {
24 | setAPIKeys(apiKeys);
25 | setLoading(false);
26 | }
27 | };
28 |
29 | useEffect(() => {
30 | fetchAndUpdateAPIKeys();
31 |
32 | const authListener = supabase.auth.onAuthStateChange(
33 | (_event, _session) => {
34 | fetchAndUpdateAPIKeys();
35 | }
36 | );
37 |
38 | const apiKeyChangesChannel = supabase
39 | .channel('custom-api-key-channel')
40 | .on(
41 | 'postgres_changes',
42 | { event: '*', schema: 'public', table: 'api_keys' },
43 | fetchAndUpdateAPIKeys
44 | )
45 | .subscribe();
46 |
47 | return () => {
48 | supabase.removeChannel(apiKeyChangesChannel);
49 | authListener.data.subscription.unsubscribe();
50 | };
51 | }, [setAPIKeys, setLoading]);
52 |
53 | return null;
54 | };
55 |
56 | export default useSyncAPIKeys;
57 |
--------------------------------------------------------------------------------
/hooks/useSyncAuth.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { useAuthStore } from '@/store/useAuthStore';
5 | import { createClient } from '@/utils/supabase/client';
6 |
7 | const supabase = createClient();
8 |
9 | const useSyncAuth = () => {
10 | const setUser = useAuthStore((state) => state.setUser);
11 | const setLoading = useAuthStore((state) => state.setLoading);
12 | const setError = useAuthStore((state) => state.setError);
13 |
14 | useEffect(() => {
15 | const checkSession = async () => {
16 | setLoading(true);
17 | try {
18 | const { data } = await supabase.auth.getSession();
19 | setUser(data?.session?.user ?? null);
20 | } catch (error) {
21 | console.error('Error fetching session:', error);
22 | setError('Failed to fetch session');
23 | } finally {
24 | setLoading(false);
25 | }
26 | };
27 |
28 | checkSession();
29 |
30 | const { data: authListener } = supabase.auth.onAuthStateChange(
31 | async (event, session) => {
32 | setUser(session?.user ?? null);
33 | if (event === 'SIGNED_OUT') {
34 | setUser(null);
35 | }
36 | }
37 | );
38 |
39 | return () => {
40 | authListener.subscription.unsubscribe();
41 | };
42 | }, [setUser, setLoading, setError]);
43 | };
44 |
45 | export default useSyncAuth;
46 |
--------------------------------------------------------------------------------
/hooks/useSyncInvites.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { createClient } from '@/utils/supabase/client';
5 | import { useInvitesStore } from '@/store/useInvitesStore';
6 |
7 | const supabase = createClient();
8 |
9 | const useSyncInvites = () => {
10 | const { setInvites, setLoading } = useInvitesStore();
11 |
12 | const fetchAndUpdateInvites = async () => {
13 | let { data: invites, error } = await supabase
14 | .from('invites')
15 | .select('*');
16 |
17 | if (error) {
18 | console.error('Error fetching invites:', error);
19 | return;
20 | }
21 |
22 | if (invites) {
23 | setInvites(invites);
24 | setLoading(false);
25 | }
26 | };
27 |
28 | useEffect(() => {
29 | fetchAndUpdateInvites();
30 |
31 | const authListener = supabase.auth.onAuthStateChange(
32 | (_event, _session) => {
33 | fetchAndUpdateInvites();
34 | }
35 | );
36 |
37 | const channel = supabase
38 | .channel('custom-all-channel')
39 | .on(
40 | 'postgres_changes',
41 | { event: '*', schema: 'public', table: 'invites' },
42 | fetchAndUpdateInvites
43 | )
44 | .subscribe();
45 |
46 | return () => {
47 | supabase.removeChannel(channel);
48 | authListener.data.subscription.unsubscribe();
49 | };
50 | }, [setInvites, setLoading]);
51 | };
52 |
53 | export default useSyncInvites;
54 |
--------------------------------------------------------------------------------
/hooks/useSyncOrgs.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import { createClient } from '@/utils/supabase/client';
5 | import { useOrgsStore } from '@/store/useOrgsStore';
6 |
7 | const supabase = createClient();
8 |
9 | const useSyncOrgs = () => {
10 | const { activeOrg, setActiveOrg, setOrgs, setLoading } = useOrgsStore();
11 |
12 | const fetchAndUpdateOrgs = async () => {
13 | let { data: orgs, error } = await supabase
14 | .from('organizations')
15 | .select('*');
16 |
17 | if (error) {
18 | console.error('Error fetching organizations:', error);
19 | return;
20 | }
21 |
22 | if (orgs) {
23 | setOrgs(orgs);
24 | if (!activeOrg && orgs.length > 0) {
25 | setActiveOrg(orgs[0]);
26 | }
27 | if (orgs.length === 0) {
28 | setActiveOrg(null);
29 | }
30 | setLoading(false);
31 | }
32 | };
33 |
34 | useEffect(() => {
35 | fetchAndUpdateOrgs();
36 |
37 | const authListener = supabase.auth.onAuthStateChange(
38 | (_event, _session) => {
39 | fetchAndUpdateOrgs();
40 | }
41 | );
42 |
43 | const channel_1 = supabase
44 | .channel('custom-all-channel')
45 | .on(
46 | 'postgres_changes',
47 | { event: '*', schema: 'public', table: 'user_org_roles' },
48 | fetchAndUpdateOrgs
49 | )
50 | .subscribe();
51 |
52 | const channel_2 = supabase
53 | .channel('custom-all-channel')
54 | .on(
55 | 'postgres_changes',
56 | { event: '*', schema: 'public', table: 'organizations' },
57 | fetchAndUpdateOrgs
58 | )
59 | .subscribe();
60 |
61 | return () => {
62 | supabase.removeChannel(channel_1);
63 | supabase.removeChannel(channel_2);
64 | authListener.data.subscription.unsubscribe();
65 | };
66 | }, [setOrgs, setActiveOrg, activeOrg, setLoading]);
67 | };
68 |
69 | export default useSyncOrgs;
70 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse, type NextRequest } from "next/server";
2 | import { createClient } from "@/utils/supabase/middleware";
3 |
4 | export async function middleware(request: NextRequest) {
5 | try {
6 | // This `try/catch` block is only here for the interactive tutorial.
7 | // Feel free to remove once you have Supabase connected.
8 | const { supabase, response } = createClient(request);
9 |
10 | // Refresh session if expired - required for Server Components
11 | // https://supabase.com/docs/guides/auth/server-side/nextjs
12 | await supabase.auth.getUser();
13 |
14 | return response;
15 | } catch (e) {
16 | // If you are here, a Supabase client could not be created!
17 | // This is likely because you have not set up environment variables.
18 | // Check out http://localhost:3000 for Next Steps.
19 | return NextResponse.next({
20 | request: {
21 | headers: request.headers,
22 | },
23 | });
24 | }
25 | }
26 |
27 | export const config = {
28 | matcher: [
29 | /*
30 | * Match all request paths except for the ones starting with:
31 | * - _next/static (static files)
32 | * - _next/image (image optimization files)
33 | * - favicon.ico (favicon file)
34 | * Feel free to modify this pattern to include more paths.
35 | */
36 | "/((?!_next/static|_next/image|favicon.ico).*)",
37 | ],
38 | };
39 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "dev": "next dev",
5 | "build": "next build",
6 | "start": "next start"
7 | },
8 | "dependencies": {
9 | "@hookform/resolvers": "^3.3.4",
10 | "@radix-ui/react-avatar": "^1.0.4",
11 | "@radix-ui/react-dialog": "^1.0.5",
12 | "@radix-ui/react-dropdown-menu": "^2.0.6",
13 | "@radix-ui/react-label": "^2.0.2",
14 | "@radix-ui/react-popover": "^1.0.7",
15 | "@radix-ui/react-select": "^2.0.0",
16 | "@radix-ui/react-slot": "^1.0.2",
17 | "@radix-ui/react-toast": "^1.1.5",
18 | "@supabase/ssr": "latest",
19 | "@supabase/supabase-js": "latest",
20 | "@tanstack/react-table": "^8.15.3",
21 | "autoprefixer": "10.4.17",
22 | "class-variance-authority": "^0.7.0",
23 | "classnames": "^2.5.1",
24 | "clsx": "^2.1.0",
25 | "cmdk": "^0.2.1",
26 | "crypto": "^1.0.1",
27 | "framer-motion": "^11.0.5",
28 | "geist": "^1.2.1",
29 | "lucide-react": "^0.325.0",
30 | "next": "latest",
31 | "postcss": "8.4.33",
32 | "react": "18.2.0",
33 | "react-dom": "18.2.0",
34 | "react-hook-form": "^7.51.1",
35 | "react-icons": "^5.0.1",
36 | "recharts": "^2.12.0",
37 | "tailwind-merge": "^2.2.1",
38 | "tailwindcss": "3.4.1",
39 | "tailwindcss-animate": "^1.0.7",
40 | "typescript": "5.3.3",
41 | "uuid": "^9.0.1",
42 | "zod": "^3.22.4",
43 | "zustand": "latest"
44 | },
45 | "devDependencies": {
46 | "@types/node": "20.11.5",
47 | "@types/node-rsa": "^1.1.4",
48 | "@types/react": "18.2.48",
49 | "@types/react-dom": "18.2.18",
50 | "@types/uuid": "^9.0.8",
51 | "encoding": "^0.1.13",
52 | "prettier": "^3.2.5",
53 | "prettier-plugin-tailwindcss": "^0.5.11"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/logo-carbon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hubbleai/supabase-user-management-dashboard/72228323d786136947a832ccdb728b853e901aac/public/logo-carbon.png
--------------------------------------------------------------------------------
/store/useAPIKeysStore.ts:
--------------------------------------------------------------------------------
1 | // useAPIKeysStore.ts
2 | import { APIKey } from '@/types/supabase';
3 | import { create } from 'zustand';
4 |
5 | interface APIKeyState {
6 | apiKeys: APIKey[];
7 | loading: boolean;
8 | setAPIKeys: (apiKeys: APIKey[]) => void;
9 | setLoading: (loading: boolean) => void;
10 | }
11 |
12 | export const useAPIKeysStore = create((set) => ({
13 | apiKeys: [],
14 | loading: true,
15 | setAPIKeys: (apiKeys) => set({ apiKeys }),
16 | setLoading: (loading) => set({ loading }),
17 | }));
18 |
--------------------------------------------------------------------------------
/store/useAuthStore.ts:
--------------------------------------------------------------------------------
1 | // store/useAuthStore.ts
2 | import { create } from 'zustand';
3 | import { User } from '@supabase/supabase-js';
4 |
5 | interface AuthState {
6 | user: User | null;
7 | loading: boolean;
8 | error: string | null;
9 | setUser: (user: User | null) => void;
10 | setLoading: (loading: boolean) => void;
11 | setError: (error: string | null) => void;
12 | }
13 |
14 | export const useAuthStore = create((set) => ({
15 | user: null,
16 | loading: true,
17 | error: null,
18 | setUser: (user) => set({ user }),
19 | setLoading: (loading) => set({ loading }),
20 | setError: (error) => set({ error }),
21 | }));
22 |
--------------------------------------------------------------------------------
/store/useInvitesStore.ts:
--------------------------------------------------------------------------------
1 | // store/useInvitesStore.ts
2 | import { Invite } from '@/types/supabase';
3 | import { create } from 'zustand';
4 |
5 | interface InviteState {
6 | invites: Invite[];
7 | loading: boolean;
8 | setInvites: (invites: Invite[]) => void;
9 | setLoading: (loading: boolean) => void;
10 | }
11 |
12 | export const useInvitesStore = create((set) => ({
13 | invites: [],
14 | loading: true,
15 | setInvites: (invites) => set({ invites }),
16 | setLoading: (loading) => set({ loading }),
17 | }));
18 |
--------------------------------------------------------------------------------
/store/useOrgsStore.ts:
--------------------------------------------------------------------------------
1 | // store/useOrgsStore.ts
2 | import { Organization } from '@/types/supabase';
3 | import { create } from 'zustand';
4 |
5 | interface OrgState {
6 | orgs: Organization[];
7 | loading: boolean;
8 | activeOrg: Organization | null;
9 | setOrgs: (orgs: Organization[]) => void;
10 | setLoading: (loading: boolean) => void;
11 | setActiveOrg: (activeOrg: Organization | null) => void;
12 | }
13 |
14 | export const useOrgsStore = create((set) => ({
15 | orgs: [],
16 | loading: true,
17 | activeOrg: null,
18 | setOrgs: (orgs) => set({ orgs }),
19 | setLoading: (loading: boolean) => set({ loading }),
20 | setActiveOrg: (activeOrg: Organization | null) => set({ activeOrg }),
21 | }));
22 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './src/**/*.{ts,tsx}',
8 | './app/**/*.{js,ts,jsx,tsx,mdx}',
9 | './components/**/*.{js,ts,jsx,tsx,mdx}',
10 | ],
11 | prefix: '',
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px',
18 | },
19 | },
20 | extend: {
21 | fontFamily: {
22 | kalam: ['Kalam', 'sans-serif'],
23 | },
24 | colors: {
25 | subtle: '#F6F6F6',
26 | border: 'hsl(var(--border))',
27 | input: 'hsl(var(--input))',
28 | ring: 'hsl(var(--ring))',
29 | background: 'hsl(var(--background))',
30 | foreground: 'hsl(var(--foreground))',
31 | primary: {
32 | DEFAULT: 'hsl(var(--primary))',
33 | foreground: 'hsl(var(--primary-foreground))',
34 | },
35 | secondary: {
36 | DEFAULT: 'hsl(var(--secondary))',
37 | foreground: 'hsl(var(--secondary-foreground))',
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))',
42 | },
43 | muted: {
44 | DEFAULT: 'hsl(var(--muted))',
45 | foreground: 'hsl(var(--muted-foreground))',
46 | },
47 | accent: {
48 | DEFAULT: 'hsl(var(--accent))',
49 | foreground: 'hsl(var(--accent-foreground))',
50 | },
51 | popover: {
52 | DEFAULT: 'hsl(var(--popover))',
53 | foreground: 'hsl(var(--popover-foreground))',
54 | },
55 | card: {
56 | DEFAULT: 'hsl(var(--card))',
57 | foreground: 'hsl(var(--card-foreground))',
58 | },
59 | },
60 | borderRadius: {
61 | lg: 'var(--radius)',
62 | md: 'calc(var(--radius) - 2px)',
63 | sm: 'calc(var(--radius) - 4px)',
64 | },
65 | keyframes: {
66 | 'accordion-down': {
67 | from: { height: '0' },
68 | to: { height: 'var(--radix-accordion-content-height)' },
69 | },
70 | 'accordion-up': {
71 | from: { height: 'var(--radix-accordion-content-height)' },
72 | to: { height: '0' },
73 | },
74 | },
75 | animation: {
76 | 'accordion-down': 'accordion-down 0.2s ease-out',
77 | 'accordion-up': 'accordion-up 0.2s ease-out',
78 | 'spin-slow': 'spin 2s linear infinite',
79 | },
80 | },
81 | },
82 | plugins: [require('tailwindcss-animate')],
83 | } satisfies Config;
84 |
85 | export default config;
86 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/types/common.ts:
--------------------------------------------------------------------------------
1 | export enum Status {
2 | Idle = 'idle',
3 | Loading = 'loading',
4 | Success = 'success',
5 | Error = 'error',
6 | }
7 |
--------------------------------------------------------------------------------
/types/supabase.ts:
--------------------------------------------------------------------------------
1 | // Types for our data models
2 |
3 | // InviteStatus enum
4 | export enum InviteStatus {
5 | Pending = 'pending',
6 | Accepted = 'accepted',
7 | Declined = 'declined',
8 | }
9 |
10 | export enum RoleId {
11 | admin = 'admin',
12 | member = 'member',
13 | }
14 |
15 | // Invite type
16 | export interface Invite {
17 | invite_id: string; // UUID
18 | recipient_email: string;
19 | org_id: string; // UUID
20 | org_name?: string; // set by server
21 | role_id: string;
22 | status: InviteStatus;
23 | created_at: Date;
24 | accepted_at?: Date | null;
25 | }
26 |
27 | // Organization type
28 | export interface Organization {
29 | org_id: string; // UUID
30 | org_name: string;
31 | created_by: string; // User ID
32 | created_at: Date;
33 | }
34 |
35 | // Role type
36 | export interface Role {
37 | role_id: RoleId;
38 | }
39 |
40 | // UserOrgRole type
41 | export interface UserOrgRole {
42 | user_id: string; // User ID
43 | org_id: string; // UUID
44 | role_id: RoleId;
45 | }
46 |
47 | // APIKey type
48 | export interface APIKey {
49 | api_key_id: string; // UUID
50 | key: string;
51 | created_by: string; // User ID
52 | org_id: string; // UUID
53 | is_active: boolean;
54 | created_at: Date;
55 | label?: string;
56 | }
57 |
--------------------------------------------------------------------------------
/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 | import { getUserOnServer } from './supabase/user';
3 | import { User } from '@supabase/supabase-js';
4 |
5 | export const authenticatePage = async (): Promise<[User, string]> => {
6 | const user_data = await getUserOnServer();
7 | const [user, secret] = user_data
8 | if (!user || !secret) {
9 | return redirect('/login');
10 | }
11 | return [user, secret];
12 | };
13 |
--------------------------------------------------------------------------------
/utils/carbon.ts:
--------------------------------------------------------------------------------
1 | export const requestCarbon = async (
2 | secret: string | null,
3 | method: string,
4 | endpoint: string,
5 | body?: { [key: string]: any },
6 | ): Promise => {
7 | let headers: HeadersInit = {}
8 | if (secret) {
9 | headers["Authorization"] = `Bearer ${secret}`;
10 | }
11 | if (method === "POST") {
12 | headers["Content-Type"] = "application/json";
13 | }
14 |
15 | return await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, {
16 | method,
17 | headers,
18 | body: body ? JSON.stringify(body) : undefined,
19 | });
20 | }
--------------------------------------------------------------------------------
/utils/supabase/client.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient } from "@supabase/ssr";
2 |
3 | export const createClient = () =>
4 | createBrowserClient(
5 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7 | );
8 |
9 | export const getUserOnClient = async () => {
10 | const supabase = createClient();
11 | const {
12 | data: { user },
13 | } = await supabase.auth.getUser();
14 |
15 | return user;
16 | };
17 |
--------------------------------------------------------------------------------
/utils/supabase/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
2 | import { type NextRequest, NextResponse } from "next/server";
3 |
4 | export const createClient = (request: NextRequest) => {
5 | // Create an unmodified response
6 | let response = NextResponse.next({
7 | request: {
8 | headers: request.headers,
9 | },
10 | });
11 |
12 | const supabase = createServerClient(
13 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
14 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
15 | {
16 | cookies: {
17 | get(name: string) {
18 | return request.cookies.get(name)?.value;
19 | },
20 | set(name: string, value: string, options: CookieOptions) {
21 | // If the cookie is updated, update the cookies for the request and response
22 | request.cookies.set({
23 | name,
24 | value,
25 | ...options,
26 | });
27 | response = NextResponse.next({
28 | request: {
29 | headers: request.headers,
30 | },
31 | });
32 | response.cookies.set({
33 | name,
34 | value,
35 | ...options,
36 | });
37 | },
38 | remove(name: string, options: CookieOptions) {
39 | // If the cookie is removed, update the cookies for the request and response
40 | request.cookies.set({
41 | name,
42 | value: "",
43 | ...options,
44 | });
45 | response = NextResponse.next({
46 | request: {
47 | headers: request.headers,
48 | },
49 | });
50 | response.cookies.set({
51 | name,
52 | value: "",
53 | ...options,
54 | });
55 | },
56 | },
57 | },
58 | );
59 |
60 | return { supabase, response };
61 | };
62 |
--------------------------------------------------------------------------------
/utils/supabase/server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
2 | import { cookies } from "next/headers";
3 |
4 | export const createClient = (cookieStore: ReturnType) => {
5 | return createServerClient(
6 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
8 | {
9 | cookies: {
10 | get(name: string) {
11 | return cookieStore.get(name)?.value;
12 | },
13 | set(name: string, value: string, options: CookieOptions) {
14 | try {
15 | cookieStore.set({ name, value, ...options });
16 | } catch (error) {
17 | // The `set` method was called from a Server Component.
18 | // This can be ignored if you have middleware refreshing
19 | // user sessions.
20 | }
21 | },
22 | remove(name: string, options: CookieOptions) {
23 | try {
24 | cookieStore.set({ name, value: "", ...options });
25 | } catch (error) {
26 | // The `delete` method was called from a Server Component.
27 | // This can be ignored if you have middleware refreshing
28 | // user sessions.
29 | }
30 | },
31 | },
32 | }
33 | );
34 | };
35 |
36 | // New abstraction that initializes both cookieStore and Supabase client
37 | export const initializeSupabase = () => {
38 | const cookieStore = cookies();
39 | const supabase = createClient(cookieStore);
40 | return supabase;
41 | };
42 |
--------------------------------------------------------------------------------
/utils/supabase/user.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from "@/utils/supabase/server";
2 | import { User } from "@supabase/supabase-js";
3 | import { createHmac } from "crypto";
4 | import { cookies } from "next/headers";
5 |
6 | const hash = (input: string): string => {
7 | const hmac = createHmac("sha256", Buffer.from(process.env.HMAC_KEY || "", 'hex'))
8 | return hmac.update(input).digest("hex")
9 | }
10 |
11 | export const getUserOnServer = async (): Promise<[User | null, string | null]> => {
12 | const cookieStore = cookies();
13 | const supabase = createClient(cookieStore);
14 |
15 | const {
16 | data: { user },
17 | } = await supabase.auth.getUser();
18 |
19 | if (!user) {
20 | return [null, null]
21 | }
22 |
23 | // When an auth.users is created, a trigger activates and creates a row in the
24 | // public.users table. That trigger is also responsible for generating a secret
25 | // using pgcrypto for the user.
26 | const { data } = await supabase.from("users")
27 | .select("secret")
28 | .eq("id", user.id)
29 | const secret: string | null = data ? hash(data[0].secret) : null
30 |
31 | return [user, secret];
32 | };
33 |
--------------------------------------------------------------------------------