├── .editorconfig
├── .env.example
├── .eslintrc.json
├── .gitignore
├── .gitmodules
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── jsconfig.json
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
├── favicon
│ ├── favicon.ico
│ ├── favicon.png
│ └── favicon.svg
└── img
│ ├── charts
│ ├── area-chart.png
│ ├── bar-chart.png
│ ├── counter-chart.png
│ ├── pie-chart.png
│ └── table-chart.png
│ ├── databases
│ ├── mysql.svg
│ ├── planetscale.svg
│ ├── postgres.svg
│ └── supabase.svg
│ ├── full-logo.png
│ └── supaboard.svg
├── src
├── app
│ ├── (app)
│ │ ├── contacts
│ │ │ ├── action.js
│ │ │ └── import
│ │ │ │ └── page.js
│ │ ├── dashboards
│ │ │ ├── [id]
│ │ │ │ ├── add-chart
│ │ │ │ │ └── page.js
│ │ │ │ ├── loading.js
│ │ │ │ └── page.js
│ │ │ ├── actions.js
│ │ │ ├── loading.js
│ │ │ └── page.js
│ │ ├── databases
│ │ │ ├── [id]
│ │ │ │ ├── loading.js
│ │ │ │ └── page.js
│ │ │ ├── actions.js
│ │ │ ├── new
│ │ │ │ └── page.js
│ │ │ └── page.js
│ │ ├── layout.js
│ │ ├── overview
│ │ │ └── page.js
│ │ ├── segments
│ │ │ ├── [id]
│ │ │ │ └── page.js
│ │ │ ├── actions.js
│ │ │ ├── new
│ │ │ │ ├── layout.js
│ │ │ │ └── page.js
│ │ │ └── page.js
│ │ ├── settings
│ │ │ ├── billing
│ │ │ │ └── page.js
│ │ │ ├── layout.js
│ │ │ ├── page.js
│ │ │ └── workspace
│ │ │ │ ├── [account_id]
│ │ │ │ ├── actions.js
│ │ │ │ └── page.js
│ │ │ │ ├── new
│ │ │ │ ├── actions.js
│ │ │ │ └── page.js
│ │ │ │ └── page.js
│ │ ├── setup
│ │ │ ├── page.js
│ │ │ └── state
│ │ │ │ └── route.js
│ │ ├── thank-you
│ │ │ └── page.js
│ │ └── workflows
│ │ │ └── page.js
│ ├── (auth)
│ │ ├── auth
│ │ │ ├── callback
│ │ │ │ └── route.js
│ │ │ └── signout
│ │ │ │ └── route.js
│ │ ├── layout.js
│ │ ├── login
│ │ │ └── page.js
│ │ └── register
│ │ │ └── page.js
│ ├── (public)
│ │ └── public
│ │ │ ├── dashboard
│ │ │ └── [hash]
│ │ │ │ └── page.js
│ │ │ └── layout.js
│ ├── api
│ │ ├── contacts
│ │ │ ├── route.js
│ │ │ └── segments
│ │ │ │ └── route.js
│ │ ├── counts
│ │ │ └── route.js
│ │ ├── dashboards
│ │ │ ├── [uuid]
│ │ │ │ ├── charts
│ │ │ │ │ └── [chart_id]
│ │ │ │ │ │ └── route.js
│ │ │ │ └── route.js
│ │ │ ├── data
│ │ │ │ └── route.js
│ │ │ ├── hash
│ │ │ │ └── [hash]
│ │ │ │ │ └── route.js
│ │ │ └── route.js
│ │ ├── databases
│ │ │ ├── [id]
│ │ │ │ └── route.js
│ │ │ └── route.js
│ │ ├── externaldb
│ │ │ └── [id]
│ │ │ │ └── route.js
│ │ ├── segments
│ │ │ ├── [id]
│ │ │ │ └── route.js
│ │ │ └── route.js
│ │ ├── settings
│ │ │ └── route.js
│ │ └── webhooks
│ │ │ └── stripe
│ │ │ └── route.js
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.js
│ └── opengraph-image.png
├── components
│ ├── Loading.js
│ ├── Sidebar.js
│ ├── Skeleton.js
│ ├── UpgradeModal.js
│ ├── UserMenu.js
│ ├── WorkspaceSwitcher.js
│ ├── auth
│ │ └── AuthForm.js
│ ├── brand
│ │ └── Logo.js
│ ├── contacts
│ │ └── steps
│ │ │ └── SelectContacts.js
│ ├── dashboards
│ │ ├── DashboardDeleteModal.js
│ │ ├── DashboardEditModal.js
│ │ ├── DashboardNewModal.js
│ │ ├── DashboardPublicModal.js
│ │ ├── DashboardSwitcher.js
│ │ ├── NoDashboards.js
│ │ ├── charts
│ │ │ ├── AreaChart.js
│ │ │ ├── BarChart.js
│ │ │ ├── ChartEditModal.js
│ │ │ ├── ChartError.js
│ │ │ ├── ChartErrorBoundary.js
│ │ │ ├── ChartHeader.js
│ │ │ ├── CounterChart.js
│ │ │ ├── PieChart.js
│ │ │ └── TableChart.js
│ │ └── steps
│ │ │ ├── ChartDataTable.js
│ │ │ ├── ChartDatabase.js
│ │ │ ├── ChartOptions.js
│ │ │ └── ChartTypes.js
│ ├── databases
│ │ ├── DatabaseDeleteModal.js
│ │ └── steps
│ │ │ ├── DatabaseDetails.js
│ │ │ └── DatabaseTypes.js
│ ├── emails
│ │ └── invite.jsx
│ ├── img
│ │ ├── Bitbucket.js
│ │ ├── Github.js
│ │ ├── Gitlab.js
│ │ └── Slack.js
│ ├── segments
│ │ ├── ContactResetModal.js
│ │ ├── SegmentDeleteModal.js
│ │ ├── SegmentEditModal.js
│ │ ├── SegmentSidebar.js
│ │ ├── SegmentSwitcher.js
│ │ └── steps
│ │ │ └── SegmentDataFilter.js
│ ├── settings
│ │ ├── PricingSection.js
│ │ ├── SettingsNav.js
│ │ └── SubscriptionSettings.js
│ ├── util
│ │ ├── Modal.js
│ │ └── index.js
│ └── workflows
│ │ └── nodes
│ │ ├── DefaultNode.js
│ │ └── util.js
├── config
│ ├── permissions.js
│ └── pricing.js
├── instrumentation.js
├── lib
│ ├── adapters
│ │ ├── mysql
│ │ │ └── querybuilder.js
│ │ └── postgres
│ │ │ └── querybuilder.js
│ ├── auth.js
│ ├── crypto.js
│ ├── database.js
│ ├── operators.js
│ └── stripe
│ │ ├── stripe-client.js
│ │ ├── stripe.js
│ │ └── supabase-admin.js
├── middleware.js
├── providers
│ └── Telemetry.js
├── store
│ └── index.js
└── util
│ └── index.js
├── supabase
└── migrations
│ ├── 20230626041550_create_base_tables.sql
│ └── 20230702062454_accounts_and_profiles.sql
└── tailwind.config.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_size = 4
7 | indent_style = tab
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_APP_URL=http://localhost:3000
2 | NEXT_PUBLIC_APP_LANDING=/overview
3 | NEXT_PUBLIC_ENV=dev
4 |
5 | # Differentiate between cloud and self-hosted
6 | IS_PLATFORM=true
7 | NEXT_PUBLIC_IS_PLATFORM=true
8 | NEXT_PUBLIC_SIGNUP_CLOSED=false
9 |
10 | # Supabase / database
11 | NEXT_PUBLIC_SUPABASE_URL=https://XYZ.supabase.co
12 | NEXT_PUBLIC_SUPABASE_ANON_KEY=XYZ
13 | SUPABASE_SERVICE_ROLE_KEY=XYZ
14 | SUPABASE_JWT_SECRET=XYZ
15 |
16 | # We encrypt all database connection details before storing them in the database
17 | # This is the key used to encrypt the connection details
18 | CRYPTO_SECRET_KEY=XYZ
19 | CRYPRO_SECRET_IV=XYZ
20 | CRYPTO_ENCRYPTION_METHOD=aes-256-cbc
21 |
22 | # Resend / email
23 | RESEND_API_KEY=re_XYZ
24 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "eqeqeq": "off",
5 | "quotes": ["error", "double"]
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | .vscode
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "src/app/api/payments-api"]
2 | path = src/app/api/payments-api
3 | url = https://github.com/supaboard/payments-api.git
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Supaboard
7 |
8 |
9 | The dashbord builder for Supabase and Postgres.
10 |
11 | Learn more »
12 |
13 |
14 | Discord
15 | ·
16 | Website
17 | ·
18 | Issues
19 | ·
20 | Discussions
21 |
22 |
23 |
24 | ## Running the app locally
25 | 1. Clone the repo
26 | `https://github.com/supaboard/app.git`
27 |
28 | 2. Install dependencies and start the dev server
29 | `npm i && npm run dev`
30 |
31 | 3. You'll need a Supabase account / Postgress database and [apply](https://supabase.com/docs/reference/cli/supabase-migration-up) the initial migration located in `/supabase`.
32 | We're using a custom schema called `supaboard`, so make sure this accessible from outside connections. In Supabase, go to Settings > API and add `supaboard` to the exposed schemas as shown below.
33 |
34 | 
35 |
36 | 4. Copy or rename the `.env.example` file to `.env` and fill in the environment variables.
37 | **Important:** The variables `IS_PLATFORM` and `NEXT_PUBLIC_IS_PLATFORM` are strongly advised to be set to `false`, otherwise you will run into account limits and other payment issues. The flag is differentiating between our hosted offer and self-hosted instances.
38 |
39 | ## env example
40 | ```
41 | NEXT_PUBLIC_APP_URL=http://localhost:3000
42 | NEXT_PUBLIC_APP_LANDING=/overview
43 | NEXT_PUBLIC_ENV=dev
44 |
45 | # Differentiate between cloud and self-hosted
46 | IS_PLATFORM=true
47 | NEXT_PUBLIC_IS_PLATFORM=true
48 | NEXT_PUBLIC_SIGNUP_CLOSED=false
49 |
50 | # Supabase / database
51 | NEXT_PUBLIC_SUPABASE_URL=XYZ.supabase.co
52 | NEXT_PUBLIC_SUPABASE_ANON_KEY=XYZ
53 | SUPABASE_SERVICE_ROLE_KEY=XYZ
54 | SUPABASE_JWT_SECRET=XYZ
55 |
56 | # We encrypt all database connection details ebfore storing them in the database
57 | # This is the key used to encrypt the connection details
58 | CRYPTO_SECRET_KEY=XYZ
59 | CRYPRO_SECRET_IV=XYZ
60 | CRYPTO_ENCRYPTION_METHOD=aes-256-cbc
61 |
62 | # Resend / email
63 | RESEND_API_KEY=re_XYZ
64 |
65 | # dev / local tunnel
66 | LOCAL_TUNNEL=https://46e7-2a02-908-4b27-3080-a5fa-9b89-c87d-c9f7.ngrok-free.app/
67 | ```
68 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./src/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const nextBuildId = require("next-build-id")
2 |
3 | const nextConfig = {
4 | generateBuildId: () => nextBuildId({ dir: __dirname }),
5 | experimental: {
6 | appDir: true,
7 | serverActions: true,
8 | esmExternals: "loose",
9 | instrumentationHook: true
10 | },
11 | productionBrowserSourceMaps: false,
12 | async redirects() {
13 | return [
14 | {
15 | source: "/",
16 | destination: "/overview",
17 | permanent: true
18 | }
19 | ]
20 | },
21 | }
22 |
23 | module.exports = nextConfig
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "supaboard",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@headlessui/react": "^1.7.15",
13 | "@heroicons/react": "^2.0.18",
14 | "@highlight-run/next": "^4.0.0",
15 | "@react-email/components": "^0.0.7",
16 | "@stripe/stripe-js": "^1.54.1",
17 | "@supabase/auth-helpers-nextjs": "^0.7.3",
18 | "@supabase/auth-ui-react": "^0.4.2",
19 | "@supabase/auth-ui-shared": "^0.1.6",
20 | "@supabase/supabase-js": "^2.26.0",
21 | "@tippyjs/react": "^4.2.6",
22 | "autoprefixer": "10.4.14",
23 | "common-tags": "^1.8.2",
24 | "crisp-sdk-web": "^1.0.21",
25 | "encoding": "^0.1.13",
26 | "eslint": "8.43.0",
27 | "eslint-config-next": "13.4.7",
28 | "mysql2": "^3.6.0",
29 | "next": "^13.4.8",
30 | "next-build-id": "^3.0.0",
31 | "pg": "^8.11.1",
32 | "postcss": "8.4.24",
33 | "posthog-js": "^1.75.3",
34 | "react": "18.2.0",
35 | "react-apexcharts": "^1.4.0",
36 | "react-dom": "18.2.0",
37 | "react-email": "^1.9.4",
38 | "react-grid-layout": "^1.3.4",
39 | "reactflow": "^11.7.4",
40 | "resend": "^0.17.1",
41 | "rsuite": "^5.37.3",
42 | "server-only": "^0.0.1",
43 | "sonner": "^0.5.0",
44 | "stripe": "^12.13.0",
45 | "tailwindcss": "3.3.2",
46 | "tedious": "^16.1.0",
47 | "vercel-submodules": "^1.0.10",
48 | "zustand": "^4.3.8"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/favicon/favicon.png
--------------------------------------------------------------------------------
/public/favicon/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/charts/area-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/charts/area-chart.png
--------------------------------------------------------------------------------
/public/img/charts/bar-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/charts/bar-chart.png
--------------------------------------------------------------------------------
/public/img/charts/counter-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/charts/counter-chart.png
--------------------------------------------------------------------------------
/public/img/charts/pie-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/charts/pie-chart.png
--------------------------------------------------------------------------------
/public/img/charts/table-chart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/charts/table-chart.png
--------------------------------------------------------------------------------
/public/img/databases/mysql.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/databases/planetscale.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/img/databases/supabase.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/public/img/full-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/public/img/full-logo.png
--------------------------------------------------------------------------------
/public/img/supaboard.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Group 4 Copy 3
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/app/(app)/contacts/action.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
5 |
6 |
7 | export const createContactConnection = async (contactConnection) => {
8 | const cookieStore = cookies()
9 | const account_id = cookieStore.get("account_id").value
10 | const supabase = createServerActionClient({ cookies }, {
11 | options: {
12 | db: { schema: "supaboard" }
13 | }
14 | })
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) {
17 | throw new Error("Not authenticated")
18 | }
19 |
20 | contactConnection.account_id = account_id
21 |
22 | try {
23 | const { data, error } = await supabase
24 | .from("contacts")
25 | .insert([
26 | contactConnection
27 | ])
28 | .select()
29 | .single()
30 |
31 | if (error) {
32 | console.log(error)
33 | throw Error(error)
34 | }
35 |
36 | return data
37 | } catch (error) {
38 | throw Error(error)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/(app)/contacts/import/page.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect, useTransition } from "react"
4 | import { useRouter } from "next/navigation"
5 | import Link from "next/link"
6 | import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"
7 | import { ChartDatabase } from "@/components/dashboards/steps/ChartDatabase"
8 | import { SelectContacts } from "@/components/contacts/steps/SelectContacts"
9 | import { createContactConnection } from "../action"
10 | import Loading from "@/components/Loading"
11 |
12 | export default function NewDatabase({ params }) {
13 | const router = useRouter()
14 | let [isPending, startTransition] = useTransition()
15 | const [activeStep, setActiveStep] = useState(1)
16 | const [data, setData] = useState(null)
17 | const [loading, setLoading] = useState(false)
18 | const [database, setDatabase] = useState(null)
19 | const [databases, setDatabases] = useState(null)
20 | const [selectedDataTable, setSelectedDataTable] = useState(null)
21 | const [attributes, setAttributes] = useState(null)
22 |
23 |
24 | useEffect(() => {
25 | const getDatabases = async () => {
26 | setLoading(true)
27 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/databases`)
28 | if (!res.ok) {
29 | throw new Error("Failed to fetch data")
30 | }
31 |
32 | let data = await res.json()
33 | if (!data || data.length == 0) {
34 | setDatabases([])
35 | setDatabase(null)
36 | setLoading(false)
37 | return
38 | }
39 |
40 | setDatabases(data)
41 | setDatabase(data[0].uuid)
42 | setLoading(false)
43 | }
44 |
45 | getDatabases()
46 | }, [])
47 |
48 |
49 | useEffect(() => {
50 | if (activeStep === 2) {
51 | const getChartData = async () => {
52 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/externaldb/${database}`)
53 | const data = await res.json()
54 | setData(data)
55 | setSelectedDataTable(data[0].name)
56 | }
57 | getChartData()
58 | }
59 | }, [activeStep, database])
60 |
61 | const createAllUsersSegment = async () => {
62 | let contactConnection = {
63 | name: "All Users",
64 | database: database,
65 | table_name: selectedDataTable,
66 | attributes: attributes,
67 | }
68 |
69 | startTransition(() => createContactConnection(contactConnection))
70 | if (!isPending) {
71 | router.push("/segments")
72 | }
73 | }
74 |
75 |
76 | return (
77 | <>
78 |
79 |
80 |
1
81 |
Choose a database
82 |
83 |
84 |
2
85 |
Define user attributes
86 |
87 |
88 |
89 |
90 |
91 | {activeStep == 1 && (
92 | <>
93 |
94 | {loading && (
95 |
96 | )}
97 | {!loading && (
98 |
105 | )}
106 |
107 | >
108 | )}
109 |
110 | {activeStep == 2 && (
111 | <>
112 |
113 |
121 |
122 |
123 |
124 |
{
127 | setActiveStep(activeStep - 1)
128 | }}
129 | >
130 |
131 | Back
132 |
133 |
134 |
135 |
{
138 | createAllUsersSegment()
139 | }}
140 | >
141 | Save contacts
142 |
143 |
144 |
145 |
146 | >
147 | )}
148 |
149 |
150 | >
151 | )
152 | }
153 |
--------------------------------------------------------------------------------
/src/app/(app)/dashboards/[id]/loading.js:
--------------------------------------------------------------------------------
1 | export default function Dashboard({ params }) {
2 | return (
3 | <>>
4 | )
5 | }
--------------------------------------------------------------------------------
/src/app/(app)/dashboards/actions.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
5 |
6 |
7 | export const createDashboard = async (formData) => {
8 | const cookieStore = cookies()
9 | const account_id = cookieStore.get("account_id").value
10 | const supabase = createServerActionClient({ cookies }, {
11 | options: {
12 | db: { schema: "supaboard" }
13 | }
14 | })
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) {
17 | throw new Error("Not authenticated")
18 | }
19 |
20 | const dashboard_name = formData.get("dashboard_name")
21 | const timeframe = formData.get("timeframe")
22 |
23 | try {
24 | const { data, error } = await supabase
25 | .from("dashboards")
26 | .insert([
27 | {
28 | name: dashboard_name,
29 | config: {
30 | timeframe,
31 | },
32 | is_public: false,
33 | account_id: account_id
34 | },
35 | ])
36 | .select()
37 | .single()
38 |
39 | if (error) {
40 | console.log(error)
41 | throw Error(error)
42 | }
43 |
44 | return data
45 | } catch (error) {
46 | throw Error(error)
47 | }
48 | }
49 |
50 |
51 | export const updateDashboard = async (dashboard) => {
52 | const supabase = createServerActionClient({ cookies }, {
53 | options: {
54 | db: { schema: "supaboard" }
55 | }
56 | })
57 | const { data: { session } } = await supabase.auth.getSession()
58 | if (!session) {
59 | throw new Error("Not authenticated")
60 | }
61 |
62 | try {
63 | const { data, error } = await supabase
64 | .from("dashboards")
65 | .update({
66 | name: dashboard.name,
67 | config: dashboard.config,
68 | is_public: dashboard.is_public || false
69 | })
70 | .eq("id", dashboard.id)
71 | .select()
72 |
73 | if (error) {
74 | console.log(error)
75 | throw Error(error)
76 | }
77 |
78 | return data
79 | } catch (error) {
80 | throw Error(error)
81 | }
82 | }
83 |
84 |
85 | export const deleteDahboard = async (dashboard) => {
86 | const supabase = createServerActionClient({ cookies }, {
87 | options: {
88 | db: { schema: "supaboard" }
89 | }
90 | })
91 | const { data: { session } } = await supabase.auth.getSession()
92 | if (!session) {
93 | throw new Error("Not authenticated")
94 | }
95 |
96 | try {
97 | const { data, error } = await supabase
98 | .from("dashboards")
99 | .delete()
100 | .eq("id", dashboard.id)
101 | .select()
102 | .single()
103 |
104 | if (error) {
105 | console.log(error)
106 | throw Error(error)
107 | }
108 |
109 | return data
110 | } catch (error) {
111 | throw Error(error)
112 | }
113 | }
114 |
115 |
116 | export const addChart = async (dashboard_uuid, dashboard_data) => {
117 | const supabase = createServerActionClient({ cookies }, {
118 | options: {
119 | db: { schema: "supaboard" }
120 | }
121 | })
122 | const { data: { session } } = await supabase.auth.getSession()
123 | if (!session) {
124 | throw new Error("Not authenticated")
125 | }
126 |
127 | console.log(dashboard_data)
128 | try {
129 | const { data, error } = await supabase
130 | .from("dashboards")
131 | .update({
132 | config: dashboard_data
133 | })
134 | .eq("uuid", dashboard_uuid)
135 | .select()
136 | .single()
137 |
138 | if (error) {
139 | console.log(error)
140 | throw Error(error)
141 | }
142 |
143 | return data
144 | } catch (error) {
145 | throw Error(error)
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/app/(app)/dashboards/loading.js:
--------------------------------------------------------------------------------
1 | import Loading from "@/components/Loading"
2 |
3 | export default async function LoadingDashboards() {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Loading dashboards...
14 |
15 |
16 |
17 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/(app)/dashboards/page.js:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers"
2 |
3 | import Link from "next/link"
4 | import { formatDate } from "@/components/util"
5 | import { NoDashboards } from "@/components/dashboards/NoDashboards"
6 |
7 | async function getData() {
8 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards`, {
9 | headers: { Cookie: cookies().toString() }
10 | })
11 |
12 | if (!res.ok) {
13 | throw new Error("Failed to fetch data")
14 | }
15 |
16 | return res.json()
17 | }
18 |
19 |
20 | export default async function Dashboards({params, searchParams}) {
21 | const dashboards = await getData()
22 |
23 | return (
24 | <>
25 |
26 | {dashboards && dashboards.length > 0 && (
27 |
28 |
29 |
30 |
31 |
32 |
33 | Name
34 |
35 |
36 | Charts
37 |
38 |
39 | Timeframe
40 |
41 |
42 | Created
43 |
44 |
45 |
46 |
47 | {dashboards.map((dashboard) => (
48 |
49 |
50 |
51 |
52 | {dashboard.name}
53 |
54 |
55 |
56 |
57 | {dashboard.config?.charts?.length || "no"} charts
58 |
59 |
60 |
61 |
62 | {typeof dashboard.config?.timeframe === "string" && dashboard.config?.timeframe?.replaceAll("_", " ")}
63 | {typeof dashboard.config?.timeframe === "object" && (
64 | <>
65 | { formatDate(dashboard.config?.timeframe[0])}
66 | -
67 | {formatDate(dashboard.config?.timeframe[1])}
68 | >
69 | )}
70 |
71 |
72 |
73 |
74 | {formatDate(dashboard.created_at)}
75 |
76 |
77 |
78 |
79 | ))}
80 |
81 |
82 |
83 |
84 | )}
85 | {(!dashboards || dashboards.length == 0) && (
86 |
87 | )}
88 |
89 | >
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/src/app/(app)/databases/[id]/loading.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import Loading from "@/components/Loading"
4 | import { LockClosedIcon } from "@heroicons/react/24/outline"
5 |
6 |
7 | export default async function LoadingEditDatabase({ params }) {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Database connection details
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Decrypting connection details...
29 |
30 |
31 |
32 |
100 |
101 |
102 |
103 |
104 |
105 | Cancel
106 |
107 |
108 |
109 |
110 | Save connection details
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | >
119 | )
120 | }
121 |
--------------------------------------------------------------------------------
/src/app/(app)/databases/actions.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
5 | import { encrypt } from "@/lib/crypto"
6 | import { redirect } from "next/navigation"
7 |
8 |
9 | export const createDatabase = async (name, databaseType, connection) => {
10 | const cookieStore = cookies()
11 | const account_id = cookieStore.get("account_id").value
12 | const supabase = createServerActionClient({ cookies }, {
13 | options: {
14 | db: { schema: "supaboard" }
15 | }
16 | })
17 | const { data: { session } } = await supabase.auth.getSession()
18 | if (!session) {
19 | throw new Error("Not authenticated")
20 | }
21 |
22 | try {
23 | const encryptedConnection = encrypt(JSON.stringify(connection))
24 | const { data, error } = await supabase
25 | .from("databases")
26 | .insert([
27 | {
28 | name: name,
29 | type: databaseType,
30 | connection: encryptedConnection,
31 | account_id: account_id
32 | }
33 | ])
34 | .select()
35 | .single()
36 |
37 | if (error) {
38 | console.log(error)
39 | throw Error(error)
40 | }
41 |
42 | return data
43 | } catch (error) {
44 | throw Error(error)
45 | }
46 | }
47 |
48 |
49 |
50 | export const updateDatabase = async (formData) => {
51 | const cookieStore = cookies()
52 | const account_id = cookieStore.get("account_id").value
53 | const supabase = createServerActionClient({ cookies }, {
54 | options: {
55 | db: { schema: "supaboard" }
56 | }
57 | })
58 | const { data: { session } } = await supabase.auth.getSession()
59 | if (!session) {
60 | throw new Error("Not authenticated")
61 | }
62 |
63 | const name = formData.get("name")
64 | const host = formData.get("host")
65 | const port = formData.get("port")
66 | const user = formData.get("user")
67 | const password = formData.get("password")
68 | const database = formData.get("database")
69 | const uuid = formData.get("uuid")
70 |
71 | let connection = {
72 | host: host,
73 | port: port,
74 | user: user,
75 | password: password,
76 | database: database,
77 | }
78 |
79 | const encryptedConnection = encrypt(JSON.stringify(connection))
80 | const { data, error } = await supabase
81 | .from("databases")
82 | .update({
83 | name: name,
84 | connection: encryptedConnection
85 | })
86 | .eq("uuid", uuid)
87 | .select()
88 | .single()
89 |
90 | if (error) {
91 | console.log(error)
92 | throw Error(error)
93 | }
94 |
95 | redirect("/databases")
96 |
97 | }
98 |
99 |
100 | export const deleteDatabase = async (database) => {
101 | const supabase = createServerActionClient({ cookies }, {
102 | options: {
103 | db: { schema: "supaboard" }
104 | }
105 | })
106 | const { data: { session } } = await supabase.auth.getSession()
107 | if (!session) {
108 | throw new Error("Not authenticated")
109 | }
110 |
111 | try {
112 | const { data, error } = await supabase
113 | .from("databases")
114 | .delete()
115 | .eq("id", database.id)
116 | .select()
117 | .single()
118 |
119 | if (error) {
120 | console.log(error)
121 | throw Error(error)
122 | }
123 |
124 | return data
125 | } catch (error) {
126 | throw Error(error)
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/app/(app)/databases/new/page.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState, useTransition } from "react"
4 | import { useRouter } from "next/navigation"
5 | import { DatabaseTypes } from "@/components/databases/steps/DatabaseTypes"
6 | import { DatabaseDetails } from "@/components/databases/steps/DatabaseDetails"
7 | import { createDatabase } from "../actions"
8 | import { can } from "@/lib/auth"
9 | import useStore from "@/store/index"
10 |
11 | export default function NewDatabase({ params }) {
12 | const router = useRouter()
13 | let [isPending, startTransition] = useTransition()
14 | const [activeStep, setActiveStep] = useState(1)
15 | const [databaseType, setDatabaseType] = useState(null)
16 | const [databaseDetails, setDatabaseDetails] = useState(null)
17 | const { showUpgradeModal, setShowUpgradeModal } = useStore()
18 |
19 | useEffect(() => {
20 | const checkCreateAllowed = async () => {
21 | const allowed = await can("create:datasource")
22 | if (!allowed) {
23 | setShowUpgradeModal("create:datasource")
24 | }
25 | }
26 |
27 | checkCreateAllowed()
28 | }, [])
29 |
30 | const createNewDatabase = async () => {
31 | let connection = {
32 | host: databaseDetails.host,
33 | port: databaseDetails.port,
34 | user: databaseDetails.user,
35 | password: databaseDetails.password,
36 | database: databaseDetails.database,
37 | }
38 |
39 | startTransition(() => createDatabase(databaseDetails.name, databaseType, connection))
40 | if (!isPending) {
41 | router.push("/databases")
42 | }
43 | }
44 |
45 |
46 | return (
47 | <>
48 |
49 |
50 |
1
51 |
Choose database type
52 |
53 |
54 |
2
55 |
Add connection details
56 |
57 |
58 |
59 |
60 |
61 | {activeStep == 1 && (
62 | <>
63 |
64 |
70 |
71 | >
72 | )}
73 |
74 | {activeStep == 2 && (
75 | <>
76 |
77 |
85 |
86 | >
87 | )}
88 |
89 |
90 | >
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/(app)/databases/page.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 | import { PlusIcon } from "@heroicons/react/24/outline"
3 | import { cookies } from "next/headers"
4 | import { formatDate } from "@/components/util"
5 |
6 | async function getData() {
7 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/databases`, {
8 | headers: { Cookie: cookies().toString() }
9 | })
10 | if (!res.ok) {
11 | throw new Error("Failed to fetch data")
12 | }
13 |
14 | return res.json()
15 | }
16 |
17 | export default async function Databases() {
18 | const databases = await getData()
19 |
20 | return (
21 |
22 |
23 |
24 |
25 | Add database
26 |
27 |
28 |
29 |
30 | {(!databases || databases.length == 0) && (
31 |
32 |
35 |
36 | You don't have any databases yet. Create a new database to get started.
37 |
38 |
39 |
40 | Create a new database
41 |
42 |
43 |
44 | )}
45 | {databases && databases.length > 0 && (
46 |
47 |
48 | {databases && databases.length > 0 && databases.map((database) => (
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
{database.type}
57 |
{database.name}
58 |
59 | Created: {formatDate(database.created_at)}
60 |
61 |
62 | Edit connection
63 |
64 |
65 |
66 |
67 |
68 | ))}
69 |
70 |
71 | )}
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/app/(app)/layout.js:
--------------------------------------------------------------------------------
1 | import { Toaster } from "sonner"
2 | import "tippy.js/dist/tippy.css"
3 | import "@/app/globals.css"
4 |
5 | import { Sidebar } from "@/components/Sidebar"
6 | import { UserMenu } from "@/components/UserMenu"
7 |
8 | export default async function RootLayout({ children }) {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
16 |
17 | {children}
18 |
19 |
20 |
21 |
22 |
23 | >
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/(app)/segments/[id]/page.js:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from "@heroicons/react/24/outline"
2 | import Link from "next/link"
3 | import { cookies } from "next/headers"
4 |
5 |
6 | async function getData(id) {
7 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/segments/${id}`, {
8 | headers: { Cookie: cookies().toString() }
9 | })
10 | if (!res.ok) {
11 | throw new Error("Failed to fetch data")
12 | }
13 |
14 | return res.json()
15 | }
16 |
17 |
18 | export default async function Segments({ params }) {
19 | const { id } = params
20 | const data = await getData(id)
21 |
22 | return (
23 |
24 | {data.contacts && data.contacts.length > 0 && (
25 |
26 |
27 |
28 |
29 |
30 |
31 | {data.attributes.map((attribute) => (
32 |
33 | {attribute.identifier}
34 |
35 | ))}
36 |
37 |
38 |
39 | {data.contacts.map((person) => (
40 |
41 | {data.attributes.map((attribute) => (
42 |
43 | {person[attribute?.name] && typeof person[attribute?.name] === "object" && (
44 | <>
45 | {JSON.stringify(person[attribute?.name])}
46 | >
47 | )}
48 | {person[attribute?.name] && typeof person[attribute?.name] !== "object" && (
49 | {person[attribute?.name] || "—"}
50 | )}
51 |
52 | ))}
53 |
54 | ))}
55 |
56 |
57 |
58 |
59 |
60 | )}
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/(app)/segments/actions.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
5 |
6 |
7 | export const createSegment = async (segmentData) => {
8 | const cookieStore = cookies()
9 | const account_id = cookieStore.get("account_id").value
10 | const supabase = createServerActionClient({ cookies }, {
11 | options: {
12 | db: { schema: "supaboard" }
13 | }
14 | })
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) {
17 | throw new Error("Not authenticated")
18 | }
19 |
20 | segmentData.account_id = account_id
21 |
22 | try {
23 | const { data, error } = await supabase
24 | .from("segments")
25 | .insert([{
26 | name: segmentData.name,
27 | config: segmentData.config,
28 | account_id: segmentData.account_id,
29 | database: segmentData.database,
30 | }])
31 | .select()
32 | .single()
33 |
34 | if (error) {
35 | console.log(error)
36 | throw Error(error)
37 | }
38 |
39 | return data
40 | } catch (error) {
41 | throw Error(error)
42 | }
43 | }
44 |
45 |
46 | export const updateSegment = async (formData) => {
47 | const cookieStore = cookies()
48 | const account_id = cookieStore.get("account_id").value
49 | const supabase = createServerActionClient({ cookies }, {
50 | options: {
51 | db: { schema: "supaboard" }
52 | }
53 | })
54 | const { data: { session } } = await supabase.auth.getSession()
55 | if (!session) {
56 | throw new Error("Not authenticated")
57 | }
58 |
59 |
60 | try {
61 | const { data, error } = await supabase
62 | .from("segments")
63 | .update([
64 | {
65 | name: formData.name,
66 | config: formData.config,
67 | }
68 | ])
69 | .eq("uuid", formData.uuid)
70 | .select()
71 | .single()
72 |
73 | if (error) {
74 | console.log(error)
75 | throw Error(error)
76 | }
77 |
78 | return data
79 | } catch (error) {
80 | throw Error(error)
81 | }
82 | }
83 |
84 |
85 | export const deleteSegment = async (segment) => {
86 | const supabase = createServerActionClient({ cookies }, {
87 | options: {
88 | db: { schema: "supaboard" }
89 | }
90 | })
91 | const { data: { session } } = await supabase.auth.getSession()
92 | if (!session) {
93 | throw new Error("Not authenticated")
94 | }
95 |
96 | try {
97 | const { data, error } = await supabase
98 | .from("segments")
99 | .delete()
100 | .eq("id", segment.id)
101 | .select()
102 | .single()
103 |
104 | if (error) {
105 | console.log(error)
106 | throw Error(error)
107 | }
108 |
109 | return data
110 | } catch (error) {
111 | throw Error(error)
112 | }
113 | }
114 |
115 | export const deleteContacts = async (segment) => {
116 | const cookieStore = cookies()
117 | const account_id = cookieStore.get("account_id").value
118 | const supabase = createServerActionClient({ cookies }, {
119 | options: {
120 | db: { schema: "supaboard" }
121 | }
122 | })
123 |
124 | const { data: { session } } = await supabase.auth.getSession()
125 | if (!session) {
126 | throw new Error("Not authenticated")
127 | }
128 |
129 | try {
130 | const { data, error } = await supabase
131 | .from("contacts")
132 | .delete()
133 | .eq("account_id", account_id)
134 | .select()
135 | .single()
136 |
137 | if (error) {
138 | console.log(error)
139 | throw Error(error)
140 | }
141 |
142 | return data
143 | } catch (error) {
144 | throw Error(error)
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/app/(app)/segments/new/layout.js:
--------------------------------------------------------------------------------
1 | export default async function SegmentsLayout({ children }) {
2 | return (
3 |
4 | {children}
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(app)/segments/page.js:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from "@heroicons/react/24/outline"
2 | import Link from "next/link"
3 | import { cookies } from "next/headers"
4 |
5 |
6 | async function getData() {
7 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/contacts`, {
8 | headers: { Cookie: cookies().toString() }
9 | })
10 | if (!res.ok) {
11 | throw new Error("Failed to fetch data")
12 | }
13 |
14 | return res.json()
15 | }
16 |
17 |
18 | export default async function Segments() {
19 | const data = await getData()
20 | const cookieStore = cookies()
21 |
22 | return (
23 |
24 | {(!data.contacts || data.contacts.length === 0) && (
25 |
26 |
29 |
30 | You don't have any users yet. Import contacts to get started.
31 |
32 |
33 |
34 | Tell us where to find your users
35 |
36 |
37 |
38 | )}
39 | {data.contacts && data.contacts.length > 0 && (
40 |
41 |
42 |
43 |
44 |
45 |
46 | {data.attributes.map((attribute) => (
47 |
48 | {attribute.identifier}
49 |
50 | ))}
51 |
52 |
53 |
54 | {data.contacts.map((person) => (
55 |
56 | {data.attributes.map((attribute) => (
57 |
58 | {person[attribute?.name] && typeof person[attribute?.name] === "object" && (
59 | <>
60 | {JSON.stringify(person[attribute?.name])}
61 | >
62 | )}
63 | {person[attribute?.name] && typeof person[attribute?.name] !== "object" && (
64 | {person[attribute?.name] || "—"}
65 | )}
66 |
67 | ))}
68 |
69 | ))}
70 |
71 |
72 |
73 |
74 |
75 | )}
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/billing/page.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"
5 | import PricingSection from "@/components/settings/PricingSection"
6 | import SubscriptionSettings from "@/components/settings/SubscriptionSettings"
7 | import Loading from "@/components/Loading"
8 |
9 |
10 | export default function Billing() {
11 | const [user, setUser] = useState(null)
12 | const [activeSubscription, setActiveSubscription] = useState(null)
13 | const [loading, setLoading] = useState(true)
14 | const [activeAccount, setActiveAccount] = useState(null)
15 | const supabase = createClientComponentClient({
16 | options: {
17 | db: { schema: "supaboard" }
18 | }
19 | })
20 |
21 | useEffect(() => {
22 | const getAccounts = async () => {
23 | setActiveAccount(document.cookie.split("; ").find(row => row.startsWith("account_id")).split("=")[1])
24 | const { data: { session } } = await supabase.auth.getSession()
25 | setUser(session.user)
26 | }
27 |
28 | getAccounts()
29 | }, [])
30 |
31 |
32 | useEffect(() => {
33 | if (!activeAccount) return
34 |
35 | const getAccountBillingStatus = async () => {
36 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/payments-api/status`)
37 | const data = await res.json()
38 | setActiveSubscription(data.subscription)
39 | setLoading(false)
40 | }
41 |
42 | getAccountBillingStatus()
43 | }, [activeAccount])
44 |
45 | return (
46 |
47 |
48 | {loading &&
}
49 | {!loading && (
50 | <>
51 |
52 | {!activeSubscription &&
}
53 | {activeSubscription && (
54 |
55 | )}
56 | >
57 | )}
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/layout.js:
--------------------------------------------------------------------------------
1 | import { SettingsNav } from "@/components/settings/SettingsNav"
2 |
3 | export default async function SettingsLayout({ children }) {
4 |
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | {children}
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/page.js:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers"
2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"
3 |
4 |
5 | // async function getData() {
6 | // const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/settings`)
7 | // if (!res.ok) {
8 | // throw new Error('Failed to fetch data')
9 | // }
10 |
11 | // return res.json()
12 | // }
13 |
14 | export default async function Settings() {
15 | const supabase = createServerComponentClient({ cookies })
16 | const { data: { session } } = await supabase.auth.getSession()
17 |
18 | // const data = await getData()
19 |
20 | return (
21 | <>
22 |
23 | Manage your personal account information
24 |
25 |
26 |
27 |
28 | Email
29 |
30 |
31 |
32 |
33 |
34 |
35 | >
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/workspace/[account_id]/actions.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { redirect } from "next/navigation"
5 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
6 | import { Resend } from "resend"
7 | import { InvitationEmail } from "@/components/emails/invite"
8 |
9 |
10 | export const updateMembers = async (memberData) => {
11 | const cookieStore = cookies()
12 | const account_id = cookieStore.get("account_id").value
13 | const supabase = createServerActionClient({ cookies }, {
14 | options: {
15 | db: { schema: "supaboard" }
16 | }
17 | })
18 | const { data: { session } } = await supabase.auth.getSession()
19 | if (!session) {
20 | throw new Error("Not authenticated")
21 | }
22 |
23 | let { email, invited_by_user_id, role, send_email } = memberData
24 |
25 | try {
26 | const query = await supabase.from("email_invitations").insert({
27 | email: email,
28 | account_id: account_id,
29 | invited_by_user_id: invited_by_user_id,
30 | role: role
31 | })
32 |
33 | if (send_email) {
34 | const { data: teamQuery, error } = await supabase
35 | .from("accounts")
36 | .select("team_name")
37 | .eq("id", account_id)
38 | .single()
39 |
40 | const res = await sendEmail(email, account_id, teamQuery.team_name, role)
41 | if (!res.id) {
42 | throw new Error("Failed to send email")
43 | }
44 | }
45 |
46 | return true
47 | } catch (error) {
48 | console.log(error)
49 | throw Error(error)
50 | }
51 | }
52 |
53 |
54 | export const sendEmail = async (email, account_id, team_name, role) => {
55 | const resend = new Resend(process.env.RESEND_API_KEY)
56 | const res = await resend.emails.send({
57 | from: "invite@supaboard.co",
58 | to: email,
59 | subject: "👋 You're invited to join your team on Supaboard",
60 | react: ,
61 | })
62 |
63 | return res
64 | }
65 |
66 | export const updateWorkspace = async (wokspace_id, formData) => {
67 | const supabase = createServerActionClient({ cookies }, {
68 | options: {
69 | db: { schema: "supaboard" }
70 | }
71 | })
72 | const { data: { session } } = await supabase.auth.getSession()
73 | if (!session) {
74 | throw new Error("Not authenticated")
75 | }
76 |
77 | if (!wokspace_id) {
78 | throw new Error("No workspace id provided")
79 | }
80 |
81 | const team_name = formData.get("team_name")
82 |
83 | const { data, error } = await supabase
84 | .from("accounts")
85 | .update([
86 | { team_name: team_name }
87 | ])
88 | .eq("id", wokspace_id)
89 | .select()
90 | .single()
91 |
92 | if (error) {
93 | console.log(error)
94 | throw Error(error)
95 | }
96 |
97 | return data
98 | }
99 |
100 |
101 | export const deleteWorkspace = async (wokspace_id) => {
102 | const supabase = createServerActionClient({ cookies }, {
103 | options: {
104 | db: { schema: "supaboard" }
105 | }
106 | })
107 | const { data: { session } } = await supabase.auth.getSession()
108 | if (!session) {
109 | throw new Error("Not authenticated")
110 | }
111 |
112 | if (!wokspace_id) {
113 | throw new Error("No workspace id provided")
114 | }
115 |
116 | const { data: dahboardsData, error: dahboardsError } = await supabase
117 | .from("dashboards")
118 | .delete()
119 | .eq("account_id", wokspace_id)
120 |
121 | const { data: accountUserData, error: accountUserError } = await supabase
122 | .from("account_user")
123 | .delete()
124 | .eq("account_id", wokspace_id)
125 |
126 | const { data: accountsData, error: accountsError } = await supabase
127 | .from("accounts")
128 | .delete()
129 | .eq("id", wokspace_id)
130 |
131 | if (accountUserError || accountsError || dahboardsError) {
132 | throw Error(accountUserError || accountsError || dahboardsError)
133 | }
134 |
135 | redirect("/settings/workspace")
136 | }
137 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/workspace/new/actions.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { cookies } from "next/headers"
4 | import { createServerActionClient } from "@supabase/auth-helpers-nextjs"
5 | import { redirect } from "next/navigation"
6 |
7 |
8 | export const createWorkspace = async (formData) => {
9 | const supabase = createServerActionClient({ cookies }, {
10 | options: {
11 | db: { schema: "supaboard" }
12 | }
13 | })
14 | const { data: { session } } = await supabase.auth.getSession()
15 | if (!session) {
16 | throw new Error("Not authenticated")
17 | }
18 |
19 | const teamName = formData.get("name")
20 |
21 | const { data, error } = await supabase
22 | .from("accounts")
23 | .insert({
24 | team_name: teamName
25 | })
26 | .select()
27 |
28 | if (error) {
29 | console.log(error)
30 | throw Error(error)
31 | }
32 |
33 | redirect("/settings/workspace")
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/(app)/settings/workspace/new/page.js:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { redirect } from "next/navigation"
4 | import { createWorkspace } from "./actions"
5 |
6 | export default async function NewWorkspace() {
7 |
8 | return (
9 | <>
10 |
11 | Create a new workspace
12 |
13 |
14 |
15 |
16 |
17 | Workspace name
18 |
19 |
20 |
33 |
34 | >
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/(app)/setup/page.js:
--------------------------------------------------------------------------------
1 |
2 | async function getData() {
3 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/setup/state`)
4 | if (!res.ok) {
5 | throw new Error("Failed to fetch data")
6 | }
7 |
8 | return res.json()
9 | }
10 |
11 | export default async function Setup() {
12 | const data = await getData()
13 |
14 | return (
15 |
16 |
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/src/app/(app)/setup/state/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { createClient } from "@supabase/supabase-js"
3 |
4 |
5 | export async function GET(req) {
6 | return NextResponse.json({ null: [] })
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/(app)/thank-you/page.js:
--------------------------------------------------------------------------------
1 | import Logo from "@/components/brand/Logo"
2 |
3 | export default async function ThankYou() {
4 | return (
5 |
6 |
7 |
8 |
9 |
Thank you!
10 |
11 | You're awesome. Thank you very much for signing up for Supaboard! If
12 | you need help with anything, don't hesitate to contact us.
13 |
14 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/(app)/workflows/page.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import ReactFlow, { ReactFlowProvider, Background, Controls, addEdge, applyEdgeChanges, applyNodeChanges } from "reactflow"
4 | import "reactflow/dist/style.css"
5 | import useStore from "@/store/index"
6 | import DefaultNode from "@/components/workflows/nodes/DefaultNode"
7 | import { useEffect, useState } from "react"
8 |
9 |
10 | const nodeTypes = {
11 | defaultNode: DefaultNode,
12 | }
13 |
14 | export default function Workflows() {
15 | const { nodes, setNodes, edges, setEdges } = useStore()
16 | const [flowInstance, setFlowInstance] = useState(null)
17 |
18 | const initFlow = async () => {
19 | let startingNode = {
20 | id: "start",
21 | type: "defaultNode",
22 | position: { x: 0, y: 0 },
23 | data: { id: "start", label: "This is the start of a conversation" },
24 | style: {
25 | width: 300,
26 | height: 200,
27 | },
28 | draggable: true,
29 | }
30 |
31 | setNodes([
32 | startingNode
33 | ])
34 | setEdges([])
35 | }
36 |
37 | useEffect(() => {
38 | if (nodes?.length == 0) {
39 | initFlow()
40 | }
41 | }, [nodes])
42 |
43 | const onNodesChange = async (changes) => {
44 | let newNodes = applyNodeChanges(changes, nodes)
45 | setNodes(newNodes)
46 | }
47 |
48 | const onEdgesChange = async (changes) => {
49 | let newEdges = applyEdgeChanges(changes, edges)
50 | setEdges(newEdges)
51 | }
52 |
53 | const onConnect = async (connection) => {
54 | let newEdges = addEdge(connection, edges)
55 | setEdges(newEdges)
56 | }
57 |
58 | return (
59 |
60 |
61 |
72 |
73 |
74 |
75 |
76 |
77 | )
78 | }
--------------------------------------------------------------------------------
/src/app/(auth)/auth/callback/route.js:
--------------------------------------------------------------------------------
1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
2 | import { cookies } from "next/headers"
3 | import { NextResponse } from "next/server"
4 |
5 | export async function GET(request) {
6 | const requestUrl = new URL(request.url)
7 | const code = requestUrl.searchParams.get("code")
8 |
9 | if (code) {
10 | const supabase = createRouteHandlerClient({ cookies })
11 | await supabase.auth.exchangeCodeForSession(code)
12 | }
13 |
14 | // URL to redirect to after sign in process completes
15 | return NextResponse.redirect(requestUrl.origin, { status: 302 })
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/(auth)/auth/signout/route.js:
--------------------------------------------------------------------------------
1 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
2 | import { cookies } from "next/headers"
3 | import { NextResponse } from "next/server"
4 |
5 | export async function POST(req) {
6 | const supabase = createRouteHandlerClient({ cookies })
7 |
8 | // Check if we have a session
9 | const {
10 | data: { session },
11 | } = await supabase.auth.getSession()
12 |
13 | if (session) {
14 | await supabase.auth.signOut()
15 | }
16 |
17 | return NextResponse.redirect(new URL("/", req.url), {
18 | status: 302,
19 | })
20 | }
--------------------------------------------------------------------------------
/src/app/(auth)/layout.js:
--------------------------------------------------------------------------------
1 | import "@/app/globals.css"
2 |
3 | export const metadata = {
4 | title: "Supaboard — Supabase and Postgres dashboards",
5 | description: "Powerful reporting dashboards on top of Supabase.",
6 | openGraph: {
7 | type: "website",
8 | locale: "en_US",
9 | url: "https://app.supaboard.co",
10 | title: "Supaboard — Supabase and Postgres dashboards",
11 | description: "Powerful reporting dashboards on top of Supabase.",
12 | siteName: "Supaboard",
13 | },
14 | twitter: {
15 | card: "summary_large_image",
16 | title: "Supaboard — Supabase and Postgres dashboards",
17 | description: "Powerful reporting dashboards on top of Supabase.",
18 | }
19 | }
20 |
21 | export default function AuthLayout({ children }) {
22 | return (
23 |
24 |
25 |
26 | {children}
27 |
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/(auth)/login/page.js:
--------------------------------------------------------------------------------
1 | import AuthForm from "@/components/auth/AuthForm"
2 | import Logo from "@/components/brand/Logo"
3 |
4 | export default function Login() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 | Sign in to continue.
13 |
14 |
15 |
18 |
19 | )
20 | }
--------------------------------------------------------------------------------
/src/app/(auth)/register/page.js:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation"
2 | import AuthForm from "@/components/auth/AuthForm"
3 | import Logo from "@/components/brand/Logo"
4 |
5 | export default async function Register() {
6 | if (process.env.NEXT_PUBLIC_SIGNUP_CLOSED === "true") {
7 | redirect("/login")
8 | }
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 | Create a new account to get started.
18 |
19 |
20 |
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/src/app/(public)/public/layout.js:
--------------------------------------------------------------------------------
1 | import "@/app/globals.css"
2 |
3 | export default function RootLayout({ children }) {
4 | return (
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/api/contacts/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { Client } from "pg"
5 | import { checkUserAllowed } from "@/lib/auth"
6 | import { getConnectionDetails } from "@/lib/database"
7 |
8 |
9 | export async function GET(req) {
10 | const cookieStore = cookies()
11 | const account_id = cookieStore.get("account_id").value
12 |
13 | const supabase = createRouteHandlerClient({ cookies }, {
14 | options: {
15 | db: { schema: "supaboard" }
16 | }
17 | })
18 |
19 | const { data: { session } } = await supabase.auth.getSession()
20 | if (!session) throw new Error("Not authenticated")
21 |
22 | const accountUser = await checkUserAllowed(supabase, session, account_id)
23 |
24 | // Step 1: Get the database connection details and where to find the contacts
25 | let query = supabase.from("contacts").select()
26 |
27 | if (process.env.IS_PLATFORM) {
28 | query = query.eq("account_id", account_id)
29 | }
30 |
31 | const { data: contacts, error } = await query
32 |
33 | if (error) {
34 | console.log(error)
35 | throw new Error("Internal Server Error")
36 | }
37 |
38 | if (!contacts || contacts.length === 0) {
39 | return NextResponse.json([])
40 | }
41 |
42 |
43 | // Step 2: Fetch the actual contacts from the external database
44 | const columns = contacts[0].attributes.map((attribute) => {
45 | return attribute.name
46 | })
47 | let result = []
48 | const connectionDetails = await getConnectionDetails(supabase, account_id, contacts[0].database)
49 | const client = new Client({
50 | host: connectionDetails.host,
51 | port: parseInt(connectionDetails.port),
52 | user: connectionDetails.user,
53 | password: connectionDetails.password,
54 | database: connectionDetails.database,
55 | })
56 | await client.connect()
57 |
58 | const externalQuery = await client.query(`SELECT ${columns} FROM ${contacts[0].table_name}`)
59 | result = externalQuery.rows
60 | client.end()
61 |
62 | let returnValue = {
63 | attributes: null,
64 | database: null,
65 | contacts: []
66 | }
67 |
68 | if (contacts.length > 0) {
69 | returnValue = {
70 | attributes: contacts[0].attributes,
71 | database: contacts[0].database,
72 | contacts: result
73 | }
74 | }
75 |
76 | return NextResponse.json(returnValue)
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/api/contacts/segments/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { Client } from "pg"
5 | import { checkUserAllowed } from "@/lib/auth"
6 | import { getConnectionDetails } from "@/lib/database"
7 |
8 |
9 | export async function GET(req) {
10 | const cookieStore = cookies()
11 | const account_id = cookieStore.get("account_id").value
12 |
13 | const supabase = createRouteHandlerClient({ cookies }, {
14 | options: {
15 | db: { schema: "supaboard" }
16 | }
17 | })
18 |
19 | const { data: { session } } = await supabase.auth.getSession()
20 | if (!session) throw new Error("Not authenticated")
21 |
22 | const accountUser = await checkUserAllowed(supabase, session, account_id)
23 |
24 | let query = supabase.from("segments").select()
25 |
26 | if (process.env.IS_PLATFORM) {
27 | query = query.eq("account_id", account_id)
28 | }
29 |
30 | const { data: segments, error } = await query
31 |
32 | if (error) {
33 | console.log(error)
34 | throw new Error("Internal Server Error")
35 | }
36 |
37 | if (!segments || segments.length === 0) {
38 | return NextResponse.json(null)
39 | }
40 |
41 | return NextResponse.json(segments)
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/counts/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 |
7 | export async function GET(req) {
8 | const account_id = cookies().get("account_id")?.value
9 | const supabase = createRouteHandlerClient({ cookies }, {
10 | options: {
11 | db: { schema: "supaboard" }
12 | }
13 | })
14 |
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) throw new Error("Not authenticated")
17 |
18 | let dbQuery = supabase.from("databases").select("*", { count: "exact"})
19 | let dashboardQuery = supabase.from("dashboards").select("*", { count: "exact"})
20 | let segmentQuery = supabase.from("segments").select("*", { count: "exact"})
21 |
22 | if (process.env.IS_PLATFORM) {
23 | dbQuery = dbQuery.eq("account_id", account_id)
24 | dashboardQuery = dashboardQuery.eq("account_id", account_id)
25 | segmentQuery = segmentQuery.eq("account_id", account_id)
26 | }
27 |
28 | const { data: databases, error: dbError } = await dbQuery
29 | const { data: dashboards, error: dahbordError } = await dashboardQuery
30 | const { data: segments, error: segmentError } = await segmentQuery
31 |
32 | if (dbError || dahbordError || segmentError) {
33 | console.log(dbError, dahbordError, segmentError)
34 | throw new Error("Failed to fetch data")
35 | }
36 |
37 | return NextResponse.json({
38 | databases: databases?.length || 0,
39 | dashboards: dashboards?.length || 0,
40 | segments: segments?.length || 0,
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/dashboards/[uuid]/charts/[chart_id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 |
4 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
5 |
6 |
7 | export async function PUT(req, { params }) {
8 | const uuid = params.uuid
9 | const chart_id = params.chart_id
10 | const data = await req.json()
11 | let newConfig
12 |
13 | const supabase = createRouteHandlerClient({ cookies }, {
14 | options: {
15 | db: { schema: "supaboard" }
16 | }
17 | })
18 |
19 | if (data.duplicate) {
20 | const chart = data.dashboard.config.charts.find((chart) => parseInt(chart.id) == parseInt(chart_id))
21 | const newChart = { ...chart, id: new Date().getTime() }
22 | newConfig = { ...data.dashboard.config, charts: [...data.dashboard.config.charts, newChart] }
23 | } else {
24 | const charts = data.dashboard.config.charts.filter((chart) => parseInt(chart.id) != parseInt(chart_id))
25 | newConfig = { ...data.dashboard.config, charts }
26 | }
27 |
28 | const { data: dashboard, error } = await supabase
29 | .from("dashboards")
30 | .update({config: newConfig })
31 | .eq("uuid", uuid)
32 | .select()
33 | .single()
34 |
35 | if (error) {
36 | console.log(error)
37 | }
38 |
39 | return NextResponse.json(dashboard || [])
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/api/dashboards/[uuid]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 |
5 | export async function GET(req, { params }) {
6 | const supabase = createRouteHandlerClient({ cookies }, {
7 | options: {
8 | db: { schema: "supaboard" }
9 | }
10 | })
11 |
12 | const uuid = params.uuid
13 |
14 | const { data: dashboard, error } = await supabase
15 | .from("dashboards")
16 | .select()
17 | .eq("uuid", uuid)
18 | .single()
19 |
20 | if (error) {
21 | console.log(error)
22 | }
23 |
24 | return NextResponse.json(dashboard || [])
25 | }
26 |
27 |
28 | export async function PUT(req, { params }) {
29 | const supabase = createRouteHandlerClient({ cookies }, {
30 | options: {
31 | db: { schema: "supaboard" }
32 | }
33 | })
34 |
35 | const uuid = params.uuid
36 | const data = await req.json()
37 |
38 | const { data: dashboard, error } = await supabase
39 | .from("dashboards")
40 | .update({config: data.config })
41 | .eq("uuid", uuid)
42 | .select()
43 | .single()
44 |
45 | if (error) {
46 | console.log(error)
47 | }
48 |
49 | return NextResponse.json(dashboard || [])
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/api/dashboards/data/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 |
5 | import { getConnectionDetails } from "@/lib/database"
6 | import { fetchPostgresData } from "@/lib/adapters/postgres/querybuilder"
7 | import { fetchMySQLData } from "@/lib/adapters/mysql/querybuilder"
8 |
9 |
10 | export async function POST(req) {
11 | try {
12 | const data = await req.json()
13 | const cookieStore = cookies()
14 | const account_id = cookieStore.get("account_id").value
15 |
16 | const supabase = createRouteHandlerClient({ cookies }, {
17 | options: {
18 | db: { schema: "supaboard" }
19 | }
20 | })
21 |
22 | const connectionDetails = await getConnectionDetails(supabase, account_id, data.database)
23 | let result
24 |
25 | if (connectionDetails.type == "postgres" || connectionDetails.type == "supabase") {
26 | result = await fetchPostgresData(data, connectionDetails)
27 | }
28 |
29 | if (connectionDetails.type == "mysql" || connectionDetails.type == "planetscale") {
30 | result = await fetchMySQLData(data, connectionDetails)
31 | }
32 |
33 | return NextResponse.json(result || [])
34 | } catch (error) {
35 | console.log(error)
36 | return NextResponse.json([])
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/api/dashboards/hash/[hash]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 |
4 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
5 |
6 |
7 | export async function GET(req, { params }) {
8 | const hash = params.hash
9 |
10 | const supabase = createRouteHandlerClient({ cookies }, {
11 | options: {
12 | db: { schema: "supaboard" }
13 | }
14 | })
15 |
16 | const { data: dashboard, error } = await supabase
17 | .from("dashboards")
18 | .select()
19 | .eq("public_hash", hash)
20 | .eq("is_public", true)
21 | .single()
22 |
23 | if (error || !dashboard) {
24 | return new NextResponse("Not found", { status: 404 })
25 | }
26 |
27 | return NextResponse.json(dashboard)
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/api/dashboards/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies, headers } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 |
7 | export async function GET(req) {
8 | const cookieStore = cookies()
9 | const account_id = cookieStore.get("account_id").value
10 |
11 | const supabase = createRouteHandlerClient({ cookies }, {
12 | options: {
13 | db: { schema: "supaboard" }
14 | }
15 | })
16 |
17 | const { data: { session } } = await supabase.auth.getSession()
18 | if (!session) throw new Error("Not authenticated")
19 |
20 | const accountUser = await checkUserAllowed(supabase, session, account_id)
21 |
22 | let query = supabase
23 | .from("dashboards")
24 | .select()
25 |
26 | if (process.env.IS_PLATFORM) {
27 | query = query.eq("account_id", account_id)
28 | }
29 |
30 | query.order("created_at", { ascending: false })
31 |
32 | const { data: dashboards, error } = await query
33 |
34 |
35 | return NextResponse.json(dashboards || [])
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/api/databases/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 |
7 | export async function GET(req, { params }) {
8 | const id = params.id
9 | const cookieStore = cookies()
10 | const account_id = cookieStore.get("account_id").value
11 | const supabase = createRouteHandlerClient({ cookies }, {
12 | options: {
13 | db: { schema: "supaboard" }
14 | }
15 | })
16 |
17 | const { data: { session } } = await supabase.auth.getSession()
18 | if (!session) throw new Error("Not authenticated")
19 |
20 | const accountUser = await checkUserAllowed(supabase, session, account_id)
21 |
22 | let query = supabase
23 | .from("databases")
24 | .select()
25 | .eq("uuid", id)
26 |
27 | if (process.env.IS_PLATFORM) {
28 | query = query.eq("account_id", account_id)
29 | }
30 |
31 | query = query.single()
32 |
33 |
34 | const { data: database, error } = await query
35 |
36 | return NextResponse.json(database || [])
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/api/databases/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 |
7 | export async function GET(req) {
8 | const cookieStore = cookies()
9 | const account_id = cookieStore.get("account_id").value
10 | const supabase = createRouteHandlerClient({ cookies }, {
11 | options: {
12 | db: { schema: "supaboard" }
13 | }
14 | })
15 |
16 | const { data: { session } } = await supabase.auth.getSession()
17 | if (!session) throw new Error("Not authenticated")
18 |
19 | const accountUser = await checkUserAllowed(supabase, session, account_id)
20 |
21 | let query = supabase
22 | .from("databases")
23 | .select()
24 |
25 | if (process.env.IS_PLATFORM) {
26 | query = query.eq("account_id", account_id)
27 | }
28 |
29 | query.order("created_at", { ascending: false })
30 |
31 | const { data: databases, error } = await query
32 |
33 | return NextResponse.json(databases || [])
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/api/externaldb/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { Client } from "pg"
5 | import mysql from "mysql2/promise"
6 |
7 |
8 | import { decrypt } from "@/lib/crypto"
9 | import { checkUserAllowed } from "@/lib/auth"
10 |
11 | export async function GET(req, { params }) {
12 | const id = params.id
13 | const cookieStore = cookies()
14 | const account_id = cookieStore.get("account_id").value
15 | const supabase = createRouteHandlerClient({ cookies }, {
16 | options: {
17 | db: { schema: "supaboard" }
18 | }
19 | })
20 |
21 | const { data: { session } } = await supabase.auth.getSession()
22 | if (!session) throw new Error("Not authenticated")
23 |
24 | const accountUser = await checkUserAllowed(supabase, session, account_id)
25 | let query = supabase
26 | .from("databases")
27 | .select()
28 | .eq("uuid", id)
29 |
30 | if (process.env.IS_PLATFORM) {
31 | query = query.eq("account_id", account_id)
32 | }
33 |
34 | query = query.single()
35 | const { data: database, error } = await query
36 |
37 | if (error) {
38 | console.log(error)
39 | throw new Error("Failed to fetch database")
40 | }
41 |
42 | const connectionDetails = JSON.parse(decrypt(database.connection))
43 | const connectionString = `postgresql://${connectionDetails.user}:${connectionDetails.password}@${connectionDetails.host}:${connectionDetails.port}/${connectionDetails.database}`
44 |
45 | let tables = {}
46 |
47 | if (database.type == "postgres" || database.type == "supabase") {
48 | const client = new Client({
49 | host: connectionDetails.host,
50 | port: parseInt(connectionDetails.port),
51 | user: connectionDetails.user,
52 | password: connectionDetails.password,
53 | database: connectionDetails.database,
54 | })
55 | await client.connect()
56 |
57 | const res = await client.query("SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, column_name")
58 | client.end()
59 |
60 |
61 |
62 | res.rows.forEach((row) => {
63 | if (!tables[row.table_name]) {
64 | tables[row.table_name] = {
65 | name: row.table_name,
66 | columns: []
67 | }
68 | }
69 | tables[row.table_name].columns.push({
70 | name: row.column_name,
71 | type: row.data_type
72 | })
73 | })
74 |
75 | tables = Object.keys(tables).map((key) => {
76 | return tables[key]
77 | })
78 | }
79 |
80 | if (database.type == "mysql" || database.type == "planetscale") {
81 | const connection = await mysql.createConnection({
82 | host: connectionDetails.host,
83 | port: parseInt(connectionDetails.port),
84 | user: connectionDetails.user,
85 | password: connectionDetails.password,
86 | database: connectionDetails.database,
87 | ssl: {
88 | rejectUnauthorized: true,
89 | }
90 | })
91 |
92 | const [rows, fields] = await connection.execute("SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = ? ORDER BY table_name, column_name", [connectionDetails.database]);
93 | connection.end()
94 |
95 | rows.forEach((row) => {
96 | if (!tables[row.TABLE_NAME]) {
97 | tables[row.TABLE_NAME] = {
98 | name: row.TABLE_NAME,
99 | columns: []
100 | }
101 | }
102 | tables[row.TABLE_NAME].columns.push({
103 | name: row.COLUMN_NAME,
104 | type: row.DATA_TYPE
105 | })
106 | })
107 |
108 | tables = Object.keys(tables).map((key) => {
109 | return tables[key]
110 | })
111 | }
112 |
113 | return NextResponse.json(tables || [])
114 | }
115 |
--------------------------------------------------------------------------------
/src/app/api/segments/[id]/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { Client } from "pg"
5 |
6 |
7 | import { decrypt } from "@/lib/crypto"
8 | import { checkUserAllowed } from "@/lib/auth"
9 | import { operatorMap } from "@/lib/operators"
10 | import { buildQuery } from "@/lib/adapters/postgres/querybuilder"
11 |
12 | export async function GET(req, { params }) {
13 | const id = params.id
14 | const cookieStore = cookies()
15 | const account_id = cookieStore.get("account_id").value
16 | const supabase = createRouteHandlerClient({ cookies }, {
17 | options: {
18 | db: { schema: "supaboard" }
19 | }
20 | })
21 |
22 | const { data: { session } } = await supabase.auth.getSession()
23 | if (!session) throw new Error("Not authenticated")
24 |
25 | const accountUser = await checkUserAllowed(supabase, session, account_id)
26 |
27 | // Get segment
28 | let query = supabase.from("segments").select().eq("uuid", id)
29 | if (process.env.IS_PLATFORM) { query = query.eq("account_id", account_id) }
30 | query = query.single()
31 | const { data: segment, error } = await query
32 |
33 | if (error) {
34 | console.log(error)
35 | throw Error(error)
36 | }
37 |
38 | // Get database
39 | let databaseQuery = supabase.from("databases").select().eq("uuid", segment.database)
40 | if (process.env.IS_PLATFORM) { databaseQuery = databaseQuery.eq("account_id", account_id) }
41 | query = databaseQuery.single()
42 | const { data: database, error: dbError } = await databaseQuery
43 |
44 | if (error || dbError) {
45 | console.log(error)
46 | console.log(dbError)
47 | throw Error(error || dbError)
48 | }
49 |
50 |
51 | const connectionDetails = JSON.parse(decrypt(database.connection))
52 | const client = new Client({
53 | host: connectionDetails.host,
54 | port: parseInt(connectionDetails.port),
55 | user: connectionDetails.user,
56 | password: connectionDetails.password,
57 | database: connectionDetails.database,
58 | })
59 | await client.connect()
60 |
61 | let segmentQuery
62 | if (segment.config.filterType == "simple") {
63 | segmentQuery = await buildQuery(segment.config)
64 | } else {
65 | segmentQuery = segment.config.filters.query
66 | }
67 |
68 |
69 | const res = await client.query(segmentQuery)
70 | client.end()
71 |
72 | let attributes = res.fields.map((field) => {
73 | return {
74 | name: field.name,
75 | identifier: field.name,
76 | type: field.dataTypeID
77 | }
78 | })
79 |
80 | return NextResponse.json({
81 | segment,
82 | attributes,
83 | contacts: res.rows
84 | })
85 | }
86 |
--------------------------------------------------------------------------------
/src/app/api/segments/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 | export async function GET(req, { params }) {
7 | const cookieStore = cookies()
8 | const account_id = cookieStore.get("account_id").value
9 | const supabase = createRouteHandlerClient({ cookies }, {
10 | options: {
11 | db: { schema: "supaboard" }
12 | }
13 | })
14 |
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) throw new Error("Not authenticated")
17 |
18 | const accountUser = await checkUserAllowed(supabase, session, account_id)
19 |
20 | let query = supabase
21 | .from("segments")
22 | .select()
23 |
24 | if (process.env.IS_PLATFORM) {
25 | query = query.eq("account_id", account_id)
26 | }
27 |
28 | query.order("created_at", { ascending: false })
29 |
30 | const { data: segments, error } = await query
31 |
32 | if (error) {
33 | console.log(error)
34 | throw Error(error)
35 | }
36 |
37 | return NextResponse.json(segments)
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/api/settings/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { cookies } from "next/headers"
3 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
4 | import { checkUserAllowed } from "@/lib/auth"
5 |
6 | export async function GET(req, { params }) {
7 | const cookieStore = cookies()
8 | const account_id = cookieStore.get("account_id").value
9 | const supabase = createRouteHandlerClient({ cookies }, {
10 | options: {
11 | db: { schema: "supaboard" }
12 | }
13 | })
14 |
15 | const { data: { session } } = await supabase.auth.getSession()
16 | if (!session) throw new Error("Not authenticated")
17 |
18 | const accountUser = await checkUserAllowed(supabase, session, account_id)
19 | let query = supabase
20 | .from("accounts")
21 | .select()
22 |
23 | if (process.env.IS_PLATFORM) {
24 | query = query.eq("account_id", account_id)
25 | }
26 |
27 | query = query.single()
28 | const { data: database, error } = await query
29 |
30 | return NextResponse.json(tables || [])
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/api/webhooks/stripe/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { stripe } from "@/lib/stripe/stripe"
3 | import { headers } from "next/headers"
4 | import { upsertProductRecord, upsertPriceRecord, manageSubscriptionStatusChange } from "@/lib/stripe/supabase-admin"
5 |
6 | const relevantEvents = new Set([
7 | "product.created",
8 | "product.updated",
9 | "price.created",
10 | "price.updated",
11 | "checkout.session.completed",
12 | "customer.subscription.created",
13 | "customer.subscription.updated",
14 | "customer.subscription.deleted"
15 | ])
16 |
17 | export async function POST(req) {
18 | const rawBody = await req.text()
19 |
20 | const headres = headers()
21 | const signature = headres.get("stripe-signature")
22 |
23 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_LIVE ?? process.env.STRIPE_WEBHOOK_SECRET
24 | let event
25 |
26 | try {
27 | if (!signature || !webhookSecret) return
28 | event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)
29 | } catch (err) {
30 | console.log(`❌ Error message: ${err.message}`)
31 | return new NextResponse.json({ error: err.message }, { status: 400 })
32 | }
33 |
34 | if (relevantEvents.has(event.type)) {
35 | try {
36 | switch (event.type) {
37 | case "product.created":
38 | case "product.updated":
39 | await upsertProductRecord(event.data.object)
40 | break
41 | case "price.created":
42 | case "price.updated":
43 | await upsertPriceRecord(event.data.object)
44 | break
45 | case "customer.subscription.created":
46 | case "customer.subscription.updated":
47 | case "customer.subscription.deleted":
48 | const subscription = event.data.object
49 | await manageSubscriptionStatusChange(
50 | subscription.id,
51 | subscription.customer,
52 | event.type === "customer.subscription.created"
53 | )
54 | break
55 | case "checkout.session.completed":
56 | const checkoutSession = event.data
57 | .object
58 | if (checkoutSession.mode === "subscription") {
59 | const subscriptionId = checkoutSession.subscription
60 | await manageSubscriptionStatusChange(
61 | subscriptionId,
62 | checkoutSession.customer,
63 | true
64 | )
65 | }
66 | break
67 | default:
68 | throw new Error("Unhandled relevant event!")
69 | }
70 | } catch (error) {
71 | console.log(error)
72 | return new NextResponse.json({ error: "Webhook handler failed. View logs" }, { status: 400 })
73 | }
74 | }
75 |
76 | return NextResponse.json({ received: true })
77 | }
78 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 |
12 | body {
13 | color: rgb(var(--foreground-rgb));
14 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
15 | overflow-x: hidden;
16 | }
17 |
18 | .mono {
19 | font-family: 'IBM Plex Mono', monospace;
20 | }
21 |
22 | .bg-gradient {
23 | background-image: linear-gradient(45deg, #E52EE5, #FF7E44);
24 | }
25 |
26 | @layer components {
27 | .btn-default {
28 | @apply text-white px-4 py-2 rounded bg-[#444] hover:bg-gray-800 transition-colors cursor-pointer;
29 | }
30 |
31 | .btn-secondary {
32 | @apply text-[#444] px-4 py-2 rounded border border-[#444] hover:bg-gray-200 transition-colors cursor-pointer;
33 | }
34 |
35 | .btn-empty {
36 | @apply text-black px-4 py-4 rounded border border-gray-200 bg-white hover:bg-gray-200 transition-colors cursor-pointer;
37 | }
38 |
39 | label {
40 | @apply text-gray-500 block uppercase text-[13px] mb-1;
41 | }
42 |
43 | .form-input {
44 | @apply block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gray-500 focus:border-gray-500 sm:text-sm;
45 | }
46 | }
47 |
48 | .rs-picker-toggle {
49 | @apply !text-dark !border-dark !shadow-none;
50 | }
51 |
52 | .rs-picker-has-value .rs-btn .rs-picker-toggle-value, .rs-picker-has-value .rs-picker-toggle .rs-picker-toggle-value {
53 | @apply !text-dark;
54 | }
55 |
56 | :root {
57 | --rs-btn-primary-bg: #444 !important;
58 | --rs-btn-primary-text: #fff !important;
59 | --rs-btn-link-text: #444 !important;
60 | --rs-btn-primary-hover-bg: #1f2937 !important;
61 |
62 | --rs-calendar-cell-selected-hover: #31dc8e !important;
63 | --rs-bg-active: #31dc8e !important;
64 | --rs-input-focus-border: #31dc8e !important;
65 | --rs-calendar-cell-selected-hover-bg: #31dc8e !important;
66 | --rs-calendar-range-bg: #c5f5db !important;
67 |
68 |
69 | --rs-listbox-option-hover-bg: #c5f5db !important;
70 | --rs-listbox-option-hover-text: #444 !important;
71 | }
72 |
73 | @keyframes blink {
74 | 0% { opacity: 0; }
75 | 100% { opacity: 1; }
76 | }
77 |
78 | .blink {
79 | animation: blink .55s ease-in alternate infinite;
80 | }
81 |
82 | .react-resizable-handle {
83 | opacity: 0.25;
84 | }
85 |
86 | @media (max-width: 768px) {
87 | .react-grid-layout {
88 | padding: 15px !important;
89 | height: auto !important;
90 | }
91 |
92 | .react-grid-item {
93 | width: 100% !important;
94 | position: relative !important;
95 | transform: none !important;
96 | margin: 15px 0 !important;
97 | height: auto !important;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/layout.js:
--------------------------------------------------------------------------------
1 | import "@/app/globals.css"
2 | import { UpgradeModal } from "@/components/UpgradeModal"
3 | import { Telemetry } from "@/providers/Telemetry"
4 |
5 | export const metadata = {
6 | metadataBase: new URL("https://app.supaboard.co"),
7 | title: "Supaboard — Supabase and Postgres dashboards",
8 | description: "Create reporting dashboards on top of Supabase with ease.",
9 | icons: {
10 | icon: "/favicon/favicon.png",
11 | },
12 | }
13 |
14 |
15 | export default function RootLayout({ children }) {
16 | if (process.env.IS_PLATFORM && process.env.NEXT_PUBLIC_ENV != "dev") {
17 | return (
18 |
19 | {children}
20 |
21 |
22 | )
23 | } else {
24 | return (
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/supaboard/app/abf3db6e1933b3d612ad2e1e8b043c02fe42a0dd/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | const Loading = ({width = 25, height = 25, className}) => {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | export default Loading
--------------------------------------------------------------------------------
/src/components/Skeleton.js:
--------------------------------------------------------------------------------
1 | const Skeleton = ({width, height , className, darkness = 100}) => {
2 | return (
3 |
4 |
5 |
6 | )
7 | }
8 |
9 | export default Skeleton
10 |
--------------------------------------------------------------------------------
/src/components/UpgradeModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import useStore from "@/store/index"
4 | import { useRouter } from "next/navigation"
5 | import Link from "next/link"
6 | import Modal from "./util/Modal"
7 |
8 | export function UpgradeModal() {
9 | const router = useRouter()
10 | const { showUpgradeModal, setShowUpgradeModal } = useStore()
11 |
12 | const redirectBack = () => {
13 | if (showUpgradeModal == "create:segment") {
14 | router.push("/segments")
15 | }
16 | if (showUpgradeModal == "create:dashboard") {
17 | router.push("/dashboards")
18 | }
19 | if (showUpgradeModal == "create:datasource") {
20 | router.push("/databases")
21 | }
22 | }
23 |
24 | return (
25 | <>
26 |
27 | {
30 | return false
31 | }}
32 | >
33 |
34 |
35 | Upgrade required
36 |
37 |
38 |
39 | {showUpgradeModal == "create:segment" && (
40 | <>
41 | You've reached the limit of segments you can create on your current plan. Please upgrade to create more segments.
42 | >
43 | )}
44 | {showUpgradeModal == "create:dashboard" && (
45 | <>
46 | You've reached the limit of dashboards you can create on your current plan. Please upgrade to create more dashboards.
47 | >
48 | )}
49 | {showUpgradeModal == "create:datasource" && (
50 | <>
51 | You've reached the limit of data sources you can create on your current plan. Please upgrade to create more data sources.
52 | >
53 | )}
54 |
55 |
56 |
57 |
58 | {
62 | setShowUpgradeModal(false)
63 | redirectBack()
64 | }}
65 | >
66 | Cancel
67 |
68 |
69 |
70 | setShowUpgradeModal(false)} className="btn-default inline-block">
71 | Upgrade now
72 |
73 |
74 |
75 |
76 |
77 | >
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/WorkspaceSwitcher.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"
5 | import { useRouter } from "next/navigation"
6 |
7 | import { getUserAccounts } from "@/lib/auth"
8 | import Loading from "@/components/Loading"
9 | import Link from "next/link"
10 |
11 | export const WorkspaceSwitcher = ({ modalState }) => {
12 | const router = useRouter()
13 | const [accounts, setAccounts] = useState(null)
14 | const [activeAccount, setActiveAccount] = useState(null)
15 |
16 | const supabase = createClientComponentClient({
17 | options: {
18 | db: { schema: "supaboard" }
19 | }
20 | })
21 |
22 |
23 | const changeTeam = async (team) => {
24 | document.cookie = `account_id=${team.account_id}; path=/;`
25 |
26 | if (modalState) {
27 | modalState(false)
28 | }
29 | router.push("/overview")
30 | router.refresh()
31 | }
32 |
33 | useEffect(() => {
34 | const getAccounts = async () => {
35 | setActiveAccount(document.cookie.split("; ").find(row => row.startsWith("account_id")).split("=")[1])
36 | const { data: { session } } = await supabase.auth.getSession()
37 | let response = await getUserAccounts(supabase, session.user.id)
38 | setAccounts(response)
39 | }
40 |
41 | getAccounts()
42 | }, [])
43 |
44 |
45 | return (
46 |
47 |
48 |
Change workspace
49 |
50 | Switch between the workspaces you're a member of
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {!accounts &&
}
60 | {accounts && accounts.map((account, index) => (
61 |
{
63 | changeTeam(account)
64 | }}
65 | className={`flex text-left items-center p-2 border ${activeAccount == account.account_id ? "border-highlight bg-green-50" : "border-gray-200"} rounded-lg w-full mb-3 hover:bg-gray-100`}
66 | key={`acc-${index}`}
67 | >
68 |
69 |
70 | {account.accounts.team_name && (
71 | account.accounts.team_name[0].toUpperCase()
72 | )}
73 | {!account.accounts.team_name && (
74 | <>P>
75 | )}
76 |
77 |
78 |
79 |
80 | {account.accounts?.team_name || "Personal"}
81 |
82 |
83 |
84 | {(activeAccount == account.account_id) && (
85 |
88 | )}
89 |
90 |
91 | ))}
92 |
93 |
94 | {
98 | if (modalState) {
99 | modalState(false)
100 | }
101 | }}
102 | >
103 | Create a new workspace
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | )
112 | }
113 |
--------------------------------------------------------------------------------
/src/components/auth/AuthForm.js:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { Auth } from "@supabase/auth-ui-react"
3 | import { ThemeSupa } from "@supabase/auth-ui-shared"
4 | import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"
5 | import { useEffect } from "react"
6 |
7 | export default function AuthForm({ view }) {
8 | const supabase = createClientComponentClient()
9 |
10 | useEffect(() => {
11 | const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
12 | if (session) {
13 | window.location.href = `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`
14 | }
15 | })
16 | }, [])
17 |
18 | return (
19 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/brand/Logo.js:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 | import Link from "next/link"
3 |
4 | const Logo = () => {
5 | return (
6 | <>
7 |
8 |
9 |
10 |
11 |
12 |
13 | Supaboard
14 |
15 |
16 |
17 | >
18 | )
19 | }
20 |
21 |
22 | export default Logo
--------------------------------------------------------------------------------
/src/components/contacts/steps/SelectContacts.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react"
4 | import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, TrashIcon } from "@heroicons/react/24/outline"
5 |
6 | export function SelectContacts({ data, selectedDataTable, setSelectedDataTable, selectedDataColumn, setSelectedDataColumn, attributes, setAttributes }) {
7 | const [expanded, setExpanded] = useState([])
8 |
9 | useEffect(() => {
10 | let attrs = []
11 | data?.find((table) => table.name == selectedDataTable)?.columns.map((column) => (
12 | attrs.push({
13 | identifier: column.name,
14 | name: column.name,
15 | type: column.type,
16 | })
17 | ))
18 |
19 | setAttributes(attrs)
20 | }, [selectedDataTable])
21 |
22 | return (
23 |
24 |
25 | Select where your users are stored and which attributes you want to use for segmentation
26 |
27 |
28 | You'll be able to filter your users by these attributes later on.
29 |
30 |
31 |
32 |
33 | Table
34 | {
37 | setSelectedDataTable(e.target.value)
38 | }}
39 | >
40 | {data?.map((table) => (
41 | {table.name}
42 | ))}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
Attributes
50 |
51 | {attributes?.map((attribute) => (
52 |
53 |
{
58 | let attrs = [...attributes]
59 | attrs.find((attr) => attr.name == attribute.name).name = e.target.value
60 | setAttributes(attrs)
61 | }}
62 | />
63 |
{
67 | let attrs = [...attributes]
68 | attrs.find((attr) => attr.name == attribute.name).type = e.target.value
69 | setAttributes(attrs)
70 | }}
71 | >
72 | string
73 | number
74 | boolean
75 | date
76 |
77 |
78 | {
79 | setAttributes(attributes.filter((attr) => attr.name != attribute.name))
80 | }} />
81 |
82 |
83 | )
84 | )}
85 |
86 |
87 |
88 | )
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/dashboards/DashboardDeleteModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Modal from "@/components/util/Modal"
4 | import { deleteDahboard } from "@/app/(app)/dashboards/actions"
5 | import { toast } from "sonner"
6 | import { useRouter } from "next/navigation"
7 |
8 | export function DashboardDeleteModal({ dashboard, showModal, setShowModal }) {
9 | const router = useRouter()
10 |
11 | return (
12 | <>
13 | setShowModal(false)}
16 | >
17 |
18 |
19 | Really detele this dashboard?
20 |
21 |
47 |
48 |
49 | >
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/dashboards/DashboardEditModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Modal from "@/components/util/Modal"
4 | import { updateDashboard } from "@/app/(app)/dashboards/actions"
5 | import { toast } from "sonner"
6 | import { useRouter } from "next/navigation"
7 |
8 | export function DashboardEditModal({ dashboard, showModal, setShowModal, setUpdateHash }) {
9 | const router = useRouter()
10 |
11 | return (
12 | <>
13 | setShowModal(false)}
16 | >
17 |
18 |
19 | Edit this dashboard
20 |
21 |
77 |
78 |
79 | >
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/dashboards/DashboardNewModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import Modal from "@/components/util/Modal"
5 | import { createDashboard } from "@/app/(app)/dashboards/actions"
6 | import { toast } from "sonner"
7 | import { useRouter } from "next/navigation"
8 | import useStore from "@/store/index"
9 | import { can } from "@/lib/auth"
10 | import Loading from "../Loading"
11 |
12 |
13 | export function DashboardNewModal({ showModal, setShowModal }) {
14 | const router = useRouter()
15 | const { showUpgradeModal, setShowUpgradeModal } = useStore()
16 | const [loading, setLoading] = useState(false)
17 |
18 | const checkCreateAllowed = async (e) => {
19 | e.preventDefault()
20 | setLoading(true)
21 |
22 | const allowed = await can("create:dashboard")
23 | if (!allowed) {
24 | setLoading(false)
25 | setShowModal(false)
26 | setShowUpgradeModal("create:dashboard")
27 | } else {
28 | e.target.form.requestSubmit()
29 | }
30 | }
31 |
32 | return (
33 | <>
34 | setShowModal(false)}
37 | >
38 |
39 |
40 | Create a dashboard
41 |
42 |
95 |
96 |
97 | >
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/src/components/dashboards/NoDashboards.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react"
4 | import { PlusIcon } from "@heroicons/react/20/solid"
5 | import { Toaster } from "sonner"
6 |
7 | import { DashboardNewModal } from "@/components/dashboards/DashboardNewModal"
8 |
9 |
10 | export function NoDashboards() {
11 | const [showModal, setShowModal] = useState(false)
12 |
13 | return (
14 | <>
15 |
16 |
19 |
20 | You don't have any dashboards yet. Create a new dashboard to get started.
21 |
22 |
setShowModal(true)}
25 | >
26 | Create a new dashboard
27 |
28 |
29 |
30 |
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/AreaChart.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import dynamic from "next/dynamic"
5 | const Chart = dynamic(() => import("react-apexcharts"), { ssr: false })
6 | import { ChartHeader } from "./ChartHeader"
7 | import Loading from "@/components/Loading"
8 | import { ChartError } from "./ChartError"
9 |
10 |
11 | export function AreaChart({ dashboard_uuid, chartData, timeframe, is_public }) {
12 | const [chartOptions, setChartOptions] = useState({})
13 | const [chartSeries, setChartSeries] = useState([])
14 | const [loading, setLoading] = useState(true)
15 | const [hasError, setHasError] = useState(false)
16 |
17 | useEffect(() => {
18 | if (!chartData) return
19 | if (!dashboard_uuid) return
20 |
21 | const fetchChartData = async () => {
22 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/data`, {
23 | method: "POST",
24 | headers: {
25 | "Accept": "application/json",
26 | "Content-Type": "application/json"
27 | },
28 | body: JSON.stringify({
29 | dashboard_uuid: dashboard_uuid,
30 | ...chartData
31 | })
32 | })
33 |
34 | const data = await res.json()
35 | if (!data || data.length === 0 || !res.ok) {
36 | setHasError(true)
37 | }
38 |
39 | let dataCol = chartData.dataCol
40 | let timeCol = chartData.timeCol
41 |
42 | let UsageByDay = data.reduce((acc, item) => {
43 | const date = new Date(item[timeCol])
44 | const day = date.getDate()
45 | const month = date.getMonth() + 1
46 | const year = date.getFullYear()
47 | const key = `${year}-${month}-${day}`
48 | if (!acc[key]) {
49 | acc[key] = 0
50 | }
51 | acc[key] += 1
52 | return acc
53 | }, {})
54 |
55 | // Limit data to timeframe
56 | if (timeframe) {
57 | UsageByDay = Object.keys(UsageByDay).reduce((acc, key) => {
58 | const date = new Date(key)
59 | const timestamp = date.getTime()
60 | const timestampFrame = timeframe.map((t) => new Date(t).getTime())
61 | if (timestamp >= timestampFrame[0] && timestamp <= timestampFrame[1]) {
62 | acc[key] = UsageByDay[key]
63 | }
64 | return acc
65 | }
66 | , {})
67 | }
68 |
69 | let series = []
70 | let dataPoints = []
71 | let days = []
72 |
73 | Object.keys(UsageByDay).map((key) => {
74 | const date = new Date(key)
75 | days.push(date.toLocaleDateString())
76 | dataPoints.push(UsageByDay[key])
77 | series.push({
78 | data: [UsageByDay[key]],
79 | name: date.toLocaleDateString()
80 | })
81 | })
82 |
83 |
84 | setChartOptions({
85 | chart: {
86 | id: "basic-bar",
87 | toolbar: {
88 | show: false
89 | }
90 | },
91 | xaxis: {
92 | categories: days
93 | },
94 | fill: {
95 | colors: ["#31dc8e", "#444"]
96 | },
97 | stroke: {
98 | colors: ["#31dc8e",]
99 | },
100 | dataLabels: {
101 | style: {
102 | colors: ["#444"]
103 | }
104 | }
105 | })
106 |
107 | setChartSeries([
108 | {
109 | name: "Amount",
110 | data: dataPoints
111 | }
112 | ])
113 |
114 | setLoading(false)
115 | }
116 |
117 | fetchChartData()
118 | }, [chartData, dashboard_uuid, timeframe])
119 |
120 | return (
121 |
122 |
123 |
124 | {loading && (
125 |
126 | )}
127 | {!loading && !hasError && (
128 |
135 | )}
136 | {!loading && hasError && (
137 |
138 | )}
139 |
140 |
141 | )
142 | }
143 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/ChartError.js:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"
3 |
4 | export function ChartError() {
5 | return (
6 |
7 |
8 |
9 |
Error loading chart
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/ChartErrorBoundary.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { ChartError } from "./ChartError"
3 |
4 |
5 | class ChartErrorBoundary extends React.Component {
6 | constructor(props) {
7 | super(props)
8 | this.state = { hasError: false }
9 | }
10 |
11 | static getDerivedStateFromError(error) {
12 | return { hasError: true }
13 | }
14 |
15 | componentDidCatch(error, errorInfo) {
16 | console.log({ error, errorInfo })
17 | }
18 |
19 | render() {
20 | if (this.state.hasError) {
21 | return (
22 |
23 | )
24 | }
25 |
26 | return this.props.children
27 | }
28 | }
29 |
30 | export default ChartErrorBoundary
31 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/ChartHeader.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { EllipsisHorizontalIcon, PencilSquareIcon, Square2StackIcon, TrashIcon } from "@heroicons/react/24/outline"
4 | import { Menu, Transition } from "@headlessui/react"
5 | import { Fragment, useEffect, useRef, useState } from "react"
6 | import { ChevronDownIcon } from "@heroicons/react/20/solid"
7 | import useStore from "@/store/index"
8 | import { ChartEditModal } from "./ChartEditModal"
9 |
10 | export function ChartHeader({ dashboard_uuid, chartData, is_public }) {
11 | const { dashboard, setDashboard } = useStore()
12 | const [showEditModal, setShowEditModal] = useState(false)
13 |
14 | const deleteChart = async () => {
15 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/${dashboard_uuid}/charts/${chartData.id}`, {
16 | method: "PUT",
17 | body: JSON.stringify({
18 | dashboard: dashboard
19 | })
20 | })
21 | const data = await res.json()
22 | setDashboard(data)
23 | }
24 |
25 | const duplicateChart = async () => {
26 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/${dashboard_uuid}/charts/${chartData.id}`, {
27 | method: "PUT",
28 | body: JSON.stringify({
29 | dashboard: dashboard,
30 | duplicate: true
31 | })
32 | })
33 | const data = await res.json()
34 | setDashboard(data)
35 | }
36 |
37 | const editChart = () => {
38 | setShowEditModal(true)
39 | }
40 |
41 | return (
42 |
43 |
44 |
45 |
{chartData.name}
46 | {!is_public && (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
63 |
64 |
65 |
66 | {({ active }) => (
67 |
72 |
73 | Edit
74 |
75 | )}
76 |
77 |
78 | {({ active }) => (
79 |
84 |
85 | Duplicate
86 |
87 | )}
88 |
89 |
90 | {({ active }) => (
91 |
96 |
97 | Delete this chart
98 |
99 | )}
100 |
101 |
102 |
103 |
104 |
105 |
106 | )}
107 |
108 |
109 | {chartData.description}
110 |
111 |
112 |
113 |
114 |
115 | )
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/CounterChart.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import { ChartHeader } from "./ChartHeader"
5 | import Loading from "@/components/Loading"
6 | import { ChartError } from "./ChartError"
7 |
8 |
9 | export function CounterChart({ dashboard_uuid, chartData, timeframe, is_public }) {
10 | const [counter, setCounter] = useState(null)
11 | const [loading, setLoading] = useState(true)
12 | const [headerSize, setHeaderSize] = useState(60)
13 | const [hasError, setHasError] = useState(false)
14 |
15 | useEffect(() => {
16 | if (!chartData) return
17 | if (!dashboard_uuid) return
18 |
19 | const fetchChartData = async () => {
20 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/data`, {
21 | method: "POST",
22 | headers: {
23 | "Accept": "application/json",
24 | "Content-Type": "application/json"
25 | },
26 | body: JSON.stringify({
27 | dashboard_uuid: dashboard_uuid,
28 | ...chartData
29 | })
30 | })
31 |
32 | const data = await res.json()
33 | if (!data || data.length === 0 || !res.ok) {
34 | setHasError(true)
35 | }
36 |
37 | let timeCol = chartData.timeCol
38 |
39 | // Limit data to timeframe
40 | if (timeframe) {
41 | const start = new Date(timeframe[0]).getTime()
42 | const end = new Date(timeframe[1]).getTime()
43 | const filteredData = data.filter((item) => {
44 | const timestamp = new Date(item[timeCol]).getTime()
45 | return timestamp >= start && timestamp <= end
46 | })
47 | setCounter(filteredData.length)
48 | } else {
49 | setCounter(data.length)
50 | }
51 |
52 | setLoading(false)
53 | }
54 |
55 | fetchChartData()
56 | }, [chartData, dashboard_uuid, timeframe])
57 |
58 | useEffect(() => {
59 | const header = document.querySelector(".chart-header")
60 | if (!header) return
61 | setHeaderSize(header.offsetHeight)
62 | }, [headerSize])
63 |
64 |
65 | return (
66 |
67 |
68 |
69 |
70 | {loading && !hasError && (
71 |
72 | )}
73 | {!loading && !hasError && (
74 | <>
75 | {counter}
76 | >
77 | )}
78 | {!loading && hasError && (
79 |
80 | )}
81 |
82 |
83 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/PieChart.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import dynamic from "next/dynamic"
5 | const Chart = dynamic(() => import("react-apexcharts"), { ssr: false })
6 | import { ChartHeader } from "./ChartHeader"
7 | import Loading from "@/components/Loading"
8 | import { ChartError } from "./ChartError"
9 |
10 |
11 | export function PieChart({ dashboard_uuid, chartData, timeframe, is_public }) {
12 | const [chartOptions, setChartOptions] = useState({})
13 | const [chartSeries, setChartSeries] = useState([])
14 | const [loading, setLoading] = useState(true)
15 | const [hasError, setHasError] = useState(false)
16 |
17 | useEffect(() => {
18 | if (!chartData) return
19 | if (!dashboard_uuid) return
20 |
21 | const fetchChartData = async () => {
22 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/data`, {
23 | method: "POST",
24 | headers: {
25 | "Accept": "application/json",
26 | "Content-Type": "application/json"
27 | },
28 | body: JSON.stringify({
29 | dashboard_uuid: dashboard_uuid,
30 | ...chartData
31 | })
32 | })
33 |
34 | const data = await res.json()
35 | if (!data || data.length === 0 || !res.ok) {
36 | setHasError(true)
37 | }
38 |
39 | let dataCol = chartData.dataCol
40 | let timeCol = chartData.timeCol
41 |
42 | let UsageByDay = data.data.reduce((acc, item) => {
43 | const date = new Date(item[timeCol])
44 | const day = date.getDate()
45 | const month = date.getMonth() + 1
46 | const year = date.getFullYear()
47 | const key = `${year}-${month}-${day}`
48 | if (!acc[key]) {
49 | acc[key] = 0
50 | }
51 | acc[key] += 1
52 | return acc
53 | }, {})
54 |
55 | // Limit data to timeframe
56 | if (timeframe) {
57 | UsageByDay = Object.keys(UsageByDay).reduce((acc, key) => {
58 | const date = new Date(key)
59 | const timestamp = date.getTime()
60 | const timestampFrame = timeframe.map((t) => new Date(t).getTime())
61 | if (timestamp >= timeframe[0] && timestamp <= timeframe[1]) {
62 | acc[key] = UsageByDay[key]
63 | }
64 | return acc
65 | }
66 | , {})
67 | }
68 |
69 | let series = []
70 | let labels = []
71 |
72 | data.map((dataPoint) => {
73 | series.push(dataPoint.source_ids.length)
74 | labels.push(dataPoint.name)
75 | })
76 |
77 | setChartOptions({
78 | chart: {
79 | id: "donut",
80 | toolbar: {
81 | show: false
82 | }
83 | },
84 | labels: labels,
85 | dataLabels: {
86 | style: {
87 | colors: ["#444"]
88 | }
89 | }
90 | })
91 |
92 | setChartSeries(series)
93 | setLoading(false)
94 | }
95 |
96 | fetchChartData()
97 | }, [chartData, dashboard_uuid, timeframe])
98 |
99 | return (
100 |
101 |
102 |
103 | {loading && !hasError && (
104 |
105 | )}
106 | {!loading && !hasError && (
107 |
114 | )}
115 | {!loading && hasError && (
116 |
117 | )}
118 |
119 |
120 | )
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/dashboards/charts/TableChart.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useEffect } from "react"
4 | import { ChartHeader } from "./ChartHeader"
5 | import Loading from "@/components/Loading"
6 | import { ChartError } from "./ChartError"
7 |
8 | export function TableChart({ dashboard_uuid, chartData, is_public }) {
9 | const [data, setData] = useState(null)
10 | const [loading, setLoading] = useState(true)
11 | const [headerSize, setHeaderSize] = useState(60)
12 | const [hasError, setHasError] = useState(false)
13 |
14 | useEffect(() => {
15 | if (!chartData) return
16 | if (!dashboard_uuid) return
17 |
18 | const fetchChartData = async () => {
19 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/dashboards/data`, {
20 | method: "POST",
21 | headers: {
22 | "Accept": "application/json",
23 | "Content-Type": "application/json"
24 | },
25 | body: JSON.stringify({
26 | dashboard_uuid: dashboard_uuid,
27 | ...chartData
28 | })
29 | })
30 |
31 | const data = await res.json()
32 | if (!data || data.length === 0 || !res.ok) {
33 | setHasError(true)
34 | }
35 |
36 | setData(data)
37 | setLoading(false)
38 | }
39 |
40 | fetchChartData()
41 | }, [chartData, dashboard_uuid])
42 |
43 | useEffect(() => {
44 | const header = document.querySelector(".chart-header")
45 | if (!header) return
46 | setHeaderSize(header.offsetHeight)
47 | }, [headerSize])
48 |
49 |
50 | return (
51 |
52 |
53 |
54 |
55 | {loading && !hasError && (
56 |
57 | )}
58 | {!loading && !hasError && (
59 |
60 |
61 |
62 |
63 |
64 | {data && data.length > 0 && Object.keys(data[0]).map((key) => (
65 |
66 | {key}
67 |
68 | ))}
69 |
70 |
71 |
72 | {data && data.length > 0 && data.slice(0,5).map((row, index) => (
73 |
74 | {Object.keys(row).map((key) => (
75 |
76 | {row[key]}
77 |
78 | ))}
79 |
80 | ))}
81 |
82 |
83 |
84 |
85 | )}
86 | {!loading && hasError && (
87 |
88 | )}
89 |
90 |
91 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/dashboards/steps/ChartDatabase.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ArrowLeftIcon, ArrowRightIcon, ArrowUpRightIcon } from "@heroicons/react/24/outline"
4 | import Link from "next/link"
5 | import { useEffect, useState } from "react"
6 |
7 | export function ChartDatabase({ activeStep, setActiveStep, databases, setDatabase, cancelUrl }) {
8 |
9 | return (
10 |
11 |
12 | Database connection
13 |
14 | {(!databases || databases.length == 0) && (
15 |
16 |
17 | You don't have any databases connected to your account. Please connect a database to continue.
18 |
19 |
20 | Connect a database
21 |
22 |
23 | )}
24 | {databases && databases.length > 0 && (
25 | <>
26 |
27 |
28 | Database
29 |
30 | {
34 | setDatabase(e.target.value)
35 | }}
36 | >
37 | {databases.map((database) => (
38 |
39 | {database.name}
40 |
41 | ))}
42 |
43 |
44 |
45 |
46 | {cancelUrl && (
47 |
51 | Cancel
52 |
53 | )}
54 | {!cancelUrl && (
55 |
{
58 | setActiveStep(activeStep - 1)
59 | }}
60 | >
61 |
62 | Back
63 |
64 | )}
65 |
66 |
67 |
{
70 | setActiveStep(activeStep + 1)
71 | }}
72 | >
73 | Next
74 |
75 |
76 |
77 |
78 | >
79 | )}
80 |
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/dashboards/steps/ChartOptions.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ArrowLeftIcon } from "@heroicons/react/24/outline"
4 |
5 | export function ChartOptions({ chartType, options, setOptions, saveChart, activeStep, setActiveStep }) {
6 | return (
7 |
8 |
9 | Chart options
10 |
11 |
12 |
13 |
14 | Chart Name
15 |
16 | {
23 | setOptions({
24 | ...options,
25 | name: e.target.value
26 | })
27 | }}
28 | />
29 |
30 |
31 |
32 | Description
33 |
34 |
47 |
48 |
49 |
50 |
{
53 | setActiveStep(activeStep - 1)
54 | }}
55 | >
56 |
57 | Back
58 |
59 |
60 |
61 | {
64 | saveChart()
65 | }}
66 | disabled={!options.name}
67 | >
68 | Save chart
69 |
70 |
71 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/databases/DatabaseDeleteModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Modal from "@/components/util/Modal"
4 | import { toast } from "sonner"
5 | import { useRouter, redirect } from "next/navigation"
6 | import { deleteDatabase } from "@/app/(app)/databases/actions"
7 |
8 | export function DatabaseDeleteModal({ database, showModal, setShowModal }) {
9 | const router = useRouter()
10 |
11 | return (
12 | <>
13 | router.push(`/databases/${database.uuid}`)}
16 | >
17 |
18 |
19 | Really delete this database connection?
20 |
21 |
52 |
53 |
54 | >
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/databases/steps/DatabaseDetails.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { ArrowLeftIcon } from "@heroicons/react/24/outline"
4 |
5 | export function DatabaseDetails({ databaseType, databaseDetails, setDatabaseDetails, activeStep, setActiveStep, createNewDatabase }) {
6 | return (
7 |
8 |
9 | Database connection details
10 |
11 |
115 |
116 |
117 |
{
120 | setActiveStep(activeStep - 1)
121 | }}
122 | >
123 |
124 | Back
125 |
126 |
127 |
128 | {
133 | createNewDatabase()
134 | }}
135 | disabled={!databaseDetails?.name || !databaseDetails?.host || !databaseDetails?.port || !databaseDetails?.user || !databaseDetails?.password || !databaseDetails?.database}
136 | >
137 | Save Database
138 |
139 |
140 |
141 |
142 |
143 | )
144 | }
145 |
--------------------------------------------------------------------------------
/src/components/databases/steps/DatabaseTypes.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { ArrowRightIcon } from "@heroicons/react/24/outline"
5 | import Tippy from "@tippyjs/react"
6 |
7 | export function DatabaseTypes({ databaseType, setDatabaseType, activeStep, setActiveStep }) {
8 | return (
9 |
10 |
11 | Database connection
12 |
13 |
14 |
15 |
16 |
{
22 | setDatabaseType("supabase")
23 | }}
24 | >
25 |
26 |
27 |
28 | Supabase
29 |
30 |
31 |
32 |
33 |
34 |
{
40 | setDatabaseType("postgres")
41 | }}
42 | >
43 |
44 |
45 |
46 | Postgres
47 |
48 |
49 |
50 |
51 |
52 |
{
58 | setDatabaseType("planetscale")
59 | }}
60 | >
61 |
62 |
63 |
64 | Planetscale
65 |
66 |
67 |
68 |
69 |
70 |
{
76 | setDatabaseType("mysql")
77 | }}
78 | >
79 |
80 |
81 |
82 | MySQL
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | Cancel
92 |
93 |
94 |
95 |
{
98 | setActiveStep(activeStep + 1)
99 | }}
100 | disabled={!databaseType}
101 | >
102 | Next
103 |
104 |
105 |
106 |
107 |
108 | )
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/emails/invite.jsx:
--------------------------------------------------------------------------------
1 | import { Body, Button, Container, Head, Heading, Hr, Html, Img, Link, Preview, Section, Text } from "@react-email/components"
2 | import * as React from "react"
3 |
4 | const baseUrl = process.env.NEXT_PUBLIC_APP_URL ? process.env.NEXT_PUBLIC_APP_URL : ""
5 |
6 | export const InvitationEmail = ({team_name, role}) => (
7 |
8 |
11 |
12 |
19 | Your Supaboard invitation
20 |
21 | <>
22 | {team_name && (
23 | <>
24 | You've been invited to join {team_name} on Supaboard. Click the button below create an account and get started.
25 | >
26 | )}
27 | {(!team_name || team_name == "") && (
28 | <>You've been invited to join a team on Supaboard. Click the button below create an account and get started.>
29 | )}
30 | >
31 |
32 |
33 |
34 | Join team
35 |
36 |
37 |
38 |
39 | This email was likely sent from a team member. If this is not the case, you can safely ignore it.
40 |
41 |
42 | Supaboard.co
43 |
44 |
45 |
46 |
47 | )
48 |
49 | export default InvitationEmail
50 |
51 | const logo = {
52 | borderRadius: 21,
53 | width: 42,
54 | height: 42,
55 | }
56 |
57 | const main = {
58 | backgroundColor: "#ffffff",
59 | fontFamily:
60 | "-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Oxygen-Sans,Ubuntu,Cantarell,\"Helvetica Neue\",sans-serif",
61 | }
62 |
63 | const container = {
64 | margin: "0 auto",
65 | padding: "20px 0 48px",
66 | width: "560px",
67 | }
68 |
69 | const heading = {
70 | fontSize: "24px",
71 | letterSpacing: "-0.5px",
72 | lineHeight: "1.3",
73 | fontWeight: "400",
74 | color: "#484848",
75 | padding: "17px 0 0",
76 | }
77 |
78 | const paragraph = {
79 | margin: "0 0 15px",
80 | fontSize: "15px",
81 | lineHeight: "1.4",
82 | color: "#3c4149",
83 | }
84 |
85 | const smallparagraph = {
86 | margin: "0 0 15px",
87 | fontSize: "12px",
88 | lineHeight: "1.4",
89 | color: "#b4becc",
90 | }
91 |
92 | const buttonContainer = {
93 | padding: "27px 0 27px",
94 | }
95 |
96 | const button = {
97 | backgroundColor: "#444",
98 | borderRadius: "3px",
99 | fontWeight: "600",
100 | color: "#fff",
101 | fontSize: "15px",
102 | textDecoration: "none",
103 | textAlign: "center",
104 | display: "block",
105 | }
106 |
107 | const reportLink = {
108 | fontSize: "12px",
109 | color: "#b4becc",
110 | }
111 |
112 | const hr = {
113 | borderColor: "#dfe1e4",
114 | margin: "42px 0 26px",
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/img/Bitbucket.js:
--------------------------------------------------------------------------------
1 | const Bitbucket = () => {
2 | return (
3 |
4 | )
5 | }
6 |
7 |
8 | export default Bitbucket
9 |
--------------------------------------------------------------------------------
/src/components/img/Github.js:
--------------------------------------------------------------------------------
1 | const GitHub = ({ size, className }) => {
2 | return (
3 |
4 | )
5 | }
6 |
7 |
8 | export default GitHub
9 |
--------------------------------------------------------------------------------
/src/components/img/Gitlab.js:
--------------------------------------------------------------------------------
1 | const Gitlab = () => {
2 | return (
3 |
4 | )
5 | }
6 |
7 |
8 | export default Gitlab
9 |
--------------------------------------------------------------------------------
/src/components/img/Slack.js:
--------------------------------------------------------------------------------
1 | const Slack = ({ size, className }) => {
2 | return (
3 |
4 | )
5 | }
6 |
7 |
8 | export default Slack
9 |
--------------------------------------------------------------------------------
/src/components/segments/ContactResetModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Modal from "@/components/util/Modal"
4 | import { toast } from "sonner"
5 | import { useRouter } from "next/navigation"
6 | import { deleteContacts } from "@/app/(app)/segments/actions"
7 |
8 | export function ContactResetModal({ showModal, setShowModal, setUpdateHash }) {
9 | const router = useRouter()
10 |
11 | return (
12 | <>
13 | setShowModal(false)}
16 | >
17 |
18 |
19 | Really detele your contacts connection?
20 |
21 |
48 |
49 |
50 | >
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/segments/SegmentDeleteModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Modal from "@/components/util/Modal"
4 | import { toast } from "sonner"
5 | import { useRouter } from "next/navigation"
6 | import { deleteSegment } from "@/app/(app)/segments/actions"
7 |
8 | export function SegmentDeleteModal({ segment, showModal, setShowModal, setUpdateHash }) {
9 | const router = useRouter()
10 |
11 | return (
12 | <>
13 | setShowModal(false)}
16 | >
17 |
18 |
19 | Really detele this segment?
20 |
21 |
48 |
49 |
50 | >
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/segments/SegmentEditModal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react"
4 |
5 | import { toast } from "sonner"
6 | import { useRouter } from "next/navigation"
7 | import Modal from "@/components/util/Modal"
8 | import { updateSegment } from "@/app/(app)/segments/actions"
9 | import { SegmentDataFilter } from "./steps/SegmentDataFilter"
10 |
11 | export function SegmentEditModal({ segment, showModal, setShowModal }) {
12 | const router = useRouter()
13 | const [externalDb, setExternalDb] = useState(null)
14 | const [filters, setFilters] = useState(null)
15 | const [filterType, setFilterType] = useState(null)
16 | const [selectedTable, setSelectedTable] = useState(null)
17 | const [segmentType, setSegmentType] = useState(null)
18 |
19 | useEffect(() => {
20 | if (!segment) return
21 | setFilters(segment.config.filters)
22 | setFilterType(segment.config.filterType)
23 | setSelectedTable(segment.config.table)
24 | setSegmentType(segment.config.type)
25 |
26 | const getDb = async () => {
27 | const dbRes = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/externaldb/${segment.database}`)
28 | const dbData = await dbRes.json()
29 | setExternalDb(dbData)
30 | }
31 |
32 | getDb()
33 | }, [segment])
34 |
35 |
36 | return (
37 | <>
38 | setShowModal(false)}
41 | >
42 |
43 |
44 | Edit this segment
45 |
46 |
109 |
110 |
111 | >
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/segments/SegmentSidebar.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useSelectedLayoutSegment, usePathname } from "next/navigation"
4 | import Link from "next/link"
5 | import { useEffect, useState } from "react"
6 |
7 |
8 | export function SegmentSidebar({ children }) {
9 | const segment = useSelectedLayoutSegment()
10 | const [loading, setLoading] = useState(false)
11 | const [segments, setSegments] = useState([])
12 |
13 | useEffect(() => {
14 | const getSegment = async () => {
15 | setLoading(true)
16 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/contacts/segments`)
17 | const data = await res.json()
18 | setSegments(data)
19 | setLoading(false)
20 | }
21 |
22 | getSegment()
23 | }, [])
24 |
25 | return (
26 |
27 |
All segments
28 | {!segments || segments.length === 0 && (
29 |
30 |
31 |
32 | You don't have any segments yet. Create a segment to get started.
33 |
34 |
35 |
36 | Create a segment
37 |
38 |
39 |
40 |
41 | )}
42 | {!loading && segments.length != 0 && (
43 |
44 | {segments.map((seg) => (
45 |
46 |
47 | {seg.name}
48 |
49 |
50 | ))}
51 |
52 | )}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/settings/SettingsNav.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { useSelectedLayoutSegment, usePathname } from "next/navigation"
5 | import { classNames } from "@/components/util"
6 |
7 | const navigation = [
8 | { name: "Personal details", href: "/settings", current: true },
9 | { name: "Workspaces", href: "/settings/workspace", current: false },
10 | { name: "Billing", href: "/settings/billing", current: false },
11 | ]
12 |
13 |
14 | export function SettingsNav({ children }) {
15 | const segment = useSelectedLayoutSegment()
16 |
17 | return (
18 |
19 |
20 | {navigation.map((item) => (
21 |
22 |
29 |
30 | {item.name}
31 |
32 |
33 |
34 | ))}
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/settings/SubscriptionSettings.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react"
2 | import { classNames } from "../util"
3 |
4 | const SubscriptionSettings = ({ activeSubscription }) => {
5 | const [loading, setLoading] = useState(false)
6 | const [plans, setPlans] = useState([])
7 |
8 | const redirectToCustomerPortal = async () => {
9 | setLoading(true)
10 | try {
11 | const res = await fetch("/api/payments-api/create-portal-link")
12 | const data = await res.json()
13 | const { url } = data
14 | window.location.assign(url)
15 | } catch (error) {
16 | if (error) return alert((error).message)
17 | }
18 | setLoading(false)
19 | }
20 |
21 | useEffect(() => {
22 | const getAvailablePlans = async () => {
23 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/payments-api/plans`)
24 | const data = await res.json()
25 | setPlans(data?.plans?.filter(plan => plan.billing_products.active))
26 | setLoading(false)
27 | }
28 |
29 | getAvailablePlans()
30 | }, [])
31 |
32 |
33 | return (
34 |
35 |
36 |
37 |
Your plan
38 |
39 | You're currently subscribed to the {activeSubscription?.product.name} plan.
40 |
41 | To change or cancel your subscription, please use the Stripe customer portal by clicking the button below.
42 |
43 |
44 |
45 |
46 |
Available plans
47 |
48 |
49 |
50 |
51 | Plan
52 | Price
53 |
54 |
55 |
56 | {plans && plans.map((plan, index) => {
57 | return (
58 |
59 |
65 | {plan.billing_products.name}
66 |
67 |
73 | ${plan.unit_amount / 100} {plan.currency.toUpperCase()} / {plan.interval}
74 |
75 |
76 | )
77 | })}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | Manage your subscription on Stripe.
89 |
90 |
95 | Open customer portal
96 |
97 |
98 |
99 |
100 | )
101 | }
102 |
103 | export default SubscriptionSettings
104 |
105 |
--------------------------------------------------------------------------------
/src/components/util/Modal.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Fragment } from "react";
4 | import { Dialog, Transition } from "@headlessui/react";
5 |
6 | const Modal = ({ showModal, onClose, children }) => {
7 |
8 | function closeModal() {
9 | onClose()
10 | }
11 |
12 | return (
13 | <>
14 |
15 |
16 |
25 |
26 |
27 |
28 |
29 |
30 |
39 |
40 |
41 | {children}
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | >
50 | )
51 | }
52 |
53 | export default Modal
--------------------------------------------------------------------------------
/src/components/util/index.js:
--------------------------------------------------------------------------------
1 | export function classNames(...classes) {
2 | return classes.filter(Boolean).join(" ")
3 | }
4 |
5 | export function formatDate(input) {
6 | const date = new Date(input)
7 | return date.toLocaleDateString("en-US", {
8 | year: "numeric",
9 | month: "long",
10 | day: "numeric"
11 | })
12 | }
--------------------------------------------------------------------------------
/src/components/workflows/nodes/DefaultNode.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from "react"
2 | import { EllipsisHorizontalIcon, PlayIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"
3 | import { Handle, Position } from "reactflow"
4 |
5 | import store from "@/store"
6 |
7 | export default function DefaultNode({ data }) {
8 | const { nodes, setNodes, edges, setEdges } = store()
9 | const currentNode = useRef(null)
10 |
11 | let node = nodes.find(node => node.id == data.id)
12 | if (!node) {
13 | node = data
14 | }
15 |
16 | const addNode = () => {
17 | let newId = (nodes.length + 1).toString()
18 | let parent = nodes.find(n => n.id == node.parentNode)
19 | if (!parent) {
20 | parent = node
21 | }
22 |
23 | setNodes([
24 | ...nodes,
25 | {
26 | id: newId,
27 | type: "defaultNode",
28 | position: { x: parent.position.x + 400, y: parent.position.y + 100 },
29 | draggable: true,
30 | data: { id: newId, label: "This is a new node" },
31 | }])
32 |
33 | setEdges([
34 | ...edges,
35 | {
36 | id: `e${newId}`,
37 | source: data.id.toString(),
38 | target: newId,
39 | targetHandle: "a",
40 | type: "smoothstep",
41 | }])
42 | }
43 |
44 |
45 | return (
46 |
50 |
51 |
52 |
53 | {node.id == "start" && (
54 |
55 | )}
56 |
57 |
58 |
59 |
60 |
{
63 | addNode()
64 | }}
65 | >
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | Start here
74 |
75 | ...
76 |
77 |
78 | {node.id != "start" && (
79 |
80 | )}
81 |
82 | )
83 | }
--------------------------------------------------------------------------------
/src/components/workflows/nodes/util.js:
--------------------------------------------------------------------------------
1 |
2 | export const deleteNode = () => {
3 | let index = data.id
4 | let updatedNodes = nodes.filter(node => node.id !== index)
5 | let updatedEdges = edges.filter(edge => edge.source !== index)
6 |
7 | // find all nodes that have this node as a parent and remove them
8 | updatedNodes.forEach(node => {
9 | if (node.parentNode == index || node.data.childOf == index) {
10 | updatedNodes = updatedNodes.filter(n => n.id !== node.id)
11 | updatedEdges = updatedEdges.filter(edge => edge.source !== node.id)
12 |
13 | if (node.type == "branchGroupNode") {
14 | let branchChildren = nodes.filter(n => n.parentNode == node.id)
15 | branchChildren.forEach(child => {
16 | updatedNodes = updatedNodes.filter(n => n.id !== child.id)
17 | updatedEdges = updatedEdges.filter(edge => edge.source !== child.id)
18 | })
19 | }
20 | }
21 | })
22 |
23 |
24 | setNodes(updatedNodes)
25 | setEdges(updatedEdges)
26 | }
27 |
28 |
29 |
30 |
31 | export const addButton = (type) => {
32 | setNodes([
33 | ...nodes,
34 | {
35 | id: (nodes.length + 1).toString(),
36 | type: "buttonNode",
37 | position: { x: 20, y: currentNode.current.clientHeight - 30 },
38 | data: { id: (nodes.length + 1).toString(), label: "Reply button", type: type, childOf: data.id },
39 | parentNode: data.id,
40 | extent: data.id,
41 | draggable: false,
42 | zIndex: 10,
43 | style: {
44 | width: 260,
45 | height: 45,
46 | }
47 | }])
48 | setEdges([...edges])
49 | }
50 |
51 |
52 | export const addNode = (action) => {
53 | setShowAddButtonMenu(false)
54 | let newId = (nodes.length + 1).toString()
55 | let parent = nodes.find(n => n.id == node.id)
56 |
57 | setNodes([
58 | ...nodes,
59 | {
60 | id: newId,
61 | type: "emptyNode",
62 | position: { x: 20, y: currentNode.current.clientHeight - 30 },
63 | data: {
64 | id: newId,
65 | type: action.type,
66 | childOf: data.id,
67 | },
68 | parentNode: data.id,
69 | extent: data.id,
70 | draggable: false,
71 | zIndex: 5,
72 | style: {
73 | width: 260,
74 | height: action.type == "message" ? 140 : 60,
75 | }
76 | }])
77 | }
78 |
79 |
--------------------------------------------------------------------------------
/src/config/permissions.js:
--------------------------------------------------------------------------------
1 | export const permissions = {
2 |
3 | "Free": {
4 | "create:segment": {
5 | limit: 0,
6 | },
7 | "create:dashboard": {
8 | limit: 1,
9 | },
10 | "create:workflow": {
11 | limit: 0,
12 | },
13 | "create:datasource": {
14 | limit: 1,
15 | },
16 | "invite:member": false,
17 | "create:workspace": {
18 | type: "personal",
19 | },
20 | },
21 |
22 | "Starter": {
23 | "create:segment": {
24 | limit: 3,
25 | },
26 | "create:dashboard": {
27 | limit: 3,
28 | },
29 | "create:workflow": {
30 | limit: 3,
31 | },
32 | "create:datasource": {
33 | limit: 3,
34 | },
35 | "invite:member": false,
36 | "create:workspace": {
37 | type: "personal",
38 | },
39 | },
40 |
41 | "Business": {
42 | "create:segment": {
43 | limit: -1,
44 | },
45 | "create:dashboard": {
46 | limit: 25,
47 | },
48 | "create:workflow": {
49 | limit: 5,
50 | },
51 | "create:datasource": {
52 | limit: 5,
53 | },
54 | "invite:member": true,
55 | "create:workspace": {
56 | type: "team",
57 | limit: 5
58 | },
59 | "create:exports": {
60 | allowed: true,
61 | },
62 | },
63 |
64 | "Enterprise": {
65 | "create:segment": {
66 | limit: 9999999,
67 | },
68 | "create:dashboard": {
69 | limit: 9999999,
70 | },
71 | "create:workflow": {
72 | limit: 9999999,
73 | },
74 | "create:datasource": {
75 | limit: 9999999,
76 | },
77 | "invite:member": true,
78 | "create:workspace": {
79 | type: "team",
80 | limit: 9999999
81 | },
82 | "create:exports": {
83 | allowed: true,
84 | },
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/config/pricing.js:
--------------------------------------------------------------------------------
1 | export const tiers = [
2 | {
3 | "name": "Free",
4 | "description": "Everything necessary to get started.",
5 | "features": [
6 | "1 dashboard",
7 | "1 data source",
8 | "All users overview",
9 | "Personal workspace"
10 | ],
11 | "price": {
12 | "monthly": 0,
13 | "annually": 0
14 | },
15 | "stripe_id_monthly": "x",
16 | "stripe_id_anually": "x",
17 | },
18 | {
19 | "name": "Starter",
20 | "description": "Everything necessary to get started.",
21 | "features": [
22 | "3 dashboards",
23 | "3 data sources",
24 | "3 workflow",
25 | "3 user segments",
26 | "Personal workspace"
27 | ],
28 | "price": {
29 | "monthly": 19,
30 | "annually": 190
31 | },
32 | "stripe_id_monthly": "price_1Ndq03JuB7aGkMDlrLxLWNHU",
33 | "stripe_id_anually": "price_1Ndq03JuB7aGkMDl3aRYhciy",
34 | },
35 | {
36 | "name": "Business",
37 | "description": "Everything necessary to get started.",
38 | "features": [
39 | "25 Dashboards",
40 | "5 data sources",
41 | "5 workflows",
42 | "Unlimited user segments",
43 | "Data exports",
44 | "Team workspace up to 5 user",
45 | ],
46 | "price": {
47 | "monthly": 49,
48 | "annually": 490
49 | },
50 | "stripe_id_monthly": "price_1NdqmkJuB7aGkMDl5eEArWAD",
51 | "stripe_id_anually": "price_1NdqmkJuB7aGkMDlLPycBlre",
52 | },
53 | {
54 | "name": "Enterprice",
55 | "description": "Everything necessary to get started.",
56 | "features": [
57 | "Unlimited Dashboards",
58 | "Unlimited sources",
59 | "Unlimited workflows",
60 | "Unlimited user segments",
61 | "Data exports",
62 | "Team workspace with unlimited user",
63 | ],
64 | "price": {
65 | "monthly": 99,
66 | "annually": 990
67 | },
68 | "stripe_id_monthly": "price_1NdqoAJuB7aGkMDlUlUoC670",
69 | "stripe_id_anually": "price_1NdqoAJuB7aGkMDlZ0jEevwM",
70 | },
71 | ]
72 |
--------------------------------------------------------------------------------
/src/instrumentation.js:
--------------------------------------------------------------------------------
1 | export async function register() {
2 | if (process.env.NEXT_RUNTIME == "nodejs" && process.env.IS_PLATFORM == true && process.env.NEXT_PUBLIC_ENV != "dev") {
3 | const { registerHighlight } = await import("@highlight-run/next/server")
4 |
5 | registerHighlight({
6 | projectID: process.env.NEXT_PUBLIC_HIGHLIGHT_KEY,
7 | })
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/adapters/mysql/querybuilder.js:
--------------------------------------------------------------------------------
1 | import mysql from "mysql2/promise"
2 |
3 |
4 | const operatorMap = {
5 | "eq": "=",
6 | "neq": "!=",
7 | "gt": ">",
8 | "lt": "<",
9 | "gte": ">=",
10 | "lte": "<=",
11 | "like": "LIKE",
12 | "ilike": "LIKE", // MySQL uses "LIKE" for case-insensitive search
13 | "nlike": "NOT LIKE",
14 | "nilike": "NOT LIKE", // MySQL uses "LIKE" for case-insensitive search
15 | "in": "IN",
16 | "nin": "NOT IN",
17 | "between": "BETWEEN",
18 | "nbetween": "NOT BETWEEN",
19 | "is": "IS",
20 | "isnot": "IS NOT",
21 | }
22 |
23 | export const fetchMySQLData = async (data, connectionDetails) => {
24 | const connection = await mysql.createConnection({
25 | host: connectionDetails.host,
26 | port: parseInt(connectionDetails.port),
27 | user: connectionDetails.user,
28 | password: connectionDetails.password,
29 | database: connectionDetails.database,
30 | ssl: {
31 | rejectUnauthorized: true,
32 | }
33 | })
34 |
35 | try {
36 | if (data.filters.query) {
37 | const [rows, fields] = await connection.execute(data.filters.query)
38 | return rows
39 | }
40 |
41 | const query = await buildComplexQuery(data)
42 | const [rows, fields] = await connection.execute(query)
43 | return rows
44 | } finally {
45 | connection.end()
46 | }
47 | }
48 |
49 | export const buildQuery = async (data) => {
50 | let query = `SELECT * FROM ${data.table}`
51 | if (data.filters && data.filters.length > 0 && data.filters[0].attribute) {
52 | query += " WHERE "
53 | data.filters.forEach((filter, index) => {
54 | if (index > 0) {
55 | query += ` ${filter.comparator} `
56 | }
57 | query += `${filter.attribute} ${operatorMap[filter.operator]} ?` // Use placeholders for values
58 | })
59 | }
60 |
61 | return query
62 | }
63 |
64 | export const buildComplexQuery = async (data) => {
65 | let dataTable = data.dataTable
66 | let dataCol = data.dataCol
67 | let timeTable = data.timeTable
68 | let timeCol = data.timeCol
69 | let filters = data.filters
70 | console.log(data)
71 |
72 | let selection = timeCol ? `${dataCol}, ${timeCol}` : dataCol
73 |
74 | let query = `SELECT ${selection} FROM ${dataTable}`
75 | if (data.filters && data.filters.length > 0) {
76 | query += " WHERE "
77 | filters.forEach((filter, index) => {
78 | if (index > 0) {
79 | query += ` ${filter.comparator} `
80 | }
81 | query += `${filter.attribute} ${operatorMap[filter.operator]} ?` // Use placeholders for values
82 | })
83 | }
84 |
85 | if (timeCol && timeCol !== "null") {
86 | query += ` ORDER BY ${timeCol} ASC`
87 | }
88 |
89 | return query
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/adapters/postgres/querybuilder.js:
--------------------------------------------------------------------------------
1 | import { Client } from "pg"
2 |
3 | const operatorMap = {
4 | "eq": "=",
5 | "neq": "!=",
6 | "gt": ">",
7 | "lt": "<",
8 | "gte": ">=",
9 | "lte": "<=",
10 | "like": "LIKE",
11 | "ilike": "LIKE", // MySQL uses "LIKE" for case-insensitive search
12 | "nlike": "NOT LIKE",
13 | "nilike": "NOT LIKE", // MySQL uses "LIKE" for case-insensitive search
14 | "in": "IN",
15 | "nin": "NOT IN",
16 | "between": "BETWEEN",
17 | "nbetween": "NOT BETWEEN",
18 | "is": "IS",
19 | "isnot": "IS NOT",
20 | }
21 |
22 |
23 |
24 | export const fetchPostgresData = async (data, connectionDetails) => {
25 | let result = []
26 | const client = new Client({
27 | host: connectionDetails.host,
28 | port: parseInt(connectionDetails.port),
29 | user: connectionDetails.user,
30 | password: connectionDetails.password,
31 | database: connectionDetails.database,
32 | })
33 | await client.connect()
34 |
35 | if (data.filters.query) {
36 | const res = await client.query(data.filters.query)
37 | result = res.rows
38 | client.end()
39 | return result
40 | }
41 |
42 | const query = await buildComplexQuery(data)
43 | const res = await client.query(query)
44 | result = res.rows
45 | client.end()
46 |
47 | return result
48 | }
49 |
50 |
51 | export const buildQuery = async (data) => {
52 | let query = `SELECT * FROM ${data.table}`
53 | if (data.filters && data.filters.length > 0 && data.filters[0].attribute) {
54 | query += " WHERE "
55 | data.filters.forEach((filter, index) => {
56 | if (index > 0) {
57 | query += ` ${filter.comparator} `
58 | }
59 | query += `${filter.attribute} ${operatorMap[filter.operator]} ?` // Use placeholders for values
60 | })
61 | }
62 |
63 | return query
64 | }
65 |
66 | export const buildComplexQuery = async (data) => {
67 | let dataTable = data.dataTable
68 | let dataCol = data.dataCol
69 | let timeTable = data.timeTable
70 | let timeCol = data.timeCol
71 | let filters = data.filters
72 |
73 | let selection = timeCol ? `${dataCol}, ${timeCol}` : dataCol
74 |
75 | let query = `SELECT ${selection} FROM ${dataTable}`
76 | if (data.filters && data.filters.length > 0) {
77 | query += " WHERE "
78 | filters.forEach((filter, index) => {
79 | if (index > 0) {
80 | query += ` ${filter.comparator} `
81 | }
82 | query += `${filter.attribute} ${operatorMap[filter.operator]} ?` // Use placeholders for values
83 | })
84 | }
85 |
86 | if (timeCol && timeCol !== "null") {
87 | query += ` ORDER BY ${timeCol} ASC`
88 | }
89 |
90 | return query
91 | }
92 |
--------------------------------------------------------------------------------
/src/lib/auth.js:
--------------------------------------------------------------------------------
1 | import { permissions } from "@/config/permissions"
2 |
3 | /**
4 | * Get the user's accounts (teams) and their roles
5 | */
6 | export const getUserAccounts = async (supabase, user_id) => {
7 | const { data: userAccounts, error } = await supabase
8 | .from("account_user")
9 | .select("account_id, account_role, accounts(*)")
10 | .eq("user_id", user_id)
11 |
12 | if (error) {
13 | console.log(error)
14 | throw new Error("Internal Server Error")
15 | }
16 |
17 | return userAccounts
18 | }
19 |
20 |
21 | /**
22 | * Check if the user is allowed to access the resource based on whether they belong to the account (team)
23 | * This defaults to true for self-hosted instances
24 | */
25 | export const checkUserAllowed = async (supabase, session, account_id) => {
26 | if (!process.env.IS_PLATFORM) {
27 | return true
28 | }
29 |
30 | const { data: accountUser, error } = await supabase
31 | .from("accounts")
32 | .select("team_name, id, personal_account, primary_owner_user_id, account_user(*)")
33 | .eq("account_user.user_id", session.user.id)
34 | .eq("account_user.account_id", account_id)
35 |
36 | if (error) {
37 | throw new Error("Internal Server Error")
38 | }
39 |
40 | if (!accountUser.length) {
41 | throw new Error("Unauthorized. You may not be allowed to access this resource.")
42 | }
43 |
44 | return accountUser[0]
45 | }
46 |
47 |
48 |
49 | export const can = async (permission) => {
50 | if (!process.env.NEXT_PUBLIC_IS_PLATFORM || process.env.NEXT_PUBLIC_ENV === "dev") {
51 | return true
52 | }
53 |
54 | try {
55 | const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/payments-api/status`)
56 | const data = await res.json()
57 | let plan = null
58 |
59 | if (data?.subscription) {
60 | plan = data.subscription.product.name
61 | } else {
62 | plan = "Free"
63 | }
64 |
65 | const plan_limits = permissions[plan]
66 | const limit = plan_limits[permission]?.limit
67 |
68 | const countsQuery = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/counts`)
69 | const counts = await countsQuery.json()
70 |
71 | if (permission === "create:segment") {
72 | return counts.segments < limit
73 | }
74 |
75 | if (permission === "create:dashboard") {
76 | return counts.dashboards < limit
77 | }
78 |
79 | if (permission === "create:datasource") {
80 | return counts.databases < limit
81 | }
82 |
83 | if (permission === "invite:member") {
84 | return plan_limits[permission]
85 | }
86 |
87 | return false
88 | } catch (err) {
89 | console.log(err)
90 | return false
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/lib/crypto.js:
--------------------------------------------------------------------------------
1 | import crypto from "crypto"
2 | const key = crypto.createHash("sha512").update(process.env.CRYPTO_SECRET_KEY).digest("hex").substring(0, 32)
3 | const encryptionIV = crypto.createHash("sha512").update(process.env.CRYPRO_SECRET_IV).digest("hex").substring(0, 16)
4 |
5 | export function encrypt(data) {
6 | const cipher = crypto.createCipheriv(process.env.CRYPTO_ENCRYPTION_METHOD, key, encryptionIV)
7 | return Buffer.from(cipher.update(data, "utf8", "hex") + cipher.final("hex")).toString("base64")
8 | }
9 |
10 | export function decrypt(encryptedData) {
11 | const buff = Buffer.from(encryptedData, "base64")
12 | const decipher = crypto.createDecipheriv(process.env.CRYPTO_ENCRYPTION_METHOD, key, encryptionIV)
13 | return (decipher.update(buff.toString("utf8"), "hex", "utf8") + decipher.final("utf8"))
14 | }
15 |
--------------------------------------------------------------------------------
/src/lib/database.js:
--------------------------------------------------------------------------------
1 | import { decrypt } from "./crypto"
2 |
3 | export const getConnectionDetails = async (supabase, account_id, tabase_uuid) => {
4 | let query = supabase
5 | .from("databases")
6 | .select()
7 | .eq("uuid", tabase_uuid)
8 |
9 | if (process.env.IS_PLATFORM) {
10 | query = query.eq("account_id", account_id)
11 | }
12 |
13 | query = query.single()
14 | const { data: database, error } = await query
15 |
16 | if (error) {
17 | console.log(error)
18 | throw new Error("Internal Server Error")
19 | }
20 |
21 | let connectionDetails = JSON.parse(decrypt(database.connection))
22 | connectionDetails.type = database.type
23 |
24 | return connectionDetails
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/operators.js:
--------------------------------------------------------------------------------
1 | export const operatorMap = {
2 | "eq": "=",
3 | "neq": "!=",
4 | "gt": ">",
5 | "gte": ">=",
6 | "lt": "<",
7 | "lte": "<=",
8 | "like": "LIKE",
9 | "not_like": "NOT LIKE",
10 | "is": "IS",
11 | "is_not": "IS NOT",
12 | "in": "IN",
13 | "not_in": "NOT IN",
14 | "between": "BETWEEN",
15 | "not_between": "NOT BETWEEN",
16 | "contains": "@>",
17 | "contained_by": "<@",
18 | "has_key": "?",
19 | "has_keys_any": "?|",
20 | "has_keys_all": "?&",
21 | }
22 |
--------------------------------------------------------------------------------
/src/lib/stripe/stripe-client.js:
--------------------------------------------------------------------------------
1 | import { loadStripe, Stripe } from "@stripe/stripe-js"
2 |
3 | let stripePromise
4 |
5 | export const getStripe = () => {
6 | if (!stripePromise) {
7 | stripePromise = loadStripe(
8 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ?? process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ""
9 | );
10 | }
11 |
12 | return stripePromise
13 | }
--------------------------------------------------------------------------------
/src/lib/stripe/stripe.js:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 |
3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
4 | apiVersion: "2020-08-27",
5 | appInfo: {
6 | name: "supaboard",
7 | version: "0.1.0"
8 | }
9 | });
10 |
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"
2 | import { NextResponse } from "next/server"
3 | import { getUserAccounts } from "@/lib/auth"
4 | import { applySetCookie } from "@/util"
5 |
6 | export async function middleware(req) {
7 | const res = NextResponse.next()
8 | const supabase = createMiddlewareClient({ req, res })
9 | await supabase.auth.getSession()
10 |
11 | const { data: { user } } = await supabase.auth.getUser()
12 |
13 | // if user is not signed in redirect the user to /
14 | if (!user) {
15 | return NextResponse.redirect(new URL("/login", req.url))
16 | }
17 |
18 | // if user is signed in and the account_id cookie is not set, set the account_id cookie to the first account
19 | if (!req.cookies.get("account_id")) {
20 | const supabaseSupaboard = createMiddlewareClient({ req, res }, {
21 | options: {
22 | db: { schema: "supaboard" }
23 | }
24 | })
25 | let accounts = await getUserAccounts(supabaseSupaboard, user.id)
26 | if (accounts.length) {
27 | const response = NextResponse.next()
28 | response.cookies.set("account_id", accounts[0].account_id)
29 | applySetCookie(req, response)
30 | return response
31 | }
32 | } else {
33 | const response = NextResponse.next()
34 | response.cookies.set("account_id", req.cookies.get("account_id").value)
35 | applySetCookie(req, response)
36 | return response
37 | }
38 |
39 | return res
40 | }
41 |
42 | export const config = {
43 | matcher: [
44 | "/",
45 | "/overview",
46 | "/dashboards",
47 | "/databases",
48 | "/segments",
49 | "/settings/:path*",
50 | ]
51 | }
52 |
--------------------------------------------------------------------------------
/src/providers/Telemetry.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Script from "next/script"
4 | import posthog from "posthog-js"
5 | import { PostHogProvider } from "posthog-js/react"
6 | import { HighlightInit, ErrorBoundary } from "@highlight-run/next/client"
7 | import { useEffect } from "react"
8 | import { Crisp } from "crisp-sdk-web"
9 |
10 | if (typeof window !== "undefined") {
11 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
12 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST
13 | })
14 | }
15 |
16 | export function Telemetry({ children }) {
17 | useEffect(() => {
18 | Crisp.configure(process.env.NEXT_PUBLIC_CRISP_ID)
19 | }, [])
20 |
21 | return (
22 | <>
23 |
32 |
33 |
34 |
35 | {children}
36 |
37 |
38 |
39 |
40 | {/* eslint-disable @next/next/no-img-element */}
41 |
46 |
47 |
48 | >
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { create } from "zustand"
2 |
3 | const useStore = create((set) => ({
4 | nodes: [],
5 | edges: [],
6 | dashboard: {},
7 | showUpgradeModal: false,
8 | showSidebar: false,
9 |
10 | setShowSidebar: (show) => {
11 | set((state) => ({ showSidebar: show }))
12 | },
13 |
14 | setShowUpgradeModal: (show) => {
15 | set((state) => ({ showUpgradeModal: show }))
16 | },
17 |
18 | setDashboard: (dash) => {
19 | set((state) => ({ dashboard: dash }))
20 | },
21 |
22 | setNodes: (nds) => {
23 | set((state) => ({ nodes: nds }))
24 | },
25 |
26 | setEdges: (eds) => {
27 | set((state) => ({ edges: eds }))
28 | },
29 | }))
30 |
31 | export default useStore
32 |
--------------------------------------------------------------------------------
/src/util/index.js:
--------------------------------------------------------------------------------
1 | import { NextResponse, NextRequest } from "next/server"
2 | import { ResponseCookies, RequestCookies } from "next/dist/server/web/spec-extension/cookies"
3 |
4 |
5 | export function classNames(...classes) {
6 | return classes.filter(Boolean).join(" ")
7 | }
8 |
9 | export function formatDate(input) {
10 | const date = new Date(input)
11 | return date.toLocaleDateString("en-US", {
12 | year: "numeric",
13 | month: "long",
14 | day: "numeric"
15 | })
16 | }
17 |
18 | export const toDateTime = (secs) => {
19 | var t = new Date("1970-01-01T00:30:00Z") // Unix epoch start.
20 | t.setSeconds(secs)
21 | return t
22 | }
23 |
24 | export const timeAgo = (date) => {
25 | const seconds = Math.floor((new Date() - new Date(date)) / 1000)
26 | let interval = seconds / 31536000
27 | if (interval > 1) {
28 | return Math.floor(interval) + " years ago"
29 | }
30 | interval = seconds / 2592000
31 | if (interval > 1) {
32 | return Math.floor(interval) + " months ago"
33 | }
34 | interval = seconds / 86400
35 | if (interval > 1) {
36 | return Math.floor(interval) + " days ago"
37 | }
38 | interval = seconds / 3600
39 | if (interval > 1) {
40 | return Math.floor(interval) + " hours ago"
41 | }
42 | interval = seconds / 60
43 | if (interval > 1) {
44 | return Math.floor(interval) + " minutes ago"
45 | }
46 | return Math.floor(seconds) + " seconds ago"
47 | }
48 |
49 |
50 | export const applySetCookie = (req, res) => {
51 | const setCookies = new ResponseCookies(res.headers)
52 | const newReqHeaders = new Headers(req.headers)
53 | const newReqCookies = new RequestCookies(newReqHeaders)
54 | setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie))
55 |
56 | NextResponse.next({
57 | request: { headers: newReqHeaders },
58 | }).headers.forEach((value, key) => {
59 | if (key === "x-middleware-override-headers" || key.startsWith("x-middleware-request-")) {
60 | res.headers.set(key, value)
61 | }
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
12 | "gradient-conic":
13 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
14 | },
15 | colors: {
16 | "dark": "#444",
17 | "highlight": "#31dc8e",
18 | }
19 | },
20 | },
21 | plugins: [],
22 | }
23 |
--------------------------------------------------------------------------------