├── supabase ├── .gitignore ├── seed.sql ├── tests │ └── database │ │ ├── 3-personal-accounts-disabled.sql │ │ ├── 5-team-accounts-disabled.sql │ │ ├── 9-profiles.sql │ │ ├── 1-basejump-schema-tests.sql │ │ ├── 7-inviting-team-member.sql │ │ └── 8-inviting-team-owner.sql ├── migrations │ ├── 00000000000000_dbdev_temp_install.sql │ ├── 00000000000001_utility_functions.sql │ └── 00000000000010_profiles.sql └── config.toml ├── public ├── robots.txt └── images │ └── basejump-logo.png ├── .gitattributes ├── src ├── styles │ └── global.css ├── types │ ├── billing.ts │ └── auth.ts ├── utils │ ├── handle-supabase-errors.ts │ ├── get-invitation-url.ts │ ├── content │ │ ├── slug-to-title.ts │ │ └── use-header-navigation.ts │ ├── admin │ │ ├── supabase-admin-client.ts │ │ └── stripe.ts │ ├── get-full-redirect-url.ts │ ├── use-theme-storage.ts │ ├── api │ │ ├── use-user-profile.ts │ │ ├── use-team-account.ts │ │ ├── use-personal-account.ts │ │ ├── use-invitation.ts │ │ ├── use-team-invitations.ts │ │ ├── use-team-accounts.ts │ │ ├── use-team-role.ts │ │ ├── use-account-billing-status.ts │ │ ├── use-team-members.ts │ │ ├── use-dashboard-overview.ts │ │ └── use-account-billing-options.ts │ └── use-auth-check.ts ├── pages │ ├── index.tsx │ ├── signup.tsx │ ├── login.tsx │ ├── dashboard │ │ ├── teams │ │ │ └── [accountId] │ │ │ │ ├── index.tsx │ │ │ │ └── settings │ │ │ │ ├── billing.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── members.tsx │ │ ├── billing.tsx │ │ ├── profile.tsx │ │ └── index.tsx │ ├── docs │ │ ├── index.tsx │ │ └── [...slug].tsx │ ├── api │ │ ├── og.tsx │ │ └── billing │ │ │ ├── portal-link.ts │ │ │ ├── setup.ts │ │ │ ├── status.ts │ │ │ └── stripe-webhooks.ts │ ├── blog │ │ ├── index.tsx │ │ └── [...slug].tsx │ ├── invitation.tsx │ └── _app.tsx ├── components │ ├── dashboard │ │ ├── dashboard-meta.tsx │ │ ├── accounts │ │ │ ├── new-account-modal.tsx │ │ │ ├── settings │ │ │ │ ├── list-team-invitations.tsx │ │ │ │ ├── list-team-members.tsx │ │ │ │ ├── remove-team-member.tsx │ │ │ │ ├── account-settings-layout.tsx │ │ │ │ ├── update-account-name.tsx │ │ │ │ ├── account-subscription.tsx │ │ │ │ ├── individual-team-member.tsx │ │ │ │ ├── individual-team-invitation.tsx │ │ │ │ ├── update-team-member-role.tsx │ │ │ │ └── invite-member.tsx │ │ │ ├── personal-account-deactivated.tsx │ │ │ ├── account-subscription-takeover │ │ │ │ ├── account-subscription-takeover.tsx │ │ │ │ ├── new-subscription.tsx │ │ │ │ └── individual-subscription-plan.tsx │ │ │ └── new-account-form.tsx │ │ ├── shared │ │ │ ├── dashboard-content.tsx │ │ │ └── settings-card.tsx │ │ ├── sidebar │ │ │ ├── team-account-menu.tsx │ │ │ ├── personal-account-menu.tsx │ │ │ ├── profile-button.tsx │ │ │ ├── theme-selector.tsx │ │ │ ├── sidebar-menu.tsx │ │ │ └── team-select-menu.tsx │ │ ├── authentication │ │ │ ├── login-password.tsx │ │ │ ├── signup-password.tsx │ │ │ └── login-magic-link.tsx │ │ ├── profile │ │ │ ├── update-email-address.tsx │ │ │ ├── update-profile-name.tsx │ │ │ └── list-teams.tsx │ │ └── dashboard-layout.tsx │ ├── core │ │ ├── portal.tsx │ │ ├── input.tsx │ │ ├── select.tsx │ │ └── loader.tsx │ ├── content-pages │ │ ├── content-footer.tsx │ │ ├── content-layout.tsx │ │ ├── content-meta.tsx │ │ ├── content-header.tsx │ │ └── content-header-mobile.tsx │ ├── basejump-default-content │ │ ├── future-content-placeholder.tsx │ │ └── logo.tsx │ └── docs │ │ ├── docs-layout.tsx │ │ └── docs-sidebar.tsx └── middleware.ts ├── .eslintrc.json ├── __tests__ ├── content │ ├── blog │ │ └── en │ │ │ ├── article-1.md │ │ │ ├── article-2.md │ │ │ └── unpublished-blog.md │ └── docs │ │ └── en │ │ ├── doc-2.md │ │ ├── unpublished-doc.md │ │ └── index.md ├── utils │ ├── handle-supabase-errors.test.ts │ ├── slug-to-title.test.ts │ └── content-helpers.test.ts ├── setup │ ├── jest.setup.js │ └── test-utils.tsx ├── core │ ├── input.test.tsx │ └── select.test.tsx └── dashboard │ ├── profile │ ├── update-email.test.tsx │ └── update-name.test.tsx │ └── accounts │ └── settings │ └── update-team-member-role.test.tsx ├── postcss.config.js ├── content ├── docs │ └── en │ │ ├── unpublished-doc.md │ │ ├── index.md │ │ └── formatting-example.md ├── blog │ └── en │ │ ├── unpublished-blog.md │ │ ├── hello-world.md │ │ └── formatting-example.md └── locales │ └── en │ ├── content.json │ └── authentication.json ├── .env.sample ├── .vscode └── settings.json ├── .github └── workflows │ ├── tests.yml │ └── pg_tests.yml ├── next.config.js ├── .gitignore ├── tsconfig.json ├── i18n.js ├── scripts └── sync-stripe.ts ├── LICENSE.md ├── README.md ├── jest.config.js ├── package.json └── tailwind.config.js /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /dashboard/ 3 | Allow: / 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/types/billing.ts: -------------------------------------------------------------------------------- 1 | export const MANUAL_SUBSCRIPTION_REQUIRED = "manual_subscription_required"; 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next", 4 | "next/core-web-vitals", 5 | "prettier" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/content/blog/en/article-1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Article 1" 3 | published: 2022-10-15 4 | --- 5 | 6 | This is test article 1 -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/basejump-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usebasejump/legacy-basejump-template/HEAD/public/images/basejump-logo.png -------------------------------------------------------------------------------- /__tests__/content/docs/en/doc-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Doc 2" 3 | category: "Example Category" 4 | published: 2022-10-16 5 | --- 6 | 7 | Test Doc 2 8 | -------------------------------------------------------------------------------- /src/utils/handle-supabase-errors.ts: -------------------------------------------------------------------------------- 1 | export default function handleSupabaseErrors(data: any, error: any) { 2 | if (error) { 3 | throw new Error(error.message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import BasejumpHomepage from "@/components/basejump-default-content/homepage"; 2 | 3 | const IndexPage = () => ; 4 | 5 | export default IndexPage; 6 | -------------------------------------------------------------------------------- /content/docs/en/unpublished-doc.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Unpublished Doc" 3 | published: 4 | --- 5 | 6 | You should never see this! it's unpublished. If you wanted to publish it, just add a published date! -------------------------------------------------------------------------------- /__tests__/content/docs/en/unpublished-doc.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Unpublished Doc" 3 | published: 4 | --- 5 | 6 | You should never see this! it's unpublished. If you wanted to publish it, just add a published date! -------------------------------------------------------------------------------- /content/blog/en/unpublished-blog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "An unpublished blog article" 3 | category: "Updates" 4 | published: 5 | --- 6 | 7 | You should never see this! it's unpublished. If you wanted to publish it, just add a published date! -------------------------------------------------------------------------------- /src/utils/get-invitation-url.ts: -------------------------------------------------------------------------------- 1 | export default function getInvitationUrl(invitationToken: string) { 2 | const baseUrl = process.env.URL || window.location.origin; 3 | return `${baseUrl}/invitation?token=${invitationToken}`; 4 | } 5 | -------------------------------------------------------------------------------- /__tests__/content/blog/en/article-2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Article 2" 3 | published: 2022-10-16 4 | category: "Example Category" 5 | description: "This is a description of the article" 6 | --- 7 | 8 | This is test article 2 9 | 10 | -------------------------------------------------------------------------------- /src/types/auth.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN_PATH = "/login"; 2 | export const REGISTER_PATH = "/signup"; 3 | export const DASHBOARD_PATH = "/dashboard"; 4 | 5 | export enum ACCOUNT_ROLES { 6 | owner = "owner", 7 | member = "member", 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/content/slug-to-title.ts: -------------------------------------------------------------------------------- 1 | export function slugToTitle(slug: string): string { 2 | if (!slug) return ""; 3 | return slug 4 | ?.split(/[-_]/gi) 5 | .map((s) => s[0].toUpperCase() + s.slice(1)) 6 | .join(" "); 7 | } 8 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | URL="http://localhost:3000" 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY=generated-by-supabase 3 | NEXT_PUBLIC_SUPABASE_URL=generated-by-supabase 4 | STRIPE_SECRET_KEY=generated-by-stripe 5 | SUPABASE_SERVICE_ROLE_KEY=generated-by-supabase 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": [ 3 | "content/locales" 4 | ], 5 | "deno.enable": true, 6 | "deno.unstable": true, 7 | "deno.enablePaths": [ 8 | "supabase/functions" 9 | ] 10 | } -------------------------------------------------------------------------------- /__tests__/content/blog/en/unpublished-blog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "An unpublished blog article" 3 | category: "Updates" 4 | published: 5 | --- 6 | 7 | You should never see this! it's unpublished. If you wanted to publish it, just add a published date! -------------------------------------------------------------------------------- /src/utils/admin/supabase-admin-client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { Database } from "@/types/supabase-types"; 3 | 4 | export const supabaseAdmin = createClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL || "", 6 | process.env.SUPABASE_SERVICE_ROLE_KEY || "" 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/dashboard/dashboard-meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | type Props = { 4 | title: string; 5 | }; 6 | 7 | const DashboardMeta = ({ title }: Props) => { 8 | return ( 9 | 10 | {title} 11 | 12 | ); 13 | }; 14 | export default DashboardMeta; 15 | -------------------------------------------------------------------------------- /content/locales/en/content.json: -------------------------------------------------------------------------------- 1 | { 2 | "blog": "Blog", 3 | "blogDescription": "Stay up to date with all of our team updates", 4 | "docs": "Docs", 5 | "docsMenu": "Documentation Menu", 6 | "closeDocsMenu": "Close Documentation Menu", 7 | "dashboard": "Dashboard", 8 | "login": "Login", 9 | "signUp": "Sign up" 10 | } -------------------------------------------------------------------------------- /src/utils/get-full-redirect-url.ts: -------------------------------------------------------------------------------- 1 | export default function getFullRedirectUrl(redirectPath: string) { 2 | if (redirectPath.startsWith("http")) return redirectPath; 3 | const baseUrl = 4 | process.env.URL || process.env.VERCEL_URL || window.location.origin; 5 | return [baseUrl, redirectPath?.replace(/^\//, "")].filter(Boolean).join("/"); 6 | } 7 | -------------------------------------------------------------------------------- /content/blog/en/hello-world.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Hello World" 3 | published: 2022-10-15 4 | category: "Updates" 5 | description: "This is an automatically generated Basejump blog article!" 6 | socialDescription: "Check out this automatically generated Basejump blog article!" 7 | socialImage: "/images/basejump-logo.png" 8 | --- 9 | 10 | Hello world, we're Basejump. It's awesome to meet you. -------------------------------------------------------------------------------- /src/utils/content/use-header-navigation.ts: -------------------------------------------------------------------------------- 1 | import useTranslation from "next-translate/useTranslation"; 2 | 3 | export default function useHeaderNavigation() { 4 | const { t } = useTranslation("content"); 5 | return [ 6 | { 7 | title: t("docs"), 8 | href: "/docs", 9 | }, 10 | { 11 | title: t("blog"), 12 | href: "/blog", 13 | }, 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Jest Tests and Linting 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Install modules 11 | run: yarn install --frozen-lockfile 12 | - name: Next Linting 13 | run: yarn lint 14 | - name: Run tests 15 | run: yarn test -------------------------------------------------------------------------------- /.github/workflows/pg_tests.yml: -------------------------------------------------------------------------------- 1 | name: PGTap Tests 2 | on: 3 | pull_request: 4 | branches: [ main ] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: supabase/setup-cli@v1 11 | with: 12 | version: 1.50.11 13 | - name: Supabase Start 14 | run: supabase start 15 | - name: Run Tests 16 | run: supabase db test -------------------------------------------------------------------------------- /src/components/core/portal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | export default function Portal(props: { children: ReactNode }) { 5 | let { children } = props; 6 | let [mounted, setMounted] = useState(false); 7 | 8 | useEffect(() => setMounted(true), []); 9 | 10 | if (!mounted) return null; 11 | return createPortal(children, document.body); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/admin/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { 4 | // https://github.com/stripe/stripe-node#configuration 5 | apiVersion: "2022-08-01", 6 | // Register this as an official Stripe plugin. 7 | // https://stripe.com/docs/building-plugins#setappinfo 8 | appInfo: { 9 | name: `Basejump App`, 10 | version: "0.1.0", 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /content/docs/en/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting Started" 3 | published: 2022-10-15 4 | description: "Basejump automatically generated documentation" 5 | --- 6 | 7 | Basejump includes support for built-in documentation. You can check out some of the markdown formatting options on 8 | the [formatting examples page](/docs/formatting-example). 9 | 10 | If you don't need documentation - not a problem! just remove the markdown files and navigation links for it. -------------------------------------------------------------------------------- /__tests__/content/docs/en/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting Started" 3 | published: 2022-10-15 4 | description: "Basejump automatically generated documentation" 5 | --- 6 | 7 | Basejump includes support for built-in documentation. You can check out some of the markdown formatting options on 8 | the [formatting examples page](/docs/formatting-example). 9 | 10 | If you don't need documentation - not a problem! just remove the markdown files and navigation links for it. -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- 1 | insert into basejump.config (enable_personal_accounts, 2 | enable_team_accounts, 3 | enable_account_billing, 4 | billing_provider, 5 | stripe_default_trial_period_days, 6 | stripe_default_account_price_id) 7 | values (TRUE, 8 | TRUE, 9 | FALSE, 10 | 'stripe', 11 | 30, 12 | null); -------------------------------------------------------------------------------- /__tests__/utils/handle-supabase-errors.test.ts: -------------------------------------------------------------------------------- 1 | import handleSupabaseErrors from "@/utils/handle-supabase-errors"; 2 | 3 | describe("Handle supabase responses", () => { 4 | test("Should know how to throw an error if an error exists", () => { 5 | expect(() => handleSupabaseErrors(null, { message: "error" })).toThrow( 6 | "error" 7 | ); 8 | }); 9 | 10 | test("Should know how to not throw an error if no error exists", () => { 11 | expect(() => handleSupabaseErrors({}, null)).not.toThrow(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/content-pages/content-footer.tsx: -------------------------------------------------------------------------------- 1 | const ContentFooter = () => { 2 | const year = new Date().getFullYear(); 3 | return ( 4 |
5 |
6 |

Your footer - the place you put footery things

7 |

8 |

© {year} usebasejump.com

9 |
10 |
11 | ); 12 | }; 13 | export default ContentFooter; 14 | -------------------------------------------------------------------------------- /__tests__/setup/jest.setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { TextDecoder, TextEncoder } from "util"; 3 | import fetchMock from "jest-fetch-mock"; 4 | 5 | /** 6 | * This is a workaround for jsdom not supporting the TextEncoder and TextDecoder APIs. 7 | */ 8 | global.TextEncoder = TextEncoder; 9 | global.TextDecoder = TextDecoder; 10 | 11 | /** 12 | * Setup tests for mocking the fetch API. 13 | */ 14 | fetchMock.enableMocks(); 15 | 16 | beforeEach(() => { 17 | // clear out the mocks 18 | fetch.resetMocks(); 19 | jest.clearAllMocks(); 20 | }); 21 | -------------------------------------------------------------------------------- /__tests__/utils/slug-to-title.test.ts: -------------------------------------------------------------------------------- 1 | import { slugToTitle } from "@/utils/content/slug-to-title"; 2 | 3 | describe("Slug to title", () => { 4 | test("Should know how to convert a slug into a title", () => { 5 | expect(slugToTitle("hello-world")).toEqual("Hello World"); 6 | expect(slugToTitle("hello-world_2")).toEqual("Hello World 2"); 7 | expect(slugToTitle("hello-world-again-again")).toEqual( 8 | "Hello World Again Again" 9 | ); 10 | }); 11 | 12 | test("Should know how to handle empty slugs", () => { 13 | expect(slugToTitle("")).toEqual(""); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require("next-transpile-modules")(["react-daisyui"]); 2 | const nextTranslate = require("next-translate"); 3 | 4 | module.exports = withTM( 5 | nextTranslate({ 6 | /** 7 | * Looking for i18n configuration? 8 | * Internationalization is handled in Basejump with the next-translate library 9 | * You can view the configuration in the `i18n.js` config file 10 | */ 11 | reactStrictMode: true, 12 | /** 13 | * Adds support for MDX files, used for docs and blog 14 | */ 15 | pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], 16 | }) 17 | ); 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # project dependencies 4 | .idea 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log* 30 | 31 | # local env files 32 | .envrc 33 | .env 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | # Supabase 43 | **/supabase/.branches 44 | **/supabase/.temp 45 | -------------------------------------------------------------------------------- /content/locales/en/authentication.json: -------------------------------------------------------------------------------- 1 | { 2 | "shared": { 3 | "email": "Email", 4 | "password": "Password", 5 | "notYetRegistered": "Not yet registered? Sign up here", 6 | "alreadyRegistered": "Already registered? Sign in here" 7 | }, 8 | "magicLink": { 9 | "checkEmail": "Check your email to continue", 10 | "tryAgain": "Try again", 11 | "emailPlaceholder": "Your email address", 12 | "emailHelpText": "We'll send you a magic link to sign in", 13 | "buttonText": "Send magic link" 14 | }, 15 | "loginPassword": { 16 | "buttonText": "Sign in" 17 | }, 18 | "signupPassword": { 19 | "buttonText": "Create your account" 20 | } 21 | } -------------------------------------------------------------------------------- /src/utils/use-theme-storage.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from "react-use"; 2 | import { useTheme } from "react-daisyui"; 3 | import { useEffect } from "react"; 4 | 5 | const THEME_STORAGE_KEY = "basejump-theme"; 6 | export default function useThemeStorage(defaultTheme?: string) { 7 | const [value, setValue, remove] = useLocalStorage( 8 | THEME_STORAGE_KEY, 9 | defaultTheme 10 | ); 11 | const { theme, setTheme } = useTheme(value); 12 | 13 | useEffect(() => { 14 | setTheme(value); 15 | }, [value, setTheme]); 16 | 17 | function setInternalTheme(theme: string) { 18 | setValue(theme); 19 | } 20 | 21 | return { theme, setTheme: setInternalTheme, clearTheme: remove }; 22 | } 23 | -------------------------------------------------------------------------------- /supabase/tests/database/3-personal-accounts-disabled.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(1); 5 | 6 | -- make sure we're setup for enabling personal accounts 7 | update basejump.config 8 | set enable_personal_accounts = false; 9 | 10 | --- we insert a user into auth.users and return the id into user_id to use 11 | select tests.create_supabase_user('test1'); 12 | 13 | 14 | -- should create the personal account automatically 15 | SELECT is_empty( 16 | $$ select * from accounts $$, 17 | 'No personal account should be created when personal acounts are disabled' 18 | ); 19 | 20 | SELECT * 21 | FROM finish(); 22 | 23 | ROLLBACK; -------------------------------------------------------------------------------- /src/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import SignupPassword from "@/components/dashboard/authentication/signup-password"; 2 | import Link from "next/link"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import { useRouter } from "next/router"; 5 | import useAuthCheck from "@/utils/use-auth-check"; 6 | 7 | const SignUpPage = () => { 8 | const { t } = useTranslation("authentication"); 9 | const router = useRouter(); 10 | const { redirectedFrom } = router.query; 11 | 12 | useAuthCheck(redirectedFrom as string); 13 | return ( 14 |
15 | 16 | 17 | {t("shared.alreadyRegistered")} 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default SignUpPage; 24 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import LoginPassword from "@/components/dashboard/authentication/login-password"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import Link from "next/link"; 4 | import { useRouter } from "next/router"; 5 | import useAuthCheck from "@/utils/use-auth-check"; 6 | 7 | const LoginPage = () => { 8 | const { t } = useTranslation("authentication"); 9 | const router = useRouter(); 10 | const { redirectedFrom } = router.query; 11 | 12 | useAuthCheck(redirectedFrom as string); 13 | 14 | return ( 15 |
16 | 17 | 18 | {t("shared.notYetRegistered")} 19 | 20 |
21 | ); 22 | }; 23 | 24 | export default LoginPage; 25 | -------------------------------------------------------------------------------- /supabase/tests/database/5-team-accounts-disabled.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(1); 5 | 6 | -- make sure we're setup for enabling personal accounts 7 | update basejump.config 8 | set enable_team_accounts = false; 9 | 10 | --- we insert a user into auth.users and return the id into user_id to use 11 | select tests.create_supabase_user('test1'); 12 | 13 | ------------ 14 | --- Primary Owner 15 | ------------ 16 | select tests.authenticate_as('test1'); 17 | 18 | -- check to see if we can create an accoiunt 19 | select throws_ok( 20 | $$ insert into accounts (team_name, personal_account) values ('test team', false) $$, 21 | 'new row violates row-level security policy for table "accounts"' 22 | ); 23 | 24 | SELECT * 25 | FROM finish(); 26 | 27 | ROLLBACK; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": [ 23 | "src/*" 24 | ], 25 | "@tests/*": [ 26 | "__tests__/setup/*" 27 | ] 28 | }, 29 | "incremental": true 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/api/use-user-profile.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "@/utils/handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export default function useUserProfile() { 7 | const user = useUser(); 8 | const { supabaseClient } = useSessionContext(); 9 | return useQuery( 10 | ["userProfile", user?.id], 11 | async () => { 12 | const { data, error } = await supabaseClient 13 | .from("profiles") 14 | .select("*") 15 | .eq("id", user?.id) 16 | .single(); 17 | handleSupabaseErrors(data, error); 18 | return data; 19 | }, 20 | { 21 | enabled: !!user && !!supabaseClient, 22 | } 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/use-auth-check.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { useSessionContext } from "@supabase/auth-helpers-react"; 3 | import { useEffect } from "react"; 4 | 5 | /** 6 | * There's an issue with Supabase Auth where oauth and magic links don't work with middleware 7 | * on the initial page load. This is because the token is sent over a hash param which 8 | * is not sent to the server. This hook sits client side, checks for an authenticated session on the login 9 | * page, and redirects the user if it's found. 10 | * @param redirectedFrom 11 | */ 12 | const useAuthCheck = (redirectedFrom?: string) => { 13 | const { replace } = useRouter(); 14 | const { session } = useSessionContext(); 15 | 16 | useEffect(() => { 17 | if (session && redirectedFrom) { 18 | replace(redirectedFrom as string); 19 | } 20 | }, [session, redirectedFrom, replace]); 21 | }; 22 | 23 | export default useAuthCheck; 24 | -------------------------------------------------------------------------------- /src/pages/dashboard/teams/[accountId]/index.tsx: -------------------------------------------------------------------------------- 1 | import FutureContentPlaceholder from "@/components/basejump-default-content/future-content-placeholder"; 2 | import { useRouter } from "next/router"; 3 | import useTeamAccount from "@/utils/api/use-team-account"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 6 | 7 | const DashboardTeamIndex = () => { 8 | const router = useRouter(); 9 | const { accountId } = router.query; 10 | const { data } = useTeamAccount(accountId as string); 11 | const { t } = useTranslation("dashboard"); 12 | return ( 13 | <> 14 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default DashboardTeamIndex; 23 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/new-account-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from "react-daisyui"; 2 | import NewAccountForm from "@/components/dashboard/accounts/new-account-form"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Portal from "@/components/core/portal"; 5 | 6 | type Props = { 7 | open: boolean; 8 | onComplete: (accountId: string) => void; 9 | onClose: () => void; 10 | }; 11 | const NewAccountModal = ({ open, onClose, onComplete }: Props) => { 12 | const { t } = useTranslation("dashboard"); 13 | return ( 14 | 15 | 16 | 17 | {t("newAccountModal.title")} 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default NewAccountModal; 28 | -------------------------------------------------------------------------------- /src/pages/dashboard/billing.tsx: -------------------------------------------------------------------------------- 1 | import DashboardContent from "@/components/dashboard/shared/dashboard-content"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import AccountSubscription from "@/components/dashboard/accounts/settings/account-subscription"; 4 | import usePersonalAccount from "@/utils/api/use-personal-account"; 5 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 6 | 7 | const PersonalAccountBilling = () => { 8 | const { t } = useTranslation("dashboard"); 9 | const { data } = usePersonalAccount(); 10 | return ( 11 | 12 | 13 | {t("billing.pageTitle")} 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default PersonalAccountBilling; 22 | -------------------------------------------------------------------------------- /__tests__/core/input.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from "@tests/test-utils"; 2 | import Input from "@/components/core/input"; 3 | 4 | describe("Input component", () => { 5 | it("renders labels and help text", async () => { 6 | await act(async () => { 7 | await render(); 8 | }); 9 | 10 | expect(screen.getByText("label-here")).toBeInTheDocument(); 11 | expect(screen.getByText("help-text-here")).toBeInTheDocument(); 12 | }); 13 | 14 | it("renders errors", async () => { 15 | await act(async () => { 16 | await render( 17 | 22 | ); 23 | }); 24 | 25 | expect(screen.getByText("error-message-here")).toBeInTheDocument(); 26 | expect(screen.getByText("error-message-here")).toHaveClass("text-error"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /__tests__/core/select.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from "@tests/test-utils"; 2 | import Select from "@/components/core/select"; 3 | 4 | describe("Input component", () => { 5 | it("renders labels and help text", async () => { 6 | await act(async () => { 7 | await render( 22 | ); 23 | }); 24 | 25 | expect(screen.getByText("error-message-here")).toBeInTheDocument(); 26 | expect(screen.getByText("error-message-here")).toHaveClass("text-error"); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HACK: Known issue with importing with the newest NextJS version. 3 | * Can be removed once resolved: https://github.com/aralroca/next-translate/issues/851 4 | */ 5 | const workaround = require("next-translate/lib/cjs/plugin/utils.js"); 6 | workaround.defaultLoader = 7 | "(lang, ns) => import(`@next-translate-root/content/locales/${lang}/${ns}.json`).then((m) => m.default)"; 8 | module.exports = { 9 | locales: ["en"], 10 | defaultLocale: "en", 11 | pages: { 12 | "/login": ["authentication", "content"], 13 | "/signup": ["authentication", "content"], 14 | "/invitation": ["dashboard", "content"], 15 | "rgx:^/dashboard": ["dashboard"], 16 | "*": ["content"], 17 | }, 18 | // HACK: Add this back in once resolved 19 | // loadLocaleFrom: (lang, ns) => 20 | // // You can use a dynamic import, fetch, whatever. You should 21 | // // return a Promise with the JSON file. 22 | // import(`./content/locales/${lang}/${ns}.json`).then((m) => m.default), 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/api/use-team-account.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 2 | import handleSupabaseErrors from "../handle-supabase-errors"; 3 | import { Database } from "@/types/supabase-types"; 4 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 5 | 6 | export default function useTeamAccount( 7 | accountId: string, 8 | options?: UseQueryOptions 9 | ) { 10 | const supabaseClient = useSupabaseClient(); 11 | return useQuery( 12 | ["teamAccount", accountId], 13 | async () => { 14 | const { data, error } = await supabaseClient 15 | .from("accounts") 16 | .select() 17 | .eq("id", accountId) 18 | .single(); 19 | handleSupabaseErrors(data, error); 20 | return data; 21 | }, 22 | { 23 | ...options, 24 | enabled: !!accountId && !!supabaseClient, 25 | } 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/basejump-default-content/future-content-placeholder.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import Image from "next/image"; 3 | 4 | type Props = { 5 | filePath: string; 6 | title?: string; 7 | }; 8 | const FutureContentPlaceholder = ({ 9 | title = "Here's where you bring the awesome!", 10 | filePath, 11 | }: Props) => { 12 | return ( 13 |
14 | Basejump 20 |

{title}

21 | {!!filePath && ( 22 |
23 |

To edit this page, check it out at:

24 |
25 |             {filePath}
26 |           
27 |
28 | )} 29 |
30 | ); 31 | }; 32 | 33 | export default FutureContentPlaceholder; 34 | -------------------------------------------------------------------------------- /scripts/sync-stripe.ts: -------------------------------------------------------------------------------- 1 | import { stripe } from "@/utils/admin/stripe"; 2 | import { 3 | upsertPriceRecord, 4 | upsertProductRecord, 5 | } from "@/utils/admin/stripe-billing-helpers"; 6 | 7 | // check if stripe key is set, exit if not 8 | if (!process.env.STRIPE_SECRET_KEY) { 9 | console.log("No Stripe key found, skipping sync"); 10 | process.exit(0); 11 | } 12 | 13 | // first we pull all products from Stripe and insert them into the billing_products table 14 | stripe.products 15 | .list() 16 | .then(async (products) => { 17 | for (const product of products.data) { 18 | await upsertProductRecord(product); 19 | } 20 | }) 21 | .catch((e) => { 22 | console.log(e); 23 | }); 24 | 25 | // then we pull all prices from Stripe and insert them into the billing_prices table 26 | stripe.prices 27 | .list() 28 | .then(async (prices) => { 29 | for (const price of prices.data) { 30 | await upsertPriceRecord(price); 31 | } 32 | }) 33 | .catch((e) => { 34 | console.log(e); 35 | }); 36 | -------------------------------------------------------------------------------- /__tests__/utils/content-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { getContentPaths } from "@/utils/content/content-helpers"; 2 | 3 | describe("Content Helpers", () => { 4 | it("Should know how to load and sort correct docs", async () => { 5 | const docs = await getContentPaths("en", "docs"); 6 | // should not load unpublished docs 7 | expect(docs.length).toEqual(2); 8 | // should sort them by published date 9 | expect(docs[0].title).toEqual("Getting Started"); 10 | // nextjs expects all dates to be strings 11 | expect(typeof docs[0].meta.published).toEqual("string"); 12 | }); 13 | 14 | it("Should know how to load and sort correct blogs", async () => { 15 | const blogs = await getContentPaths("en", "blog"); 16 | // should not load unpublished blogs 17 | expect(blogs.length).toEqual(2); 18 | // should sort them by published date 19 | expect(blogs[0].title).toEqual("Article 1"); 20 | // nextjs expects all dates to be strings 21 | expect(typeof blogs[0].meta.published).toEqual("string"); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/content-pages/content-layout.tsx: -------------------------------------------------------------------------------- 1 | import ContentHeader from "./content-header"; 2 | import ContentFooter from "./content-footer"; 3 | import { Drawer } from "react-daisyui"; 4 | import { useToggle } from "react-use"; 5 | import ContentHeaderMobile from "@/components/content-pages/content-header-mobile"; 6 | import { useRouter } from "next/router"; 7 | import { useEffect } from "react"; 8 | 9 | const ContentLayout = ({ children }) => { 10 | const [isSidebarOpen, toggleSidebar] = useToggle(false); 11 | const router = useRouter(); 12 | 13 | useEffect(() => { 14 | toggleSidebar(false); 15 | }, [router.asPath, toggleSidebar]); 16 | return ( 17 | } 19 | open={isSidebarOpen} 20 | onClickOverlay={toggleSidebar} 21 | > 22 | 23 |
{children}
24 | 25 |
26 | ); 27 | }; 28 | 29 | export default ContentLayout; 30 | -------------------------------------------------------------------------------- /src/pages/dashboard/teams/[accountId]/settings/billing.tsx: -------------------------------------------------------------------------------- 1 | import AccountSettingsLayout from "@/components/dashboard/accounts/settings/account-settings-layout"; 2 | import AccountSubscription from "@/components/dashboard/accounts/settings/account-subscription"; 3 | import { useRouter } from "next/router"; 4 | import useTeamAccount from "@/utils/api/use-team-account"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 7 | 8 | const TeamSettingsBilling = () => { 9 | const router = useRouter(); 10 | const { accountId } = router.query; 11 | const { data } = useTeamAccount(accountId as string); 12 | const { t } = useTranslation("dashboard"); 13 | return ( 14 | 15 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default TeamSettingsBilling; 24 | -------------------------------------------------------------------------------- /src/pages/dashboard/teams/[accountId]/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import AccountSettingsLayout from "@/components/dashboard/accounts/settings/account-settings-layout"; 2 | import UpdateAccountName from "@/components/dashboard/accounts/settings/update-account-name"; 3 | import { useRouter } from "next/router"; 4 | import useTeamAccount from "@/utils/api/use-team-account"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 7 | 8 | const DashboardTeamSettingsIndex = () => { 9 | const router = useRouter(); 10 | const { accountId } = router.query; 11 | const { data } = useTeamAccount(accountId as string); 12 | const { t } = useTranslation("dashboard"); 13 | return ( 14 | 15 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default DashboardTeamSettingsIndex; 24 | -------------------------------------------------------------------------------- /src/utils/api/use-personal-account.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export default function usePersonalAccount( 7 | options?: UseQueryOptions 8 | ) { 9 | const user = useUser(); 10 | const { supabaseClient } = useSessionContext(); 11 | return useQuery( 12 | ["personalAccount", user?.id], 13 | async () => { 14 | const { data, error } = await supabaseClient 15 | .from("accounts") 16 | .select() 17 | .eq("primary_owner_user_id", user?.id) 18 | .eq("personal_account", true) 19 | .maybeSingle(); 20 | handleSupabaseErrors(data, error); 21 | return data; 22 | }, 23 | { 24 | ...options, 25 | enabled: !!user && !!supabaseClient, 26 | } 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/dashboard/profile.tsx: -------------------------------------------------------------------------------- 1 | import UpdateProfileName from "@/components/dashboard/profile/update-profile-name"; 2 | import DashboardContent from "@/components/dashboard/shared/dashboard-content"; 3 | import UpdateEmailAddress from "@/components/dashboard/profile/update-email-address"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import ListTeams from "@/components/dashboard/profile/list-teams"; 6 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 7 | 8 | const DashboardProfile = () => { 9 | const { t } = useTranslation("dashboard"); 10 | return ( 11 | 12 | 13 | {t("profile.pageTitle")} 14 | 15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default DashboardProfile; 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 usebasejump.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /src/utils/api/use-invitation.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | type Invitation = { 7 | team_name?: string; 8 | active: boolean; 9 | }; 10 | export default function useInvitation( 11 | invitationToken: string, 12 | options?: UseQueryOptions 13 | ) { 14 | const user = useUser(); 15 | const supabaseClient = useSupabaseClient(); 16 | return useQuery( 17 | ["invitation", invitationToken], 18 | async () => { 19 | const { data, error } = await supabaseClient.rpc("lookup_invitation", { 20 | lookup_invitation_token: invitationToken, 21 | }); 22 | handleSupabaseErrors(data, error); 23 | 24 | return data as unknown as Invitation; 25 | }, 26 | { 27 | ...options, 28 | enabled: 29 | Boolean(invitationToken) && Boolean(user) && Boolean(supabaseClient), 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/list-team-invitations.tsx: -------------------------------------------------------------------------------- 1 | import useTranslation from "next-translate/useTranslation"; 2 | import Loader from "@/components/core/loader"; 3 | import useTeamInvitations from "@/utils/api/use-team-invitations"; 4 | import IndividualTeamInvitation from "@/components/dashboard/accounts/settings/individual-team-invitation"; 5 | 6 | type Props = { 7 | accountId: string; 8 | }; 9 | const ListTeamInvitations = ({ accountId }: Props) => { 10 | const { t } = useTranslation("dashboard"); 11 | const { data, isLoading, refetch } = useTeamInvitations(accountId); 12 | 13 | return ( 14 | <> 15 | {isLoading ? ( 16 | 17 | ) : ( 18 |
19 | {data?.map((invitation) => ( 20 | refetch()} 22 | key={invitation.id} 23 | invitation={invitation} 24 | /> 25 | ))} 26 |
27 | )} 28 | 29 | ); 30 | }; 31 | 32 | export default ListTeamInvitations; 33 | -------------------------------------------------------------------------------- /src/utils/api/use-team-invitations.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export default function useTeamInvitations( 7 | accountId: string, 8 | options?: UseQueryOptions< 9 | Database["public"]["Tables"]["invitations"]["Row"][] 10 | > 11 | ) { 12 | const user = useUser(); 13 | const { supabaseClient } = useSessionContext(); 14 | return useQuery( 15 | ["teamInvitations", accountId], 16 | async () => { 17 | const { data, error } = await supabaseClient 18 | .from("invitations") 19 | .select("*") 20 | .order("created_at", { ascending: false }) 21 | .match({ account_id: accountId }); 22 | handleSupabaseErrors(data, error); 23 | 24 | return data; 25 | }, 26 | { 27 | ...options, 28 | enabled: !!accountId && !!user && !!supabaseClient, 29 | } 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareSupabaseClient } from "@supabase/auth-helpers-nextjs"; 2 | import type { NextRequest } from "next/server"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function middleware(req: NextRequest) { 6 | // We need to create a response and hand it to the supabase client to be able to modify the response headers. 7 | const res = NextResponse.next(); 8 | 9 | const supabase = await createMiddlewareSupabaseClient({ req, res }); 10 | 11 | const { 12 | data: { session }, 13 | } = await supabase.auth.getSession(); 14 | 15 | // Check auth condition 16 | if (session) { 17 | // Authentication successful, forward request to protected route. 18 | return res; 19 | } 20 | 21 | // Auth condition not met, redirect to home page. 22 | const redirectUrl = req.nextUrl.clone(); 23 | redirectUrl.pathname = 24 | req.nextUrl.pathname === "/invitation" ? "/signup" : "/login"; 25 | redirectUrl.searchParams.set(`redirectedFrom`, req.url); 26 | return NextResponse.redirect(redirectUrl); 27 | } 28 | 29 | export const config = { 30 | matcher: ["/dashboard/:path*", "/invitation"], 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/list-team-members.tsx: -------------------------------------------------------------------------------- 1 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import Loader from "@/components/core/loader"; 4 | import useTeamMembers from "@/utils/api/use-team-members"; 5 | import IndividualTeamMember from "@/components/dashboard/accounts/settings/individual-team-member"; 6 | 7 | type Props = { 8 | accountId: string; 9 | }; 10 | const ListTeamMembers = ({ accountId }: Props) => { 11 | const { t } = useTranslation("dashboard"); 12 | const { data, isLoading } = useTeamMembers(accountId); 13 | 14 | return ( 15 | 19 | {isLoading ? ( 20 | 21 | ) : ( 22 |
23 | {data?.map((member) => ( 24 | 25 | ))} 26 |
27 | )} 28 |
29 | ); 30 | }; 31 | 32 | export default ListTeamMembers; 33 | -------------------------------------------------------------------------------- /src/utils/api/use-team-accounts.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | type TeamAccountWithRole = Database["public"]["Tables"]["accounts"]["Row"] & { 7 | account_role: string; 8 | }; 9 | 10 | export default function useTeamAccounts( 11 | options?: UseQueryOptions 12 | ) { 13 | const user = useUser(); 14 | const { supabaseClient } = useSessionContext(); 15 | return useQuery( 16 | ["teamAccounts", user?.id], 17 | async () => { 18 | const { data, error } = await supabaseClient 19 | .from("account_user") 20 | .select("account_role, account:account_id (*)") 21 | .eq("user_id", user?.id); 22 | handleSupabaseErrors(data, error); 23 | 24 | return data 25 | ?.filter((a) => a.account?.personal_account === false) 26 | ?.map(({ account_role, account }) => ({ 27 | ...account, 28 | account_role, 29 | })); 30 | }, 31 | { 32 | ...options, 33 | enabled: !!user && !!supabaseClient, 34 | } 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/basejump-default-content/logo.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import Image from "next/image"; 3 | import cx from "classnames"; 4 | 5 | type Props = { 6 | size: "sm" | "lg"; 7 | className?: string; 8 | }; 9 | 10 | const Logo = ({ size = "lg", className }: Props) => { 11 | const height = size === "sm" ? 40 : 150; 12 | const width = size === "sm" ? 40 : 150; 13 | return ( 14 |
24 |
30 | Basejump Logo 36 |
37 |

43 | Basejump 44 |

45 |
46 | ); 47 | }; 48 | 49 | export default Logo; 50 | -------------------------------------------------------------------------------- /src/components/content-pages/content-meta.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import getFullRedirectUrl from "@/utils/get-full-redirect-url"; 3 | 4 | type Props = { 5 | title: string; 6 | description: string; 7 | socialDescription?: string; 8 | socialImage?: string; 9 | }; 10 | 11 | const ContentMeta = ({ 12 | title, 13 | description, 14 | socialDescription, 15 | socialImage, 16 | }: Props) => { 17 | return ( 18 | 19 | {title} 20 | 21 | 22 | 23 | 24 | 28 | 32 | {!!socialImage && ( 33 | <> 34 | 35 | 39 | 40 | )} 41 | 42 | ); 43 | }; 44 | 45 | export default ContentMeta; 46 | -------------------------------------------------------------------------------- /__tests__/setup/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactElement, useState } from "react"; 2 | import { render, RenderOptions } from "@testing-library/react"; 3 | import { Theme } from "react-daisyui"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | import { createBrowserSupabaseClient } from "@supabase/auth-helpers-nextjs"; 6 | import { ToastContainer } from "react-toastify"; 7 | import { Database } from "@/types/supabase-types"; 8 | import { SessionContextProvider } from "@supabase/auth-helpers-react/src/components/SessionContext"; 9 | 10 | const queryClient = new QueryClient(); 11 | 12 | const AllTheProviders: FC<{ children: ReactElement }> = ({ children }) => { 13 | const [supabaseClient] = useState(() => 14 | createBrowserSupabaseClient() 15 | ); 16 | 17 | return ( 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | const customRender = ( 28 | ui: ReactElement, 29 | options?: Omit 30 | ) => render(ui, { wrapper: AllTheProviders, ...options }); 31 | 32 | export * from "@testing-library/react"; 33 | export { customRender as render }; 34 | -------------------------------------------------------------------------------- /src/components/dashboard/shared/dashboard-content.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Only used for formatting downstream, makes it clear that the 3 | * children are the content of the dashboard content 4 | * @param children 5 | * @constructor 6 | */ 7 | const DashboardContent = ({ children }) => { 8 | return
{children}
; 9 | }; 10 | 11 | /** 12 | * Sets up the title for the dashboard content 13 | * @param children 14 | * @constructor 15 | */ 16 | function Title({ children }) { 17 | return ( 18 |
19 | {children} 20 |
21 | ); 22 | } 23 | 24 | /** 25 | * Sets up the container for the dashboard content 26 | * Handles max-width and top border primarily 27 | * @param children 28 | * @constructor 29 | */ 30 | function Content({ children }) { 31 | return ( 32 |
33 |
{children}
34 |
35 | ); 36 | } 37 | 38 | function Tabs({ children }) { 39 | return ( 40 |
41 |
{children}
42 |
43 | ); 44 | } 45 | 46 | DashboardContent.Tabs = Tabs; 47 | DashboardContent.Title = Title; 48 | DashboardContent.Content = Content; 49 | export default DashboardContent; 50 | -------------------------------------------------------------------------------- /src/pages/docs/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getContentBySlug, 3 | getDocsNavigation, 4 | } from "@/utils/content/content-helpers"; 5 | import { MDXRemote } from "next-mdx-remote"; 6 | import { serialize } from "next-mdx-remote/serialize"; 7 | import DocsLayout from "@/components/docs/docs-layout"; 8 | import ContentMeta from "@/components/content-pages/content-meta"; 9 | 10 | const DocsIndex = ({ navigation, content, title, meta }) => { 11 | return ( 12 | 13 | 19 |
20 |

{title}

21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default DocsIndex; 28 | 29 | export async function getStaticProps({ params, locale, ...rest }) { 30 | const doc = await getContentBySlug("index", { 31 | locale, 32 | contentType: "docs", 33 | }); 34 | 35 | const navigation = await getDocsNavigation(locale); 36 | 37 | const content = await serialize(doc.content); 38 | return { 39 | props: { 40 | ...doc, 41 | navigation, 42 | content, 43 | }, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/components/dashboard/shared/settings-card.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import cx from "classnames"; 3 | 4 | type Props = { 5 | className?: string; 6 | children: ReactNode; 7 | title?: string; 8 | description?: string; 9 | disabled?: boolean; 10 | }; 11 | const SettingsCard = ({ 12 | children, 13 | title, 14 | description, 15 | disabled, 16 | className, 17 | }: Props) => ( 18 |
21 | {!!title && ( 22 |
23 |

{title}

24 | {!!description &&

{description}

} 25 |
26 | )} 27 | {children} 28 | {disabled && ( 29 |
30 | )} 31 |
32 | ); 33 | 34 | const SettingsCardFooter = ({ children }) => ( 35 |
36 | {children} 37 |
38 | ); 39 | 40 | const SettingsCardBody = ({ children }) => ( 41 |
{children}
42 | ); 43 | 44 | SettingsCard.Body = SettingsCardBody; 45 | SettingsCard.Footer = SettingsCardFooter; 46 | 47 | export default SettingsCard; 48 | -------------------------------------------------------------------------------- /src/pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const config = { 5 | runtime: "experimental-edge", 6 | }; 7 | 8 | export default function SocialImage(req: NextRequest) { 9 | try { 10 | const { searchParams } = new URL(req.url); 11 | 12 | const hasTitle = searchParams.has("title"); 13 | const title = hasTitle ? searchParams.get("title")?.slice(0, 100) : ""; 14 | console.log("title found", title); 15 | return new ImageResponse( 16 | ( 17 |
33 | {title} 34 |
35 | ), 36 | { 37 | width: 1200, 38 | height: 600, 39 | } 40 | ); 41 | } catch (e) { 42 | console.log(`${e.message}`); 43 | return new Response(`Failed to generate an image`, { 44 | status: 500, 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/core/input.tsx: -------------------------------------------------------------------------------- 1 | import { Input as InnerInput, InputProps } from "react-daisyui"; 2 | import { ForwardedRef, forwardRef } from "react"; 3 | import cx from "classnames"; 4 | 5 | type Props = InputProps & { 6 | label?: string; 7 | helpText?: string; 8 | errorMessage?: string; 9 | }; 10 | const Input = forwardRef( 11 | ( 12 | { label, helpText, errorMessage, color, ...props }: Props, 13 | ref: ForwardedRef 14 | ) => { 15 | return ( 16 |
17 | 26 | 31 | {(!!helpText || !!errorMessage) && ( 32 | 41 | )} 42 |
43 | ); 44 | } 45 | ); 46 | 47 | Input.displayName = "Input"; 48 | 49 | export default Input; 50 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/personal-account-deactivated.tsx: -------------------------------------------------------------------------------- 1 | import ListTeams from "@/components/dashboard/profile/list-teams"; 2 | import useTeamAccounts from "@/utils/api/use-team-accounts"; 3 | import NewAccountForm from "@/components/dashboard/accounts/new-account-form"; 4 | import Loader from "@/components/core/loader"; 5 | import { useRouter } from "next/router"; 6 | import useTranslation from "next-translate/useTranslation"; 7 | 8 | const PersonalAccountDeactivated = () => { 9 | const { data, isLoading } = useTeamAccounts(); 10 | const router = useRouter(); 11 | const { t } = useTranslation("dashboard"); 12 | return ( 13 |
14 | {isLoading ? ( 15 | 16 | ) : !data || data?.length === 0 ? ( 17 |
18 |

19 | {t("personalAccountDeactivated.createFirstAccount")} 20 |

21 |

22 | {t("personalAccountDeactivated.firstAccountDescription")} 23 |

24 | 26 | router.push(`/dashboard/teams/${accountId}`) 27 | } 28 | /> 29 |
30 | ) : ( 31 | 32 | )} 33 |
34 | ); 35 | }; 36 | 37 | export default PersonalAccountDeactivated; 38 | -------------------------------------------------------------------------------- /src/utils/api/use-team-role.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export type UseTeamRoleResponse = { 7 | is_primary_owner: boolean; 8 | account_role: Database["public"]["Tables"]["account_user"]["Row"]["account_role"]; 9 | }; 10 | 11 | /** 12 | * Get the current user's role in a team 13 | * @param accountId 14 | * @param options 15 | */ 16 | export default function useTeamRole( 17 | accountId: string, 18 | options?: UseQueryOptions 19 | ) { 20 | const user = useUser(); 21 | const supabaseClient = useSupabaseClient(); 22 | const { data, isLoading } = useQuery( 23 | ["teamRole", accountId], 24 | async () => { 25 | const { data, error } = await supabaseClient 26 | .rpc("current_user_account_role", { 27 | lookup_account_id: accountId, 28 | }) 29 | .single(); 30 | handleSupabaseErrors(data, error); 31 | 32 | return data; 33 | }, 34 | { 35 | ...options, 36 | enabled: !!accountId && !!user && !!supabaseClient, 37 | } 38 | ); 39 | 40 | return { 41 | accountRole: data?.account_role, 42 | isPrimaryOwner: data?.is_primary_owner, 43 | isLoading, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/api/use-account-billing-status.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import { Database } from "@/types/supabase-types"; 4 | 5 | type UseAccountBillingStatusResponse = { 6 | subscription_id: string; 7 | subscription_active: boolean; 8 | status: string; 9 | is_primary_owner: boolean; 10 | billing_email?: string; 11 | plan_name?: string; 12 | account_role: Database["public"]["Tables"]["account_user"]["Row"]["account_role"]; 13 | billing_enabled: boolean; 14 | }; 15 | 16 | /** 17 | * Get a given accounts billing status. Returns "missing" if it has not yet been setup. 18 | * @param accountId 19 | * @param options 20 | */ 21 | export default function useAccountBillingStatus( 22 | accountId: string, 23 | options?: UseQueryOptions 24 | ) { 25 | const user = useUser(); 26 | const { supabaseClient } = useSessionContext(); 27 | return useQuery( 28 | ["accountBillingStatus", accountId], 29 | async () => { 30 | const response = await fetch( 31 | `/api/billing/status?accountId=${accountId}` 32 | ); 33 | if (!response.ok) { 34 | throw new Error(response.statusText); 35 | } 36 | const data = await response.json(); 37 | return data; 38 | }, 39 | { 40 | ...options, 41 | enabled: !!accountId && !!user && !!supabaseClient, 42 | } 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/api/use-team-members.ts: -------------------------------------------------------------------------------- 1 | import { useSessionContext, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export type UseTeamMembersResponse = { 7 | user_id: string; 8 | account_id: string; 9 | is_primary_owner: boolean; 10 | account_role: Database["public"]["Tables"]["account_user"]["Row"]["account_role"]; 11 | name: string; 12 | }; 13 | 14 | export default function useTeamMembers( 15 | accountId: string, 16 | options?: UseQueryOptions 17 | ) { 18 | const user = useUser(); 19 | const { supabaseClient } = useSessionContext(); 20 | return useQuery( 21 | ["teamMembers", accountId], 22 | async () => { 23 | const { data, error } = await supabaseClient 24 | .from("account_user") 25 | .select("*, profiles(name), accounts(primary_owner_user_id)") 26 | .match({ account_id: accountId }); 27 | handleSupabaseErrors(data, error); 28 | 29 | return data?.map(({ account_role, user_id, profiles, accounts }) => ({ 30 | account_role, 31 | account_id: accountId, 32 | name: profiles.name, 33 | is_primary_owner: accounts.primary_owner_user_id === user_id, 34 | user_id, 35 | })); 36 | }, 37 | { 38 | ...options, 39 | enabled: !!accountId && !!user && !!supabaseClient, 40 | } 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/remove-team-member.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import { toast } from "react-toastify"; 4 | import { Button } from "react-daisyui"; 5 | import { UseTeamMembersResponse } from "@/utils/api/use-team-members"; 6 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 7 | import { Database } from "@/types/supabase-types"; 8 | 9 | type Props = { 10 | member: UseTeamMembersResponse; 11 | onComplete?: () => void; 12 | }; 13 | 14 | const RemoveTeamMember = ({ member, onComplete }: Props) => { 15 | const queryClient = useQueryClient(); 16 | const supabaseClient = useSupabaseClient(); 17 | 18 | const { t } = useTranslation("dashboard"); 19 | 20 | const removeMember = useMutation(async () => { 21 | const { error } = await supabaseClient 22 | .from("account_user") 23 | .delete() 24 | .eq("user_id", member.user_id) 25 | .eq("account_id", member.account_id); 26 | if (error) { 27 | toast.error(error.message); 28 | } 29 | await queryClient.invalidateQueries(["teamMembers"]); 30 | if (onComplete) { 31 | onComplete(); 32 | } 33 | }); 34 | 35 | return ( 36 | 45 | ); 46 | }; 47 | 48 | export default RemoveTeamMember; 49 | -------------------------------------------------------------------------------- /supabase/migrations/00000000000000_dbdev_temp_install.sql: -------------------------------------------------------------------------------- 1 | /** 2 | This should be automatically installed, but handling here manually until it is 3 | https://database.dev/installer 4 | */ 5 | create extension if not exists http with schema extensions; 6 | create extension if not exists pg_tle; 7 | select pgtle.uninstall_extension_if_exists('supabase-dbdev'); 8 | drop extension if exists "supabase-dbdev"; 9 | select pgtle.install_extension( 10 | 'supabase-dbdev', 11 | resp.contents ->> 'version', 12 | 'PostgreSQL package manager', 13 | resp.contents ->> 'sql' 14 | ) 15 | from http( 16 | ( 17 | 'GET', 18 | 'https://api.database.dev/rest/v1/' 19 | || 'package_versions?select=sql,version' 20 | || '&package_name=eq.supabase-dbdev' 21 | || '&order=version.desc' 22 | || '&limit=1', 23 | array [ 24 | ('apiKey', 25 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header 26 | ], 27 | null, 28 | null 29 | ) 30 | ) x, 31 | lateral ( 32 | select ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 33 | ) resp(contents); 34 | create extension "supabase-dbdev"; 35 | select dbdev.install('supabase-dbdev'); 36 | drop extension if exists "supabase-dbdev"; 37 | create extension "supabase-dbdev"; -------------------------------------------------------------------------------- /src/components/docs/docs-layout.tsx: -------------------------------------------------------------------------------- 1 | import DocsSidebar from "@/components/docs/docs-sidebar"; 2 | import { ReactNode, useEffect } from "react"; 3 | import { useToggle } from "react-use"; 4 | import { Drawer } from "react-daisyui"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import { ChevronRightIcon } from "@heroicons/react/outline"; 7 | import { useRouter } from "next/router"; 8 | 9 | type Props = { 10 | navigation: any; 11 | children: ReactNode; 12 | }; 13 | 14 | const DocsLayout = ({ navigation, children }) => { 15 | const [isSidebarOpen, toggleSidebar] = useToggle(false); 16 | const { t } = useTranslation("content"); 17 | const router = useRouter(); 18 | 19 | useEffect(() => { 20 | toggleSidebar(false); 21 | }, [router.asPath, toggleSidebar]); 22 | 23 | return ( 24 | } 26 | open={isSidebarOpen} 27 | onClickOverlay={toggleSidebar} 28 | > 29 |
30 |
31 | 32 |
33 | 42 |
{children}
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default DocsLayout; 49 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/team-account-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "react-daisyui"; 2 | import Link from "next/link"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import { useRouter } from "next/router"; 5 | import cx from "classnames"; 6 | import { useMemo } from "react"; 7 | import { ACCOUNT_ROLES } from "@/types/auth"; 8 | import { UseDashboardOverviewResponse } from "@/utils/api/use-dashboard-overview"; 9 | 10 | type Props = { 11 | currentAccount: UseDashboardOverviewResponse[0]; 12 | }; 13 | 14 | const TeamAccountMenu = ({ currentAccount }: Props) => { 15 | const { t } = useTranslation("dashboard"); 16 | const router = useRouter(); 17 | const menu = useMemo(() => { 18 | const items = [ 19 | { 20 | label: t("teamAccountMenu.dashboard"), 21 | href: `/dashboard/teams/${currentAccount.account_id}`, 22 | isActive: router.pathname === "/dashboard/teams/[accountId]", 23 | }, 24 | ]; 25 | if (currentAccount.account_role === ACCOUNT_ROLES.owner) { 26 | items.push({ 27 | label: t("teamAccountMenu.settings"), 28 | href: `/dashboard/teams/${currentAccount.account_id}/settings`, 29 | isActive: router.pathname.includes( 30 | "/dashboard/teams/[accountId]/settings" 31 | ), 32 | }); 33 | } 34 | return items; 35 | }, [currentAccount, router.pathname, t]); 36 | return ( 37 | 38 | {menu.map((item) => ( 39 | 40 | 41 | {item.label} 42 | 43 | 44 | ))} 45 | 46 | ); 47 | }; 48 | 49 | export default TeamAccountMenu; 50 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "@/components/core/loader"; 2 | import usePersonalAccount from "@/utils/api/use-personal-account"; 3 | import PersonalAccountDeactivated from "@/components/dashboard/accounts/personal-account-deactivated"; 4 | import FutureContentPlaceholder from "@/components/basejump-default-content/future-content-placeholder"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 7 | 8 | const DashboardIndex = () => { 9 | const { data: personalAccount, isLoading } = usePersonalAccount(); 10 | const { t } = useTranslation("dashboard"); 11 | /** 12 | * This page does the heavy lifting for handling the fact that 13 | * Basejump supports personal accounts, team accounts and a combination 14 | * of both. If no personal account is loaded, it means that personal 15 | * accounts are deactivated. In that case, we show current teams and 16 | * prompt them to create one if none exist. If a personal account is 17 | * loaded, we show the personal account dashboard page 18 | */ 19 | 20 | return ( 21 | <> 22 | 23 | {isLoading ? ( 24 | 25 | ) : !personalAccount ? ( 26 | // Personal accounts are deactivated, so we 27 | // prompt the user to jump to a team dashboard 28 |
29 | 30 |
31 | ) : ( 32 | // Replace me with your content! 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default DashboardIndex; 40 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/personal-account-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Menu } from "react-daisyui"; 2 | import Link from "next/link"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import { useRouter } from "next/router"; 5 | import cx from "classnames"; 6 | import { UseDashboardOverviewResponse } from "@/utils/api/use-dashboard-overview"; 7 | import { useMemo } from "react"; 8 | import { ACCOUNT_ROLES } from "@/types/auth"; 9 | 10 | type Props = { 11 | currentAccount: UseDashboardOverviewResponse[0]; 12 | }; 13 | const PersonalAccountMenu = ({ currentAccount }: Props) => { 14 | const { t } = useTranslation("dashboard"); 15 | const router = useRouter(); 16 | const menu = useMemo(() => { 17 | const internal = [ 18 | { 19 | label: t("personalAccountMenu.dashboard"), 20 | href: "/dashboard", 21 | isActive: router.asPath === "/dashboard", 22 | }, 23 | { 24 | label: t("personalAccountMenu.profile"), 25 | href: "/dashboard/profile", 26 | isActive: router.asPath === "/dashboard/profile", 27 | }, 28 | ]; 29 | if (currentAccount?.account_role === ACCOUNT_ROLES.owner) { 30 | internal.push({ 31 | label: t("personalAccountMenu.billing"), 32 | href: "/dashboard/billing", 33 | isActive: router.asPath === "/dashboard/billing", 34 | }); 35 | } 36 | return internal; 37 | }, [router.asPath, currentAccount, t]); 38 | return ( 39 | 40 | {menu.map((item) => ( 41 | 42 | 43 | {item.label} 44 | 45 | 46 | ))} 47 | 48 | ); 49 | }; 50 | 51 | export default PersonalAccountMenu; 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Basejump SaaS starter for Supabase 2 | 3 | Basejump is an open source starter for Supabase. It provides personal accounts, shared team accounts, billing 4 | subscriptions with Stripe and a dashboard template. 5 | 6 | [Learn more at usebasejump.com](https://usebasejump.com). 7 | 8 | ## Installation 9 | 10 | ```bash 11 | yarn 12 | yarn dev 13 | ``` 14 | 15 | ## Typescript and generated types 16 | 17 | We've implemented automatic type generation based off of your Supabase database config. You can learn more about this 18 | setup [in the supabase docs on type generation](https://supabase.com/docs/guides/api/generating-types) 19 | 20 | To update your types, run: 21 | 22 | ```bash 23 | yarn generate-types 24 | ``` 25 | 26 | You can then reference them as 27 | 28 | ```javascript 29 | import Database from '@/types/supabase-types'; 30 | 31 | const profile: Database['public']['Tables']['profiles']['Row'] = {name: 'John Doe'}; 32 | ``` 33 | 34 | ## Code Formatting and linting 35 | 36 | The project is configured to use ESLint and Prettier. Prettier is run through ESLint, not on its own. 37 | 38 | * Prettier: [Prettier ESLint Plugin](https://github.com/prettier/eslint-plugin-prettier) 39 | * ESLint: [NextJS ESLint](https://nextjs.org/docs/basic-features/eslint) 40 | 41 | ## Internationalizatoin and translations 42 | 43 | Basejump uses NextJS built in internationalization, and adds `next-translate` for translation support. 44 | 45 | * [NextJS Internationalization](https://nextjs.org/docs/basic-features/i18n) 46 | * [next-translate](https://github.com/aralroca/next-translate) 47 | 48 | ## Thanks & Credits 49 | 50 |

Hosting has generously been provided by Vercel

51 | 56 | Powered by Vercel 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/pages/blog/index.tsx: -------------------------------------------------------------------------------- 1 | import { getContentPaths } from "@/utils/content/content-helpers"; 2 | import ContentMeta from "@/components/content-pages/content-meta"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Link from "next/link"; 5 | import { MDXRemote } from "next-mdx-remote"; 6 | import { serialize } from "next-mdx-remote/serialize"; 7 | 8 | const BlogIndex = ({ articles }) => { 9 | const { t } = useTranslation("content"); 10 | return ( 11 |
12 | 17 |
18 | {articles.map((article) => ( 19 |
20 | {!!article.meta?.category && ( 21 | 22 | {article.meta.category} 23 | 24 | )} 25 | 31 |

{article.title}

32 | 33 | 34 |
35 | ))} 36 |
37 |
38 | ); 39 | }; 40 | 41 | export default BlogIndex; 42 | 43 | export async function getStaticProps({ params, locale, ...rest }) { 44 | const content = await getContentPaths(locale, "blog"); 45 | const articles = []; 46 | for (const article of content) { 47 | articles.push({ 48 | ...article, 49 | content: await serialize(article.content), 50 | }); 51 | } 52 | return { 53 | props: { 54 | articles: articles.reverse(), 55 | }, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/dashboard/teams/[accountId]/settings/members.tsx: -------------------------------------------------------------------------------- 1 | import AccountSettingsLayout from "@/components/dashboard/accounts/settings/account-settings-layout"; 2 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import InviteMember from "@/components/dashboard/accounts/settings/invite-member"; 5 | import { useRouter } from "next/router"; 6 | import ListMembers from "@/components/dashboard/accounts/settings/list-team-members"; 7 | import ListTeamInvitations from "@/components/dashboard/accounts/settings/list-team-invitations"; 8 | import useTeamInvitations from "@/utils/api/use-team-invitations"; 9 | import useTeamAccount from "@/utils/api/use-team-account"; 10 | import DashboardMeta from "@/components/dashboard/dashboard-meta"; 11 | 12 | const TeamSettingsMembers = () => { 13 | const { t } = useTranslation("dashboard"); 14 | const router = useRouter(); 15 | const { accountId } = router.query; 16 | const { refetch } = useTeamInvitations(accountId as string); 17 | const { data } = useTeamAccount(accountId as string); 18 | return ( 19 | 20 | 23 |
24 | 28 |
29 | refetch()} 32 | /> 33 |
34 | 35 |
36 | 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default TeamSettingsMembers; 43 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/account-subscription-takeover/account-subscription-takeover.tsx: -------------------------------------------------------------------------------- 1 | import { UseDashboardOverviewResponse } from "@/utils/api/use-dashboard-overview"; 2 | import useAccountBillingStatus from "@/utils/api/use-account-billing-status"; 3 | import NewSubscription from "@/components/dashboard/accounts/account-subscription-takeover/new-subscription"; 4 | import { MANUAL_SUBSCRIPTION_REQUIRED } from "@/types/billing"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import AccountSubscription from "@/components/dashboard/accounts/settings/account-subscription"; 7 | 8 | type Props = { 9 | currentAccount: UseDashboardOverviewResponse[0]; 10 | }; 11 | const AccountSubscriptionTakeover = ({ currentAccount }: Props) => { 12 | const { t } = useTranslation("dashboard"); 13 | const { data: subscriptionData } = useAccountBillingStatus( 14 | currentAccount?.account_id 15 | ); 16 | return ( 17 |
18 | {[ 19 | "incomplete_expired", 20 | "canceled", 21 | MANUAL_SUBSCRIPTION_REQUIRED, 22 | ].includes(subscriptionData?.status) && ( 23 | <> 24 |

25 | {t("accountSubscriptionTakeover.newSubscriptionTitle")} 26 |

27 | 28 | 29 | )} 30 | {["incomplete", "past_due", "unpaid"].includes( 31 | subscriptionData?.status 32 | ) && ( 33 | <> 34 |

35 | {t( 36 | `accountSubscriptionTakeover.fixExistingSubscriptionTitle.${subscriptionData.status}` 37 | )} 38 |

39 | 40 | 41 | )} 42 |
43 | ); 44 | }; 45 | 46 | export default AccountSubscriptionTakeover; 47 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require("next/jest"); 2 | const createJestConfig = nextJest({ 3 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 4 | dir: "./", 5 | }); 6 | 7 | // Add any custom config to be passed to Jest 8 | const customJestConfig = { 9 | // Add more setup options before each test is run 10 | setupFilesAfterEnv: ["/__tests__/setup/jest.setup.js"], 11 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 12 | preset: "ts-jest", 13 | moduleDirectories: ["node_modules", "/"], 14 | testEnvironment: "jest-environment-jsdom", 15 | moduleNameMapper: { 16 | "^@/(.*)$": "/src/$1", 17 | "^@tests/(.*)$": "/__tests__/setup/$1", 18 | }, 19 | testPathIgnorePatterns: ["/__tests__/setup/"], 20 | /** 21 | * This is where you'll define node_module libraries that need to be transformed still 22 | * By default all node_modules are ignored 23 | */ 24 | transformIgnorePatterns: ["/node_modules/(?!(@supabase|jose)/)"], 25 | globals: { 26 | "ts-jest": { 27 | tsconfig: "/tsconfig.json", 28 | }, 29 | }, 30 | collectCoverageFrom: [ 31 | "/src/utils/**/*.{js,jsx,ts,tsx}", 32 | "/src/components/**/*.{js,jsx,ts,tsx}", 33 | ], 34 | }; 35 | 36 | module.exports = async () => { 37 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 38 | const nextDefaults = await createJestConfig(customJestConfig)(); 39 | 40 | // Next forces the transformIgnorePatterns to include `node_modules`, but that breaks b/c of the supabase transform issue 41 | // so we need to override it with our own transformIgnorePatterns 42 | nextDefaults.transformIgnorePatterns = [ 43 | "/node_modules/(?!(@supabase|jose)/)", 44 | "^.+\\.module\\.(css|sass|scss)$", 45 | ]; 46 | 47 | return nextDefaults; 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/profile-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dropdown } from "react-daisyui"; 2 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 3 | 4 | import { Database } from "@/types/supabase-types"; 5 | import Link from "next/link"; 6 | import { useMemo } from "react"; 7 | import { useRouter } from "next/router"; 8 | import useTranslation from "next-translate/useTranslation"; 9 | import useUserProfile from "@/utils/api/use-user-profile"; 10 | 11 | type Props = { 12 | className?: string; 13 | }; 14 | const DashboardProfileButton = ({ className }: Props) => { 15 | const { data: profile } = useUserProfile(); 16 | const user = useUser(); 17 | const { t } = useTranslation("dashboard"); 18 | const supabaseClient = useSupabaseClient(); 19 | const router = useRouter(); 20 | 21 | const menuButtonText = useMemo( 22 | () => profile?.name || t("profileButton.yourAccount"), 23 | [profile, t] 24 | ); 25 | return ( 26 |
27 | 28 | 29 | 30 |
31 |

{t("profileButton.loggedInAs")}

32 |

{user?.email}

33 |
34 | 35 | {t("profileButton.editProfile")} 36 | 37 | { 39 | await supabaseClient.auth.signOut(); 40 | await router.push("/"); 41 | }} 42 | > 43 | {t("shared.logOut")} 44 | 45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export default DashboardProfileButton; 52 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/theme-selector.tsx: -------------------------------------------------------------------------------- 1 | import { MoonIcon, SunIcon } from "@heroicons/react/outline"; 2 | import { Button, Dropdown } from "react-daisyui"; 3 | import { useMemo } from "react"; 4 | import useThemeStorage from "@/utils/use-theme-storage"; 5 | 6 | export const AVAILABLE_THEMES = { 7 | light: { 8 | name: "Light", 9 | id: "light", 10 | Icon: SunIcon, 11 | }, 12 | dark: { 13 | name: "Dark", 14 | id: "dark", 15 | Icon: MoonIcon, 16 | }, 17 | night: { 18 | name: "Night", 19 | id: "night", 20 | Icon: MoonIcon, 21 | }, 22 | dracula: { 23 | name: "Dracula", 24 | id: "dracula", 25 | Icon: MoonIcon, 26 | }, 27 | }; 28 | 29 | const ThemeSelector = () => { 30 | const { theme, setTheme, clearTheme } = useThemeStorage(); 31 | 32 | const selectedTheme = useMemo(() => { 33 | return AVAILABLE_THEMES[theme]; 34 | }, [theme]); 35 | 36 | return ( 37 | 38 | 45 | 46 | {Object.values(AVAILABLE_THEMES).map((themeOption) => ( 47 | 61 | ))} 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default ThemeSelector; 68 | -------------------------------------------------------------------------------- /src/components/core/select.tsx: -------------------------------------------------------------------------------- 1 | import { SelectProps } from "react-daisyui"; 2 | import { ForwardedRef, forwardRef, SelectHTMLAttributes } from "react"; 3 | import { twMerge } from "tailwind-merge"; 4 | import cx from "classnames"; 5 | 6 | type Props = SelectHTMLAttributes & { 7 | label?: string; 8 | helpText?: string; 9 | errorMessage?: string; 10 | color?: SelectProps["color"]; 11 | size?: SelectProps["size"]; 12 | bordered?: boolean; 13 | }; 14 | const Select = forwardRef( 15 | ( 16 | { 17 | label, 18 | helpText, 19 | errorMessage, 20 | color, 21 | value, 22 | children, 23 | className, 24 | bordered = true, 25 | size, 26 | ...props 27 | }: Props, 28 | ref: ForwardedRef 29 | ) => { 30 | return ( 31 |
32 | 41 | 54 | {(!!helpText || !!errorMessage) && ( 55 | 64 | )} 65 |
66 | ); 67 | } 68 | ); 69 | 70 | Select.displayName = "Select"; 71 | 72 | export default Select; 73 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/account-subscription-takeover/new-subscription.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import useAccountBillingOptions from "@/utils/api/use-account-billing-options"; 3 | import { Button, ButtonGroup } from "react-daisyui"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import IndividualSubscriptionPlan from "@/components/dashboard/accounts/account-subscription-takeover/individual-subscription-plan"; 6 | 7 | const NewSubscription = ({ currentAccount }) => { 8 | const [activeTab, setActiveTab] = useState(null); 9 | const [tabs, setTabs] = useState([]); 10 | const { t } = useTranslation("dashboard"); 11 | 12 | const { data } = useAccountBillingOptions(currentAccount?.account_id, { 13 | onSuccess(data) { 14 | const options = new Set(data?.map((option) => option.interval)); 15 | setTabs(Array.from(options)); 16 | if (!activeTab || !options.has(activeTab)) { 17 | setActiveTab(Array.from(options)[0]); 18 | } 19 | }, 20 | }); 21 | 22 | const currentOptions = useMemo(() => { 23 | return data?.filter((option) => option.interval === activeTab) || []; 24 | }, [data, activeTab]); 25 | 26 | return ( 27 |
28 | 29 | {tabs.map((tab) => ( 30 | 37 | ))} 38 | 39 |
40 | {currentOptions.map((option) => ( 41 | 46 | ))} 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default NewSubscription; 53 | -------------------------------------------------------------------------------- /src/components/dashboard/authentication/login-password.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { Button } from "react-daisyui"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Input from "@/components/core/input"; 5 | import { toast } from "react-toastify"; 6 | import { DASHBOARD_PATH } from "@/types/auth"; 7 | import { useRouter } from "next/router"; 8 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 9 | 10 | type LOGIN_FORM = { 11 | email: string; 12 | password: string; 13 | }; 14 | 15 | type Props = { 16 | redirectTo?: string; 17 | buttonText?: string; 18 | }; 19 | 20 | const LoginPassword = ({ redirectTo = DASHBOARD_PATH, buttonText }: Props) => { 21 | const { t } = useTranslation("authentication"); 22 | const router = useRouter(); 23 | const supabaseClient = useSupabaseClient(); 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { isSubmitting }, 28 | } = useForm(); 29 | 30 | async function onSubmit({ email, password }: LOGIN_FORM) { 31 | const { error } = await supabaseClient.auth.signInWithPassword({ 32 | email, 33 | password, 34 | }); 35 | if (error) toast.error(error.message); 36 | if (!error) { 37 | await router.push(redirectTo); 38 | } 39 | } 40 | 41 | return ( 42 |
43 | 48 | 53 | 61 |
62 | ); 63 | }; 64 | 65 | export default LoginPassword; 66 | -------------------------------------------------------------------------------- /src/utils/api/use-dashboard-overview.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "../handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export type UseDashboardOverviewResponse = { 7 | team_name: string; 8 | account_id: string; 9 | account_role: Database["public"]["Tables"]["account_user"]["Row"]["account_role"]; 10 | subscription_active: boolean; 11 | subscription_status: Database["public"]["Tables"]["billing_subscriptions"]["Row"]["status"]; 12 | personal_account: boolean; 13 | team_account: boolean; 14 | }[]; 15 | 16 | export default function useDashboardOverview( 17 | options?: UseQueryOptions 18 | ) { 19 | const user = useUser(); 20 | const supabaseClient = useSupabaseClient(); 21 | return useQuery( 22 | ["dashboardOverview", user?.id], 23 | async () => { 24 | const { data, error } = await supabaseClient 25 | .from("accounts") 26 | .select( 27 | "team_name, id, personal_account, billing_subscriptions (status), account_user!inner(account_role)" 28 | ) 29 | .eq("account_user.user_id", user?.id); 30 | 31 | handleSupabaseErrors(data, error); 32 | 33 | return data?.map((account) => ({ 34 | team_name: account.team_name, 35 | account_id: account.id, 36 | account_role: account.account_user?.[0]?.account_role, 37 | subscription_active: ["active", "trialing"].includes( 38 | account.billing_subscriptions?.[0]?.status 39 | ), 40 | subscription_status: account.billing_subscriptions?.[0]?.status, 41 | personal_account: account.personal_account, 42 | team_account: !account.personal_account, 43 | })); 44 | }, 45 | { 46 | ...options, 47 | enabled: !!user && !!supabaseClient, 48 | } 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/api/use-account-billing-options.ts: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useQuery, UseQueryOptions } from "@tanstack/react-query"; 3 | import handleSupabaseErrors from "@/utils/handle-supabase-errors"; 4 | import { Database } from "@/types/supabase-types"; 5 | 6 | export type UseAccountBillingOptionsResponse = Array<{ 7 | product_name: string; 8 | product_description: string; 9 | currency: string; 10 | price: number; 11 | price_id: string; 12 | interval: string; 13 | }>; 14 | 15 | /** 16 | * Get a given accounts account-subscription-takeover status. Returns "missing" if it has not yet been setup. 17 | * @param accountId 18 | * @param options 19 | */ 20 | export default function useAccountBillingOptions( 21 | accountId: string, 22 | options?: UseQueryOptions 23 | ) { 24 | const user = useUser(); 25 | const supabaseClient = useSupabaseClient(); 26 | return useQuery( 27 | ["accountBillingOptions", accountId], 28 | async () => { 29 | const { data, error } = await supabaseClient 30 | .from("billing_products") 31 | .select( 32 | "name, description, billing_prices (currency, unit_amount, id, interval, type)" 33 | ); 34 | 35 | handleSupabaseErrors(data, error); 36 | 37 | const results = []; 38 | 39 | data?.forEach((product) => { 40 | // @ts-ignore 41 | product.billing_prices?.forEach((price) => { 42 | results.push({ 43 | product_name: product.name, 44 | product_description: product.description, 45 | currency: price.currency, 46 | price: price.unit_amount, 47 | price_id: price.id, 48 | interval: price.type === "one_time" ? "one_time" : price.interval, 49 | }); 50 | }); 51 | }); 52 | 53 | return results; 54 | }, 55 | { 56 | ...options, 57 | enabled: !!accountId && !!user && !!supabaseClient, 58 | } 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/pages/api/billing/portal-link.ts: -------------------------------------------------------------------------------- 1 | import { withApiAuth } from "@supabase/auth-helpers-nextjs"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { createOrRetrieveCustomer } from "@/utils/admin/stripe-billing-helpers"; 4 | import { stripe } from "@/utils/admin/stripe"; 5 | import getFullRedirectUrl from "@/utils/get-full-redirect-url"; 6 | import { ACCOUNT_ROLES } from "@/types/auth"; 7 | 8 | const createPortalLink = async ( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | supabaseServerClient 12 | ) => { 13 | if (req.method !== "POST") { 14 | return res.status(405).json({ error: "Method Not Allowed" }); 15 | } 16 | const { accountId } = req.body; 17 | 18 | if (!accountId) { 19 | return res.status(400).json({ error: "Missing account ID" }); 20 | } 21 | 22 | const { data: currentUserRole } = await supabaseServerClient 23 | .rpc("current_user_account_role", { 24 | lookup_account_id: accountId, 25 | }) 26 | .single(); 27 | 28 | // only owners are allowed to update billing subscriptions 29 | if (currentUserRole?.account_role !== ACCOUNT_ROLES.owner) { 30 | return res.status(404).json({ error: "Account not found" }); 31 | } 32 | 33 | try { 34 | const { 35 | data: { user }, 36 | } = await supabaseServerClient.auth.getUser(); 37 | const customer = await createOrRetrieveCustomer({ 38 | accountId: accountId as string, 39 | email: user.email || "", 40 | }); 41 | 42 | if (!customer) throw Error("Could not get customer"); 43 | const { url } = await stripe.billingPortal.sessions.create({ 44 | customer, 45 | return_url: getFullRedirectUrl( 46 | currentUserRole?.is_personal_account 47 | ? "/dashboard/billing" 48 | : `/dashboard/teams/${accountId}/settings/billing` 49 | ), 50 | }); 51 | 52 | return res.status(200).json({ url }); 53 | } catch (err: any) { 54 | console.log(err); 55 | res.status(500).json({ error: { statusCode: 500, message: err.message } }); 56 | } 57 | }; 58 | 59 | export default withApiAuth(createPortalLink); 60 | -------------------------------------------------------------------------------- /src/pages/invitation.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import useInvitation from "@/utils/api/use-invitation"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import { Button } from "react-daisyui"; 5 | import { useMutation } from "@tanstack/react-query"; 6 | import handleSupabaseErrors from "@/utils/handle-supabase-errors"; 7 | import Loader from "@/components/core/loader"; 8 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 9 | import { Database } from "@/types/supabase-types"; 10 | 11 | const Invitation = () => { 12 | const router = useRouter(); 13 | const { token } = router.query; 14 | const { t } = useTranslation("dashboard"); 15 | const supabaseClient = useSupabaseClient(); 16 | 17 | const { data, isLoading } = useInvitation(token as string); 18 | 19 | const acceptInvitation = useMutation( 20 | async (invitationToken: string) => { 21 | const { data, error } = await supabaseClient.rpc("accept_invitation", { 22 | lookup_invitation_token: invitationToken, 23 | }); 24 | 25 | handleSupabaseErrors(data, error); 26 | return data; 27 | }, 28 | { 29 | onSuccess(accountId) { 30 | router.push(`/dashboard/teams/${accountId}`); 31 | }, 32 | } 33 | ); 34 | 35 | return ( 36 |
37 | {isLoading ? ( 38 | 39 | ) : !data?.active ? ( 40 |

{t("invitation.invalid")}

41 | ) : ( 42 | <> 43 |

{t("invitation.title")}

44 |

{data?.team_name}

45 | 52 | 53 | )} 54 |
55 | ); 56 | }; 57 | 58 | export default Invitation; 59 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/new-account-form.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { toast } from "react-toastify"; 3 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 4 | import Input from "@/components/core/input"; 5 | import { Button } from "react-daisyui"; 6 | import useTranslation from "next-translate/useTranslation"; 7 | import { Database } from "@/types/supabase-types"; 8 | 9 | type Props = { 10 | onComplete: (accountId: string) => void; 11 | }; 12 | 13 | type FORM_DATA = { 14 | name: string; 15 | }; 16 | 17 | const NewAccountForm = ({ onComplete }: Props) => { 18 | const user = useUser(); 19 | const supabaseClient = useSupabaseClient(); 20 | const { t } = useTranslation("dashboard"); 21 | const { 22 | register, 23 | handleSubmit, 24 | reset, 25 | formState: { isSubmitting, errors }, 26 | } = useForm(); 27 | 28 | async function onSubmit(data: FORM_DATA) { 29 | if (!user) return; 30 | const response = await supabaseClient 31 | .from("accounts") 32 | .insert({ 33 | team_name: data.name, 34 | }) 35 | .select(); 36 | 37 | if (response.error) { 38 | toast.error(response.error.message); 39 | } 40 | 41 | if (response?.data?.[0]?.id) { 42 | reset(); 43 | toast.success(t("shared.successfulChange")); 44 | if (onComplete) { 45 | onComplete(response.data?.[0]?.id); 46 | } 47 | } 48 | } 49 | 50 | return ( 51 |
55 | 64 | 67 |
68 | ); 69 | }; 70 | 71 | export default NewAccountForm; 72 | -------------------------------------------------------------------------------- /__tests__/dashboard/profile/update-email.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render, screen, waitFor } from "@tests/test-utils"; 2 | import UpdateEmailAddress from "@/components/dashboard/profile/update-email-address"; 3 | import { toast } from "react-toastify"; 4 | 5 | jest.spyOn(toast, "success"); 6 | 7 | const updateUser = jest.fn(({ email }) => { 8 | return { 9 | data: { 10 | user: { 11 | new_email: email, 12 | }, 13 | }, 14 | }; 15 | }); 16 | 17 | jest.mock("@supabase/auth-helpers-react", () => { 18 | const original = jest.requireActual("@supabase/auth-helpers-react"); 19 | return { 20 | ...original, 21 | useUser: jest.fn(() => ({ 22 | id: "1234-5678", 23 | email: "test@test.com", 24 | })), 25 | useSupabaseClient: () => ({ 26 | auth: { 27 | updateUser, 28 | }, 29 | }), 30 | }; 31 | }); 32 | 33 | describe("Update user email", () => { 34 | beforeEach(async () => { 35 | await act(async () => { 36 | render(); 37 | }); 38 | }); 39 | it("let's you update your email", async () => { 40 | const emailInput = await screen.getByTestId("email"); 41 | const email = "test2@test.com"; 42 | await act(async () => { 43 | fireEvent.input(emailInput, { 44 | target: { 45 | value: email, 46 | }, 47 | }); 48 | 49 | fireEvent.submit(screen.getByRole("button")); 50 | }); 51 | 52 | expect(emailInput.value).toEqual(email); 53 | expect(updateUser).toHaveBeenCalledWith({ 54 | email, 55 | }); 56 | expect(toast.success).toHaveBeenCalled(); 57 | }); 58 | 59 | it("Should error if the email is empty", async () => { 60 | const emailInput = await screen.getByTestId("email"); 61 | await act(async () => { 62 | fireEvent.input(emailInput, { 63 | target: { 64 | value: "", 65 | }, 66 | }); 67 | fireEvent.submit(screen.getByRole("button")); 68 | }); 69 | 70 | await waitFor(() => 71 | expect(screen.getByTestId("email")).toHaveClass("input-error") 72 | ); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/sidebar-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "react-daisyui"; 2 | import cx from "classnames"; 3 | import { XIcon } from "@heroicons/react/outline"; 4 | import ProfileButton from "@/components/dashboard/sidebar/profile-button"; 5 | import ThemeSelector from "@/components/dashboard/sidebar/theme-selector"; 6 | import TeamAccountMenu from "@/components/dashboard/sidebar/team-account-menu"; 7 | import PersonalAccountMenu from "@/components/dashboard/sidebar/personal-account-menu"; 8 | import TeamSelectMenu from "@/components/dashboard/sidebar/team-select-menu"; 9 | import { UseDashboardOverviewResponse } from "@/utils/api/use-dashboard-overview"; 10 | import Logo from "@/components/basejump-default-content/logo"; 11 | 12 | type Props = { 13 | className?: string; 14 | onClose?: () => void; 15 | currentAccount?: UseDashboardOverviewResponse[0]; 16 | }; 17 | const SidebarMenu = ({ className, onClose, currentAccount }: Props) => { 18 | return ( 19 |
25 |
26 |
27 | 28 | 36 |
37 |
38 | 39 |
40 | {currentAccount?.team_account === true ? ( 41 | 42 | ) : ( 43 | 44 | )} 45 |
46 | 47 |
48 | 49 | 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default SidebarMenu; 56 | -------------------------------------------------------------------------------- /src/pages/api/billing/setup.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { stripe } from "@/utils/admin/stripe"; 3 | import getFullRedirectUrl from "@/utils/get-full-redirect-url"; 4 | import { withApiAuth } from "@supabase/auth-helpers-nextjs"; 5 | import { ACCOUNT_ROLES } from "@/types/auth"; 6 | import { createOrRetrieveCustomer } from "@/utils/admin/stripe-billing-helpers"; 7 | 8 | const BillingSetup = async ( 9 | req: NextApiRequest, 10 | res: NextApiResponse, 11 | supabaseServerClient 12 | ) => { 13 | if (req.method !== "POST") { 14 | return res.status(405).json({ error: "Method Not Allowed" }); 15 | } 16 | 17 | const { accountId, priceId } = req.body; 18 | 19 | if (!accountId) { 20 | return res.status(400).json({ error: "Missing account ID" }); 21 | } 22 | 23 | const { 24 | data: { user }, 25 | } = await supabaseServerClient.auth.getUser(); 26 | 27 | const { data: currentUserRole } = await supabaseServerClient 28 | .rpc("current_user_account_role", { 29 | lookup_account_id: accountId, 30 | }) 31 | .single(); 32 | 33 | // only owners are allowed to update billing subscriptions 34 | if (currentUserRole?.account_role !== ACCOUNT_ROLES.owner) { 35 | return res.status(404).json({ error: "Account not found" }); 36 | } 37 | 38 | const customerId = await createOrRetrieveCustomer({ 39 | accountId, 40 | email: user.email, 41 | }); 42 | 43 | const session = await stripe.checkout.sessions.create({ 44 | payment_method_types: ["card"], 45 | customer: customerId, 46 | line_items: [{ price: priceId, quantity: 1 }], 47 | mode: "subscription", 48 | success_url: getFullRedirectUrl( 49 | currentUserRole.is_personal_account 50 | ? "/dashboard/billing" 51 | : `/dashboard/teams/${accountId}/settings/billing` 52 | ), 53 | cancel_url: getFullRedirectUrl( 54 | currentUserRole.is_personal_account 55 | ? "/dashboard/billing" 56 | : `/dashboard/teams/${accountId}/settings/billing` 57 | ), 58 | }); 59 | res.status(200).json({ url: session.url }); 60 | }; 61 | 62 | export default withApiAuth(BillingSetup); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Basejump", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next", 6 | "build": "yarn sync-stripe && next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "type-check": "tsc", 10 | "test": "NEXT_PUBLIC_SUPABASE_URL=http://localhost NEXT_PUBLIC_SUPABASE_ANON_KEY=anon-key jest", 11 | "generate-types": "supabase gen types typescript --local > ./src/types/supabase-types.ts && eslint ./src/types/supabase-types.ts --fix", 12 | "sync-stripe": "npx tsx scripts/sync-stripe.ts" 13 | }, 14 | "dependencies": { 15 | "@heroicons/react": "^1.0.6", 16 | "@supabase/auth-helpers-nextjs": "^0.5.1", 17 | "@supabase/auth-helpers-react": "^0.3.1", 18 | "@supabase/supabase-js": "^2.0.5", 19 | "@tanstack/react-query": "^4.14.5", 20 | "@vercel/og": "^0.0.20", 21 | "classnames": "^2.3.2", 22 | "date-fns": "^2.29.3", 23 | "gray-matter": "^4.0.3", 24 | "next": "13.0.2", 25 | "next-mdx-remote": "^4.2.0", 26 | "next-translate": "^1.6.0", 27 | "react": "^18.2.0", 28 | "react-daisyui": "^2.4.6", 29 | "react-dom": "^18.2.0", 30 | "react-hook-form": "^7.39.1", 31 | "react-toastify": "^9.1.1", 32 | "react-use": "^17.4.0", 33 | "stripe": "^10.16.0", 34 | "tailwind-merge": "^1.8.0" 35 | }, 36 | "devDependencies": { 37 | "@tailwindcss/typography": "^0.5.8", 38 | "@testing-library/jest-dom": "^5.16.5", 39 | "@testing-library/react": "^13.4.0", 40 | "@types/jest": "^29.2.2", 41 | "@types/node": "^18.11.9", 42 | "@types/react": "^18.0.25", 43 | "@types/react-dom": "^18.0.8", 44 | "autoprefixer": "^10.4.13", 45 | "daisyui": "^2.38.1", 46 | "eslint": "8.27.0", 47 | "eslint-config-next": "13.0.2", 48 | "eslint-config-prettier": "^8.5.0", 49 | "eslint-plugin-prettier": "^4.2.1", 50 | "jest": "29.3.0", 51 | "jest-environment-jsdom": "29.3.0", 52 | "jest-fetch-mock": "^3.0.3", 53 | "next-transpile-modules": "^10.0.0", 54 | "postcss": "^8.4.18", 55 | "prettier": "^2.7.1", 56 | "tailwindcss": "^3.2.2", 57 | "ts-jest": "^29.0.3", 58 | "typescript": "^4.8.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/account-settings-layout.tsx: -------------------------------------------------------------------------------- 1 | import DashboardContent from "@/components/dashboard/shared/dashboard-content"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import { useRouter } from "next/router"; 4 | import { useMemo } from "react"; 5 | import Link from "next/link"; 6 | import cx from "classnames"; 7 | 8 | const AccountSettingsLayout = ({ children }) => { 9 | const { t } = useTranslation("dashboard"); 10 | const router = useRouter(); 11 | const { accountId } = router.query; 12 | const tabs = useMemo(() => { 13 | return [ 14 | { 15 | label: t("teamAccountSettings.tabs.general"), 16 | href: `/dashboard/teams/${accountId}/settings`, 17 | isActive: router.asPath === `/dashboard/teams/${accountId}/settings`, 18 | }, 19 | { 20 | label: t("teamAccountSettings.tabs.members"), 21 | href: `/dashboard/teams/${accountId}/settings/members`, 22 | isActive: 23 | router.asPath === `/dashboard/teams/${accountId}/settings/members`, 24 | }, 25 | { 26 | label: t("teamAccountSettings.tabs.billing"), 27 | href: `/dashboard/teams/${accountId}/settings/billing`, 28 | isActive: 29 | router.asPath === `/dashboard/teams/${accountId}/settings/billing`, 30 | }, 31 | ]; 32 | }, [accountId, router.asPath, t]); 33 | return ( 34 | 35 | 36 | {t("teamAccountSettings.pageTitle")} 37 | 38 | 39 |
40 | {tabs.map(({ label, href, isActive }) => ( 41 | 48 | {label} 49 | 50 | ))} 51 |
52 |
53 | {children} 54 |
55 | ); 56 | }; 57 | 58 | export default AccountSettingsLayout; 59 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/global.css"; 2 | import "react-toastify/dist/ReactToastify.css"; 3 | import type { AppProps } from "next/app"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | import ContentLayout from "../components/content-pages/content-layout"; 6 | import DashboardLayout from "../components/dashboard/dashboard-layout"; 7 | import { Theme } from "react-daisyui"; 8 | import useThemeStorage from "@/utils/use-theme-storage"; 9 | import { ToastContainer } from "react-toastify"; 10 | import { useEffect, useState } from "react"; 11 | import { createBrowserSupabaseClient } from "@supabase/auth-helpers-nextjs"; 12 | import { SessionContextProvider } from "@supabase/auth-helpers-react"; 13 | import { Database } from "@/types/supabase-types"; 14 | 15 | const queryClient = new QueryClient(); 16 | 17 | function MyApp({ Component, pageProps, router }: AppProps) { 18 | const isDashboardPath = router.pathname.startsWith("/dashboard"); 19 | const { theme } = useThemeStorage(); 20 | const [supabaseClient] = useState(() => 21 | createBrowserSupabaseClient() 22 | ); 23 | 24 | useEffect(() => { 25 | // our dropdowns are used for navigation a lot 26 | // they work off css focus states, so they don't get removed 27 | // on navigation transitions. this is a hack to force them to 28 | const element = window?.document?.activeElement as HTMLElement; 29 | if (typeof element?.blur === "function") { 30 | element.blur(); 31 | } 32 | }, [router.asPath]); 33 | return ( 34 | 35 | 39 | 40 | {isDashboardPath ? ( 41 | 42 | 43 | 44 | ) : ( 45 | 46 | 47 | 48 | )} 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export default MyApp; 57 | -------------------------------------------------------------------------------- /src/components/dashboard/profile/update-email-address.tsx: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useForm } from "react-hook-form"; 3 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import { Button } from "react-daisyui"; 6 | import Input from "@/components/core/input"; 7 | import { toast } from "react-toastify"; 8 | import { Database } from "@/types/supabase-types"; 9 | 10 | type FORM_DATA = { 11 | email: string; 12 | }; 13 | 14 | const UpdateEmailAddress = () => { 15 | const user = useUser(); 16 | const supabaseClient = useSupabaseClient(); 17 | const { t } = useTranslation("dashboard"); 18 | const { 19 | register, 20 | handleSubmit, 21 | formState: { isSubmitting, errors }, 22 | } = useForm(); 23 | 24 | async function onSubmit(newEmail: FORM_DATA) { 25 | if (!user) return; 26 | const { data, error } = await supabaseClient.auth.updateUser({ 27 | email: newEmail.email, 28 | }); 29 | if (error) { 30 | toast.error(error.message); 31 | return; 32 | } 33 | if (!!data?.user?.new_email) { 34 | toast.success(t("updateEmailAddress.successfulChange")); 35 | } 36 | } 37 | 38 | return ( 39 | 43 | {!!user && ( 44 |
45 | 46 | 53 | 54 | 55 | 58 | 59 |
60 | )} 61 |
62 | ); 63 | }; 64 | 65 | export default UpdateEmailAddress; 66 | -------------------------------------------------------------------------------- /__tests__/dashboard/profile/update-name.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render, screen, waitFor } from "@tests/test-utils"; 2 | import UpdateProfileName from "@/components/dashboard/profile/update-profile-name"; 3 | import { toast } from "react-toastify"; 4 | 5 | jest.spyOn(toast, "success"); 6 | 7 | jest.mock("@/utils/api/use-user-profile", () => ({ 8 | __esModule: true, 9 | default: jest.fn(() => ({ 10 | data: { 11 | name: "John Doe", 12 | }, 13 | })), 14 | })); 15 | 16 | jest.mock("@supabase/auth-helpers-react", () => { 17 | const original = jest.requireActual("@supabase/auth-helpers-react"); 18 | return { 19 | ...original, 20 | useUser: jest.fn(() => ({ 21 | id: "1234-5678", 22 | })), 23 | }; 24 | }); 25 | 26 | describe("Update profile name", () => { 27 | beforeEach(async () => { 28 | await act(async () => { 29 | render(); 30 | }); 31 | }); 32 | it.skip("let's you update your profile name", async () => { 33 | const nameInput = await screen.getByTestId("name"); 34 | const name = "Fred Flinstone"; 35 | await act(async () => { 36 | fireEvent.input(nameInput, { 37 | target: { 38 | value: name, 39 | }, 40 | }); 41 | 42 | fireEvent.submit(screen.getByRole("button")); 43 | }); 44 | 45 | expect(nameInput.value).toEqual(name); 46 | await waitFor( 47 | () => 48 | expect(fetch).toBeCalledWith( 49 | expect.stringContaining("profiles?id=eq.1234-5678"), 50 | expect.objectContaining({ 51 | method: "PATCH", 52 | body: JSON.stringify({ name }), 53 | }) 54 | ), 55 | { timeout: 1000 } 56 | ); 57 | expect(toast.success).toHaveBeenCalled(); 58 | }); 59 | 60 | it("Should error if the name is empty", async () => { 61 | const nameInput = await screen.getByTestId("name"); 62 | await act(async () => { 63 | fireEvent.input(nameInput, { 64 | target: { 65 | value: "", 66 | }, 67 | }); 68 | fireEvent.submit(screen.getByRole("button")); 69 | }); 70 | 71 | await waitFor(() => 72 | expect(screen.getByTestId("name")).toHaveClass("input-error") 73 | ); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/components/docs/docs-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Menu } from "react-daisyui"; 3 | import { GetDocsNavigationResponse } from "@/utils/content/content-helpers"; 4 | import { Fragment } from "react"; 5 | import { useRouter } from "next/router"; 6 | import cx from "classnames"; 7 | import useTranslation from "next-translate/useTranslation"; 8 | import { XIcon } from "@heroicons/react/outline"; 9 | 10 | type Props = { 11 | navigation: GetDocsNavigationResponse; 12 | onClose?: () => void; 13 | }; 14 | 15 | const DocsSidebar = ({ navigation, onClose }: Props) => { 16 | const router = useRouter(); 17 | const { t } = useTranslation("content"); 18 | return ( 19 |
20 | 29 | 30 | {navigation.rootPaths.map((rootPath) => ( 31 | 35 | 36 | {rootPath.title} 37 | 38 | 39 | ))} 40 | {Object.keys(navigation.categories).map((category) => ( 41 | 42 | 43 | {category} 44 | 45 | {navigation.categories[category].map((categoryPath) => ( 46 | 52 | 53 | {categoryPath.title} 54 | 55 | 56 | ))} 57 | 58 | ))} 59 | 60 |
61 | ); 62 | }; 63 | 64 | export default DocsSidebar; 65 | -------------------------------------------------------------------------------- /src/components/content-pages/content-header.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Navbar } from "react-daisyui"; 2 | import Link from "next/link"; 3 | import { useUser } from "@supabase/auth-helpers-react"; 4 | import { useRouter } from "next/router"; 5 | import Logo from "@/components/basejump-default-content/logo"; 6 | import useTranslation from "next-translate/useTranslation"; 7 | import { MenuIcon } from "@heroicons/react/outline"; 8 | import useHeaderNavigation from "@/utils/content/use-header-navigation"; 9 | 10 | type Props = { 11 | toggleSidebar: () => void; 12 | }; 13 | 14 | const ContentHeader = ({ toggleSidebar }: Props) => { 15 | const user = useUser(); 16 | const router = useRouter(); 17 | 18 | const { t } = useTranslation("content"); 19 | 20 | const navigation = useHeaderNavigation(); 21 | 22 | return ( 23 | 24 |
25 | {router.asPath !== "/" && ( 26 | 27 | 28 | 29 | )} 30 |
31 | {navigation.map((nav) => ( 32 | 38 | {nav.title} 39 | 40 | ))} 41 |
42 |
43 |
44 | {!!user ? ( 45 | 46 | {t("dashboard")} 47 | 48 | ) : ( 49 | <> 50 | 51 | {t("login")} 52 | 53 | 54 | {t("signUp")} 55 | 56 | 57 | )} 58 |
59 |
60 | 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default ContentHeader; 69 | -------------------------------------------------------------------------------- /src/pages/blog/[...slug].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getContentBySlug, 3 | getContentPaths, 4 | } from "@/utils/content/content-helpers"; 5 | import { MDXRemote } from "next-mdx-remote"; 6 | import { serialize } from "next-mdx-remote/serialize"; 7 | import Link from "next/link"; 8 | import useTranslation from "next-translate/useTranslation"; 9 | import ContentMeta from "@/components/content-pages/content-meta"; 10 | 11 | const BlogShow = ({ content, title, meta }) => { 12 | const { t } = useTranslation("content"); 13 | return ( 14 |
15 | 21 |
22 |
    23 |
  • 24 | 25 | {t("home")} 26 | 27 |
  • 28 |
  • 29 | 30 | {t("blog")} 31 | 32 |
  • 33 |
  • {title}
  • 34 |
35 |
36 | {!!meta?.category && ( 37 | {meta.category} 38 | )} 39 |

{title}

40 | 41 |
42 | ); 43 | }; 44 | 45 | export default BlogShow; 46 | 47 | export async function getStaticProps({ params, locale, ...rest }) { 48 | const blog = await getContentBySlug(params.slug?.[0], { 49 | locale, 50 | contentType: "blog", 51 | }); 52 | 53 | const content = await serialize(blog.content); 54 | return { 55 | props: { 56 | ...blog, 57 | content, 58 | }, 59 | }; 60 | } 61 | 62 | export async function getStaticPaths({ locales }) { 63 | const paths = []; 64 | for (const locale of locales) { 65 | const filePaths = await getContentPaths(locale, "blog"); 66 | filePaths.forEach((filePath) => { 67 | paths.push({ 68 | params: { 69 | slug: [filePath.slug], 70 | }, 71 | locale, 72 | }); 73 | }); 74 | } 75 | 76 | return { 77 | paths, 78 | fallback: false, 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /src/pages/docs/[...slug].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getContentBySlug, 3 | getContentPaths, 4 | getDocsNavigation, 5 | } from "@/utils/content/content-helpers"; 6 | import { MDXRemote } from "next-mdx-remote"; 7 | import { serialize } from "next-mdx-remote/serialize"; 8 | import DocsLayout from "@/components/docs/docs-layout"; 9 | import ContentMeta from "@/components/content-pages/content-meta"; 10 | 11 | const DocsShow = ({ navigation, content, title, meta }) => { 12 | return ( 13 | 14 | 20 |
21 | {!!meta?.category && ( 22 | 23 | {meta.category} 24 | 25 | )} 26 |

{title}

27 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default DocsShow; 34 | 35 | export async function getStaticProps({ params, locale, ...rest }) { 36 | const doc = await getContentBySlug(params.slug?.[0], { 37 | locale, 38 | contentType: "docs", 39 | }); 40 | 41 | const navigation = await getDocsNavigation(locale); 42 | 43 | const content = await serialize(doc.content); 44 | return { 45 | props: { 46 | ...doc, 47 | navigation, 48 | content, 49 | }, 50 | }; 51 | } 52 | 53 | export async function getStaticPaths({ locales }) { 54 | const paths = []; 55 | for (const locale of locales) { 56 | const filePaths = await getContentPaths(locale, "docs"); 57 | filePaths 58 | .filter( 59 | // Resolves an issue where returning empty paths collides with the index page 60 | // known issue: https://github.com/vercel/next.js/issues/12717 61 | (filePath) => !filePath.slug.includes("index") && filePath.slug !== "" 62 | ) 63 | .forEach((filePath) => { 64 | paths.push({ 65 | params: { 66 | slug: [filePath.slug], 67 | }, 68 | locale, 69 | }); 70 | }); 71 | } 72 | 73 | return { 74 | paths, 75 | fallback: false, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/components/dashboard/authentication/signup-password.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { Button } from "react-daisyui"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Input from "@/components/core/input"; 5 | import { toast } from "react-toastify"; 6 | import { DASHBOARD_PATH } from "@/types/auth"; 7 | import { useRouter } from "next/router"; 8 | import getFullRedirectUrl from "@/utils/get-full-redirect-url"; 9 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 10 | 11 | type LOGIN_FORM = { 12 | email: string; 13 | password: string; 14 | }; 15 | 16 | type Props = { 17 | redirectTo?: string; 18 | buttonText?: string; 19 | metadata?: { 20 | [key: string]: string | number | boolean; 21 | }; 22 | }; 23 | 24 | const SignupPassword = ({ 25 | redirectTo = DASHBOARD_PATH, 26 | buttonText, 27 | metadata, 28 | }: Props) => { 29 | const { t } = useTranslation("authentication"); 30 | const router = useRouter(); 31 | const supabaseClient = useSupabaseClient(); 32 | const { 33 | register, 34 | handleSubmit, 35 | formState: { isSubmitting }, 36 | } = useForm(); 37 | 38 | async function onSubmit({ email, password }: LOGIN_FORM) { 39 | const { error } = await supabaseClient.auth.signUp({ 40 | email, 41 | password, 42 | options: { 43 | data: { 44 | ...metadata, 45 | }, 46 | emailRedirectTo: getFullRedirectUrl(redirectTo), 47 | }, 48 | }); 49 | if (error) toast.error(error.message); 50 | if (!error) { 51 | await router.push(redirectTo); 52 | } 53 | } 54 | 55 | return ( 56 |
57 | 62 | 67 | 75 |
76 | ); 77 | }; 78 | 79 | export default SignupPassword; 80 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const plugin = require("tailwindcss/plugin"); 3 | 4 | module.exports = { 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx}", 8 | "node_modules/daisyui/dist/**/*.js", 9 | "node_modules/react-daisyui/dist/**/*.js", 10 | ], 11 | theme: { 12 | extend: {}, 13 | }, 14 | plugins: [ 15 | require("@tailwindcss/typography"), 16 | require("daisyui"), 17 | plugin(({ addComponents, theme }) => { 18 | addComponents({ 19 | ".border-base-outline": { 20 | borderColor: "hsl(var(--bc) / var(--tw-border-opacity))", 21 | "--tw-border-opacity": "0.2", 22 | }, 23 | ".divide-base-outline": { 24 | "&>:not([hidden])~:not([hidden])": { 25 | borderColor: "hsl(var(--bc) / var(--tw-border-opacity))", 26 | "--tw-border-opacity": "0.2", 27 | }, 28 | }, 29 | }); 30 | }), 31 | plugin(({ addComponents, theme }) => { 32 | const headings = { 33 | ".h1": { 34 | fontSize: theme("fontSize.2xl"), 35 | fontWeight: theme("fontWeight.medium"), 36 | }, 37 | ".h2": { 38 | fontSize: theme("fontSize.xl"), 39 | fontWeight: theme("fontWeight.medium"), 40 | }, 41 | ".h3": { 42 | fontSize: theme("fontSize.lg"), 43 | fontWeight: theme("fontWeight.medium"), 44 | }, 45 | ".h4": { 46 | fontSize: theme("fontSize.lg"), 47 | fontWeight: theme("fontWeight.medium"), 48 | }, 49 | "@screen md": { 50 | ".h1": { 51 | fontSize: theme("fontSize.3xl"), 52 | fontWeight: theme("fontWeight.medium"), 53 | }, 54 | ".h2": { 55 | fontSize: theme("fontSize.2xl"), 56 | fontWeight: theme("fontWeight.medium"), 57 | }, 58 | ".h3": { 59 | fontSize: theme("fontSize.xl"), 60 | fontWeight: theme("fontWeight.medium"), 61 | }, 62 | ".h4": { 63 | fontSize: theme("fontSize.lg"), 64 | fontWeight: theme("fontWeight.medium"), 65 | }, 66 | }, 67 | }; 68 | addComponents(headings, { 69 | variants: ["responsive"], 70 | }); 71 | }), 72 | ], 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/dashboard/profile/update-profile-name.tsx: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useForm } from "react-hook-form"; 3 | import useUserProfile from "@/utils/api/use-user-profile"; 4 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 5 | import useTranslation from "next-translate/useTranslation"; 6 | import { Button } from "react-daisyui"; 7 | import Input from "@/components/core/input"; 8 | import { toast } from "react-toastify"; 9 | import { Database } from "@/types/supabase-types"; 10 | 11 | type FORM_DATA = { 12 | name: string; 13 | }; 14 | 15 | const UpdateProfileName = () => { 16 | const user = useUser(); 17 | const supabaseClient = useSupabaseClient(); 18 | const { data: profile } = useUserProfile(); 19 | const { t } = useTranslation("dashboard"); 20 | const { 21 | register, 22 | handleSubmit, 23 | formState: { isSubmitting, errors }, 24 | } = useForm(); 25 | 26 | async function onSubmit(data: FORM_DATA) { 27 | if (!user) return; 28 | const response = await supabaseClient 29 | .from("profiles") 30 | .update({ 31 | name: data.name, 32 | }) 33 | .eq("id", user.id); 34 | 35 | if (response.error) { 36 | toast.error(response.error.message); 37 | } 38 | 39 | if (!!data) { 40 | toast.success(t("shared.successfulChange")); 41 | } 42 | } 43 | 44 | return ( 45 | 49 | {!!profile && ( 50 |
51 | 52 | 61 | 62 | 63 | 66 | 67 |
68 | )} 69 |
70 | ); 71 | }; 72 | 73 | export default UpdateProfileName; 74 | -------------------------------------------------------------------------------- /src/components/content-pages/content-header-mobile.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Divider, Menu } from "react-daisyui"; 2 | import cx from "classnames"; 3 | import { XIcon } from "@heroicons/react/outline"; 4 | import Logo from "@/components/basejump-default-content/logo"; 5 | import Link from "next/link"; 6 | import useHeaderNavigation from "@/utils/content/use-header-navigation"; 7 | import { useUser } from "@supabase/auth-helpers-react"; 8 | import useTranslation from "next-translate/useTranslation"; 9 | 10 | type Props = { 11 | className?: string; 12 | onClose?: () => void; 13 | }; 14 | const ContentHeaderMobile = ({ className, onClose }: Props) => { 15 | const navigation = useHeaderNavigation(); 16 | const user = useUser(); 17 | const { t } = useTranslation("content"); 18 | return ( 19 |
25 |
26 |
27 | 28 | 29 | 30 | 38 |
39 | 40 | {navigation.map((item) => ( 41 | 42 | 43 | {item.title} 44 | 45 | 46 | ))} 47 | 48 | {!!user ? ( 49 | 50 | 51 | {t("dashboard")} 52 | 53 | 54 | ) : ( 55 | <> 56 | 57 | 58 | {t("login")} 59 | 60 | 61 | 62 | 63 | {t("signUp")} 64 | 65 | 66 | 67 | )} 68 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default ContentHeaderMobile; 75 | -------------------------------------------------------------------------------- /content/blog/en/formatting-example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Formatting Example" 3 | published: 2022-10-16 4 | category: "Example Category" 5 | description: "This is a quick example page showing the support for markdown elements" 6 | --- 7 | 8 | This is an example file showing off all the various Markdown elements 9 | 10 | ## Paragraphs 11 | 12 | Lorem ipsum dolor sit amet, *consectetur adipiscing elit*. Maecenas commodo mi fringilla, volutpat erat et, tristique 13 | metus. **Fusce quis hendrerit ipsum**. Phasellus convallis auctor turpis, at fringilla nibh blandit fermentum. Donec 14 | rhoncus 15 | consequat eros vel iaculis. Aenean nulla ex, aliquam euismod nibh ut, dignissim convallis est. Aenean dictum, libero ac 16 | sodales condimentum, arcu lacus porttitor metus, nec pellentesque velit elit id magna. Mauris lobortis vulputate mi ac 17 | tempus. Nullam vitae eros elit. Donec elementum facilisis cursus. Sed nulla massa, mollis id lacinia non, tristique quis 18 | ligula. 19 | 20 | Nulla a justo varius, tempor nibh a, tincidunt ligula. Duis ac augue sit amet dolor mattis pretium. Phasellus eu 21 | bibendum erat. Ut nec dolor non nunc ullamcorper luctus id eu libero. Phasellus nibh eros, laoreet quis eros non, 22 | malesuada vulputate arcu. In non tortor velit. Etiam feugiat, nisi nec bibendum fringilla, massa nisl egestas lectus, 23 | sed tincidunt nisi nunc a magna. Suspendisse faucibus sem non facilisis placerat. In hac habitasse platea dictumst. 24 | Pellentesque pharetra eu nibh in suscipit. 25 | 26 | ## Title Tags 27 | 28 | # # Title 1 29 | 30 | ## ## Title 2 31 | 32 | ### ### Title 3 33 | 34 | #### #### Title 4 35 | 36 |
37 | 38 | ## List items 39 | 40 | * bullet 41 | * list 42 | * Items 43 | * here 44 | 45 | 1. ordered 46 | 2. list 47 | 3. items 48 | 4. here 49 | 50 | ## Images 51 | 52 | ![Image alt text](/images/placeholder-image.svg) 53 | 54 | ## Tables 55 | 56 | Tables can be added using standard HTML 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
NameJob
JanePilot
JohnCo-Pilot
76 | 77 | ## Code Block 78 | 79 | ```javascript 80 | function sayHello(name) { 81 | console.log(`hello ${name}`) 82 | } 83 | 84 | sayHello("world"); 85 | ``` 86 | 87 | ## Quotes 88 | 89 | > There's always money in the banana stand 90 | 91 | -------------------------------------------------------------------------------- /content/docs/en/formatting-example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Formatting Example" 3 | category: "Example Category" 4 | published: 2022-10-16 5 | description: "This is a quick example page showing the support for markdown elements" 6 | --- 7 | 8 | This is an example file showing off all the various Markdown elements 9 | 10 | ## Paragraphs 11 | 12 | Lorem ipsum dolor sit amet, *consectetur adipiscing elit*. Maecenas commodo mi fringilla, volutpat erat et, tristique 13 | metus. **Fusce quis hendrerit ipsum**. Phasellus convallis auctor turpis, at fringilla nibh blandit fermentum. Donec 14 | rhoncus 15 | consequat eros vel iaculis. Aenean nulla ex, aliquam euismod nibh ut, dignissim convallis est. Aenean dictum, libero ac 16 | sodales condimentum, arcu lacus porttitor metus, nec pellentesque velit elit id magna. Mauris lobortis vulputate mi ac 17 | tempus. Nullam vitae eros elit. Donec elementum facilisis cursus. Sed nulla massa, mollis id lacinia non, tristique quis 18 | ligula. 19 | 20 | Nulla a justo varius, tempor nibh a, tincidunt ligula. Duis ac augue sit amet dolor mattis pretium. Phasellus eu 21 | bibendum erat. Ut nec dolor non nunc ullamcorper luctus id eu libero. Phasellus nibh eros, laoreet quis eros non, 22 | malesuada vulputate arcu. In non tortor velit. Etiam feugiat, nisi nec bibendum fringilla, massa nisl egestas lectus, 23 | sed tincidunt nisi nunc a magna. Suspendisse faucibus sem non facilisis placerat. In hac habitasse platea dictumst. 24 | Pellentesque pharetra eu nibh in suscipit. 25 | 26 | ## Title Tags 27 | 28 | # # Title 1 29 | 30 | ## ## Title 2 31 | 32 | ### ### Title 3 33 | 34 | #### #### Title 4 35 | 36 |
37 | 38 | ## List items 39 | 40 | * bullet 41 | * list 42 | * Items 43 | * here 44 | 45 | 1. ordered 46 | 2. list 47 | 3. items 48 | 4. here 49 | 50 | ## Images 51 | 52 | ![Image alt text](/images/placeholder-image.svg) 53 | 54 | ## Tables 55 | 56 | Tables can be added using standard HTML 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
NameJob
JanePilot
JohnCo-Pilot
76 | 77 | ## Code Block 78 | 79 | ```javascript 80 | function sayHello(name) { 81 | console.log(`hello ${name}`) 82 | } 83 | 84 | sayHello("world"); 85 | ``` 86 | 87 | ## Quotes 88 | 89 | > There's always money in the banana stand 90 | 91 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/update-account-name.tsx: -------------------------------------------------------------------------------- 1 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 2 | import { useForm } from "react-hook-form"; 3 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import { Button } from "react-daisyui"; 6 | import Input from "@/components/core/input"; 7 | import { toast } from "react-toastify"; 8 | import useTeamAccount from "@/utils/api/use-team-account"; 9 | import { Database } from "@/types/supabase-types"; 10 | 11 | type FORM_DATA = { 12 | name: string; 13 | }; 14 | 15 | type Props = { 16 | accountId: string; 17 | }; 18 | 19 | const UpdateAccountName = ({ accountId }: Props) => { 20 | const user = useUser(); 21 | const supabaseClient = useSupabaseClient(); 22 | const { data } = useTeamAccount(accountId); 23 | const { t } = useTranslation("dashboard"); 24 | const { 25 | register, 26 | handleSubmit, 27 | 28 | formState: { isSubmitting, errors }, 29 | } = useForm(); 30 | 31 | async function onSubmit(data: FORM_DATA) { 32 | if (!user || !accountId) return; 33 | const response = await supabaseClient 34 | .from("accounts") 35 | .update({ 36 | team_name: data.name, 37 | }) 38 | .match({ id: accountId }); 39 | if (response.error) { 40 | toast.error(response.error.message || response.statusText); 41 | } 42 | 43 | if (!!response.data) { 44 | toast.success(t("shared.successfulChange")); 45 | } 46 | } 47 | 48 | return ( 49 | 53 | {!!data && ( 54 |
55 | 56 | 65 | 66 | 67 | 70 | 71 |
72 | )} 73 |
74 | ); 75 | }; 76 | 77 | export default UpdateAccountName; 78 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/account-subscription.tsx: -------------------------------------------------------------------------------- 1 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 2 | import useTranslation from "next-translate/useTranslation"; 3 | import useAccountBillingStatus from "@/utils/api/use-account-billing-status"; 4 | import { Button } from "react-daisyui"; 5 | import { useMutation } from "@tanstack/react-query"; 6 | 7 | type Props = { 8 | accountId: string; 9 | }; 10 | 11 | const AccountSubscription = ({ accountId }: Props) => { 12 | const { t } = useTranslation("dashboard"); 13 | 14 | const { data } = useAccountBillingStatus(accountId); 15 | 16 | const getSubscriptionUrl = useMutation( 17 | async () => { 18 | const res = await fetch("/api/billing/portal-link", { 19 | method: "POST", 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | body: JSON.stringify({ accountId }), 24 | }); 25 | const { url } = await res.json(); 26 | return url; 27 | }, 28 | { 29 | onSuccess(url) { 30 | window.location.href = url; 31 | }, 32 | } 33 | ); 34 | return ( 35 | <> 36 | {data?.billing_enabled === false ? ( 37 |
38 |

39 | {t("accountSubscription.billingDisabled")} 40 |

41 |

42 | {t("accountSubscription.billingDisabledDescription")} 43 |

44 |
45 | ) : ( 46 | 50 | 51 |

52 | {data?.plan_name} - {data?.status} 53 |

54 |

55 | {t("accountSubscription.billingEmail", { 56 | email: data?.billing_email, 57 | })} 58 |

59 |
60 | 61 | 68 | 69 |
70 | )} 71 | 72 | ); 73 | }; 74 | 75 | export default AccountSubscription; 76 | -------------------------------------------------------------------------------- /__tests__/dashboard/accounts/settings/update-team-member-role.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from "@tests/test-utils"; 2 | import { toast } from "react-toastify"; 3 | import { ACCOUNT_ROLES } from "@/types/auth"; 4 | import UpdateTeamMemberRole from "@/components/dashboard/accounts/settings/update-team-member-role"; 5 | import { UseTeamMembersResponse } from "@/utils/api/use-team-members"; 6 | 7 | jest.spyOn(toast, "success"); 8 | 9 | describe("Update team member role", () => { 10 | beforeEach(async () => { 11 | await act(async () => { 12 | const member = {} as UseTeamMembersResponse; 13 | render(); 14 | }); 15 | }); 16 | 17 | test.skip("primary owners can change the primary owner", async () => { 18 | jest.mock("@/utils/api/use-team-role", () => ({ 19 | __esModule: true, 20 | default: jest.fn(() => ({ 21 | data: { 22 | accountRole: ACCOUNT_ROLES.owner, 23 | isPrimaryOwner: true, 24 | }, 25 | })), 26 | })); 27 | expect(false).toBeTruthy(); 28 | // const nameInput = await screen.getByTestId("name"); 29 | // const name = "Fred Flinstone"; 30 | // await act(async () => { 31 | // fireEvent.input(nameInput, { 32 | // target: { 33 | // value: name, 34 | // }, 35 | // }); 36 | // 37 | // fireEvent.submit(screen.getByRole("button")); 38 | // }); 39 | // 40 | // expect(nameInput.value).toEqual(name); 41 | // await waitFor( 42 | // () => 43 | // expect(fetch).toBeCalledWith( 44 | // expect.stringContaining("profiles?id=eq.1234-5678"), 45 | // expect.objectContaining({ 46 | // method: "PATCH", 47 | // body: JSON.stringify({ name }), 48 | // }) 49 | // ), 50 | // { timeout: 1000 } 51 | // ); 52 | // expect(toast.success).toHaveBeenCalled(); 53 | }); 54 | 55 | test.skip("Regular owners cannot change the primary owner", async () => { 56 | // const nameInput = await screen.getByTestId("name"); 57 | // await act(async () => { 58 | // fireEvent.input(nameInput, { 59 | // target: { 60 | // value: "", 61 | // }, 62 | // }); 63 | // fireEvent.submit(screen.getByRole("button")); 64 | // }); 65 | // 66 | // await waitFor(() => 67 | // expect(screen.getByTestId("name")).toHaveClass("input-error") 68 | // ); 69 | expect(false).toBeTruthy(); 70 | }); 71 | 72 | test.skip("Primary owners only see the primary owner option on other owners", async () => { 73 | expect(false).toBeTruthy(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/components/dashboard/authentication/login-magic-link.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { Button, Input } from "react-daisyui"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import getFullRedirectUrl from "@/utils/get-full-redirect-url"; 6 | import { DASHBOARD_PATH } from "@/types/auth"; 7 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 8 | import { Database } from "@/types/supabase-types"; 9 | 10 | type LOGIN_FORM = { 11 | email: string; 12 | }; 13 | 14 | type Props = { 15 | redirectTo?: string; 16 | buttonText?: string; 17 | }; 18 | 19 | const LoginMagicLink = ({ redirectTo = DASHBOARD_PATH, buttonText }: Props) => { 20 | const { t } = useTranslation("authentication"); 21 | const supabaseClient = useSupabaseClient(); 22 | const [emailSent, setEmailSent] = useState(false); 23 | 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { isSubmitting }, 28 | } = useForm(); 29 | 30 | async function onSubmit({ email }: LOGIN_FORM) { 31 | const { error } = await supabaseClient.auth.signInWithOtp({ 32 | email, 33 | options: { 34 | shouldCreateUser: true, 35 | emailRedirectTo: getFullRedirectUrl(redirectTo), 36 | }, 37 | }); 38 | if (error) throw error; 39 | setEmailSent(true); 40 | } 41 | 42 | return ( 43 | <> 44 | {emailSent ? ( 45 |
46 |

{t("magicLink.checkEmail")}

47 | 50 |
51 | ) : ( 52 |
53 |
54 | 55 | 60 | 63 |
64 | 72 |
73 | )} 74 | 75 | ); 76 | }; 77 | 78 | export default LoginMagicLink; 79 | -------------------------------------------------------------------------------- /src/components/dashboard/dashboard-layout.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { Button, Drawer } from "react-daisyui"; 3 | import SidebarMenu from "./sidebar/sidebar-menu"; 4 | import useDashboardOverview from "@/utils/api/use-dashboard-overview"; 5 | import { useRouter } from "next/router"; 6 | import AccountSubscriptionTakeover from "@/components/dashboard/accounts/account-subscription-takeover/account-subscription-takeover"; 7 | import useAccountBillingStatus from "@/utils/api/use-account-billing-status"; 8 | import { MenuIcon } from "@heroicons/react/outline"; 9 | import Logo from "@/components/basejump-default-content/logo"; 10 | 11 | const DashboardLayout = ({ children }) => { 12 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 13 | const router = useRouter(); 14 | const { accountId } = router.query; 15 | 16 | function toggleSidebar() { 17 | setIsSidebarOpen(!isSidebarOpen); 18 | } 19 | 20 | const { data, refetch: refetchDashboardOverview } = useDashboardOverview(); 21 | 22 | useEffect(() => { 23 | /** 24 | * Close sidebar when route changes 25 | */ 26 | setIsSidebarOpen(false); 27 | refetchDashboardOverview(); 28 | }, [router.asPath, refetchDashboardOverview]); 29 | const currentAccount = useMemo(() => { 30 | if (!accountId) { 31 | return data?.find((a) => a.personal_account); 32 | } 33 | return data?.find((a) => a.account_id === accountId); 34 | }, [data, accountId]); 35 | 36 | const { data: subscriptionData } = useAccountBillingStatus( 37 | currentAccount?.account_id 38 | ); 39 | 40 | return ( 41 |
42 | 48 | } 49 | mobile 50 | open={isSidebarOpen} 51 | onClickOverlay={toggleSidebar} 52 | > 53 |
54 |
55 | 56 | 64 |
65 | {children} 66 |
67 |
68 | {subscriptionData?.billing_enabled && 69 | !["active", "trialing"].includes(subscriptionData?.status) && ( 70 | 71 | )} 72 |
73 | ); 74 | }; 75 | 76 | export default DashboardLayout; 77 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/account-subscription-takeover/individual-subscription-plan.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "react-daisyui"; 2 | import { useRouter } from "next/router"; 3 | import { UseAccountBillingOptionsResponse } from "@/utils/api/use-account-billing-options"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import { useMutation } from "@tanstack/react-query"; 6 | import { UseDashboardOverviewResponse } from "@/utils/api/use-dashboard-overview"; 7 | import { toast } from "react-toastify"; 8 | 9 | type Props = { 10 | plan: UseAccountBillingOptionsResponse[0]; 11 | currentAccount: UseDashboardOverviewResponse[0]; 12 | }; 13 | const IndividualSubscriptionPlan = ({ plan, currentAccount }: Props) => { 14 | const router = useRouter(); 15 | const { t } = useTranslation("dashboard"); 16 | 17 | const setupCheckoutLink = useMutation( 18 | async () => { 19 | const res = await fetch("/api/billing/setup", { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json", 23 | }, 24 | body: JSON.stringify({ 25 | accountId: currentAccount?.account_id, 26 | priceId: plan.price_id, 27 | }), 28 | }); 29 | 30 | const jsonResponse = await res.json(); 31 | 32 | if (!res.ok) { 33 | throw new Error(jsonResponse.error); 34 | } 35 | return jsonResponse.url; 36 | }, 37 | { 38 | onSuccess(url) { 39 | console.log("whoooop", url); 40 | if (!url) return; 41 | window.location.href = url; 42 | }, 43 | onError(error: any) { 44 | toast.error(error.message); 45 | }, 46 | } 47 | ); 48 | 49 | return ( 50 |
51 |
52 |

{plan.product_name}

53 |

{plan.product_description}

54 |
55 |
56 |
57 |

58 | {new Intl.NumberFormat(router.locale, { 59 | style: "currency", 60 | currency: plan.currency, 61 | minimumFractionDigits: 0, 62 | }).format((plan.price || 0) / 100)} 63 |

64 |

/ {t(`newSubscriptions.intervals.${plan.interval}`)}

65 |
66 | 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default IndividualSubscriptionPlan; 80 | -------------------------------------------------------------------------------- /src/pages/api/billing/status.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { createOrRetrieveSubscription } from "@/utils/admin/stripe-billing-helpers"; 3 | import { withApiAuth } from "@supabase/auth-helpers-nextjs"; 4 | import { MANUAL_SUBSCRIPTION_REQUIRED } from "@/types/billing"; 5 | import { supabaseAdmin } from "@/utils/admin/supabase-admin-client"; 6 | 7 | const BillingStatus = async ( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | supabaseServerClient 11 | ) => { 12 | const { accountId } = req.query; 13 | 14 | if (!accountId) { 15 | return res.status(400).json({ error: "Missing account ID" }); 16 | } 17 | 18 | const { 19 | data: { user }, 20 | } = await supabaseServerClient.auth.getUser(); 21 | 22 | const { data } = await supabaseServerClient 23 | .rpc("current_user_account_role", { 24 | lookup_account_id: accountId, 25 | }) 26 | .single(); 27 | 28 | const { data: config } = await supabaseAdmin 29 | .rpc("get_service_role_config") 30 | .single(); 31 | 32 | if (!data) { 33 | return res.status(404).json({ error: "Account not found" }); 34 | } 35 | 36 | // @ts-ignore 37 | if (config.enable_account_billing === false) { 38 | // If billing is disabled, return the account as active 39 | return res.status(200).json({ 40 | subscription_id: null, 41 | subscription_active: true, 42 | status: "active", 43 | is_primary_owner: false, 44 | billing_email: null, 45 | plan_name: null, 46 | account_role: data.account_role, 47 | billing_enabled: false, 48 | }); 49 | } 50 | 51 | try { 52 | const subscriptionData = await createOrRetrieveSubscription({ 53 | accountId: accountId as string, 54 | email: user.email, 55 | }); 56 | return res.status(200).json({ 57 | subscription_id: subscriptionData.id, 58 | subscription_active: ["trialing", "active"].includes( 59 | subscriptionData.status 60 | ), 61 | plan_name: subscriptionData.plan_name, 62 | billing_email: subscriptionData.billing_email, 63 | status: subscriptionData.status, 64 | account_role: data?.account_role, 65 | is_primary_owner: data?.is_primary_owner, 66 | billing_enabled: true, 67 | }); 68 | } catch (error) { 69 | if (error.message === MANUAL_SUBSCRIPTION_REQUIRED) { 70 | return res.status(200).json({ 71 | subscription_id: null, 72 | subscription_active: false, 73 | plan_name: null, 74 | billing_email: null, 75 | status: MANUAL_SUBSCRIPTION_REQUIRED, 76 | account_role: data?.account_role, 77 | is_primary_owner: data?.is_primary_owner, 78 | billing_enabled: true, 79 | }); 80 | } 81 | return res.status(500).json({ error: error.message }); 82 | } 83 | }; 84 | 85 | export default withApiAuth(BillingStatus); 86 | -------------------------------------------------------------------------------- /src/components/dashboard/profile/list-teams.tsx: -------------------------------------------------------------------------------- 1 | import useTeamAccounts from "@/utils/api/use-team-accounts"; 2 | import SettingsCard from "@/components/dashboard/shared/settings-card"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Loader from "@/components/core/loader"; 5 | import { DASHBOARD_PATH } from "@/types/auth"; 6 | import { useRouter } from "next/router"; 7 | import { useToggle } from "react-use"; 8 | import NewAccountModal from "@/components/dashboard/accounts/new-account-modal"; 9 | import { Button } from "react-daisyui"; 10 | import Link from "next/link"; 11 | 12 | const ListTeams = () => { 13 | const [newAccount, toggleNewAccount] = useToggle(false); 14 | const { t } = useTranslation("dashboard"); 15 | const router = useRouter(); 16 | const { data, isLoading, refetch } = useTeamAccounts(); 17 | 18 | async function onAccountCreated(accountId: string) { 19 | await refetch(); 20 | toggleNewAccount(false); 21 | await router.push(`${DASHBOARD_PATH}?accountId=${accountId}`); 22 | } 23 | 24 | return ( 25 | <> 26 | 30 | {isLoading ? ( 31 | 32 | ) : ( 33 |
34 | {data?.map((account) => ( 35 |
36 |
37 |

{account.team_name}

38 |

{account.account_role}

39 |
40 |
41 | 46 | {t("listTeams.viewTeam")} 47 | 48 | 53 | {t("listTeams.manageTeam")} 54 | 55 |
56 |
57 | ))} 58 |
59 | )} 60 | 61 | 64 | 65 |
66 | 71 | 72 | ); 73 | }; 74 | 75 | export default ListTeams; 76 | -------------------------------------------------------------------------------- /supabase/tests/database/9-profiles.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(6); 5 | -- make sure we're setup for enabling personal accounts 6 | update basejump.config 7 | set enable_team_accounts = true; 8 | 9 | -- setup the users we need for testing 10 | select tests.create_supabase_user('test1', 'test@test.com'); 11 | select tests.create_supabase_user('test2', 'test2@test.com'); 12 | 13 | 14 | --- start acting as an authenticated user 15 | select tests.authenticate_as('test1'); 16 | 17 | -- user should have access to their own profile 18 | select is( 19 | (select name from profiles where id = tests.get_supabase_uid('test1')), 20 | 'test', 21 | 'User should have access to their own profile, profile should auto-set name to first half of email' 22 | ); 23 | 24 | -- user should not have access to other profiles 25 | select is_empty( 26 | $$ select * from profiles where id <> tests.get_supabase_uid('test1') $$, 27 | 'User should not have access to any other profiles' 28 | ); 29 | 30 | -- Users should be able to update their own names 31 | select row_eq( 32 | $$ update profiles set name = 'test update' where id = tests.get_supabase_uid('test1') returning name $$, 33 | ROW ('test update'::text), 34 | 'User should be able to update their own name' 35 | ); 36 | 37 | -- User should not be able to update other users names 38 | select results_ne( 39 | $$ update profiles set name = 'test update' where id = tests.get_supabase_uid('test2') returning 1 $$, 40 | $$ values(1) $$, 41 | 'Should not be able to update profile' 42 | ); 43 | 44 | -- Create a new account so you can start sharing profiles 45 | insert into accounts (id, team_name, personal_account) 46 | values ('eb3a0306-7331-4c42-a580-970e7ba6a11d', 'test team', false); 47 | 48 | -- set role to postgres, and then insert an account_user for the second user 49 | select tests.clear_authentication(); 50 | set local role postgres; 51 | insert into account_user (account_id, user_id, account_role) 52 | values ('eb3a0306-7331-4c42-a580-970e7ba6a11d', tests.get_supabase_uid('test2'), 'owner'); 53 | 54 | -- back to authenticated user 55 | select tests.authenticate_as('test1'); 56 | 57 | -- User should now have access to the second profile 58 | select row_eq( 59 | $$ select name from profiles where id = tests.get_supabase_uid('test2') $$, 60 | ROW ('test2'::text), 61 | 'User should have access to teammates profiles' 62 | ); 63 | 64 | -- still can't update teammates profiles 65 | select results_ne( 66 | $$ update profiles set name = 'test update' where id = tests.get_supabase_uid('test2') returning 1 $$, 67 | $$ values(1) $$, 68 | 'Should not be able to update profile' 69 | ); 70 | SELECT * 71 | FROM finish(); 72 | 73 | ROLLBACK; -------------------------------------------------------------------------------- /src/components/core/loader.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, SVGProps } from "react"; 2 | 3 | export default function Loader( 4 | props: JSX.IntrinsicAttributes & SVGProps 5 | ): ReactElement { 6 | const speed = Number(String(props.speed ?? 0.7)); 7 | const fill = props.fill ?? "#000"; 8 | const stroke = props.stroke ?? "transparent"; 9 | const fillOpacity = props.fillOpacity; 10 | const strokeOpacity = props.strokeOpacity; 11 | return ( 12 | 29 | 30 | 36 | 44 | 45 | 52 | 60 | 61 | 68 | 76 | 77 | 84 | 92 | 93 | 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/individual-team-member.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Button, Dropdown, Modal } from "react-daisyui"; 2 | import { DotsHorizontalIcon } from "@heroicons/react/outline"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Portal from "@/components/core/portal"; 5 | import UpdateTeamMemberRole from "@/components/dashboard/accounts/settings/update-team-member-role"; 6 | import { useToggle } from "react-use"; 7 | import RemoveTeamMember from "@/components/dashboard/accounts/settings/remove-team-member"; 8 | 9 | const IndividualTeamMember = ({ member }) => { 10 | const [updateRole, toggleUpdateRole] = useToggle(false); 11 | const [removeMember, toggleRemoveMember] = useToggle(false); 12 | const { t } = useTranslation("dashboard"); 13 | 14 | return ( 15 | <> 16 |
17 |
18 |

{member.name}

19 |
20 | {member.is_primary_owner ? ( 21 | {t("listTeamMembers.primaryOwner")} 22 | ) : ( 23 | {member.account_role} 24 | )} 25 |
26 |
27 | {!member.is_primary_owner && ( 28 |
29 | 30 | 33 | 34 | toggleUpdateRole(true)}> 35 | {t("listTeamMembers.updateRole")} 36 | 37 | toggleRemoveMember(true)}> 38 | {t("listTeamMembers.removeMember")} 39 | 40 | 41 | 42 |
43 | )} 44 |
45 | 46 | 47 | {updateRole && ( 48 | 49 | 50 | {t("listTeamMembers.updateRoleTitle", { name: member.name })} 51 | 52 | toggleUpdateRole(false)} 55 | /> 56 | 57 | )} 58 | {removeMember && ( 59 | 60 | 61 | {t("listTeamMembers.removeMemberTitle", { name: member.name })} 62 | 63 | 64 | 65 | )} 66 | 67 | 68 | ); 69 | }; 70 | 71 | export default IndividualTeamMember; 72 | -------------------------------------------------------------------------------- /supabase/tests/database/1-basejump-schema-tests.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(22); 5 | 6 | select has_schema('basejump', 'Basejump schema should exist'); 7 | 8 | select has_table('basejump', 'config', 'Basejump config table should exist'); 9 | 10 | select tests.rls_enabled('public'); 11 | 12 | select columns_are('basejump', 'config', 13 | Array ['enable_personal_accounts', 'enable_team_accounts', 'enable_account_billing', 'billing_provider', 'stripe_default_trial_period_days', 'stripe_default_account_price_id'], 14 | 'Basejump config table should have the correct columns'); 15 | 16 | select ok(basejump.is_set('enable_personal_accounts')), 'Basejump config should have personal accounts enabled'; 17 | select ok(basejump.is_set('enable_team_accounts')), 'Basejump config should have team accounts enabled'; 18 | select ok((basejump.get_config() ->> 'enable_account_billing')::boolean = false, 19 | 'Basejump config should have account billing disabled'); 20 | select ok(basejump.get_config() ->> 'billing_provider' = 'stripe', 21 | 'Basejump config should have stripe as the billing provider'); 22 | select ok((basejump.get_config() ->> 'stripe_default_trial_period_days')::int = 30), 23 | 'Basejump config should have a default trial period'; 24 | 25 | 26 | select function_returns('basejump', 'generate_token', Array ['integer'], 'bytea', 27 | 'Basejump generate_token function should exist'); 28 | select function_returns('basejump', 'trigger_set_timestamps', 'trigger', 29 | 'Basejump trigger_set_timestamps function should exist'); 30 | 31 | SELECT schema_privs_are('basejump', 'anon', Array [NULL], 'Anon should not have access to basejump schema'); 32 | 33 | -- set the role to anonymous for verifying access tests 34 | set role anon; 35 | select throws_ok('select basejump.get_config()'); 36 | select throws_ok('select basejump.is_set(''enable_personal_accounts'')'); 37 | select throws_ok('select basejump.generate_token(1)'); 38 | select throws_ok('select public.get_service_role_config()'); 39 | 40 | -- set the role to the service_role for testing access 41 | set role service_role; 42 | select ok(public.get_service_role_config() is not null), 43 | 'Basejump get_service_role_config should be accessible to the service role'; 44 | 45 | -- set the role to authenticated for tests 46 | set role authenticated; 47 | select ok(basejump.get_config() is not null), 'Basejump get_config should be accessible to authenticated users'; 48 | select ok(basejump.is_set('enable_personal_accounts')), 49 | 'Basejump is_set should be accessible to authenticated users'; 50 | select ok(basejump.generate_token(1) is not null), 51 | 'Basejump generate_token should be accessible to authenticated users'; 52 | select throws_ok('select public.get_service_role_config()'); 53 | select isnt_empty('select * from basejump.config', 'authenticated users should have access to Basejump config'); 54 | 55 | SELECT * 56 | FROM finish(); 57 | 58 | ROLLBACK; -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/individual-team-invitation.tsx: -------------------------------------------------------------------------------- 1 | import formatDistance from "date-fns/formatDistance"; 2 | import { Badge, Button } from "react-daisyui"; 3 | import { ClipboardCopyIcon, TrashIcon } from "@heroicons/react/outline"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import { useCopyToClipboard } from "react-use"; 6 | import getInvitationUrl from "@/utils/get-invitation-url"; 7 | import { useMutation } from "@tanstack/react-query"; 8 | import { toast } from "react-toastify"; 9 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 10 | import { Database } from "@/types/supabase-types"; 11 | 12 | type Props = { 13 | invitation: Database["public"]["Tables"]["invitations"]["Row"]; 14 | onChange?: () => void; 15 | }; 16 | const IndividualTeamInvitation = ({ invitation, onChange }: Props) => { 17 | const { t } = useTranslation("dashboard"); 18 | const supabaseClient = useSupabaseClient(); 19 | const [state, copyToClipboard] = useCopyToClipboard(); 20 | 21 | const deleteInvitation = useMutation( 22 | async (invitationId: string) => { 23 | const { data, error } = await supabaseClient 24 | .from("invitations") 25 | .delete() 26 | .match({ id: invitationId }); 27 | if (error) { 28 | toast.error(error.message); 29 | } 30 | 31 | return { data, error }; 32 | }, 33 | { 34 | onSuccess() { 35 | onChange?.(); 36 | }, 37 | } 38 | ); 39 | 40 | return ( 41 |
45 |
46 |

47 | {t("listTeamInvitations.linkDescription", { 48 | date: formatDistance(new Date(invitation.created_at), new Date(), { 49 | addSuffix: true, 50 | }), 51 | })} 52 |

53 |
54 | 59 | {invitation.invitation_type} 60 | 61 | {invitation.account_role} 62 |
63 |
64 |
65 | 73 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default IndividualTeamInvitation; 89 | -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/update-team-member-role.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { Button, Checkbox } from "react-daisyui"; 3 | import useTranslation from "next-translate/useTranslation"; 4 | import Select from "@/components/core/select"; 5 | import { userTypeOptions } from "@/components/dashboard/accounts/settings/invite-member"; 6 | import useTeamRole from "@/utils/api/use-team-role"; 7 | import { UseTeamMembersResponse } from "@/utils/api/use-team-members"; 8 | import { toast } from "react-toastify"; 9 | import { useQueryClient } from "@tanstack/react-query"; 10 | import { useSupabaseClient } from "@supabase/auth-helpers-react"; 11 | import { Database } from "@/types/supabase-types"; 12 | 13 | type USER_ROLE_FORM = { 14 | account_role: Database["public"]["Tables"]["account_user"]["Row"]["account_role"]; 15 | make_primary_owner?: boolean; 16 | }; 17 | 18 | type Props = { 19 | member: UseTeamMembersResponse; 20 | onComplete?: () => void; 21 | }; 22 | 23 | const UpdateTeamMemberRole = ({ member, onComplete }: Props) => { 24 | const { t } = useTranslation("dashboard"); 25 | const supabaseClient = useSupabaseClient(); 26 | const { isPrimaryOwner } = useTeamRole(member.account_id); 27 | const queryClient = useQueryClient(); 28 | const { 29 | register, 30 | handleSubmit, 31 | watch, 32 | formState: { isSubmitting }, 33 | } = useForm({ 34 | defaultValues: { 35 | account_role: member.account_role, 36 | make_primary_owner: false, 37 | }, 38 | }); 39 | 40 | async function onSubmit(formData) { 41 | const { error } = await supabaseClient.rpc("update_account_user_role", { 42 | account_id: member.account_id, 43 | user_id: member.user_id, 44 | new_account_role: formData.account_role, 45 | make_primary_owner: formData.make_primary_owner, 46 | }); 47 | if (error) { 48 | toast.error(error.message); 49 | } 50 | await queryClient.invalidateQueries(["teamMembers"]); 51 | if (onComplete) { 52 | onComplete(); 53 | } 54 | } 55 | 56 | const isOwner = watch("account_role") === "owner"; 57 | 58 | return ( 59 |
60 | 70 | {isOwner && isPrimaryOwner && ( 71 |
72 | 76 |
77 | )} 78 | 86 |
87 | ); 88 | }; 89 | 90 | export default UpdateTeamMemberRole; 91 | -------------------------------------------------------------------------------- /supabase/tests/database/7-inviting-team-member.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(8); 5 | 6 | -- make sure we're setup for enabling personal accounts 7 | update basejump.config 8 | set enable_team_accounts = true; 9 | 10 | --- Create the users we need for testing 11 | select tests.create_supabase_user('test1'); 12 | select tests.create_supabase_user('invited'); 13 | 14 | --- start acting as an authenticated user 15 | select tests.authenticate_as('test1'); 16 | 17 | insert into accounts (id, team_name, personal_account) 18 | values ('d126ecef-35f6-4b5d-9f28-d9f00a9fb46f', 'test', false); 19 | 20 | -- create invitation 21 | SELECT row_eq( 22 | $$ insert into invitations (account_id, account_role, token, invitation_type) values ('d126ecef-35f6-4b5d-9f28-d9f00a9fb46f', 'member', 'test_member_single_use_token', 'one-time') returning 1 $$, 23 | ROW (1), 24 | 'Owners should be able to add invitations for new members' 25 | ); 26 | 27 | -- auth as new user 28 | select tests.authenticate_as('invited'); 29 | 30 | -- should NOT be able to lookup invitations directly 31 | SELECT is( 32 | (select count(*)::int from invitations), 33 | 0, 34 | 'Cannot load invitations directly' 35 | ); 36 | 37 | -- should be able to lookup an invitation I know the token for 38 | SELECT row_eq( 39 | $$ select lookup_invitation('test_member_single_use_token')::text $$, 40 | ROW (json_build_object( 41 | 'active', true, 42 | 'team_name', 'test')::text 43 | ), 44 | 'Should be able to lookup an invitation I know the token for' 45 | ); 46 | 47 | -- should not be able to lookup a fake token 48 | SELECT row_eq( 49 | $$ select lookup_invitation('not-real-token')::text $$, 50 | ROW (json_build_object( 51 | 'active', false, 52 | 'team_name', null)::text 53 | ), 54 | 'Fake tokens should fail lookup gracefully' 55 | ); 56 | 57 | -- should not be able to accept a fake invitation 58 | SELECT throws_ok( 59 | $$ select accept_invitation('not-a-real-token') $$, 60 | 'Invitation not found' 61 | ); 62 | 63 | -- should be able to accept an invitation 64 | SELECT lives_ok( 65 | $$ select accept_invitation('test_member_single_use_token') $$, 66 | 'Should be able to accept an invitation' 67 | ); 68 | 69 | -- should be able to get the team from get_accounts_for_current_user 70 | SELECT ok( 71 | (select 'd126ecef-35f6-4b5d-9f28-d9f00a9fb46f' IN (select basejump.get_accounts_for_current_user())), 72 | 'Should now be a part of the team' 73 | ); 74 | 75 | -- should have the correct role on the team 76 | SELECT row_eq( 77 | $$ select account_role from account_user where account_id = 'd126ecef-35f6-4b5d-9f28-d9f00a9fb46f'::uuid and user_id = tests.get_supabase_uid('invited') $$, 78 | ROW ('member'::account_role), 79 | 'Should have the correct account role after accepting an invitation' 80 | ); 81 | 82 | SELECT * 83 | FROM finish(); 84 | 85 | ROLLBACK; -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working 2 | # directory name when running `supabase init`. 3 | project_id = "basejump" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = ["public", "storage", "graphql_public"] 11 | # Extra schemas to add to the search_path of every request. public is always included. 12 | extra_search_path = ["public", "extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 21 | # server_version;` on the remote database to check. 22 | major_version = 15 23 | 24 | [studio] 25 | # Port to use for Supabase Studio. 26 | port = 54323 27 | 28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 29 | # are monitored, and you can view the emails that would have been sent from the web interface. 30 | [inbucket] 31 | # Port to use for the email testing server web interface. 32 | port = 54324 33 | smtp_port = 54325 34 | pop3_port = 54326 35 | 36 | [storage] 37 | # The maximum file size allowed (e.g. "5MB", "500KB"). 38 | file_size_limit = "50MiB" 39 | 40 | [auth] 41 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 42 | # in emails. 43 | site_url = "http://localhost:3000" 44 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 45 | additional_redirect_urls = ["https://localhost:3000"] 46 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one 47 | # week). 48 | jwt_expiry = 3600 49 | # Allow/disallow new user signups to your project. 50 | enable_signup = true 51 | 52 | [auth.email] 53 | # Allow/disallow new user signups via email to your project. 54 | enable_signup = true 55 | # If enabled, a user will be required to confirm any email change on both the old, and new email 56 | # addresses. If disabled, only the new email is required to confirm. 57 | double_confirm_changes = false 58 | # If enabled, users need to confirm their email address before signing in. 59 | enable_confirmations = false 60 | 61 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 62 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, 63 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 64 | [auth.external.apple] 65 | enabled = false 66 | client_id = "" 67 | secret = "" 68 | # Overrides the default auth redirectUrl. 69 | redirect_uri = "" 70 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 71 | # or any other third-party OIDC providers. 72 | url = "" 73 | 74 | [analytics] 75 | enabled = false 76 | port = 54327 77 | vector_port = 54328 78 | # Setup BigQuery project to enable log viewer on local development stack. 79 | # See: https://logflare.app/guides/bigquery-setup 80 | gcp_project_id = "" 81 | gcp_project_number = "" 82 | gcp_jwt_path = "supabase/gcloud.json" 83 | -------------------------------------------------------------------------------- /src/components/dashboard/sidebar/team-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import useTranslation from "next-translate/useTranslation"; 2 | import { Button, Dropdown, Menu } from "react-daisyui"; 3 | import { PlusIcon } from "@heroicons/react/solid"; 4 | import { ChevronDownIcon } from "@heroicons/react/outline"; 5 | import { DASHBOARD_PATH } from "@/types/auth"; 6 | import { useToggle } from "react-use"; 7 | import { useRouter } from "next/router"; 8 | import Link from "next/link"; 9 | import NewAccountModal from "@/components/dashboard/accounts/new-account-modal"; 10 | import useDashboardOverview, { 11 | UseDashboardOverviewResponse, 12 | } from "@/utils/api/use-dashboard-overview"; 13 | import { useMemo } from "react"; 14 | 15 | type Props = { 16 | currentAccount: UseDashboardOverviewResponse[0]; 17 | }; 18 | 19 | const TeamSelectMenu = ({ currentAccount }: Props) => { 20 | const { t } = useTranslation("dashboard"); 21 | const router = useRouter(); 22 | const [newAccount, toggleNewAccount] = useToggle(false); 23 | 24 | const { data, refetch } = useDashboardOverview(); 25 | 26 | const personalAccount = useMemo( 27 | () => data?.find((a) => a.personal_account), 28 | [data] 29 | ); 30 | 31 | const teamAccounts = useMemo( 32 | () => data?.filter((a) => a.team_account), 33 | [data] 34 | ); 35 | 36 | async function onAccountCreated(accountId: string) { 37 | await refetch(); 38 | toggleNewAccount(false); 39 | await router.push(`/dashboard/teams/${accountId}`); 40 | } 41 | 42 | return ( 43 | <> 44 | 45 | 57 | 58 | {!!personalAccount && ( 59 | <> 60 | 61 |

{t("teamSelectMenu.personalAccount")}

62 |
63 | 64 | {t("teamSelectMenu.myAccount")} 65 | 66 | 67 | )} 68 | 69 |

{t("teamSelectMenu.teams")}

70 |
71 | {teamAccounts?.map((account) => ( 72 | 78 | {account.team_name} 79 | 80 | ))} 81 | 82 |
83 | 84 |

{t("teamSelectMenu.newAccount")}

85 |
86 |
87 |
88 |
89 | 94 | 95 | ); 96 | }; 97 | 98 | export default TeamSelectMenu; 99 | -------------------------------------------------------------------------------- /supabase/tests/database/8-inviting-team-owner.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | CREATE EXTENSION "basejump-supabase_test_helpers"; 3 | 4 | select plan(9); 5 | 6 | -- make sure we're setup for enabling personal accounts 7 | update basejump.config 8 | set enable_team_accounts = true; 9 | 10 | -- create the users we need for testing 11 | select tests.create_supabase_user('test1'); 12 | select tests.create_supabase_user('invited'); 13 | 14 | --- start acting as an authenticated user 15 | select tests.authenticate_as('test1'); 16 | 17 | insert into accounts (id, team_name, personal_account) 18 | values ('d126ecef-35f6-4b5d-9f28-d9f00a9fb46f', 'test', false); 19 | 20 | -- create invitation 21 | SELECT row_eq( 22 | $$ insert into invitations (account_id, account_role, token, invitation_type) values ('d126ecef-35f6-4b5d-9f28-d9f00a9fb46f', 'owner', 'test_owner_single_use_token', 'one-time') returning 1 $$, 23 | ROW (1), 24 | 'Owners should be able to add invitations for new owners' 25 | ); 26 | 27 | -- auth as new user 28 | select tests.authenticate_as('invited'); 29 | 30 | -- should NOT be able to lookup invitations directly 31 | SELECT is( 32 | (select count(*)::int from invitations), 33 | 0, 34 | 'Cannot load invitations directly' 35 | ); 36 | 37 | -- should be able to lookup an invitation I know the token for 38 | SELECT row_eq( 39 | $$ select lookup_invitation('test_owner_single_use_token')::text $$, 40 | ROW (json_build_object( 41 | 'active', true, 42 | 'team_name', 'test')::text 43 | ), 44 | 'Should be able to lookup an invitation I know the token for' 45 | ); 46 | 47 | -- should not be able to lookup a fake token 48 | SELECT row_eq( 49 | $$ select lookup_invitation('not-real-token')::text $$, 50 | ROW (json_build_object( 51 | 'active', false, 52 | 'team_name', null)::text 53 | ), 54 | 'Fake tokens should fail lookup gracefully' 55 | ); 56 | 57 | -- should not be able to accept a fake invitation 58 | SELECT throws_ok( 59 | $$ select accept_invitation('not-a-real-token') $$, 60 | 'Invitation not found' 61 | ); 62 | 63 | -- should be able to accept an invitation 64 | SELECT lives_ok( 65 | $$ select accept_invitation('test_owner_single_use_token') $$, 66 | 'Should be able to accept an invitation' 67 | ); 68 | 69 | -- should be able to get the team from get_accounts_for_current_user 70 | SELECT ok( 71 | (select 'd126ecef-35f6-4b5d-9f28-d9f00a9fb46f' IN (select basejump.get_accounts_for_current_user())), 72 | 'Should now be a part of the team' 73 | ); 74 | 75 | -- should be able to get the team from get_accounts_for_current_user 76 | SELECT ok( 77 | (select 'd126ecef-35f6-4b5d-9f28-d9f00a9fb46f' IN 78 | (select basejump.get_accounts_for_current_user('owner'))), 79 | 'Should now be a part of the team as an owner' 80 | ); 81 | 82 | -- should have the correct role on the team 83 | SELECT row_eq( 84 | $$ select account_role from account_user where account_id = 'd126ecef-35f6-4b5d-9f28-d9f00a9fb46f'::uuid and user_id = tests.get_supabase_uid('invited') $$, 85 | ROW ('owner'::account_role), 86 | 'Should have the correct account role after accepting an invitation' 87 | ); 88 | 89 | SELECT * 90 | FROM finish(); 91 | 92 | ROLLBACK; -------------------------------------------------------------------------------- /src/pages/api/billing/stripe-webhooks.ts: -------------------------------------------------------------------------------- 1 | import { stripe } from "@/utils/admin/stripe"; 2 | import { 3 | manageSubscriptionStatusChange, 4 | upsertCustomerRecord, 5 | upsertPriceRecord, 6 | upsertProductRecord, 7 | } from "@/utils/admin/stripe-billing-helpers"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | import Stripe from "stripe"; 10 | import { Readable } from "node:stream"; 11 | 12 | // Stripe requires the raw body to construct the event. 13 | export const config = { 14 | api: { 15 | bodyParser: false, 16 | }, 17 | }; 18 | 19 | async function buffer(readable: Readable) { 20 | const chunks = []; 21 | for await (const chunk of readable) { 22 | chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); 23 | } 24 | return Buffer.concat(chunks); 25 | } 26 | 27 | const relevantEvents = new Set([ 28 | "product.created", 29 | "product.updated", 30 | "price.created", 31 | "price.updated", 32 | "checkout.session.completed", 33 | "customer.subscription.created", 34 | "customer.subscription.updated", 35 | "customer.subscription.deleted", 36 | "customer.created", 37 | "customer.updated", 38 | "customer.deleted", 39 | ]); 40 | 41 | const stripeWebhookHandler = async ( 42 | req: NextApiRequest, 43 | res: NextApiResponse 44 | ) => { 45 | if (req.method !== "POST") { 46 | res.setHeader("Allow", "POST"); 47 | res.status(405).end("Method not allowed"); 48 | return; 49 | } 50 | 51 | const buf = await buffer(req); 52 | const sig = req.headers["stripe-signature"]; 53 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 54 | let event: Stripe.Event; 55 | 56 | try { 57 | if (!sig || !webhookSecret) return; 58 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); 59 | } catch (err: any) { 60 | console.log(`❌ Error message: ${err.message}`); 61 | return res.status(400).send(`Webhook Error: ${err.message}`); 62 | } 63 | 64 | if (relevantEvents.has(event.type)) { 65 | try { 66 | switch (event.type) { 67 | case "customer.created": 68 | case "customer.updated": 69 | case "customer.deleted": 70 | await upsertCustomerRecord(event.data.object as Stripe.Customer); 71 | break; 72 | case "product.created": 73 | case "product.updated": 74 | await upsertProductRecord(event.data.object as Stripe.Product); 75 | break; 76 | case "price.created": 77 | case "price.updated": 78 | await upsertPriceRecord(event.data.object as Stripe.Price); 79 | break; 80 | case "customer.subscription.created": 81 | case "customer.subscription.updated": 82 | case "customer.subscription.deleted": 83 | const subscription = event.data.object as Stripe.Subscription; 84 | await manageSubscriptionStatusChange( 85 | subscription.id, 86 | subscription.customer as string 87 | ); 88 | break; 89 | case "checkout.session.completed": 90 | const checkoutSession = event.data.object as Stripe.Checkout.Session; 91 | if (checkoutSession.mode === "subscription") { 92 | const subscriptionId = checkoutSession.subscription; 93 | await manageSubscriptionStatusChange( 94 | subscriptionId as string, 95 | checkoutSession.customer as string 96 | ); 97 | } 98 | break; 99 | default: 100 | throw new Error("Unhandled relevant event!"); 101 | } 102 | } catch (error) { 103 | console.log(error); 104 | return res 105 | .status(400) 106 | .send('Webhook error: "Webhook handler failed. View logs."'); 107 | } 108 | } 109 | 110 | res.json({ received: true }); 111 | }; 112 | 113 | export default stripeWebhookHandler; 114 | -------------------------------------------------------------------------------- /supabase/migrations/00000000000001_utility_functions.sql: -------------------------------------------------------------------------------- 1 | /** 2 | * We want to enable pgtap for testing 3 | */ 4 | create extension if not exists pgtap with schema extensions; 5 | select dbdev.install('basejump-supabase_test_helpers'); 6 | 7 | /** 8 | * By default we want to revoke execute from public 9 | */ 10 | ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; 11 | ALTER DEFAULT PRIVILEGES IN SCHEMA PUBLIC REVOKE EXECUTE ON FUNCTIONS FROM anon, authenticated; 12 | 13 | /** 14 | Create a schema for us to use for private functions 15 | */ 16 | CREATE SCHEMA IF NOT EXISTS basejump; 17 | GRANT USAGE ON SCHEMA basejump to authenticated; 18 | GRANT USAGE ON SCHEMA basejump to service_role; 19 | 20 | CREATE TABLE IF NOT EXISTS basejump.config 21 | ( 22 | enable_personal_accounts boolean default true, 23 | enable_team_accounts boolean default true 24 | ); 25 | 26 | -- enable select on the config table 27 | GRANT SELECT ON basejump.config TO authenticated, service_role; 28 | 29 | -- enable RLS on config 30 | ALTER TABLE basejump.config 31 | ENABLE ROW LEVEL SECURITY; 32 | 33 | create policy "Basejump settings can be read by authenticated users" on basejump.config 34 | for select 35 | to authenticated 36 | using ( 37 | true 38 | ); 39 | 40 | /** 41 | Get the full config object to check basejump settings 42 | This is not accessible fromt he outside, so can only be used inside postgres functions 43 | */ 44 | CREATE OR REPLACE FUNCTION basejump.get_config() 45 | RETURNS json AS 46 | $$ 47 | DECLARE 48 | result RECORD; 49 | BEGIN 50 | SELECT * from basejump.config limit 1 into result; 51 | return row_to_json(result); 52 | END; 53 | $$ LANGUAGE plpgsql; 54 | 55 | grant execute on function basejump.get_config() to authenticated; 56 | 57 | /** 58 | Sometimes it's useful for supabase admin clients to access settings 59 | but we dont' want to expose this to anyone else, so it's not granted to anyone but 60 | the service key 61 | */ 62 | CREATE OR REPLACE FUNCTION public.get_service_role_config() 63 | RETURNS json AS 64 | $$ 65 | DECLARE 66 | result RECORD; 67 | BEGIN 68 | SELECT * from basejump.config limit 1 into result; 69 | return row_to_json(result); 70 | END; 71 | $$ LANGUAGE plpgsql; 72 | 73 | /** 74 | Check a specific boolean config value 75 | */ 76 | CREATE OR REPLACE FUNCTION basejump.is_set(field_name text) 77 | RETURNS boolean AS 78 | $$ 79 | DECLARE 80 | result BOOLEAN; 81 | BEGIN 82 | execute format('select %I from basejump.config limit 1', field_name) into result; 83 | return result; 84 | END; 85 | $$ LANGUAGE plpgsql; 86 | 87 | grant execute on function basejump.is_set(text) to authenticated; 88 | 89 | 90 | /** 91 | * Automatic handling for maintaining created_at and updated_at timestamps 92 | * on tables 93 | */ 94 | CREATE OR REPLACE FUNCTION basejump.trigger_set_timestamps() 95 | RETURNS TRIGGER AS 96 | $$ 97 | BEGIN 98 | if TG_OP = 'INSERT' then 99 | NEW.created_at = now(); 100 | NEW.updated_at = now(); 101 | else 102 | NEW.updated_at = now(); 103 | NEW.created_at = OLD.created_at; 104 | end if; 105 | RETURN NEW; 106 | END 107 | $$ LANGUAGE plpgsql; 108 | 109 | /** 110 | Generates a secure token - used internally for invitation tokens 111 | but could be used elsewhere. Check out the invitations table for more info on 112 | how it's used 113 | */ 114 | CREATE OR REPLACE FUNCTION basejump.generate_token(length int) 115 | RETURNS bytea AS 116 | $$ 117 | BEGIN 118 | return replace(replace(replace(encode(gen_random_bytes(length)::bytea, 'base64'), '/', '-'), '+', '_'), '\', '-'); 119 | END 120 | $$ LANGUAGE plpgsql; 121 | 122 | grant execute on function basejump.generate_token(int) to authenticated; 123 | 124 | -- TODO: is this needed? 125 | -- CREATE OR REPLACE FUNCTION trigger_id_protection() 126 | -- RETURNS TRIGGER AS 127 | -- $$ 128 | -- BEGIN 129 | -- NEW.id = OLD.id; 130 | -- RETURN NEW; 131 | -- END 132 | -- $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- /supabase/migrations/00000000000010_profiles.sql: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a profile table is a recommended convention for Supabase 3 | * Any data related to the user can be added here instead of directly 4 | * on your auth.user table. The email is added here only for information purposes 5 | * it's needed to let account members know who's an active member 6 | * You cannot edit the email directly in the profile, you must change 7 | * the email of the user using the provided Supabase methods 8 | */ 9 | create table public.profiles 10 | ( 11 | -- the user's ID from the auth.users table out of supabase 12 | id uuid unique references auth.users not null, 13 | -- the user's name 14 | name text, 15 | -- when the profile was created 16 | updated_at timestamp with time zone, 17 | -- when the profile was last updated 18 | created_at timestamp with time zone, 19 | primary key (id) 20 | ); 21 | 22 | -- Create the relationship with auth.users so we can do a join query 23 | -- using postgREST 24 | ALTER TABLE public.account_user 25 | ADD CONSTRAINT account_user_profiles_fkey FOREIGN KEY (user_id) 26 | REFERENCES profiles (id) MATCH SIMPLE 27 | ON UPDATE NO ACTION 28 | ON DELETE NO ACTION; 29 | 30 | -- manage timestamps 31 | CREATE TRIGGER set_profiles_timestamp 32 | BEFORE INSERT OR UPDATE 33 | ON public.profiles 34 | FOR EACH ROW 35 | EXECUTE FUNCTION basejump.trigger_set_timestamps(); 36 | 37 | 38 | alter table public.profiles 39 | enable row level security; 40 | 41 | -- permissions for viewing profiles for user and team members (ideally as two separate policies) 42 | -- add permissions for updating profiles for the user only 43 | create policy "Users can view their own profiles" on profiles 44 | for select 45 | to authenticated 46 | using ( 47 | id = auth.uid() 48 | ); 49 | 50 | create policy "Users can view their teammates profiles" on profiles 51 | for select 52 | to authenticated 53 | using ( 54 | id IN (SELECT account_user.user_id 55 | FROM account_user 56 | WHERE (account_user.user_id <> auth.uid())) 57 | ); 58 | 59 | 60 | create policy "Profiles are editable by their own user only" on profiles 61 | for update 62 | to authenticated 63 | using ( 64 | id = auth.uid() 65 | ); 66 | 67 | /** 68 | * We maintain a profile table with users information. 69 | * We also want to provide an option to automatically create the first account 70 | * for a new user. This is a good way to get folks through the onboarding flow easier 71 | * potentially 72 | */ 73 | create function basejump.run_new_user_setup() 74 | returns trigger 75 | language plpgsql 76 | security definer 77 | set search_path = public 78 | as 79 | $$ 80 | declare 81 | first_account_name text; 82 | first_account_id uuid; 83 | generated_user_name text; 84 | begin 85 | 86 | -- first we setup the user profile 87 | -- TODO: see if we can get the user's name from the auth.users table once we learn how oauth works 88 | -- TODO: If no name is provided, use the first part of the email address 89 | if new.email IS NOT NULL then 90 | generated_user_name := split_part(new.email, '@', 1); 91 | end if; 92 | 93 | insert into public.profiles (id, name) values (new.id, generated_user_name); 94 | 95 | -- only create the first account if private accounts is enabled 96 | if basejump.is_set('enable_personal_accounts') = true then 97 | -- create the new users's personal account 98 | insert into public.accounts (primary_owner_user_id, personal_account) 99 | values (NEW.id, true) 100 | returning id into first_account_id; 101 | 102 | -- add them to the account_user table so they can act on it 103 | insert into public.account_user (account_id, user_id, account_role) 104 | values (first_account_id, NEW.id, 'owner'); 105 | end if; 106 | return NEW; 107 | end; 108 | $$; 109 | 110 | -- trigger the function every time a user is created 111 | create trigger on_auth_user_created 112 | after insert 113 | on auth.users 114 | for each row 115 | execute procedure basejump.run_new_user_setup(); -------------------------------------------------------------------------------- /src/components/dashboard/accounts/settings/invite-member.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import handleSupabaseErrors from "@/utils/handle-supabase-errors"; 3 | import { Alert, Button } from "react-daisyui"; 4 | import useTranslation from "next-translate/useTranslation"; 5 | import Select from "@/components/core/select"; 6 | import { useState } from "react"; 7 | import getInvitationUrl from "@/utils/get-invitation-url"; 8 | import { useCopyToClipboard } from "react-use"; 9 | import { ClipboardCopyIcon } from "@heroicons/react/outline"; 10 | import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react"; 11 | import { Database } from "@/types/supabase-types"; 12 | 13 | type Props = { 14 | accountId: string; 15 | onComplete?: () => void; 16 | }; 17 | 18 | type INVITE_FORM = { 19 | invitationType: "one-time" | "24-hour"; 20 | email: string; 21 | userType: Database["public"]["Tables"]["invitations"]["Row"]["account_role"]; 22 | }; 23 | 24 | type INVITE_FORM_USER_TYPES = Array<{ 25 | value: Database["public"]["Tables"]["invitations"]["Row"]["account_role"]; 26 | label: string; 27 | }>; 28 | 29 | export const userTypeOptions: INVITE_FORM_USER_TYPES = [ 30 | { 31 | label: "Owner", 32 | value: "owner", 33 | }, 34 | { 35 | label: "Member", 36 | value: "member", 37 | }, 38 | ]; 39 | 40 | const invitationTypes = [ 41 | { 42 | label: "One-time link", 43 | value: "one-time", 44 | }, 45 | { 46 | label: "24 hour link", 47 | value: "24-hour", 48 | }, 49 | ]; 50 | 51 | const defaultInvitationType = "one-time"; 52 | 53 | const InviteMember = ({ accountId, onComplete }: Props) => { 54 | const [invitationLink, setInvitationLink] = useState(null); 55 | const [state, copyToClipboard] = useCopyToClipboard(); 56 | const { t } = useTranslation("dashboard"); 57 | const user = useUser(); 58 | const supabaseClient = useSupabaseClient(); 59 | const { 60 | register, 61 | handleSubmit, 62 | formState: { isSubmitting }, 63 | } = useForm(); 64 | 65 | async function onSubmit(invitation: INVITE_FORM) { 66 | const { data, error } = await supabaseClient 67 | .from("invitations") 68 | .insert({ 69 | invitation_type: invitation.invitationType, 70 | invited_by_user_id: user.id, 71 | account_id: accountId, 72 | account_role: invitation.userType, 73 | }) 74 | .select(); 75 | 76 | handleSupabaseErrors(data, error); 77 | 78 | if (data) { 79 | setInvitationLink(getInvitationUrl(data?.[0]?.token)); 80 | } 81 | 82 | if (!error && onComplete) { 83 | onComplete(); 84 | } 85 | } 86 | 87 | return ( 88 | <> 89 |
90 |
91 | 102 | 113 | 116 |
117 |
118 | {!!invitationLink && ( 119 | 120 |
121 |

{t("inviteMember.linkGenerated")}

122 | 130 |
131 |
132 | )} 133 | 134 | ); 135 | }; 136 | 137 | export default InviteMember; 138 | --------------------------------------------------------------------------------