├── . prettierignore ├── . vscode └── settings.json ├── .do └── deploy.template.yaml ├── .env.example ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── __tests__ └── lib │ └── commom.spec.ts ├── app.json ├── components ├── account │ ├── ProfileImageUpload.tsx │ ├── UpdateAccount.tsx │ ├── UpdatePassword.tsx │ └── index.ts ├── apiKey │ ├── APIKeys.tsx │ ├── APIKeysContainer.tsx │ └── NewAPIKey.tsx ├── auth │ ├── GithubButton.tsx │ ├── GoogleButton.tsx │ ├── Join.tsx │ ├── JoinWithInvitation.tsx │ ├── ResetPassword.tsx │ └── index.ts ├── defaultLanding │ ├── FAQSection.tsx │ ├── FeatureSection.tsx │ ├── HeroSection.tsx │ ├── PricingSection.tsx │ └── data │ │ ├── faq.json │ │ ├── features.json │ │ └── pricing.json ├── directorySync │ ├── CreateDirectory.tsx │ ├── Directory.tsx │ └── index.ts ├── invitation │ ├── InviteMember.tsx │ ├── PendingInvitations.tsx │ └── index.ts ├── layouts │ ├── AccountLayout.tsx │ ├── AuthLayout.tsx │ └── index.ts ├── saml │ ├── CreateConnection.tsx │ └── index.ts ├── shared │ ├── AccessControl.tsx │ ├── Alert.tsx │ ├── Badge.tsx │ ├── Card.tsx │ ├── ConfirmationDialog.tsx │ ├── CopyToClipboardButton.tsx │ ├── EmptyState.tsx │ ├── Error.tsx │ ├── InputWithCopyButton.tsx │ ├── InputWithLabel.tsx │ ├── LetterAvatar.tsx │ ├── Loading.tsx │ ├── Navbar.tsx │ ├── Sidebar.tsx │ ├── SidebarItem.tsx │ ├── Table.tsx │ ├── TeamDropdown.tsx │ ├── WithLoadingAndError.tsx │ └── index.ts ├── team │ ├── CreateTeam.tsx │ ├── Members.tsx │ ├── RemoveTeam.tsx │ ├── TeamSettings.tsx │ ├── TeamTab.tsx │ ├── Teams.tsx │ ├── UpdateMemberRole.tsx │ └── index.ts └── webhook │ ├── CreateWebhook.tsx │ ├── EditWebhook.tsx │ ├── EventTypes.tsx │ ├── Form.tsx │ ├── Webhooks.tsx │ └── index.ts ├── docker-compose.yml ├── hooks ├── useCanAccess.ts ├── useDirectory.ts ├── useInvitation.ts ├── useInvitations.ts ├── usePermissions.ts ├── useSAMLConfig.ts ├── useTeam.ts ├── useTeamMembers.ts ├── useTeams.ts ├── useWebhook.ts └── useWebhooks.ts ├── i18next.d.ts ├── jest.config.js ├── jest.setup.js ├── lib ├── app.ts ├── auth.ts ├── common.ts ├── cookie.ts ├── email │ ├── freeEmailService.json │ ├── sendEmail.ts │ ├── sendPasswordResetEmail.ts │ ├── sendTeamInviteEmail.ts │ ├── sendVerificationEmail.ts │ ├── sendWelcomeEmail.ts │ └── utils.ts ├── env.ts ├── errors.ts ├── fetcher.ts ├── inferSSRProps.ts ├── jackson.ts ├── permissions.ts ├── prisma.ts ├── retraced.ts ├── session.ts ├── svix.ts └── teams.ts ├── locales └── en │ └── common.json ├── middleware.ts ├── models ├── account.ts ├── apiKey.ts ├── invitation.ts ├── team.ts └── user.ts ├── next-env.d.ts ├── next-i18next.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── auth │ │ ├── [...nextauth].ts │ │ ├── forgot-password.ts │ │ ├── join.ts │ │ ├── reset-password.ts │ │ └── sso │ │ │ ├── acs.ts │ │ │ └── verify.ts │ ├── hello.ts │ ├── idp.ts │ ├── invitations │ │ └── [token].ts │ ├── oauth │ │ ├── authorize.ts │ │ ├── saml.ts │ │ ├── token.ts │ │ └── userinfo.ts │ ├── password.ts │ ├── scim │ │ └── v2.0 │ │ │ └── [...directory].ts │ ├── teams │ │ ├── [slug] │ │ │ ├── api-keys │ │ │ │ ├── [apiKeyId].ts │ │ │ │ └── index.ts │ │ │ ├── directory-sync.ts │ │ │ ├── index.ts │ │ │ ├── invitations.ts │ │ │ ├── members.ts │ │ │ ├── permissions.ts │ │ │ ├── saml.ts │ │ │ └── webhooks │ │ │ │ ├── [endpointId].ts │ │ │ │ └── index.ts │ │ └── index.ts │ └── users.ts ├── auth │ ├── forgot-password.tsx │ ├── join.tsx │ ├── login.tsx │ ├── magic-link.tsx │ ├── reset-password │ │ └── [token].tsx │ ├── sso │ │ ├── idp-select.tsx │ │ └── index.tsx │ ├── verify-email-token.tsx │ └── verify-email.tsx ├── dashboard.tsx ├── index.tsx ├── invitations │ └── [token].tsx ├── settings │ ├── account.tsx │ └── password.tsx └── teams │ ├── [slug] │ ├── api-keys.tsx │ ├── audit-logs.tsx │ ├── directory-sync.tsx │ ├── members.tsx │ ├── products.tsx │ ├── saml.tsx │ ├── settings.tsx │ └── webhooks.tsx │ ├── index.tsx │ └── switch.tsx ├── playwright.config.ts ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230625203909_init │ │ └── migration.sql │ ├── 20230703084002_add_api_keys │ │ └── migration.sql │ ├── 20230703084117_add_index_user │ │ └── migration.sql │ ├── 20230720113226_make_password_optional │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── favicon.ico ├── home-hero.svg ├── logo.png ├── saas-starter-kit-poster.png ├── user-default-profile.jpeg └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tests └── e2e │ └── auth │ └── login.spec.ts ├── tsconfig.json └── types ├── base.ts ├── index.ts ├── next-auth.d.ts └── next.ts /. prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | public 4 | **/**/node_modules 5 | **/**/.next 6 | **/**/public 7 | npm/migration/** 8 | 9 | *.lock 10 | *.log 11 | 12 | .gitignore 13 | .npmignore 14 | .prettierignore 15 | .DS_Store 16 | .eslintignore -------------------------------------------------------------------------------- /. vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnPaste": true, 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true, 7 | "source.fixAll.format": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.do/deploy.template.yaml: -------------------------------------------------------------------------------- 1 | spec: 2 | name: saas-starter-kit 3 | services: 4 | - name: web 5 | git: 6 | branch: main 7 | repo_clone_url: https://github.com/boxyhq/saas-starter-kit.git 8 | envs: 9 | - key: NEXTAUTH_URL 10 | value: ${APP_URL} 11 | - key: APP_URL 12 | value: ${APP_URL} 13 | - key: NEXTAUTH_SECRET 14 | - key: SMTP_HOST 15 | - key: SMTP_PORT 16 | - key: SMTP_USER 17 | - key: SMTP_PASSWORD 18 | - key: SMTP_FROM 19 | - key: DATABASE_URL 20 | - key: SVIX_URL 21 | - key: SVIX_API_KEY 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL=http://localhost:4002 2 | 3 | # You can use openssl to generate a random 32 character key: openssl rand -base64 32 4 | NEXTAUTH_SECRET=rZTFtfNuSMajLnfFrWT2PZ3lX8WZv7W/Xs2H8hkEY6g= 5 | 6 | # SMTP / Email settings 7 | SMTP_HOST= 8 | SMTP_PORT= 9 | SMTP_USER= 10 | SMTP_PASSWORD= 11 | SMTP_FROM= 12 | 13 | # If you are using Docker, you can retrieve the values from: docker-compose.yml 14 | DATABASE_URL=postgresql://:@localhost:5432/ 15 | 16 | APP_URL=http://localhost:4002 17 | 18 | SVIX_URL=https://api.eu.svix.com 19 | SVIX_API_KEY= 20 | 21 | GITHUB_CLIENT_ID= 22 | GITHUB_CLIENT_SECRET= 23 | 24 | GOOGLE_CLIENT_ID= 25 | GOOGLE_CLIENT_SECRET= 26 | 27 | RETRACED_URL= 28 | RETRACED_API_KEY= 29 | RETRACED_PROJECT_ID= 30 | 31 | # Hide landing page and redirect to login page 32 | HIDE_LANDING_PAGE=false 33 | 34 | # SSO groups can be prefixed with this identifier in order to avoid conflicts with other groups. 35 | # For example boxyhq-admin would be resolved to admin, boxyhq-member would be resolved to member, etc. 36 | GROUP_PREFIX=boxyhq- 37 | 38 | # Users will need to confirm their email before accessing the app feature 39 | CONFIRM_EMAIL=false 40 | 41 | # Disable non-business email signup 42 | DISABLE_NON_BUSINESS_EMAIL_SIGNUP=false 43 | 44 | # Mixpanel 45 | NEXT_PUBLIC_MIXPANEL_TOKEN= -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | 'next/core-web-vitals', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | }, 21 | plugins: ['react', '@typescript-eslint'], 22 | rules: { 23 | '@typescript-eslint/no-explicit-any': 'warn', 24 | }, 25 | settings: { 26 | react: { 27 | version: 'detect', 28 | }, 29 | }, 30 | overrides: [ 31 | { 32 | files: ['*.js'], 33 | }, 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'daily' 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub action to build 2 | 3 | name: Build 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | - release 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - run: npm install 20 | - run: npm run check-lint 21 | - run: npm run test 22 | - run: npm run build 23 | -------------------------------------------------------------------------------- /.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 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | 40 | # Sentry 41 | .sentryclirc 42 | 43 | .env 44 | 45 | # Tests 46 | /report 47 | 48 | # eslint 49 | .eslintcache -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | out -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "singleQuote": true, 7 | "semi": true, 8 | "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 9 | "importOrderSeparation": true, 10 | "importOrderSortSpecifiers": true 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are greatly appreciated. 4 | 5 | The development branch is `main`. This is the branch that all pull requests should be made against. 6 | 7 | Please try to create bug reports that are: 8 | 9 | - Reproducible. Include steps to reproduce the problem. 10 | - Specific. Include as much detail as possible: which version, what environment, etc. 11 | - Unique. Do not duplicate existing opened issues. 12 | - Scoped to a Single Bug. One bug per report. 13 | 14 | ## Code Style 15 | 16 | Please follow the [node style guide](https://github.com/felixge/node-style-guide). 17 | 18 | ## Testing 19 | 20 | Please test your changes before submitting the PR. 21 | 22 | ## Good First Issues 23 | 24 | We have a list of help wanted that contains small features and bugs with a relatively limited scope. Nevertheless, this is a great place to get started, gain experience, and get familiar with our contribution process. 25 | 26 | ## Development 27 | 28 | Please follow these simple steps to get a local copy up and running. 29 | 30 | ### 1. Setup 31 | 32 | Clone or fork this GitHub repository 33 | 34 | ```bash 35 | git clone https://github.com/boxyhq/saas-starter-kit.git 36 | ``` 37 | 38 | ### 2. Go to the project folder 39 | 40 | ```bash 41 | cd saas-starter-kit 42 | ``` 43 | 44 | ### 3. Install dependencies 45 | 46 | ```bash 47 | npm install 48 | ``` 49 | 50 | ### 4. Set up your .env file 51 | 52 | Duplicate `.env.example` to `.env`. 53 | 54 | ```bash 55 | cp .env.example .env 56 | ``` 57 | 58 | ### 5. Set up database schema 59 | 60 | ```bash 61 | npx prisma db push 62 | ``` 63 | 64 | ### 6. Start the server 65 | 66 | In a development environment: 67 | 68 | ```bash 69 | npm run dev 70 | ``` 71 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./node_modules/.bin/next start -------------------------------------------------------------------------------- /__tests__/lib/commom.spec.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest } from 'next'; 2 | 3 | import { createRandomString, extractAuthToken } from '../../lib/common'; 4 | 5 | describe('Lib - commom', () => { 6 | describe('Random string', () => { 7 | it('should create a random string with default length 6', () => { 8 | const result = createRandomString(); 9 | expect(result).toBeTruthy(); 10 | expect(result.length).toBe(6); 11 | }); 12 | 13 | it('should create a random string with random length', () => { 14 | const length = Math.round(Math.random() * 10); 15 | const result = createRandomString(length); 16 | expect(result).toBeTruthy(); 17 | expect(result.length).toBe(length); 18 | }); 19 | }); 20 | 21 | describe('extractAuthToken', () => { 22 | it('should return a token for a bearer token', () => { 23 | const token = createRandomString(10); 24 | const mock = { 25 | headers: { authorization: `Bearer ${token}` }, 26 | } as NextApiRequest; 27 | const result = extractAuthToken(mock); 28 | expect(result).toBe(token); 29 | }); 30 | 31 | it('should return null when token is empty', () => { 32 | const mock = { headers: { authorization: '' } } as NextApiRequest; 33 | const result = extractAuthToken(mock); 34 | expect(result).toBeNull(); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BoxyHQ Enterprise SaaS Starter Kit", 3 | "description": "The Open Source Next.js Enterprise SaaS Starter Kit. Next.js based SaaS starter kit that saves you months of development by starting you off with all the features that are the same in every product, so you can focus on what makes your app unique.", 4 | "repository": "https://github.com/boxyhq/saas-starter-kit", 5 | "logo": "https://boxyhq.com/img/logo.png", 6 | "keywords": ["saas", "starter", "kit", "enterprise"], 7 | "env": { 8 | "NEXTAUTH_URL": { 9 | "description": "Next.js authentication URL." 10 | }, 11 | "NEXTAUTH_SECRET": { 12 | "description": "Next.js authentication secret." 13 | }, 14 | "SMTP_HOST": { 15 | "description": "SMTP server host name." 16 | }, 17 | "SMTP_PORT": { 18 | "description": "SMTP server port number." 19 | }, 20 | "SMTP_USER": { 21 | "description": "SMTP server username." 22 | }, 23 | "SMTP_PASSWORD": { 24 | "description": "SMTP server password." 25 | }, 26 | "SMTP_FROM": { 27 | "description": "SMTP server sender's email address." 28 | }, 29 | "DATABASE_URL": { 30 | "description": "Hosted Database URL." 31 | }, 32 | "APP_URL": { 33 | "description": "Public root URL of the Enterprise SaaS installation, replace with the app name.", 34 | "value": "https://.herokuapp.com" 35 | }, 36 | "SVIX_URL": { 37 | "description": "SVIX URL.", 38 | "required": false 39 | }, 40 | "SVIX_API_KEY": { 41 | "description": "SVIX API key.", 42 | "required": false 43 | } 44 | }, 45 | "success_url": "/" 46 | } 47 | -------------------------------------------------------------------------------- /components/account/ProfileImageUpload.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import Image from 'next/image'; 3 | import React, { ChangeEvent, useRef } from 'react'; 4 | 5 | const ProfileImageUpload = ({ formik }) => { 6 | const { t } = useTranslation('common'); 7 | const imageInputRef = useRef(null); 8 | 9 | const handleImageChange = (event: ChangeEvent) => { 10 | const file = event.target.files?.[0]; 11 | 12 | if (file) { 13 | const reader = new FileReader(); 14 | 15 | reader.onloadend = () => { 16 | const base64String = reader.result; 17 | formik.setFieldValue('image', base64String); 18 | }; 19 | 20 | reader.readAsDataURL(file); 21 | } 22 | }; 23 | 24 | return ( 25 |
26 | 29 |
30 |
31 | {formik.values.image ? ( 32 | {t('profile-picture')} 39 | ) : ( 40 | {t('profile-picture')} 51 | )} 52 |
53 |
54 | handleImageChange(e)} 60 | /> 61 | 67 | {formik.touched.image && formik.errors.image && ( 68 |
{formik.errors.image}
69 | )} 70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default ProfileImageUpload; 77 | -------------------------------------------------------------------------------- /components/account/UpdateAccount.tsx: -------------------------------------------------------------------------------- 1 | import { Card, InputWithLabel } from '@/components/shared'; 2 | import { getAxiosError } from '@/lib/common'; 3 | import { User } from '@prisma/client'; 4 | import axios from 'axios'; 5 | import { useFormik } from 'formik'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { Button } from 'react-daisyui'; 8 | import toast from 'react-hot-toast'; 9 | import type { ApiResponse } from 'types'; 10 | import * as Yup from 'yup'; 11 | 12 | import ProfileImageUpload from './ProfileImageUpload'; 13 | 14 | const schema = Yup.object().shape({ 15 | name: Yup.string().required(), 16 | email: Yup.string().required(), 17 | image: Yup.mixed() 18 | .required('Please select an image') 19 | .test('fileFormat', 'Only JPG, PNG, and GIF files are allowed', (value) => { 20 | if (!value) return true; 21 | 22 | const inputValue = value as string; 23 | const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif']; 24 | 25 | const base64Parts = inputValue.split(';base64,'); 26 | const mimeType = base64Parts[0].split(':')[1]; 27 | const fileExtension = mimeType.split('/')[1]; 28 | 29 | return allowedExtensions.includes(fileExtension.toLowerCase()); 30 | }), 31 | }); 32 | 33 | const UpdateAccount = ({ user }: { user: User }) => { 34 | const { t } = useTranslation('common'); 35 | 36 | const formik = useFormik({ 37 | initialValues: { 38 | name: user.name, 39 | email: user.email, 40 | image: user.image, 41 | }, 42 | validationSchema: schema, 43 | onSubmit: async (values) => { 44 | try { 45 | await axios.put>('/api/users', { 46 | ...values, 47 | }); 48 | 49 | toast.success(t('successfully-updated')); 50 | } catch (error) { 51 | toast.error(getAxiosError(error)); 52 | } 53 | }, 54 | }); 55 | 56 | return ( 57 |
58 | 59 | 60 |
61 | 62 | 71 | 80 |
81 |
82 | 83 |
84 | 93 |
94 |
95 |
96 |
97 | ); 98 | }; 99 | 100 | export default UpdateAccount; 101 | -------------------------------------------------------------------------------- /components/account/UpdatePassword.tsx: -------------------------------------------------------------------------------- 1 | import { Card, InputWithLabel } from '@/components/shared'; 2 | import { getAxiosError } from '@/lib/common'; 3 | import axios from 'axios'; 4 | import { useFormik } from 'formik'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { Button } from 'react-daisyui'; 7 | import toast from 'react-hot-toast'; 8 | import * as Yup from 'yup'; 9 | 10 | const schema = Yup.object().shape({ 11 | currentPassword: Yup.string().required(), 12 | newPassword: Yup.string().required().min(7), 13 | }); 14 | 15 | const UpdatePassword = () => { 16 | const { t } = useTranslation('common'); 17 | 18 | const formik = useFormik({ 19 | initialValues: { 20 | currentPassword: '', 21 | newPassword: '', 22 | }, 23 | validationSchema: schema, 24 | onSubmit: async (values) => { 25 | try { 26 | await axios.put(`/api/password`, values); 27 | 28 | toast.success(t('successfully-updated')); 29 | formik.resetForm(); 30 | } catch (error: any) { 31 | toast.error(getAxiosError(error)); 32 | } 33 | }, 34 | }); 35 | 36 | return ( 37 | <> 38 |
39 | 40 | 41 |
42 | 55 | 68 |
69 |
70 | 71 |
72 | 81 |
82 |
83 |
84 |
85 | 86 | ); 87 | }; 88 | 89 | export default UpdatePassword; 90 | -------------------------------------------------------------------------------- /components/account/index.ts: -------------------------------------------------------------------------------- 1 | export { default as UpdatePassword } from './UpdatePassword'; 2 | export { default as UpdateAccount } from './UpdateAccount'; 3 | -------------------------------------------------------------------------------- /components/apiKey/APIKeysContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Error, Loading } from '@/components/shared'; 2 | import { TeamTab } from '@/components/team'; 3 | import useTeam from 'hooks/useTeam'; 4 | import { useTranslation } from 'next-i18next'; 5 | import { useState } from 'react'; 6 | import { Button } from 'react-daisyui'; 7 | 8 | import APIKeys from './APIKeys'; 9 | import NewAPIKey from './NewAPIKey'; 10 | 11 | const APIKeysContainer = () => { 12 | const { t } = useTranslation('common'); 13 | const [createModalVisible, setCreateModalVisible] = useState(false); 14 | const { isLoading, isError, team } = useTeam(); 15 | 16 | if (isLoading) { 17 | return ; 18 | } 19 | 20 | if (isError) { 21 | return ; 22 | } 23 | 24 | if (!team) { 25 | return ; 26 | } 27 | 28 | return ( 29 | <> 30 | 31 |
32 |
33 | 41 |
42 | 43 |
44 | 49 | 50 | ); 51 | }; 52 | 53 | export default APIKeysContainer; 54 | -------------------------------------------------------------------------------- /components/auth/GithubButton.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from 'next-auth/react'; 2 | import { Button } from 'react-daisyui'; 3 | import { useTranslation } from 'next-i18next'; 4 | 5 | const GithubButton = () => { 6 | const { t } = useTranslation('common'); 7 | 8 | return ( 9 | 35 | ); 36 | }; 37 | 38 | export default GithubButton; 39 | -------------------------------------------------------------------------------- /components/auth/GoogleButton.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from 'next-auth/react'; 2 | import { Button } from 'react-daisyui'; 3 | import { useTranslation } from 'next-i18next'; 4 | 5 | const GoogleButton = () => { 6 | const { t } = useTranslation('common'); 7 | 8 | return ( 9 | 35 | ); 36 | }; 37 | 38 | export default GoogleButton; 39 | -------------------------------------------------------------------------------- /components/auth/Join.tsx: -------------------------------------------------------------------------------- 1 | import { InputWithLabel } from '@/components/shared'; 2 | import { getAxiosError } from '@/lib/common'; 3 | import type { User } from '@prisma/client'; 4 | import axios from 'axios'; 5 | import { useFormik } from 'formik'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { useRouter } from 'next/router'; 8 | import { Button } from 'react-daisyui'; 9 | import toast from 'react-hot-toast'; 10 | import type { ApiResponse } from 'types'; 11 | import * as Yup from 'yup'; 12 | 13 | const Join = () => { 14 | const router = useRouter(); 15 | const { t } = useTranslation('common'); 16 | 17 | const formik = useFormik({ 18 | initialValues: { 19 | name: '', 20 | email: '', 21 | password: '', 22 | team: '', 23 | }, 24 | validationSchema: Yup.object().shape({ 25 | name: Yup.string().required(), 26 | email: Yup.string().required().email(), 27 | password: Yup.string().required().min(7), 28 | team: Yup.string().required().min(3), 29 | }), 30 | onSubmit: async (values) => { 31 | try { 32 | const response = await axios.post< 33 | ApiResponse 34 | >('/api/auth/join', { 35 | ...values, 36 | }); 37 | 38 | const { confirmEmail } = response.data.data; 39 | 40 | formik.resetForm(); 41 | 42 | if (confirmEmail) { 43 | router.push('/auth/verify-email'); 44 | } else { 45 | toast.success(t('successfully-joined')); 46 | router.push('/auth/login'); 47 | } 48 | } catch (error: any) { 49 | toast.error(getAxiosError(error)); 50 | } 51 | }, 52 | }); 53 | 54 | return ( 55 |
56 |
57 | 66 | 75 | 84 | 93 |
94 |
95 | 105 |

{t('sign-up-message')}

106 |
107 |
108 | ); 109 | }; 110 | 111 | export default Join; 112 | -------------------------------------------------------------------------------- /components/auth/JoinWithInvitation.tsx: -------------------------------------------------------------------------------- 1 | import { Error, InputWithLabel, Loading } from '@/components/shared'; 2 | import { getAxiosError } from '@/lib/common'; 3 | import type { User } from '@prisma/client'; 4 | import axios from 'axios'; 5 | import { useFormik } from 'formik'; 6 | import useInvitation from 'hooks/useInvitation'; 7 | import { useTranslation } from 'next-i18next'; 8 | import { useRouter } from 'next/router'; 9 | import { Button } from 'react-daisyui'; 10 | import toast from 'react-hot-toast'; 11 | import type { ApiResponse } from 'types'; 12 | import * as Yup from 'yup'; 13 | 14 | const JoinWithInvitation = ({ 15 | inviteToken, 16 | next, 17 | }: { 18 | inviteToken: string; 19 | next: string; 20 | }) => { 21 | const router = useRouter(); 22 | const { t } = useTranslation('common'); 23 | 24 | const { isLoading, isError, invitation } = useInvitation(inviteToken); 25 | 26 | const formik = useFormik({ 27 | initialValues: { 28 | name: '', 29 | email: invitation?.email, 30 | password: '', 31 | }, 32 | validationSchema: Yup.object().shape({ 33 | name: Yup.string().required(), 34 | email: Yup.string().required().email(), 35 | password: Yup.string().required().min(7), 36 | }), 37 | enableReinitialize: true, 38 | onSubmit: async (values) => { 39 | try { 40 | await axios.post>('/api/auth/join', { 41 | ...values, 42 | }); 43 | 44 | formik.resetForm(); 45 | toast.success(t('successfully-joined')); 46 | 47 | return next ? router.push(next) : router.push('/auth/login'); 48 | } catch (error: any) { 49 | toast.error(getAxiosError(error)); 50 | } 51 | }, 52 | }); 53 | 54 | if (isLoading) { 55 | return ; 56 | } 57 | 58 | if (isError) { 59 | return ; 60 | } 61 | 62 | return ( 63 |
64 | 73 | 82 | 91 | 101 |
102 |

{t('sign-up-message')}

103 |
104 | 105 | ); 106 | }; 107 | 108 | export default JoinWithInvitation; 109 | -------------------------------------------------------------------------------- /components/auth/ResetPassword.tsx: -------------------------------------------------------------------------------- 1 | import { InputWithLabel } from '@/components/shared'; 2 | import { getAxiosError } from '@/lib/common'; 3 | import axios from 'axios'; 4 | import { useFormik } from 'formik'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { useRouter } from 'next/router'; 7 | import { useState } from 'react'; 8 | import { Button } from 'react-daisyui'; 9 | import { toast } from 'react-hot-toast'; 10 | import { ApiResponse } from 'types'; 11 | import * as Yup from 'yup'; 12 | 13 | const ResetPassword = () => { 14 | const [submitting, setSubmitting] = useState(false); 15 | const router = useRouter(); 16 | const { t } = useTranslation('common'); 17 | const { token } = router.query as { token: string }; 18 | 19 | const formik = useFormik({ 20 | initialValues: { 21 | password: '', 22 | confirmPassword: '', 23 | }, 24 | validationSchema: Yup.object().shape({ 25 | password: Yup.string().required().min(8), 26 | confirmPassword: Yup.string().test( 27 | 'passwords-match', 28 | 'Passwords must match', 29 | (value, context) => value === context.parent.password 30 | ), 31 | }), 32 | onSubmit: async (values) => { 33 | setSubmitting(true); 34 | 35 | try { 36 | await axios.post('/api/auth/reset-password', { 37 | ...values, 38 | token, 39 | }); 40 | 41 | setSubmitting(false); 42 | formik.resetForm(); 43 | toast.success(t('password-updated')); 44 | router.push('/auth/login'); 45 | } catch (error: any) { 46 | toast.error(getAxiosError(error)); 47 | } 48 | }, 49 | }); 50 | 51 | return ( 52 |
53 |
54 |
55 | 64 | 77 |
78 |
79 | 89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | export default ResetPassword; 96 | -------------------------------------------------------------------------------- /components/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Join } from './Join'; 2 | export { default as JoinWithInvitation } from './JoinWithInvitation'; 3 | export { default as ResetPasswordForm } from './ResetPassword'; 4 | -------------------------------------------------------------------------------- /components/defaultLanding/FAQSection.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import { Card } from 'react-daisyui'; 3 | 4 | import faqs from './data/faq.json'; 5 | 6 | const FAQSection = () => { 7 | const { t } = useTranslation('common'); 8 | return ( 9 |
10 |
11 |

12 | {t('frequently-asked')} 13 |

14 |

15 | Lorem Ipsum is simply dummy text of the printing and typesetting 16 | industry. 17 |

18 |
19 |
20 | {faqs.map((faq, index) => { 21 | return ( 22 | 23 | 24 | Q. {faq.question} 25 |

A. {faq.answer}

26 |
27 |
28 | ); 29 | })} 30 |
31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | export default FAQSection; 38 | -------------------------------------------------------------------------------- /components/defaultLanding/FeatureSection.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | 3 | import features from './data/features.json'; 4 | 5 | const FeatureSection = () => { 6 | const { t } = useTranslation('common'); 7 | return ( 8 |
9 |
10 |

11 | {t('features')} 12 |

13 |

14 | Lorem Ipsum is simply dummy text of the printing and typesetting 15 | industry. 16 |

17 |
18 |
19 | {features.map((feature: any, index) => { 20 | return ( 21 |
22 |
23 |

{feature.name}

24 |

{feature.description}

25 |
26 |
27 | ); 28 | })} 29 |
30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default FeatureSection; 37 | -------------------------------------------------------------------------------- /components/defaultLanding/HeroSection.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import Link from 'next/link'; 3 | 4 | const HeroSection = () => { 5 | const { t } = useTranslation('common'); 6 | return ( 7 |
8 |
9 |
10 |

{t('enterprise-saas-kit')}

11 |

12 | {t('kickstart-your-enterprise')} 13 |

14 |
15 | 19 | {t('get-started')} 20 | 21 | 25 | GitHub 26 | 27 |
28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default HeroSection; 35 | -------------------------------------------------------------------------------- /components/defaultLanding/PricingSection.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@heroicons/react/20/solid'; 2 | import { useTranslation } from 'next-i18next'; 3 | import { Button, Card } from 'react-daisyui'; 4 | 5 | import plans from './data/pricing.json'; 6 | 7 | const PricingSection = () => { 8 | const { t } = useTranslation('common'); 9 | return ( 10 |
11 |
12 |

13 | {t('pricing')} 14 |

15 |

16 | Lorem Ipsum is simply dummy text of the printing and typesetting 17 | industry. 18 |

19 |
20 |
21 | {plans.map((plan, index) => { 22 | return ( 23 | 24 | 25 | 26 | {plan.currency} {plan.amount} / {plan.duration} 27 | 28 |

{plan.description}

29 |
30 |
    31 | {plan.benefits.map((benefit: string) => { 32 | return ( 33 |
  • 34 | 35 | {benefit} 36 |
  • 37 | ); 38 | })} 39 |
40 |
41 |
42 | 43 | 50 | 51 |
52 | ); 53 | })} 54 |
55 |
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default PricingSection; 62 | -------------------------------------------------------------------------------- /components/defaultLanding/data/faq.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "question": "What is BoxyHQ?", 4 | "answer": "Enterprise readiness for B2B SaaS, straight out of the box." 5 | }, 6 | { 7 | "question": "What BoxyHQ offers?", 8 | "answer": "At BoxyHQ we enable you to add plug-and-play enterprise-ready features to your SaaS product. Show enterprise customers and InfoSec teams you are ready to pass their processes with flying colors. We are open-source and free!" 9 | }, 10 | { 11 | "question": "Is BoxyHQ offers developer security tools?", 12 | "answer": "We have curated a list of awesome open-source developer security tools.It includes security principles and controls relevant to popular compliance certifications (like ISO27001, SOC2, MVSP, etc.)" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /components/defaultLanding/data/features.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Create account", 4 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 5 | }, 6 | { 7 | "name": "Sign in with Email and Password", 8 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 9 | }, 10 | { 11 | "name": "Sign in with Magic link", 12 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 13 | }, 14 | { 15 | "name": "Sign in with SAML SSO", 16 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 17 | }, 18 | { 19 | "name": "Directory Sync (SCIM)", 20 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 21 | }, 22 | { 23 | "name": "Update account", 24 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 25 | }, 26 | { 27 | "name": "Invite users to the team", 28 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 29 | }, 30 | { 31 | "name": "Manage team members", 32 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 33 | }, 34 | { 35 | "name": "Update team settings", 36 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 37 | }, 38 | { 39 | "name": "Webhooks & Events", 40 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 41 | }, 42 | { 43 | "name": "Create team", 44 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /components/defaultLanding/data/pricing.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "amount": "30k", 4 | "currency": "USD", 5 | "duration": "month", 6 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", 7 | "benefits": [ 8 | "Up to 20 users.", 9 | "Free storage upto 1GB", 10 | "Lifetime validity" 11 | ] 12 | }, 13 | { 14 | "amount": "120k", 15 | "currency": "USD", 16 | "duration": "month", 17 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", 18 | "benefits": ["Up to 500 users.", "Storage upto 50GB", "24*7 support"] 19 | }, 20 | { 21 | "amount": "350k", 22 | "currency": "USD", 23 | "duration": "month", 24 | "description": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", 25 | "benefits": [ 26 | "Up to 1000 users.", 27 | "Storage upto 100GB", 28 | "24*7 support", 29 | "Data backups" 30 | ] 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /components/directorySync/Directory.tsx: -------------------------------------------------------------------------------- 1 | import { Error, Loading } from '@/components/shared'; 2 | import { Team } from '@prisma/client'; 3 | import useDirectory from 'hooks/useDirectory'; 4 | import { useTranslation } from 'next-i18next'; 5 | 6 | const Directory = ({ team }: { team: Team }) => { 7 | const { t } = useTranslation('common'); 8 | const { isLoading, isError, directories } = useDirectory(team.slug); 9 | 10 | if (isLoading) { 11 | return ; 12 | } 13 | 14 | if (isError) { 15 | return ; 16 | } 17 | 18 | if (directories && directories.length === 0) { 19 | return null; 20 | } 21 | 22 | const directory = directories[0]; 23 | 24 | return ( 25 |
26 |

{t('directory-sync-message')}

27 |
28 | 31 | 36 |
37 |
38 | 41 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default Directory; 52 | -------------------------------------------------------------------------------- /components/directorySync/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CreateDirectory } from './CreateDirectory'; 2 | export { default as Directory } from './Directory'; 3 | -------------------------------------------------------------------------------- /components/invitation/InviteMember.tsx: -------------------------------------------------------------------------------- 1 | import { getAxiosError } from '@/lib/common'; 2 | import { availableRoles } from '@/lib/permissions'; 3 | import type { Invitation, Team } from '@prisma/client'; 4 | import axios from 'axios'; 5 | import { useFormik } from 'formik'; 6 | import useInvitations from 'hooks/useInvitations'; 7 | import { useTranslation } from 'next-i18next'; 8 | import React from 'react'; 9 | import { Button, Input, Modal } from 'react-daisyui'; 10 | import toast from 'react-hot-toast'; 11 | import type { ApiResponse } from 'types'; 12 | import * as Yup from 'yup'; 13 | 14 | const InviteMember = ({ 15 | visible, 16 | setVisible, 17 | team, 18 | }: { 19 | visible: boolean; 20 | setVisible: (visible: boolean) => void; 21 | team: Team; 22 | }) => { 23 | const { mutateInvitation } = useInvitations(team.slug); 24 | const { t } = useTranslation('common'); 25 | 26 | const formik = useFormik({ 27 | initialValues: { 28 | email: '', 29 | role: availableRoles[0].id, 30 | }, 31 | validationSchema: Yup.object().shape({ 32 | email: Yup.string().email().required(), 33 | role: Yup.string() 34 | .required() 35 | .oneOf(availableRoles.map((r) => r.id)), 36 | }), 37 | onSubmit: async (values) => { 38 | try { 39 | await axios.post>( 40 | `/api/teams/${team.slug}/invitations`, 41 | { 42 | ...values, 43 | } 44 | ); 45 | 46 | toast.success(t('invitation-sent')); 47 | 48 | mutateInvitation(); 49 | setVisible(false); 50 | formik.resetForm(); 51 | } catch (error: any) { 52 | toast.error(getAxiosError(error)); 53 | } 54 | }, 55 | }); 56 | 57 | return ( 58 | 59 |
60 | 61 | {t('invite-new-member')} 62 | 63 | 64 |
65 |

{t('invite-member-message')}

66 |
67 | 75 | 87 |
88 |
89 |
90 | 91 | 100 | 110 | 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default InviteMember; 117 | -------------------------------------------------------------------------------- /components/invitation/index.ts: -------------------------------------------------------------------------------- 1 | export { default as InviteMember } from './InviteMember'; 2 | export { default as PendingInvitations } from './PendingInvitations'; 3 | -------------------------------------------------------------------------------- /components/layouts/AccountLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar, Sidebar } from '@/components/shared'; 2 | import React from 'react'; 3 | 4 | interface AccountLayoutProps { 5 | children: React.ReactNode; 6 | } 7 | 8 | export default function AccountLayout({ children }: AccountLayoutProps) { 9 | return ( 10 | <> 11 | 12 |
13 | 14 |
15 |
16 |
17 |
{children}
18 |
19 |
20 |
21 |
22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import app from '@/lib/app'; 2 | import { useTranslation } from 'next-i18next'; 3 | import Image from 'next/image'; 4 | 5 | interface AuthLayoutProps { 6 | children: React.ReactNode; 7 | heading?: string; 8 | description?: string; 9 | } 10 | 11 | export default function AuthLayout({ 12 | children, 13 | heading, 14 | description, 15 | }: AuthLayoutProps) { 16 | const { t } = useTranslation('common'); 17 | 18 | return ( 19 |
20 |
21 |
22 | {app.name} 29 | {heading && ( 30 |

31 | {t(heading)} 32 |

33 | )} 34 | {description && ( 35 |

{t(description)}

36 | )} 37 |
38 | {children} 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AccountLayout } from './AccountLayout'; 2 | export { default as AuthLayout } from './AuthLayout'; 3 | -------------------------------------------------------------------------------- /components/saml/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CreateConnection } from './CreateConnection'; 2 | -------------------------------------------------------------------------------- /components/shared/AccessControl.tsx: -------------------------------------------------------------------------------- 1 | import type { Action, Resource } from '@/lib/permissions'; 2 | import useCanAccess from 'hooks/useCanAccess'; 3 | 4 | interface AccessControlProps { 5 | children: React.ReactNode; 6 | resource: Resource; 7 | actions: Action[]; 8 | } 9 | 10 | export const AccessControl = ({ 11 | children, 12 | resource, 13 | actions, 14 | }: AccessControlProps) => { 15 | const { canAccess } = useCanAccess(); 16 | 17 | if (!canAccess(resource, actions)) { 18 | return null; 19 | } 20 | 21 | return <>{children}; 22 | }; 23 | -------------------------------------------------------------------------------- /components/shared/Alert.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert as AlertCore, AlertProps } from 'react-daisyui'; 3 | 4 | const Alert = (props: AlertProps) => { 5 | const { children, className, ...rest } = props; 6 | 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | export default Alert; 15 | -------------------------------------------------------------------------------- /components/shared/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { BadgeProps, Badge as BaseBadge } from 'react-daisyui'; 2 | 3 | const Badge = (props: BadgeProps) => { 4 | const { children, className } = props; 5 | 6 | return ( 7 | <> 8 | 12 | {children} 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default Badge; 19 | -------------------------------------------------------------------------------- /components/shared/Card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CardProps { 4 | heading: string; 5 | children: React.ReactNode; 6 | } 7 | 8 | interface CardBodyProps { 9 | children: React.ReactNode; 10 | className?: string; 11 | } 12 | 13 | const Card = (props: CardProps) => { 14 | const { heading, children } = props; 15 | 16 | return ( 17 |
18 |
19 | {heading} 20 |
21 |
{children}
22 |
23 | ); 24 | }; 25 | 26 | const Body = (props: CardBodyProps) => { 27 | const { children, className } = props; 28 | 29 | return
{children}
; 30 | }; 31 | 32 | const Footer = ({ children }: { children: React.ReactNode }) => { 33 | return
{children}
; 34 | }; 35 | 36 | Card.Body = Body; 37 | Card.Footer = Footer; 38 | 39 | export default Card; 40 | -------------------------------------------------------------------------------- /components/shared/ConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | import { Button, Modal } from 'react-daisyui'; 3 | 4 | interface ConfirmationDialogProps { 5 | title: string; 6 | visible: boolean; 7 | onConfirm: () => void | Promise; 8 | onCancel: () => void; 9 | confirmText?: string; 10 | cancelText?: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | const ConfirmationDialog = ({ 15 | title, 16 | children, 17 | visible, 18 | onConfirm, 19 | onCancel, 20 | confirmText, 21 | cancelText, 22 | }: ConfirmationDialogProps) => { 23 | const { t } = useTranslation('common'); 24 | 25 | const handleConfirm = async () => { 26 | await onConfirm(); 27 | onCancel(); 28 | }; 29 | 30 | return ( 31 | 32 | {title} 33 | 34 |
35 | {children} 36 |
37 |
38 | 39 | 42 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default ConfirmationDialog; 51 | -------------------------------------------------------------------------------- /components/shared/CopyToClipboardButton.tsx: -------------------------------------------------------------------------------- 1 | import { copyToClipboard } from '@/lib/common'; 2 | import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'; 3 | import { useTranslation } from 'next-i18next'; 4 | import { Button } from 'react-daisyui'; 5 | import { toast } from 'react-hot-toast'; 6 | 7 | interface CopyToClipboardProps { 8 | value: string; 9 | } 10 | 11 | const CopyToClipboardButton = ({ value }: CopyToClipboardProps) => { 12 | const { t } = useTranslation('common'); 13 | 14 | const handleCopy = () => { 15 | copyToClipboard(value); 16 | toast.success(t('copied-to-clipboard')); 17 | }; 18 | 19 | return ( 20 | 29 | ); 30 | }; 31 | 32 | export default CopyToClipboardButton; 33 | -------------------------------------------------------------------------------- /components/shared/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { FolderPlusIcon } from '@heroicons/react/24/outline'; 2 | import React from 'react'; 3 | 4 | interface EmptyStateProps { 5 | title: string; 6 | description?: string; 7 | } 8 | 9 | const EmptyState = ({ title, description }: EmptyStateProps) => { 10 | return ( 11 |
12 | 13 |

{title}

14 | {description &&

{description}

} 15 |
16 | ); 17 | }; 18 | 19 | export default EmptyState; 20 | -------------------------------------------------------------------------------- /components/shared/Error.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'next-i18next'; 2 | 3 | import Alert from './Alert'; 4 | 5 | interface ErrorProps { 6 | message?: string; 7 | } 8 | 9 | const Error = (props: ErrorProps) => { 10 | const { message } = props; 11 | const { t } = useTranslation('common'); 12 | 13 | return ( 14 | 15 |

{message || t('unknown-error')}

16 |
17 | ); 18 | }; 19 | 20 | export default Error; 21 | -------------------------------------------------------------------------------- /components/shared/InputWithCopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { Input, InputProps } from 'react-daisyui'; 2 | 3 | import CopyToClipboardButton from './CopyToClipboardButton'; 4 | 5 | interface InputWithCopyButtonProps extends InputProps { 6 | label: string; 7 | description?: string; 8 | } 9 | 10 | const InputWithCopyButton = (props: InputWithCopyButtonProps) => { 11 | const { label, value, description, ...rest } = props; 12 | 13 | return ( 14 |
15 |
16 | 19 | 20 |
21 | 26 | {description && ( 27 | 30 | )} 31 |
32 | ); 33 | }; 34 | 35 | export default InputWithCopyButton; 36 | -------------------------------------------------------------------------------- /components/shared/InputWithLabel.tsx: -------------------------------------------------------------------------------- 1 | import { Input, InputProps } from 'react-daisyui'; 2 | 3 | interface InputWithLabelProps extends InputProps { 4 | label: string; 5 | error?: string; 6 | descriptionText?: string; 7 | } 8 | 9 | const InputWithLabel = (props: InputWithLabelProps) => { 10 | const { label, error, descriptionText, ...rest } = props; 11 | 12 | const classes = Array(); 13 | 14 | if (error) { 15 | classes.push('input-error'); 16 | } 17 | 18 | return ( 19 |
20 | 23 | 24 | {(error || descriptionText) && ( 25 | 30 | )} 31 |
32 | ); 33 | }; 34 | 35 | export default InputWithLabel; 36 | -------------------------------------------------------------------------------- /components/shared/LetterAvatar.tsx: -------------------------------------------------------------------------------- 1 | const LetterAvatar = ({ name }: { name: string }) => { 2 | return ( 3 |
4 | {name.charAt(0).toUpperCase()} 5 |
6 | ); 7 | }; 8 | 9 | export default LetterAvatar; 10 | -------------------------------------------------------------------------------- /components/shared/Loading.tsx: -------------------------------------------------------------------------------- 1 | const Loading = () => { 2 | return ; 3 | }; 4 | 5 | export default Loading; 6 | -------------------------------------------------------------------------------- /components/shared/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import app from '@/lib/app'; 2 | import { signOut } from 'next-auth/react'; 3 | import Link from 'next/link'; 4 | import { Button } from 'react-daisyui'; 5 | 6 | export default function Navbar() { 7 | return ( 8 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /components/shared/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CodeBracketIcon, 3 | Cog6ToothIcon, 4 | LockClosedIcon, 5 | RectangleStackIcon, 6 | UserCircleIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import { useTranslation } from 'next-i18next'; 9 | import { useRouter } from 'next/router'; 10 | import React from 'react'; 11 | 12 | import SidebarItem, { type SidebarMenuItem } from './SidebarItem'; 13 | import TeamDropdown from './TeamDropdown'; 14 | 15 | interface SidebarMenus { 16 | [key: string]: SidebarMenuItem[]; 17 | } 18 | 19 | export default function Sidebar() { 20 | const router = useRouter(); 21 | const { t } = useTranslation('common'); 22 | 23 | const { slug } = router.query; 24 | 25 | const sidebarMenus: SidebarMenus = { 26 | personal: [ 27 | { 28 | name: t('all-teams'), 29 | href: '/teams', 30 | icon: RectangleStackIcon, 31 | }, 32 | { 33 | name: t('account'), 34 | href: '/settings/account', 35 | icon: UserCircleIcon, 36 | }, 37 | { 38 | name: t('password'), 39 | href: '/settings/password', 40 | icon: LockClosedIcon, 41 | }, 42 | ], 43 | team: [ 44 | { 45 | name: t('all-products'), 46 | href: `/teams/${slug}/products`, 47 | icon: CodeBracketIcon, 48 | }, 49 | { 50 | name: t('settings'), 51 | href: `/teams/${slug}/settings`, 52 | icon: Cog6ToothIcon, 53 | }, 54 | ], 55 | }; 56 | 57 | const menus = sidebarMenus[slug ? 'team' : 'personal']; 58 | 59 | return ( 60 | <> 61 | 101 |
105 | 106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /components/shared/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import NextLink from 'next/link'; 3 | 4 | export interface SidebarMenuItem { 5 | name: string; 6 | href: string; 7 | icon?: any; 8 | active?: boolean; 9 | items?: Omit[]; 10 | className?: string; 11 | } 12 | 13 | const SidebarItem = ({ 14 | href, 15 | name, 16 | icon, 17 | active, 18 | className, 19 | }: SidebarMenuItem) => { 20 | const Icon = icon; 21 | 22 | return ( 23 | 31 |
32 | {Icon && } 33 | {name} 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default SidebarItem; 40 | -------------------------------------------------------------------------------- /components/shared/Table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | type TableProps = { 4 | body: JSX.Element | JSX.Element[]; 5 | head?: JSX.Element | JSX.Element[]; 6 | className?: string; 7 | containerClassName?: string; 8 | borderless?: boolean; 9 | headTrClasses?: string; 10 | }; 11 | 12 | function Table({ body, head }: TableProps) { 13 | return ( 14 |
15 | 16 | 17 | {head} 18 | 19 | {body} 20 |
21 |
22 | ); 23 | } 24 | 25 | type ThProps = { 26 | children?: React.ReactNode; 27 | className?: string; 28 | style?: React.CSSProperties; 29 | }; 30 | 31 | const Th: React.FC = ({ children, className, style }) => { 32 | const classes = ['px-6 py-3 text-left']; 33 | 34 | if (className) classes.push(className); 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | 43 | type TrProps = { 44 | children: React.ReactNode; 45 | className?: string; 46 | hoverable?: boolean; 47 | style?: React.CSSProperties; 48 | onClick?: () => void; 49 | }; 50 | 51 | const Tr: React.FC = ({ children, onClick, style }) => { 52 | return ( 53 | 58 | {children} 59 | 60 | ); 61 | }; 62 | 63 | type TdProps = { 64 | children: React.ReactNode; 65 | colSpan?: number; 66 | className?: string; 67 | style?: React.CSSProperties; 68 | } & React.HTMLProps; 69 | 70 | const Td: React.FC = ({ children, colSpan, style, ...rest }) => { 71 | return ( 72 | 73 | {children} 74 | 75 | ); 76 | }; 77 | 78 | Table.th = Th; 79 | Table.td = Td; 80 | Table.tr = Tr; 81 | 82 | export default Table; 83 | -------------------------------------------------------------------------------- /components/shared/WithLoadingAndError.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Loading } from '@/components/shared'; 2 | 3 | interface WithLoadingAndErrorProps { 4 | isLoading: boolean; 5 | error: any; 6 | children: React.ReactNode; 7 | } 8 | 9 | const WithLoadingAndError = (props: WithLoadingAndErrorProps) => { 10 | const { isLoading, error, children } = props; 11 | 12 | if (isLoading) { 13 | return ; 14 | } 15 | 16 | if (error) { 17 | return {error.message}; 18 | } 19 | 20 | return <>{children}; 21 | }; 22 | 23 | export default WithLoadingAndError; 24 | -------------------------------------------------------------------------------- /components/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Alert } from './Alert'; 2 | export { default as Sidebar } from './Sidebar'; 3 | export { default as Table } from './Table'; 4 | export { default as Navbar } from './Navbar'; 5 | export { default as Card } from './Card'; 6 | export { default as LetterAvatar } from './LetterAvatar'; 7 | export { default as EmptyState } from './EmptyState'; 8 | export { default as InputWithLabel } from './InputWithLabel'; 9 | export { default as Loading } from './Loading'; 10 | export { default as Error } from './Error'; 11 | export { default as WithLoadingAndError } from './WithLoadingAndError'; 12 | export { default as InputWithCopyButton } from './InputWithCopyButton'; 13 | export { default as CopyToClipboardButton } from './CopyToClipboardButton'; 14 | -------------------------------------------------------------------------------- /components/team/CreateTeam.tsx: -------------------------------------------------------------------------------- 1 | import { getAxiosError } from '@/lib/common'; 2 | import type { Team } from '@prisma/client'; 3 | import axios from 'axios'; 4 | import { useFormik } from 'formik'; 5 | import useTeams from 'hooks/useTeams'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { useRouter } from 'next/router'; 8 | import React from 'react'; 9 | import { Button, Input, Modal } from 'react-daisyui'; 10 | import toast from 'react-hot-toast'; 11 | import type { ApiResponse } from 'types'; 12 | import * as Yup from 'yup'; 13 | 14 | const CreateTeam = ({ 15 | visible, 16 | setVisible, 17 | }: { 18 | visible: boolean; 19 | setVisible: (visible: boolean) => void; 20 | }) => { 21 | const { t } = useTranslation('common'); 22 | const { mutateTeams } = useTeams(); 23 | const router = useRouter(); 24 | 25 | const formik = useFormik({ 26 | initialValues: { 27 | name: '', 28 | }, 29 | validationSchema: Yup.object().shape({ 30 | name: Yup.string().required(), 31 | }), 32 | onSubmit: async (values) => { 33 | try { 34 | const response = await axios.post>('/api/teams/', { 35 | ...values, 36 | }); 37 | 38 | const { data: teamCreated } = response.data; 39 | 40 | if (teamCreated) { 41 | toast.success(t('team-created')); 42 | mutateTeams(); 43 | formik.resetForm(); 44 | setVisible(false); 45 | router.push(`/teams/${teamCreated.slug}/settings`); 46 | } 47 | } catch (error: any) { 48 | toast.error(getAxiosError(error)); 49 | } 50 | }, 51 | }); 52 | 53 | return ( 54 | 55 |
56 | {t('create-team')} 57 | 58 |
59 |

{t('members-of-a-team')}

60 |
61 | 68 |
69 |
70 |
71 | 72 | 81 | 91 | 92 |
93 |
94 | ); 95 | }; 96 | 97 | export default CreateTeam; 98 | -------------------------------------------------------------------------------- /components/team/RemoveTeam.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/shared'; 2 | import { Team } from '@prisma/client'; 3 | import axios from 'axios'; 4 | import { useTranslation } from 'next-i18next'; 5 | import { useRouter } from 'next/router'; 6 | import React, { useState } from 'react'; 7 | import { Button } from 'react-daisyui'; 8 | import toast from 'react-hot-toast'; 9 | 10 | import ConfirmationDialog from '../shared/ConfirmationDialog'; 11 | 12 | const RemoveTeam = ({ team }: { team: Team }) => { 13 | const router = useRouter(); 14 | const { t } = useTranslation('common'); 15 | const [loading, setLoading] = useState(false); 16 | const [askConfirmation, setAskConfirmation] = useState(false); 17 | 18 | const removeTeam = async () => { 19 | setLoading(true); 20 | 21 | const response = await axios.delete(`/api/teams/${team.slug}`); 22 | 23 | setLoading(false); 24 | 25 | const { data, error } = response.data; 26 | 27 | if (error) { 28 | toast.error(error.message); 29 | return; 30 | } 31 | 32 | if (data) { 33 | toast.success(t('team-removed-successfully')); 34 | return router.push('/teams'); 35 | } 36 | }; 37 | 38 | return ( 39 | <> 40 | 41 | 42 |

{t('remove-team-warning')}

43 | 52 |
53 |
54 | setAskConfirmation(false)} 58 | onConfirm={removeTeam} 59 | > 60 | {t('remove-team-confirmation')} 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default RemoveTeam; 67 | -------------------------------------------------------------------------------- /components/team/TeamTab.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Cog6ToothIcon, 3 | DocumentMagnifyingGlassIcon, 4 | KeyIcon, 5 | PaperAirplaneIcon, 6 | ShieldExclamationIcon, 7 | UserPlusIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import type { Team } from '@prisma/client'; 10 | import classNames from 'classnames'; 11 | import useCanAccess from 'hooks/useCanAccess'; 12 | import Link from 'next/link'; 13 | 14 | interface TeamTabProps { 15 | activeTab: string; 16 | team: Team; 17 | heading?: string; 18 | } 19 | 20 | const TeamTab = (props: TeamTabProps) => { 21 | const { activeTab, team, heading } = props; 22 | 23 | const { canAccess } = useCanAccess(); 24 | 25 | const navigations = [ 26 | { 27 | name: 'Settings', 28 | href: `/teams/${team.slug}/settings`, 29 | active: activeTab === 'settings', 30 | icon: Cog6ToothIcon, 31 | }, 32 | ]; 33 | 34 | if (canAccess('team_member', ['create', 'update', 'read', 'delete'])) { 35 | navigations.push({ 36 | name: 'Members', 37 | href: `/teams/${team.slug}/members`, 38 | active: activeTab === 'members', 39 | icon: UserPlusIcon, 40 | }); 41 | } 42 | 43 | if (canAccess('team_sso', ['create', 'update', 'read', 'delete'])) { 44 | navigations.push({ 45 | name: 'Single Sign-On', 46 | href: `/teams/${team.slug}/saml`, 47 | active: activeTab === 'saml', 48 | icon: ShieldExclamationIcon, 49 | }); 50 | } 51 | 52 | if (canAccess('team_dsync', ['create', 'update', 'read', 'delete'])) { 53 | navigations.push({ 54 | name: 'Directory Sync', 55 | href: `/teams/${team.slug}/directory-sync`, 56 | active: activeTab === 'directory-sync', 57 | icon: UserPlusIcon, 58 | }); 59 | } 60 | 61 | if (canAccess('team_audit_log', ['create', 'update', 'read', 'delete'])) { 62 | navigations.push({ 63 | name: 'Audit Logs', 64 | href: `/teams/${team.slug}/audit-logs`, 65 | active: activeTab === 'audit-logs', 66 | icon: DocumentMagnifyingGlassIcon, 67 | }); 68 | } 69 | 70 | if (canAccess('team_webhook', ['create', 'update', 'read', 'delete'])) { 71 | navigations.push({ 72 | name: 'Webhooks', 73 | href: `/teams/${team.slug}/webhooks`, 74 | active: activeTab === 'webhooks', 75 | icon: PaperAirplaneIcon, 76 | }); 77 | } 78 | 79 | if (canAccess('team_api_key', ['create', 'update', 'read', 'delete'])) { 80 | navigations.push({ 81 | name: 'API Keys', 82 | href: `/teams/${team.slug}/api-keys`, 83 | active: activeTab === 'api-keys', 84 | icon: KeyIcon, 85 | }); 86 | } 87 | 88 | return ( 89 |
90 |

91 | {heading ? heading : team.name} 92 |

93 | 114 |
115 | ); 116 | }; 117 | 118 | export default TeamTab; 119 | -------------------------------------------------------------------------------- /components/team/UpdateMemberRole.tsx: -------------------------------------------------------------------------------- 1 | import { availableRoles } from '@/lib/permissions'; 2 | import { Team, TeamMember } from '@prisma/client'; 3 | import axios from 'axios'; 4 | import { useTranslation } from 'next-i18next'; 5 | import toast from 'react-hot-toast'; 6 | 7 | interface UpdateMemberRoleProps { 8 | team: Team; 9 | member: TeamMember; 10 | } 11 | 12 | const UpdateMemberRole = ({ team, member }: UpdateMemberRoleProps) => { 13 | const { t } = useTranslation('common'); 14 | 15 | const updateRole = async (member: TeamMember, role: string) => { 16 | await axios.patch(`/api/teams/${team.slug}/members`, { 17 | memberId: member.userId, 18 | role, 19 | }); 20 | 21 | toast.success(t('member-role-updated')); 22 | }; 23 | 24 | return ( 25 | 35 | ); 36 | }; 37 | 38 | export default UpdateMemberRole; 39 | -------------------------------------------------------------------------------- /components/team/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Teams } from './Teams'; 2 | export { default as CreateTeam } from './CreateTeam'; 3 | export { default as TeamTab } from './TeamTab'; 4 | export { default as Members } from './Members'; 5 | export { default as RemoveTeam } from './RemoveTeam'; 6 | export { default as TeamSettings } from './TeamSettings'; 7 | -------------------------------------------------------------------------------- /components/webhook/CreateWebhook.tsx: -------------------------------------------------------------------------------- 1 | import type { Team } from '@prisma/client'; 2 | import axios from 'axios'; 3 | import type { FormikHelpers } from 'formik'; 4 | import useWebhooks from 'hooks/useWebhooks'; 5 | import { useTranslation } from 'next-i18next'; 6 | import React from 'react'; 7 | import toast from 'react-hot-toast'; 8 | import type { ApiResponse } from 'types'; 9 | import type { WebookFormSchema } from 'types'; 10 | 11 | import ModalForm from './Form'; 12 | 13 | const CreateWebhook = ({ 14 | visible, 15 | setVisible, 16 | team, 17 | }: { 18 | visible: boolean; 19 | setVisible: (visible: boolean) => void; 20 | team: Team; 21 | }) => { 22 | const { mutateWebhooks } = useWebhooks(team.slug); 23 | const { t } = useTranslation('common'); 24 | 25 | const onSubmit = async ( 26 | values: WebookFormSchema, 27 | formikHelpers: FormikHelpers 28 | ) => { 29 | const { name, url, eventTypes } = values; 30 | 31 | const response = await axios.post( 32 | `/api/teams/${team.slug}/webhooks`, 33 | { 34 | name, 35 | url, 36 | eventTypes, 37 | } 38 | ); 39 | 40 | const { data: webhooks, error } = response.data; 41 | 42 | if (error) { 43 | toast.error(error.message); 44 | return; 45 | } 46 | 47 | if (webhooks) { 48 | toast.success(t('webhook-created')); 49 | } 50 | 51 | mutateWebhooks(); 52 | setVisible(false); 53 | formikHelpers.resetForm(); 54 | }; 55 | 56 | return ( 57 | 67 | ); 68 | }; 69 | 70 | export default CreateWebhook; 71 | -------------------------------------------------------------------------------- /components/webhook/EditWebhook.tsx: -------------------------------------------------------------------------------- 1 | import { Error, Loading } from '@/components/shared'; 2 | import type { Team } from '@prisma/client'; 3 | import axios from 'axios'; 4 | import type { FormikHelpers } from 'formik'; 5 | import useWebhook from 'hooks/useWebhook'; 6 | import useWebhooks from 'hooks/useWebhooks'; 7 | import { useTranslation } from 'next-i18next'; 8 | import React from 'react'; 9 | import toast from 'react-hot-toast'; 10 | import type { EndpointOut } from 'svix'; 11 | import type { WebookFormSchema } from 'types'; 12 | import type { ApiResponse } from 'types'; 13 | 14 | import ModalForm from './Form'; 15 | 16 | const EditWebhook = ({ 17 | visible, 18 | setVisible, 19 | team, 20 | endpoint, 21 | }: { 22 | visible: boolean; 23 | setVisible: (visible: boolean) => void; 24 | team: Team; 25 | endpoint: EndpointOut; 26 | }) => { 27 | const { isLoading, isError, webhook } = useWebhook(team.slug, endpoint.id); 28 | const { t } = useTranslation('common'); 29 | const { mutateWebhooks } = useWebhooks(team.slug); 30 | 31 | if (isLoading || !webhook) { 32 | return ; 33 | } 34 | 35 | if (isError) { 36 | return ; 37 | } 38 | 39 | const onSubmit = async ( 40 | values: WebookFormSchema, 41 | formikHelpers: FormikHelpers 42 | ) => { 43 | const { name, url, eventTypes } = values; 44 | 45 | const response = await axios.put( 46 | `/api/teams/${team.slug}/webhooks/${endpoint.id}`, 47 | { 48 | name, 49 | url, 50 | eventTypes, 51 | } 52 | ); 53 | 54 | const { data: webhooks, error } = response.data; 55 | 56 | if (error) { 57 | toast.error(error.message); 58 | return; 59 | } 60 | 61 | if (webhooks) { 62 | toast.success(t('webhook-created')); 63 | } 64 | 65 | mutateWebhooks(); 66 | formikHelpers.resetForm(); 67 | setVisible(false); 68 | }; 69 | 70 | return ( 71 | 81 | ); 82 | }; 83 | 84 | export default EditWebhook; 85 | -------------------------------------------------------------------------------- /components/webhook/EventTypes.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import type { WebookFormSchema } from 'types'; 3 | 4 | export const eventTypes = [ 5 | 'member.created', 6 | 'member.removed', 7 | 'invitation.created', 8 | 'invitation.removed', 9 | ]; 10 | 11 | const EventTypes = ({ 12 | onChange, 13 | values, 14 | }: { 15 | onChange: any; 16 | values: WebookFormSchema['eventTypes']; 17 | }) => { 18 | const events: ReactElement[] = []; 19 | 20 | eventTypes.forEach((eventType) => { 21 | events.push( 22 |
23 | 31 | 32 |
33 | ); 34 | }); 35 | 36 | return <>{events}; 37 | }; 38 | 39 | export default EventTypes; 40 | -------------------------------------------------------------------------------- /components/webhook/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Webhooks } from './Webhooks'; 2 | export { default as CreateWebhook } from './CreateWebhook'; 3 | export { default as EditWebhook } from './EditWebhook'; 4 | export { default as EventTypes } from './EventTypes'; 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: postgres 5 | restart: always 6 | environment: 7 | POSTGRES_PASSWORD: admin 8 | POSTGRES_USER: admin 9 | POSTGRES_DB: saas-starter-kit 10 | ports: 11 | - 5432:5432 12 | -------------------------------------------------------------------------------- /hooks/useCanAccess.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Resource } from '@/lib/permissions'; 2 | 3 | import usePermissions from './usePermissions'; 4 | 5 | const useCanAccess = () => { 6 | const { permissions, isError, isLoading } = usePermissions(); 7 | 8 | const canAccess = (resource: Resource, actions: Action[]) => { 9 | return (permissions || []).some( 10 | (permission) => 11 | permission.resource === resource && 12 | (permission.actions === '*' || 13 | permission.actions.some((action) => actions.includes(action))) 14 | ); 15 | }; 16 | 17 | return { 18 | isLoading, 19 | isError, 20 | canAccess, 21 | }; 22 | }; 23 | 24 | export default useCanAccess; 25 | -------------------------------------------------------------------------------- /hooks/useDirectory.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import { Directory } from '@boxyhq/saml-jackson'; 3 | import useSWR, { mutate } from 'swr'; 4 | import { ApiResponse } from 'types'; 5 | 6 | const useDirectory = (slug: string) => { 7 | const url = `/api/teams/${slug}/directory-sync`; 8 | 9 | const { data, error, isLoading } = useSWR>( 10 | slug ? url : null, 11 | fetcher 12 | ); 13 | 14 | const mutateDirectory = async () => { 15 | mutate(url); 16 | }; 17 | 18 | return { 19 | isLoading, 20 | isError: error, 21 | directories: data?.data || [], 22 | mutateDirectory, 23 | }; 24 | }; 25 | 26 | export default useDirectory; 27 | -------------------------------------------------------------------------------- /hooks/useInvitation.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import { Invitation, Team } from '@prisma/client'; 3 | import useSWR from 'swr'; 4 | import { ApiResponse } from 'types'; 5 | 6 | const useInvitation = (token: string) => { 7 | const url = `/api/invitations/${token}`; 8 | 9 | const { data, error, isLoading } = useSWR< 10 | ApiResponse 11 | >(token ? url : null, fetcher); 12 | 13 | return { 14 | isLoading, 15 | isError: error, 16 | invitation: data?.data, 17 | }; 18 | }; 19 | 20 | export default useInvitation; 21 | -------------------------------------------------------------------------------- /hooks/useInvitations.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import { Invitation } from '@prisma/client'; 3 | import useSWR, { mutate } from 'swr'; 4 | import { ApiResponse } from 'types'; 5 | 6 | const useInvitations = (slug: string) => { 7 | const url = `/api/teams/${slug}/invitations`; 8 | 9 | const { data, error, isLoading } = useSWR>( 10 | url, 11 | fetcher 12 | ); 13 | 14 | const mutateInvitation = async () => { 15 | mutate(url); 16 | }; 17 | 18 | return { 19 | isLoading, 20 | isError: error, 21 | invitations: data?.data, 22 | mutateInvitation, 23 | }; 24 | }; 25 | 26 | export default useInvitations; 27 | -------------------------------------------------------------------------------- /hooks/usePermissions.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { Permission } from '@/lib/permissions'; 3 | import { useRouter } from 'next/router'; 4 | import { useEffect, useState } from 'react'; 5 | import useSWR from 'swr'; 6 | import type { ApiResponse } from 'types'; 7 | 8 | const usePermissions = () => { 9 | const router = useRouter(); 10 | const [teamSlug, setTeamSlug] = useState(null); 11 | 12 | const { slug } = router.query as { slug: string }; 13 | 14 | useEffect(() => { 15 | if (slug) { 16 | setTeamSlug(slug); 17 | } 18 | }, [router.query]); 19 | 20 | const { data, error, isLoading } = useSWR>( 21 | teamSlug ? `/api/teams/${teamSlug}/permissions` : null, 22 | fetcher 23 | ); 24 | 25 | return { 26 | isLoading, 27 | isError: error, 28 | permissions: data?.data, 29 | }; 30 | }; 31 | 32 | export default usePermissions; 33 | -------------------------------------------------------------------------------- /hooks/useSAMLConfig.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { SAMLSSORecord } from '@boxyhq/saml-jackson'; 3 | import useSWR, { mutate } from 'swr'; 4 | import type { ApiResponse, SPSAMLConfig } from 'types'; 5 | 6 | const useSAMLConfig = (slug: string | undefined) => { 7 | const url = `/api/teams/${slug}/saml`; 8 | 9 | const { data, error, isLoading } = useSWR< 10 | ApiResponse 11 | >(slug ? url : null, fetcher); 12 | 13 | const mutateSamlConfig = async () => { 14 | mutate(url); 15 | }; 16 | 17 | return { 18 | isLoading, 19 | isError: error, 20 | samlConfig: data?.data, 21 | mutateSamlConfig, 22 | }; 23 | }; 24 | 25 | export default useSAMLConfig; 26 | -------------------------------------------------------------------------------- /hooks/useTeam.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { Team } from '@prisma/client'; 3 | import { useRouter } from 'next/router'; 4 | import useSWR from 'swr'; 5 | import type { ApiResponse } from 'types'; 6 | 7 | const useTeam = (slug?: string) => { 8 | const { query, isReady } = useRouter(); 9 | 10 | const teamSlug = slug || (isReady ? query.slug : null); 11 | 12 | const { data, error, isLoading } = useSWR>( 13 | teamSlug ? `/api/teams/${teamSlug}` : null, 14 | fetcher 15 | ); 16 | 17 | return { 18 | isLoading, 19 | isError: error, 20 | team: data?.data, 21 | }; 22 | }; 23 | 24 | export default useTeam; 25 | -------------------------------------------------------------------------------- /hooks/useTeamMembers.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { TeamMember, User } from '@prisma/client'; 3 | import useSWR, { mutate } from 'swr'; 4 | import type { ApiResponse } from 'types'; 5 | 6 | type TeamMemberWithUser = TeamMember & { user: User }; 7 | 8 | const useTeamMembers = (slug: string) => { 9 | const url = `/api/teams/${slug}/members`; 10 | 11 | const { data, error, isLoading } = useSWR>( 12 | url, 13 | fetcher 14 | ); 15 | 16 | const mutateTeamMembers = async () => { 17 | mutate(url); 18 | }; 19 | 20 | return { 21 | isLoading, 22 | isError: error, 23 | members: data?.data, 24 | mutateTeamMembers, 25 | }; 26 | }; 27 | 28 | export default useTeamMembers; 29 | -------------------------------------------------------------------------------- /hooks/useTeams.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import useSWR, { mutate } from 'swr'; 3 | import type { ApiResponse, TeamWithMemberCount } from 'types'; 4 | 5 | const useTeams = () => { 6 | const url = `/api/teams`; 7 | 8 | const { data, error, isLoading } = useSWR>( 9 | url, 10 | fetcher 11 | ); 12 | 13 | const mutateTeams = async () => { 14 | mutate(url); 15 | }; 16 | 17 | return { 18 | isLoading, 19 | isError: error, 20 | teams: data?.data, 21 | mutateTeams, 22 | }; 23 | }; 24 | 25 | export default useTeams; 26 | -------------------------------------------------------------------------------- /hooks/useWebhook.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { EndpointOut } from 'svix'; 3 | import useSWR, { mutate } from 'swr'; 4 | import type { ApiResponse } from 'types'; 5 | 6 | const useWebhook = (slug: string, endpointId: string | null) => { 7 | const url = `/api/teams/${slug}/webhooks/${endpointId}`; 8 | 9 | const { data, error, isLoading } = useSWR>( 10 | slug ? url : null, 11 | fetcher 12 | ); 13 | 14 | const mutateWebhook = async () => { 15 | mutate(url); 16 | }; 17 | 18 | return { 19 | isLoading, 20 | isError: error, 21 | webhook: data?.data, 22 | mutateWebhook, 23 | }; 24 | }; 25 | 26 | export default useWebhook; 27 | -------------------------------------------------------------------------------- /hooks/useWebhooks.ts: -------------------------------------------------------------------------------- 1 | import fetcher from '@/lib/fetcher'; 2 | import type { EndpointOut } from 'svix'; 3 | import useSWR, { mutate } from 'swr'; 4 | import type { ApiResponse } from 'types'; 5 | 6 | const useWebhooks = (slug: string) => { 7 | const url = `/api/teams/${slug}/webhooks`; 8 | 9 | const { data, error, isLoading } = useSWR>( 10 | slug ? url : null, 11 | fetcher 12 | ); 13 | 14 | const mutateWebhooks = async () => { 15 | mutate(url); 16 | }; 17 | 18 | return { 19 | isLoading, 20 | isError: error, 21 | webhooks: data?.data, 22 | mutateWebhooks, 23 | }; 24 | }; 25 | 26 | export default useWebhooks; 27 | -------------------------------------------------------------------------------- /i18next.d.ts: -------------------------------------------------------------------------------- 1 | import 'i18next'; 2 | 3 | declare module 'i18next' { 4 | interface CustomTypeOptions { 5 | returnNull: false; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // jest.config.js 3 | const nextJest = require('next/jest'); 4 | 5 | const createJestConfig = nextJest({ 6 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 7 | dir: './', 8 | }); 9 | 10 | // Add any custom config to be passed to Jest 11 | /** @type {import('jest').Config} */ 12 | const customJestConfig = { 13 | // Add more setup options before each test is run 14 | // setupFilesAfterEnv: ['/jest.setup.js'], 15 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 16 | moduleDirectories: ['node_modules', '/'], 17 | testEnvironment: 'jest-environment-jsdom', 18 | testPathIgnorePatterns: ['/tests/e2e'], 19 | }; 20 | 21 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 22 | module.exports = createJestConfig(customJestConfig); 23 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | // Used for __tests__/testing-library.js 4 | // Learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /lib/app.ts: -------------------------------------------------------------------------------- 1 | import packageInfo from '../package.json'; 2 | 3 | const app = { 4 | version: packageInfo.version, 5 | name: 'BoxyHQ', 6 | logoUrl: 'https://boxyhq.com/img/logo.png', 7 | url: 'http://localhost:4002', 8 | }; 9 | 10 | export default app; 11 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { compare, hash } from 'bcryptjs'; 2 | 3 | export async function hashPassword(password: string) { 4 | return await hash(password, 12); 5 | } 6 | 7 | export async function verifyPassword(password: string, hashedPassword: string) { 8 | return await compare(password, hashedPassword); 9 | } 10 | -------------------------------------------------------------------------------- /lib/common.ts: -------------------------------------------------------------------------------- 1 | import { enc, lib } from 'crypto-js'; 2 | import type { NextApiRequest } from 'next'; 3 | 4 | export const createRandomString = (length = 6) => { 5 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 6 | const charactersLength = characters.length; 7 | 8 | let string = ''; 9 | 10 | for (let i = 0; i < length; i++) { 11 | string += characters.charAt(Math.floor(Math.random() * charactersLength)); 12 | } 13 | 14 | return string; 15 | }; 16 | 17 | // Create token 18 | export function generateToken(length = 64) { 19 | const tokenBytes = lib.WordArray.random(length); 20 | 21 | return enc.Base64.stringify(tokenBytes); 22 | } 23 | 24 | export const slugify = (text: string) => { 25 | return text 26 | .toString() 27 | .toLowerCase() 28 | .replace(/\s+/g, '-') // Replace spaces with - 29 | .replace(/[^\w-]+/g, '') // Remove all non-word chars 30 | .replace(/--+/g, '-') // Replace multiple - with single - 31 | .replace(/^-+/, '') // Trim - from start of text 32 | .replace(/-+$/, ''); // Trim - from end of text 33 | }; 34 | 35 | // Fetch the auth token from the request headers 36 | export const extractAuthToken = (req: NextApiRequest): string | null => { 37 | const authHeader = req.headers.authorization || null; 38 | 39 | return authHeader ? authHeader.split(' ')[1] : null; 40 | }; 41 | 42 | export const getAxiosError = (error: any): string => { 43 | if (error.response) { 44 | return error.response.data.error.message; 45 | } 46 | 47 | return error.message; 48 | }; 49 | 50 | export const validateEmail = (email: string): boolean => { 51 | const re = /\S+@\S+\.\S+/; 52 | return re.test(email); 53 | }; 54 | 55 | export const validatePassword = (password: string): boolean => { 56 | // Password should be at least 8 characters long 57 | if (password.length < 8) { 58 | return false; 59 | } 60 | 61 | // Password should have at least one lowercase letter 62 | if (!/[a-z]/.test(password)) { 63 | return false; 64 | } 65 | 66 | // Password should have at least one uppercase letter 67 | if (!/[A-Z]/.test(password)) { 68 | return false; 69 | } 70 | 71 | // Password should have at least one number 72 | if (!/\d/.test(password)) { 73 | return false; 74 | } 75 | 76 | // Password should have at least one special character 77 | if (!/[^a-zA-Z0-9]/.test(password)) { 78 | return false; 79 | } 80 | 81 | return true; 82 | }; 83 | 84 | export const copyToClipboard = (text: string) => { 85 | navigator.clipboard.writeText(text); 86 | }; 87 | -------------------------------------------------------------------------------- /lib/cookie.ts: -------------------------------------------------------------------------------- 1 | import { getCookie } from 'cookies-next'; 2 | import type { GetServerSidePropsContext } from 'next'; 3 | 4 | export const getParsedCookie = ( 5 | req: GetServerSidePropsContext['req'], 6 | res: GetServerSidePropsContext['res'] 7 | ): { 8 | token: string | null; 9 | url: string | null; 10 | } => { 11 | const cookie = getCookie('pending-invite', { req, res }); 12 | 13 | return cookie 14 | ? JSON.parse(cookie as string) 15 | : { 16 | token: null, 17 | url: null, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/email/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | 3 | import env from '../env'; 4 | 5 | const transporter = nodemailer.createTransport({ 6 | host: env.smtp.host, 7 | port: env.smtp.port, 8 | secure: false, 9 | auth: { 10 | user: env.smtp.user, 11 | pass: env.smtp.password, 12 | }, 13 | }); 14 | 15 | interface EmailData { 16 | to: string; 17 | subject: string; 18 | html: string; 19 | text?: string; 20 | } 21 | 22 | export const sendEmail = async (data: EmailData) => { 23 | if (!env.smtp.host) { 24 | return; 25 | } 26 | 27 | const emailDefaults = { 28 | from: env.smtp.from, 29 | }; 30 | 31 | await transporter.sendMail({ ...emailDefaults, ...data }); 32 | }; 33 | -------------------------------------------------------------------------------- /lib/email/sendPasswordResetEmail.ts: -------------------------------------------------------------------------------- 1 | import env from '../env'; 2 | import { sendEmail } from './sendEmail'; 3 | 4 | export const sendPasswordResetEmail = async (email: string, url: string) => { 5 | await sendEmail({ 6 | to: email, 7 | subject: 'Reset Your BoxyHQ Password', 8 | html: ` 9 | Dear User, 10 |

11 | We have received a request to reset your BoxyHQ password. If you did not request a password reset, please ignore this email. 12 |

13 | To reset your password, please click on the link below: 14 |

15 | Reset Password Link 16 |

17 | This link will expire in 60 minutes. After that, you will need to request another password reset. 18 |

19 | Thank you for using BoxyHQ. 20 |

21 | Best regards, 22 |

23 | The BoxyHQ Team 24 | `, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/email/sendTeamInviteEmail.ts: -------------------------------------------------------------------------------- 1 | import { Invitation, Team } from '@prisma/client'; 2 | 3 | import env from '../env'; 4 | import { sendEmail } from './sendEmail'; 5 | 6 | export const sendTeamInviteEmail = async ( 7 | team: Team, 8 | invitation: Invitation 9 | ) => { 10 | const invitationLink = `${env.appUrl}/invitations/${invitation.token}`; 11 | 12 | await sendEmail({ 13 | to: invitation.email, 14 | subject: 'Team Invitation', 15 | html: `You have been invited to join the team, ${team.name}. 16 |

Click the below link to accept the invitation and join the team. 17 |

${invitationLink}`, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /lib/email/sendVerificationEmail.ts: -------------------------------------------------------------------------------- 1 | import type { User, VerificationToken } from '@prisma/client'; 2 | 3 | import app from '../app'; 4 | import env from '../env'; 5 | import { sendEmail } from './sendEmail'; 6 | 7 | export const sendVerificationEmail = async ({ 8 | user, 9 | verificationToken, 10 | }: { 11 | user: User; 12 | verificationToken: VerificationToken; 13 | }) => { 14 | const verificationLink = `${ 15 | env.appUrl 16 | }/auth/verify-email-token?token=${encodeURIComponent( 17 | verificationToken.token 18 | )}`; 19 | 20 | await sendEmail({ 21 | to: user.email, 22 | subject: `Confirm your ${app.name} account`, 23 | html: ` 24 |

Before we can get started, we need to confirm your account.

25 |

Thank you for signing up for ${app.name}. To confirm your account, please click the link below.

26 |

${verificationLink}

27 |

If you did not create an account, no further action is required.

28 | `, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/email/sendWelcomeEmail.ts: -------------------------------------------------------------------------------- 1 | import env from '../env'; 2 | import { sendEmail } from './sendEmail'; 3 | 4 | export const sendWelcomeEmail = async ( 5 | name: string, 6 | email: string, 7 | team: string 8 | ) => { 9 | await sendEmail({ 10 | to: email, 11 | subject: 'Welcome to BoxyHQ', 12 | html: `Hello ${name}, 13 |

You have been successfully signed up to BoxyHQ on team ${team}. Click the below link to login now. 14 |

Login`, 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /lib/email/utils.ts: -------------------------------------------------------------------------------- 1 | import blockedDomains from './freeEmailService.json'; 2 | 3 | export const isBusinessEmail = (email: string) => { 4 | if (email.indexOf('@') > 0 && email.indexOf('@') < email.length - 3) { 5 | const emailDomain = email.split('@')[1]; 6 | 7 | return !blockedDomains[emailDomain]; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/env.ts: -------------------------------------------------------------------------------- 1 | const env = { 2 | databaseUrl: `${process.env.DATABASE_URL}`, 3 | appUrl: `${process.env.APP_URL}`, 4 | product: 'boxyhq', 5 | redirectAfterSignIn: '/teams/switch', 6 | 7 | // SAML Jackson configuration 8 | saml: { 9 | issuer: 'https://saml.boxyhq.com', 10 | path: '/api/oauth/saml', 11 | callback: `${process.env.APP_URL}`, 12 | }, 13 | 14 | // SMTP configuration for NextAuth 15 | smtp: { 16 | host: process.env.SMTP_HOST, 17 | port: Number(process.env.SMTP_PORT), 18 | user: process.env.SMTP_USER, 19 | password: process.env.SMTP_PASSWORD, 20 | from: process.env.SMTP_FROM, 21 | }, 22 | 23 | // NextAuth configuration 24 | nextAuth: { 25 | secret: process.env.NEXTAUTH_SECRET, 26 | }, 27 | 28 | // Svix 29 | svix: { 30 | url: `${process.env.SVIX_URL}`, 31 | apiKey: `${process.env.SVIX_API_KEY}`, 32 | }, 33 | 34 | //Social login: Github 35 | github: { 36 | clientId: `${process.env.GITHUB_CLIENT_ID}`, 37 | clientSecret: `${process.env.GITHUB_CLIENT_SECRET}`, 38 | }, 39 | 40 | //Social login: Google 41 | google: { 42 | clientId: `${process.env.GOOGLE_CLIENT_ID}`, 43 | clientSecret: `${process.env.GOOGLE_CLIENT_SECRET}`, 44 | }, 45 | 46 | // Retraced configuration 47 | retraced: { 48 | url: process.env.RETRACED_URL 49 | ? `${process.env.RETRACED_URL}/auditlog` 50 | : undefined, 51 | apiKey: process.env.RETRACED_API_KEY, 52 | projectId: process.env.RETRACED_PROJECT_ID, 53 | }, 54 | 55 | groupPrefix: process.env.GROUP_PREFIX, 56 | 57 | // Users will need to confirm their email before accessing the app feature 58 | confirmEmail: process.env.CONFIRM_EMAIL === 'true', 59 | 60 | // Mixpanel configuration 61 | mixpanel: { 62 | token: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN, 63 | }, 64 | 65 | disableNonBusinessEmailSignup: 66 | process.env.DISABLE_NON_BUSINESS_EMAIL_SIGNUP === 'true' ? true : false, 67 | }; 68 | 69 | export default env; 70 | -------------------------------------------------------------------------------- /lib/errors.ts: -------------------------------------------------------------------------------- 1 | export class ApiError extends Error { 2 | status: number; 3 | 4 | constructor(status: number, message: string) { 5 | super(message); 6 | this.status = status; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const fetcher = async (url: string) => { 4 | try { 5 | const { data } = await axios.get(url); 6 | 7 | return data; 8 | } catch (error: any) { 9 | const message = 10 | error.response?.data?.error?.message || 'Something went wrong'; 11 | 12 | throw new Error(message); 13 | } 14 | }; 15 | 16 | export default fetcher; 17 | -------------------------------------------------------------------------------- /lib/inferSSRProps.ts: -------------------------------------------------------------------------------- 1 | type GetSSRResult = 2 | | { props: TProps } 3 | | { redirect: any } 4 | | { notFound: boolean }; 5 | 6 | type GetSSRFn = (args: any) => Promise>; 7 | 8 | export type inferSSRProps> = TFn extends GetSSRFn< 9 | infer TProps 10 | > 11 | ? NonNullable 12 | : never; 13 | -------------------------------------------------------------------------------- /lib/jackson.ts: -------------------------------------------------------------------------------- 1 | import jackson, { 2 | IConnectionAPIController, 3 | IDirectorySyncController, 4 | IOAuthController, 5 | JacksonOption, 6 | } from '@boxyhq/saml-jackson'; 7 | 8 | import env from './env'; 9 | 10 | const opts = { 11 | externalUrl: env.appUrl, 12 | samlPath: env.saml.path, 13 | samlAudience: env.saml.issuer, 14 | db: { 15 | engine: 'sql', 16 | type: 'postgres', 17 | url: env.databaseUrl, 18 | }, 19 | idpDiscoveryPath: '/auth/sso/idp-select', 20 | openid: {}, 21 | } as JacksonOption; 22 | 23 | let apiController: IConnectionAPIController; 24 | let oauthController: IOAuthController; 25 | let directorySync: IDirectorySyncController; 26 | 27 | const g = global as any; 28 | 29 | export default async function init() { 30 | if (!g.apiController || !g.oauthController || !g.directorySync) { 31 | const ret = await jackson(opts); 32 | 33 | apiController = ret.apiController; 34 | oauthController = ret.oauthController; 35 | directorySync = ret.directorySyncController; 36 | 37 | g.apiController = apiController; 38 | g.oauthController = oauthController; 39 | g.directorySync = directorySync; 40 | } else { 41 | apiController = g.apiController; 42 | oauthController = g.oauthController; 43 | directorySync = g.directorySync; 44 | } 45 | 46 | return { 47 | apiController, 48 | oauthController, 49 | directorySync, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /lib/permissions.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@prisma/client'; 2 | 3 | export type RoleType = (typeof Role)[keyof typeof Role]; 4 | export type Action = 'create' | 'update' | 'read' | 'delete' | 'leave'; 5 | export type Resource = 6 | | 'team' 7 | | 'team_member' 8 | | 'team_invitation' 9 | | 'team_sso' 10 | | 'team_dsync' 11 | | 'team_audit_log' 12 | | 'team_webhook' 13 | | 'team_api_key'; 14 | 15 | export type RolePermissions = { 16 | [role in RoleType]: Permission[]; 17 | }; 18 | 19 | export type Permission = { 20 | resource: Resource; 21 | actions: Action[] | '*'; 22 | }; 23 | 24 | export const availableRoles = [ 25 | { 26 | id: Role.MEMBER, 27 | name: 'Member', 28 | }, 29 | { 30 | id: Role.ADMIN, 31 | name: 'Admin', 32 | }, 33 | { 34 | id: Role.OWNER, 35 | name: 'Owner', 36 | }, 37 | ]; 38 | 39 | export const permissions: RolePermissions = { 40 | OWNER: [ 41 | { 42 | resource: 'team', 43 | actions: '*', 44 | }, 45 | { 46 | resource: 'team_member', 47 | actions: '*', 48 | }, 49 | { 50 | resource: 'team_invitation', 51 | actions: '*', 52 | }, 53 | { 54 | resource: 'team_sso', 55 | actions: '*', 56 | }, 57 | { 58 | resource: 'team_dsync', 59 | actions: '*', 60 | }, 61 | { 62 | resource: 'team_audit_log', 63 | actions: '*', 64 | }, 65 | { 66 | resource: 'team_webhook', 67 | actions: '*', 68 | }, 69 | { 70 | resource: 'team_api_key', 71 | actions: '*', 72 | }, 73 | ], 74 | ADMIN: [ 75 | { 76 | resource: 'team', 77 | actions: '*', 78 | }, 79 | { 80 | resource: 'team_member', 81 | actions: '*', 82 | }, 83 | { 84 | resource: 'team_invitation', 85 | actions: '*', 86 | }, 87 | { 88 | resource: 'team_sso', 89 | actions: '*', 90 | }, 91 | { 92 | resource: 'team_dsync', 93 | actions: '*', 94 | }, 95 | { 96 | resource: 'team_audit_log', 97 | actions: '*', 98 | }, 99 | { 100 | resource: 'team_webhook', 101 | actions: '*', 102 | }, 103 | { 104 | resource: 'team_api_key', 105 | actions: '*', 106 | }, 107 | ], 108 | MEMBER: [ 109 | { 110 | resource: 'team', 111 | actions: ['read', 'leave'], 112 | }, 113 | ], 114 | }; 115 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | declare global { 4 | // allow global `var` declarations 5 | // eslint-disable-next-line no-var 6 | var prisma: PrismaClient | undefined; 7 | } 8 | 9 | export const prisma = 10 | global.prisma || 11 | new PrismaClient({ 12 | //log: ["error"], 13 | }); 14 | 15 | if (process.env.NODE_ENV !== 'production') global.prisma = prisma; 16 | -------------------------------------------------------------------------------- /lib/retraced.ts: -------------------------------------------------------------------------------- 1 | import type { Team } from '@prisma/client'; 2 | import { Client } from '@retracedhq/retraced'; 3 | import type { CRUD, Event } from '@retracedhq/retraced'; 4 | import type { User } from 'next-auth'; 5 | 6 | import env from './env'; 7 | 8 | export type EventType = 9 | | 'member.invitation.create' 10 | | 'member.invitation.delete' 11 | | 'member.remove' 12 | | 'member.update' 13 | | 'sso.connection.create' 14 | | 'sso.connection.delete' 15 | | 'dsync.connection.create' 16 | | 'webhook.create' 17 | | 'webhook.delete' 18 | | 'webhook.update' 19 | | 'team.create' 20 | | 'team.update' 21 | | 'team.delete'; 22 | 23 | type Request = { 24 | action: EventType; 25 | user: User; 26 | team: Team; 27 | crud: CRUD; 28 | // target: Target; 29 | }; 30 | 31 | let retracedClient: Client; 32 | 33 | const getRetracedClient = () => { 34 | if (!env.retraced.apiKey || !env.retraced.projectId || !env.retraced.url) { 35 | return; 36 | } 37 | 38 | if (!retracedClient) { 39 | retracedClient = new Client({ 40 | endpoint: env.retraced.url, 41 | apiKey: env.retraced.apiKey, 42 | projectId: env.retraced.projectId, 43 | }); 44 | } 45 | 46 | return retracedClient; 47 | }; 48 | 49 | export const sendAudit = async (request: Request) => { 50 | const retracedClient = getRetracedClient(); 51 | 52 | if (!retracedClient) { 53 | return; 54 | } 55 | 56 | const { action, user, team, crud } = request; 57 | 58 | const event: Event = { 59 | action, 60 | crud, 61 | group: { 62 | id: team.id, 63 | name: team.name, 64 | }, 65 | actor: { 66 | id: user.id, 67 | name: user.name as string, 68 | }, 69 | created: new Date(), 70 | }; 71 | 72 | return await retracedClient.reportEvent(event); 73 | }; 74 | 75 | export const getViewerToken = async (groupId: string, actorId: string) => { 76 | const retracedClient = getRetracedClient(); 77 | 78 | if (!retracedClient) { 79 | return; 80 | } 81 | 82 | try { 83 | return await retracedClient.getViewerToken(groupId, actorId, true); 84 | } catch (error: any) { 85 | throw new Error( 86 | 'Unable to get viewer token from Retraced. Please check Retraced configuration.' 87 | ); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /lib/session.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GetServerSidePropsContext, 3 | NextApiRequest, 4 | NextApiResponse, 5 | } from 'next'; 6 | import { getServerSession } from 'next-auth/next'; 7 | 8 | import { authOptions } from '../pages/api/auth/[...nextauth]'; 9 | 10 | export const getSession = async ( 11 | req: NextApiRequest | GetServerSidePropsContext['req'], 12 | res: NextApiResponse | GetServerSidePropsContext['res'] 13 | ) => { 14 | return await getServerSession(req, res, authOptions); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/svix.ts: -------------------------------------------------------------------------------- 1 | import { EndpointIn, Svix } from 'svix'; 2 | 3 | import env from './env'; 4 | 5 | const svix = new Svix(env.svix.apiKey); 6 | 7 | export const findOrCreateApp = async (name: string, uid: string) => { 8 | if (!env.svix.apiKey) { 9 | return; 10 | } 11 | 12 | return await svix.application.getOrCreate({ name, uid }); 13 | }; 14 | 15 | export const createWebhook = async (appId: string, data: EndpointIn) => { 16 | if (!env.svix.apiKey) { 17 | return; 18 | } 19 | 20 | return await svix.endpoint.create(appId, data); 21 | }; 22 | 23 | export const updateWebhook = async ( 24 | appId: string, 25 | endpointId: string, 26 | data: EndpointIn 27 | ) => { 28 | if (!env.svix.apiKey) { 29 | return; 30 | } 31 | 32 | return await svix.endpoint.update(appId, endpointId, data); 33 | }; 34 | 35 | export const findWebhook = async (appId: string, endpointId: string) => { 36 | if (!env.svix.apiKey) { 37 | return; 38 | } 39 | 40 | return await svix.endpoint.get(appId, endpointId); 41 | }; 42 | 43 | export const listWebhooks = async (appId: string) => { 44 | if (!env.svix.apiKey) { 45 | return; 46 | } 47 | 48 | return await svix.endpoint.list(appId); 49 | }; 50 | 51 | export const deleteWebhook = async (appId: string, endpointId: string) => { 52 | if (!env.svix.apiKey) { 53 | return; 54 | } 55 | 56 | return await svix.endpoint.delete(appId, endpointId); 57 | }; 58 | 59 | export const sendEvent = async ( 60 | appId: string, 61 | eventType: string, 62 | payload: any 63 | ) => { 64 | if (!env.svix.apiKey) { 65 | return; 66 | } 67 | 68 | return await svix.message.create(appId, { 69 | eventType: eventType, 70 | payload: { 71 | event: eventType, 72 | data: payload, 73 | }, 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /lib/teams.ts: -------------------------------------------------------------------------------- 1 | import { Role, TeamMember } from '@prisma/client'; 2 | import type { User } from 'next-auth'; 3 | 4 | export const isTeamAdmin = (user: User, members: TeamMember[]) => { 5 | return ( 6 | members.filter( 7 | (member) => 8 | member.userId === user.id && 9 | (member.role === Role.ADMIN || member.role === Role.OWNER) 10 | ).length > 0 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import micromatch from 'micromatch'; 2 | import { getToken } from 'next-auth/jwt'; 3 | import { NextResponse } from 'next/server'; 4 | import type { NextRequest } from 'next/server'; 5 | 6 | // Add routes that don't require authentication 7 | const unAuthenticatedRoutes = [ 8 | '/api/hello', 9 | '/api/auth/**', 10 | '/api/oauth/**', 11 | '/api/scim/v2.0/**', 12 | '/auth/**', 13 | '/invitations/*', 14 | '/api/invitations/*', 15 | ]; 16 | 17 | export default async function middleware(req: NextRequest) { 18 | const { pathname } = req.nextUrl; 19 | 20 | // Bypass routes that don't require authentication 21 | if (micromatch.isMatch(pathname, unAuthenticatedRoutes)) { 22 | return NextResponse.next(); 23 | } 24 | 25 | const token = await getToken({ 26 | req, 27 | }); 28 | 29 | // No token, redirect to signin page 30 | if (!token) { 31 | const url = new URL('/auth/login', req.url); 32 | url.searchParams.set('callbackUrl ', encodeURI(req.url)); 33 | 34 | return NextResponse.redirect(url); 35 | } 36 | 37 | // All good, let the request through 38 | return NextResponse.next(); 39 | } 40 | 41 | export const config = { 42 | matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], 43 | }; 44 | -------------------------------------------------------------------------------- /models/account.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | 3 | export const getAccount = async (key: { userId: string }) => { 4 | return await prisma.account.findFirst({ 5 | where: key, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /models/apiKey.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import { createHash, randomBytes } from 'crypto'; 3 | 4 | interface CreateApiKeyParams { 5 | name: string; 6 | teamId: string; 7 | } 8 | 9 | export const hashApiKey = (apiKey: string) => { 10 | return createHash('sha256').update(apiKey).digest('hex'); 11 | }; 12 | 13 | export const generateUniqueApiKey = () => { 14 | const apiKey = randomBytes(16).toString('hex'); 15 | 16 | return [hashApiKey(apiKey), apiKey]; 17 | }; 18 | 19 | export const createApiKey = async (params: CreateApiKeyParams) => { 20 | const { name, teamId } = params; 21 | 22 | const [hashedKey, apiKey] = generateUniqueApiKey(); 23 | 24 | await prisma.apiKey.create({ 25 | data: { 26 | name, 27 | hashedKey: hashedKey, 28 | team: { connect: { id: teamId } }, 29 | }, 30 | }); 31 | 32 | return apiKey; 33 | }; 34 | 35 | export const fetchApiKeys = async (teamId: string) => { 36 | return prisma.apiKey.findMany({ 37 | where: { 38 | teamId, 39 | }, 40 | }); 41 | }; 42 | 43 | export const deleteApiKey = async (id: string) => { 44 | return prisma.apiKey.delete({ 45 | where: { 46 | id, 47 | }, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /models/invitation.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import { Role } from '@prisma/client'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | export const getInvitations = async (teamId: string) => { 6 | return await prisma.invitation.findMany({ 7 | where: { 8 | teamId, 9 | }, 10 | }); 11 | }; 12 | 13 | export const getInvitation = async ( 14 | key: { token: string } | { id: string } 15 | ) => { 16 | return await prisma.invitation.findUniqueOrThrow({ 17 | where: key, 18 | include: { 19 | team: true, 20 | }, 21 | }); 22 | }; 23 | 24 | export const createInvitation = async (param: { 25 | teamId: string; 26 | invitedBy: string; 27 | email: string; 28 | role: Role; 29 | }) => { 30 | const { teamId, invitedBy, email, role } = param; 31 | 32 | return await prisma.invitation.create({ 33 | data: { 34 | token: uuidv4(), 35 | expires: new Date(), 36 | teamId, 37 | invitedBy, 38 | email, 39 | role, 40 | }, 41 | }); 42 | }; 43 | 44 | export const deleteInvitation = async ( 45 | key: { token: string } | { id: string } 46 | ) => { 47 | return await prisma.invitation.delete({ 48 | where: key, 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /models/user.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '@/lib/errors'; 2 | import { Action, Resource, permissions } from '@/lib/permissions'; 3 | import { prisma } from '@/lib/prisma'; 4 | import { Role, TeamMember } from '@prisma/client'; 5 | import type { Session } from 'next-auth'; 6 | 7 | export const createUser = async (param: { 8 | name: string; 9 | email: string; 10 | password?: string; 11 | }) => { 12 | const { name, email, password } = param; 13 | 14 | return await prisma.user.create({ 15 | data: { 16 | name, 17 | email, 18 | password: password ? password : '', 19 | }, 20 | }); 21 | }; 22 | 23 | export const getUser = async (key: { id: string } | { email: string }) => { 24 | return await prisma.user.findUnique({ 25 | where: key, 26 | }); 27 | }; 28 | 29 | export const getUserBySession = async (session: Session | null) => { 30 | if (session === null || session.user === null) { 31 | return null; 32 | } 33 | 34 | const id = session?.user?.id; 35 | 36 | if (!id) { 37 | return null; 38 | } 39 | 40 | return await getUser({ id }); 41 | }; 42 | 43 | export const deleteUser = async (key: { id: string } | { email: string }) => { 44 | return await prisma.user.delete({ 45 | where: key, 46 | }); 47 | }; 48 | 49 | export const isAllowed = (role: Role, resource: Resource, action: Action) => { 50 | const rolePermissions = permissions[role]; 51 | 52 | if (!rolePermissions) { 53 | return false; 54 | } 55 | 56 | for (const permission of rolePermissions) { 57 | if (permission.resource === resource) { 58 | if (permission.actions === '*' || permission.actions.includes(action)) { 59 | return true; 60 | } 61 | } 62 | } 63 | 64 | return false; 65 | }; 66 | 67 | export const throwIfNotAllowed = ( 68 | teamMember: TeamMember, 69 | resource: Resource, 70 | action: Action 71 | ) => { 72 | if (isAllowed(teamMember.role, resource, action)) { 73 | return true; 74 | } 75 | 76 | throw new ApiError( 77 | 403, 78 | `You are not allowed to perform ${action} on ${resource}` 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-i18next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | i18n: { 6 | defaultLocale: 'en', 7 | locales: ['en'], 8 | }, 9 | localePath: path.resolve('./locales'), 10 | }; 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint @typescript-eslint/no-var-requires: "off" */ 2 | const { i18n } = require('./next-i18next.config'); 3 | 4 | // Redirect to login page if landing page is hidden 5 | const hideLandingPage = process.env.HIDE_LANDING_PAGE === 'true' ? true : false; 6 | const redirects = []; 7 | 8 | if (hideLandingPage) { 9 | redirects.push({ 10 | source: '/', 11 | destination: '/auth/login', 12 | permanent: true, 13 | }); 14 | } 15 | 16 | /** @type {import('next').NextConfig} */ 17 | const nextConfig = { 18 | reactStrictMode: true, 19 | images: { 20 | domains: ['boxyhq.com'], 21 | }, 22 | i18n, 23 | async redirects() { 24 | return redirects; 25 | }, 26 | }; 27 | 28 | module.exports = nextConfig; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-starter-kit", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --port 4002", 7 | "build": "next build", 8 | "start": "next start --port 4002", 9 | "check-types": "tsc --pretty --noEmit", 10 | "check-format": "prettier --check .", 11 | "check-lint": "eslint . --ext ts --ext tsx --ext js", 12 | "format": "prettier --write .", 13 | "test-all": "npm run check-format && npm run check-lint && npm run check-types && npm run build", 14 | "prepare": "husky install", 15 | "playwright:update": "playwright install && playwright install-deps", 16 | "test:e2e": "playwright test", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage --env=jsdom" 20 | }, 21 | "dependencies": { 22 | "@boxyhq/saml-jackson": "1.11.2", 23 | "@heroicons/react": "2.0.18", 24 | "@hookform/resolvers": "3.2.0", 25 | "@next-auth/prisma-adapter": "1.0.7", 26 | "@prisma/client": "^5.1.1", 27 | "@retracedhq/logs-viewer": "2.5.0", 28 | "@retracedhq/retraced": "0.7.0", 29 | "@tailwindcss/typography": "0.5.9", 30 | "autoprefixer": "10.4.15", 31 | "axios": "1.4.0", 32 | "bcryptjs": "2.4.3", 33 | "classnames": "2.3.2", 34 | "cookies-next": "2.1.2", 35 | "crypto-js": "4.1.1", 36 | "formik": "2.4.3", 37 | "i18next": "22.5.1", 38 | "mixpanel-browser": "^2.47.0", 39 | "moment": "2.29.4", 40 | "next": "13.4.16", 41 | "next-auth": "4.23.1", 42 | "next-i18next": "13.3.0", 43 | "nodemailer": "6.9.4", 44 | "react": "18.2.0", 45 | "react-daisyui": "3.1.2", 46 | "react-dom": "18.2.0", 47 | "react-hot-toast": "2.4.1", 48 | "react-i18next": "12.3.1", 49 | "react-icons": "4.10.1", 50 | "svix": "1.8.1", 51 | "swr": "2.2.1", 52 | "uuid": "9.0.0", 53 | "yup": "1.2.0", 54 | "zod": "3.22.1" 55 | }, 56 | "devDependencies": { 57 | "@playwright/test": "1.37.0", 58 | "@tailwindcss/forms": "0.5.4", 59 | "@testing-library/jest-dom": "6.0.0", 60 | "@testing-library/react": "14.0.0", 61 | "@trivago/prettier-plugin-sort-imports": "4.2.0", 62 | "@types/bcryptjs": "2.4.2", 63 | "@types/crypto-js": "4.1.1", 64 | "@types/jest": "29.5.3", 65 | "@types/mixpanel-browser": "^2.47.1", 66 | "@types/mocha": "10.0.1", 67 | "@types/node": "20.5.0", 68 | "@types/nodemailer": "6.4.9", 69 | "@types/react": "18.2.20", 70 | "@types/uuid": "9.0.2", 71 | "@typescript-eslint/eslint-plugin": "6.4.0", 72 | "@typescript-eslint/parser": "6.4.0", 73 | "daisyui": "2.52.0", 74 | "eslint": "8.47.0", 75 | "eslint-config-next": "13.4.16", 76 | "eslint-config-prettier": "9.0.0", 77 | "eslint-plugin-react": "7.33.2", 78 | "husky": "8.0.3", 79 | "jest": "29.6.2", 80 | "jest-environment-jsdom": "29.6.2", 81 | "lint-staged": "14.0.0", 82 | "postcss": "8.4.28", 83 | "prettier": "3.0.2", 84 | "prettier-plugin-tailwindcss": "0.5.3", 85 | "prisma": "^5.1.1", 86 | "tailwindcss": "3.3.3", 87 | "typescript": "5.1.6" 88 | }, 89 | "husky": { 90 | "hooks": { 91 | "pre-commit": "lint-staged" 92 | } 93 | }, 94 | "lint-staged": { 95 | "*.{js,ts}": "eslint --cache --fix", 96 | "*.{js,ts,css,md}": "prettier --write" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AccountLayout } from '@/components/layouts'; 2 | import app from '@/lib/app'; 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { appWithTranslation } from 'next-i18next'; 5 | import Head from 'next/head'; 6 | import { Toaster } from 'react-hot-toast'; 7 | import type { AppPropsWithLayout } from 'types'; 8 | import mixpanel from 'mixpanel-browser'; 9 | 10 | import '../styles/globals.css'; 11 | import { useEffect } from 'react'; 12 | import env from '@/lib/env'; 13 | 14 | function MyApp({ Component, pageProps }: AppPropsWithLayout) { 15 | const { session, ...props } = pageProps; 16 | 17 | // Add mixpanel 18 | useEffect(() => { 19 | if (env.mixpanel.token) { 20 | mixpanel.init(env.mixpanel.token, { 21 | debug: true, 22 | ignore_dnt: true, 23 | track_pageview: true, 24 | }); 25 | } 26 | }, []); 27 | 28 | const getLayout = 29 | Component.getLayout || ((page) => {page}); 30 | 31 | return ( 32 | <> 33 | 34 | {app.name} 35 | 36 | 37 | 38 | {getLayout()} 39 | 40 | 41 | ); 42 | } 43 | 44 | export default appWithTranslation(MyApp); 45 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/auth/forgot-password.ts: -------------------------------------------------------------------------------- 1 | import { generateToken, validateEmail } from '@/lib/common'; 2 | import { sendPasswordResetEmail } from '@/lib/email/sendPasswordResetEmail'; 3 | import { ApiError } from '@/lib/errors'; 4 | import { prisma } from '@/lib/prisma'; 5 | import type { NextApiRequest, NextApiResponse } from 'next'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | try { 12 | switch (req.method) { 13 | case 'POST': 14 | await handlePOST(req, res); 15 | break; 16 | default: 17 | res.setHeader('Allow', 'POST'); 18 | res.status(405).json({ 19 | error: { message: `Method ${req.method} Not Allowed` }, 20 | }); 21 | } 22 | } catch (error: any) { 23 | const message = error.message || 'Something went wrong'; 24 | const status = error.status || 500; 25 | 26 | res.status(status).json({ error: { message } }); 27 | } 28 | } 29 | 30 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 31 | const { email } = req.body; 32 | 33 | if (!email || !validateEmail(email)) { 34 | throw new ApiError(422, 'The e-mail address you entered is invalid'); 35 | } 36 | 37 | const user = await prisma.user.findUnique({ 38 | where: { email }, 39 | }); 40 | 41 | if (!user) { 42 | throw new ApiError(422, `We can't find a user with that e-mail address`); 43 | } 44 | 45 | const resetToken = generateToken(); 46 | 47 | await prisma.passwordReset.create({ 48 | data: { 49 | email, 50 | token: resetToken, 51 | expiresAt: new Date(Date.now() + 60 * 60 * 1000), // Expires in 1 hour 52 | }, 53 | }); 54 | 55 | await sendPasswordResetEmail(email, encodeURIComponent(resetToken)); 56 | 57 | res.json({}); 58 | }; 59 | -------------------------------------------------------------------------------- /pages/api/auth/join.ts: -------------------------------------------------------------------------------- 1 | import { hashPassword } from '@/lib/auth'; 2 | import { generateToken, slugify } from '@/lib/common'; 3 | import { sendVerificationEmail } from '@/lib/email/sendVerificationEmail'; 4 | import { prisma } from '@/lib/prisma'; 5 | import { isBusinessEmail } from '@/lib/email/utils'; 6 | import env from '@/lib/env'; 7 | import { ApiError } from '@/lib/errors'; 8 | import { createTeam, isTeamExists } from 'models/team'; 9 | import { createUser, getUser } from 'models/user'; 10 | import type { NextApiRequest, NextApiResponse } from 'next'; 11 | 12 | export default async function handler( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | const { method } = req; 17 | 18 | try { 19 | switch (method) { 20 | case 'POST': 21 | await handlePOST(req, res); 22 | break; 23 | default: 24 | res.setHeader('Allow', 'POST'); 25 | res.status(405).json({ 26 | error: { message: `Method ${method} Not Allowed` }, 27 | }); 28 | } 29 | } catch (error: any) { 30 | const message = error.message || 'Something went wrong'; 31 | const status = error.status || 500; 32 | 33 | res.status(status).json({ error: { message } }); 34 | } 35 | } 36 | 37 | // Signup the user 38 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 39 | const { name, email, password, team } = req.body; 40 | 41 | const existingUser = await getUser({ email }); 42 | 43 | if (existingUser) { 44 | throw new ApiError(400, 'An user with this email already exists.'); 45 | } 46 | 47 | if (env.disableNonBusinessEmailSignup && !isBusinessEmail(email)) { 48 | throw new ApiError( 49 | 400, 50 | `We currently only accept work email addresses for sign-up. Please use your work email to create an account. If you don't have a work email, feel free to contact our support team for assistance.` 51 | ); 52 | } 53 | 54 | // Create a new team 55 | if (team) { 56 | const slug = slugify(team); 57 | 58 | const nameCollisions = await isTeamExists([{ name: team }, { slug }]); 59 | 60 | if (nameCollisions > 0) { 61 | throw new ApiError(400, 'A team with this name already exists.'); 62 | } 63 | } 64 | 65 | const user = await createUser({ 66 | name, 67 | email, 68 | password: await hashPassword(password), 69 | }); 70 | 71 | if (team) { 72 | const slug = slugify(team); 73 | 74 | await createTeam({ 75 | userId: user.id, 76 | name: team, 77 | slug, 78 | }); 79 | } 80 | 81 | // Send account verification email 82 | if (env.confirmEmail) { 83 | const verificationToken = await prisma.verificationToken.create({ 84 | data: { 85 | identifier: email, 86 | token: generateToken(), 87 | expires: new Date(Date.now() + 24 * 60 * 60 * 1000), // Expires in 24 hours 88 | }, 89 | }); 90 | 91 | await sendVerificationEmail({ user, verificationToken }); 92 | } 93 | 94 | res.status(201).json({ 95 | data: { 96 | user, 97 | confirmEmail: env.confirmEmail, 98 | }, 99 | }); 100 | }; 101 | -------------------------------------------------------------------------------- /pages/api/auth/reset-password.ts: -------------------------------------------------------------------------------- 1 | import { hashPassword } from '@/lib/auth'; 2 | import { validatePassword } from '@/lib/common'; 3 | import { prisma } from '@/lib/prisma'; 4 | import type { NextApiRequest, NextApiResponse } from 'next'; 5 | import { ApiError } from 'next/dist/server/api-utils'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | const { method } = req; 12 | 13 | try { 14 | switch (method) { 15 | case 'POST': 16 | await handlePOST(req, res); 17 | break; 18 | default: 19 | res.setHeader('Allow', 'POST'); 20 | res.status(405).json({ 21 | error: { message: `Method ${method} Not Allowed` }, 22 | }); 23 | } 24 | } catch (error: any) { 25 | const message = error.message || 'Something went wrong'; 26 | const status = error.status || 500; 27 | 28 | res.status(status).json({ error: { message } }); 29 | } 30 | } 31 | 32 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 33 | const { token, password } = req.body; 34 | 35 | if (!token) { 36 | throw new ApiError(422, 'Password reset token is required'); 37 | } 38 | 39 | if (!password || !validatePassword(password)) { 40 | throw new ApiError(422, 'Password does not meet requirements'); 41 | } 42 | 43 | const passwordReset = await prisma.passwordReset.findUnique({ 44 | where: { token }, 45 | }); 46 | 47 | if (!passwordReset) { 48 | throw new ApiError( 49 | 422, 50 | 'Invalid password reset token. Please request a new one.' 51 | ); 52 | } 53 | 54 | if (passwordReset.expiresAt < new Date()) { 55 | throw new ApiError( 56 | 422, 57 | 'Password reset token has expired. Please request a new one.' 58 | ); 59 | } 60 | 61 | const hashedPassword = await hashPassword(password); 62 | 63 | await Promise.all([ 64 | prisma.user.update({ 65 | where: { email: passwordReset.email }, 66 | data: { 67 | password: hashedPassword, 68 | }, 69 | }), 70 | prisma.passwordReset.delete({ 71 | where: { token }, 72 | }), 73 | ]); 74 | 75 | res.status(200).json({ message: 'Password reset successfully' }); 76 | }; 77 | -------------------------------------------------------------------------------- /pages/api/auth/sso/acs.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import handlerProxy from '../../oauth/saml'; 4 | 5 | // This is a legacy endpoint that is maintained for backwards compatibility. 6 | // The new endpoint is pages/api/oauth/saml.ts 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | return handlerProxy(req, res); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/auth/sso/verify.ts: -------------------------------------------------------------------------------- 1 | import env from '@/lib/env'; 2 | import jackson from '@/lib/jackson'; 3 | import { getTeam } from 'models/team'; 4 | import { NextApiRequest, NextApiResponse } from 'next'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | const { method } = req; 11 | 12 | try { 13 | switch (method) { 14 | case 'POST': 15 | await handlePOST(req, res); 16 | break; 17 | default: 18 | res.setHeader('Allow', 'POST'); 19 | res.status(405).json({ 20 | error: { message: `Method ${method} Not Allowed` }, 21 | }); 22 | } 23 | } catch (err: any) { 24 | res.status(400).json({ 25 | error: { message: err.message }, 26 | }); 27 | } 28 | } 29 | 30 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 31 | const { apiController } = await jackson(); 32 | 33 | const { slug } = JSON.parse(req.body) as { slug: string }; 34 | 35 | if (!slug) { 36 | throw new Error('Missing the SSO identifier.'); 37 | } 38 | 39 | const team = await getTeam({ slug }); 40 | 41 | if (!team) { 42 | throw new Error('Team not found.'); 43 | } 44 | 45 | const connections = await apiController.getConnections({ 46 | tenant: team.id, 47 | product: env.product, 48 | }); 49 | 50 | if (!connections || connections.length === 0) { 51 | throw new Error('No SSO connections found for this team.'); 52 | } 53 | 54 | const data = { 55 | teamId: team.id, 56 | }; 57 | 58 | res.json({ data }); 59 | }; 60 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | res.status(200).json({ message: 'Hello World!' }); 8 | } 9 | -------------------------------------------------------------------------------- /pages/api/idp.ts: -------------------------------------------------------------------------------- 1 | import jackson from '@/lib/jackson'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { directorySync } = await jackson(); 9 | 10 | // List of directory sync providers 11 | res.status(200).json({ data: directorySync.providers() }); 12 | } 13 | -------------------------------------------------------------------------------- /pages/api/invitations/[token].ts: -------------------------------------------------------------------------------- 1 | import { getInvitation } from 'models/invitation'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { method } = req; 9 | 10 | try { 11 | switch (method) { 12 | case 'GET': 13 | await handleGET(req, res); 14 | break; 15 | default: 16 | res.setHeader('Allow', 'GET'); 17 | res.status(405).json({ 18 | error: { message: `Method ${method} Not Allowed` }, 19 | }); 20 | } 21 | } catch (error: any) { 22 | res.status(400).json({ 23 | error: { message: error.message }, 24 | }); 25 | } 26 | } 27 | 28 | // Get the invitation by token 29 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 30 | const { token } = req.query as { token: string }; 31 | 32 | const invitation = await getInvitation({ token }); 33 | 34 | res.status(200).json({ data: invitation }); 35 | }; 36 | -------------------------------------------------------------------------------- /pages/api/oauth/authorize.ts: -------------------------------------------------------------------------------- 1 | import jackson from '@/lib/jackson'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { method } = req; 9 | 10 | try { 11 | switch (method) { 12 | case 'GET': 13 | case 'POST': 14 | await handleAuthorize(req, res); 15 | break; 16 | default: 17 | res.setHeader('Allow', 'GET, POST'); 18 | res.status(405).json({ 19 | error: { message: `Method ${method} Not Allowed` }, 20 | }); 21 | } 22 | } catch (err: any) { 23 | res.status(500).json({ error: { message: err.message } }); 24 | } 25 | } 26 | 27 | const handleAuthorize = async (req: NextApiRequest, res: NextApiResponse) => { 28 | const { oauthController } = await jackson(); 29 | 30 | const requestParams = req.method === 'GET' ? req.query : req.body; 31 | 32 | const { redirect_url, authorize_form } = await oauthController.authorize( 33 | requestParams 34 | ); 35 | 36 | if (redirect_url) { 37 | res.redirect(302, redirect_url); 38 | } else { 39 | res.setHeader('Content-Type', 'text/html; charset=utf-8'); 40 | res.send(authorize_form); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /pages/api/oauth/saml.ts: -------------------------------------------------------------------------------- 1 | import jackson from '@/lib/jackson'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { method } = req; 9 | 10 | try { 11 | switch (method) { 12 | case 'POST': 13 | await handlePOST(req, res); 14 | break; 15 | default: 16 | res.setHeader('Allow', 'POST'); 17 | res.status(405).json({ 18 | error: { message: `Method ${method} Not Allowed` }, 19 | }); 20 | } 21 | } catch (err: any) { 22 | // TODO: Handle error 23 | console.error('saml error:', err); 24 | } 25 | } 26 | 27 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 28 | const { oauthController } = await jackson(); 29 | 30 | const { RelayState, SAMLResponse } = req.body; 31 | 32 | const { redirect_url } = await oauthController.samlResponse({ 33 | RelayState, 34 | SAMLResponse, 35 | }); 36 | 37 | if (!redirect_url) { 38 | throw new Error('No redirect URL found.'); 39 | } 40 | 41 | res.redirect(302, redirect_url); 42 | }; 43 | -------------------------------------------------------------------------------- /pages/api/oauth/token.ts: -------------------------------------------------------------------------------- 1 | import jackson from '@/lib/jackson'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | const { method } = req; 9 | 10 | try { 11 | switch (method) { 12 | case 'POST': 13 | await handlePOST(req, res); 14 | break; 15 | default: 16 | res.setHeader('Allow', 'POST'); 17 | res.status(405).json({ 18 | error: { message: `Method ${method} Not Allowed` }, 19 | }); 20 | } 21 | } catch (err: any) { 22 | // TODO: Handle error 23 | console.error('token error:', err); 24 | } 25 | } 26 | 27 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 28 | const { oauthController } = await jackson(); 29 | 30 | const token = await oauthController.token(req.body); 31 | 32 | res.json(token); 33 | }; 34 | -------------------------------------------------------------------------------- /pages/api/oauth/userinfo.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '@/lib/errors'; 2 | import jackson from '@/lib/jackson'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | const { method } = req; 10 | 11 | try { 12 | switch (method) { 13 | case 'GET': 14 | await handleGET(req, res); 15 | break; 16 | default: 17 | res.setHeader('Allow', 'GET'); 18 | res.status(405).json({ 19 | error: { message: `Method ${method} Not Allowed` }, 20 | }); 21 | } 22 | } catch (error: any) { 23 | const message = error.message || 'Something went wrong'; 24 | const status = error.status || 500; 25 | 26 | res.status(status).json({ error: { message } }); 27 | } 28 | } 29 | 30 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 31 | const { oauthController } = await jackson(); 32 | 33 | let token = req.headers.authorization?.split(' ')[1]; 34 | 35 | if (!token) { 36 | let arr: string[] = []; 37 | arr = arr.concat(req.query.access_token || ''); 38 | 39 | if (arr[0].length > 0) { 40 | token = arr[0]; 41 | } 42 | } 43 | 44 | if (!token) { 45 | throw new ApiError(401, 'Unauthorized'); 46 | } 47 | 48 | const profile = await oauthController.userInfo(token); 49 | 50 | res.json(profile); 51 | }; 52 | -------------------------------------------------------------------------------- /pages/api/password.ts: -------------------------------------------------------------------------------- 1 | import { hashPassword, verifyPassword } from '@/lib/auth'; 2 | import { prisma } from '@/lib/prisma'; 3 | import { getSession } from '@/lib/session'; 4 | import type { NextApiRequest, NextApiResponse } from 'next'; 5 | import { ApiError } from 'next/dist/server/api-utils'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | const { method } = req; 12 | 13 | try { 14 | switch (method) { 15 | case 'PUT': 16 | await handlePUT(req, res); 17 | break; 18 | default: 19 | res.setHeader('Allow', 'PUT'); 20 | res.status(405).json({ 21 | error: { message: `Method ${method} Not Allowed` }, 22 | }); 23 | } 24 | } catch (error: any) { 25 | const message = error.message || 'Something went wrong'; 26 | const status = error.status || 500; 27 | 28 | res.status(status).json({ error: { message } }); 29 | } 30 | } 31 | 32 | const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => { 33 | const session = await getSession(req, res); 34 | 35 | const { currentPassword, newPassword } = req.body as { 36 | currentPassword: string; 37 | newPassword: string; 38 | }; 39 | 40 | const user = await prisma.user.findFirstOrThrow({ 41 | where: { id: session?.user.id }, 42 | }); 43 | 44 | if (!(await verifyPassword(currentPassword, user.password as string))) { 45 | throw new ApiError(400, 'Your current password is incorrect'); 46 | } 47 | 48 | await prisma.user.update({ 49 | where: { id: session?.user.id }, 50 | data: { password: await hashPassword(newPassword) }, 51 | }); 52 | 53 | res.status(200).json({ data: user }); 54 | }; 55 | -------------------------------------------------------------------------------- /pages/api/scim/v2.0/[...directory].ts: -------------------------------------------------------------------------------- 1 | import { hashPassword } from '@/lib/auth'; 2 | import { createRandomString, extractAuthToken } from '@/lib/common'; 3 | import jackson from '@/lib/jackson'; 4 | import { prisma } from '@/lib/prisma'; 5 | import type { 6 | DirectorySyncEvent, 7 | DirectorySyncRequest, 8 | } from '@boxyhq/saml-jackson'; 9 | import { Role } from '@prisma/client'; 10 | import { addTeamMember } from 'models/team'; 11 | import { deleteUser, getUser } from 'models/user'; 12 | import type { NextApiRequest, NextApiResponse } from 'next'; 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) { 18 | const { directorySync } = await jackson(); 19 | 20 | const { method, query, body } = req; 21 | 22 | const directory = query.directory as string[]; 23 | const [directoryId, path, resourceId] = directory; 24 | 25 | // Handle the SCIM API requests 26 | const request: DirectorySyncRequest = { 27 | method: method as string, 28 | body: body ? JSON.parse(body) : undefined, 29 | directoryId, 30 | resourceId, 31 | resourceType: path === 'Users' ? 'users' : 'groups', 32 | apiSecret: extractAuthToken(req), 33 | query: { 34 | count: req.query.count ? parseInt(req.query.count as string) : undefined, 35 | startIndex: req.query.startIndex 36 | ? parseInt(req.query.startIndex as string) 37 | : undefined, 38 | filter: req.query.filter as string, 39 | }, 40 | }; 41 | 42 | const { status, data } = await directorySync.requests.handle( 43 | request, 44 | handleEvents 45 | ); 46 | 47 | res.status(status).json(data); 48 | } 49 | 50 | // Handle the SCIM events 51 | const handleEvents = async (event: DirectorySyncEvent) => { 52 | const { event: action, tenant: teamId, data } = event; 53 | 54 | // User has been created 55 | if (action === 'user.created' && 'email' in data) { 56 | const user = await prisma.user.upsert({ 57 | where: { 58 | email: data.email, 59 | }, 60 | update: { 61 | name: `${data.first_name} ${data.last_name}`, 62 | }, 63 | create: { 64 | name: `${data.first_name} ${data.last_name}`, 65 | email: data.email, 66 | password: await hashPassword(createRandomString()), 67 | }, 68 | }); 69 | 70 | await addTeamMember(teamId, user.id, Role.MEMBER); 71 | } 72 | 73 | // User has been updated 74 | if (action === 'user.updated' && 'email' in data) { 75 | if (data.active === true) { 76 | const user = await prisma.user.upsert({ 77 | where: { 78 | email: data.email, 79 | }, 80 | update: { 81 | name: `${data.first_name} ${data.last_name}`, 82 | }, 83 | create: { 84 | name: `${data.first_name} ${data.last_name}`, 85 | email: data.email, 86 | password: await hashPassword(createRandomString()), 87 | }, 88 | }); 89 | 90 | await addTeamMember(teamId, user.id, Role.MEMBER); 91 | 92 | return; 93 | } 94 | 95 | const user = await getUser({ email: data.email }); 96 | 97 | if (!user) { 98 | return; 99 | } 100 | 101 | if (data.active === false) { 102 | await deleteUser({ id: user.id }); 103 | } 104 | } 105 | 106 | // User has been removed 107 | if (action === 'user.deleted' && 'email' in data) { 108 | await deleteUser({ email: data.email }); 109 | } 110 | }; 111 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/api-keys/[apiKeyId].ts: -------------------------------------------------------------------------------- 1 | import { deleteApiKey } from 'models/apiKey'; 2 | import { throwIfNoTeamAccess } from 'models/team'; 3 | import { throwIfNotAllowed } from 'models/user'; 4 | import type { NextApiRequest, NextApiResponse } from 'next'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | const { method } = req; 11 | 12 | try { 13 | switch (method) { 14 | case 'DELETE': 15 | await handleDELETE(req, res); 16 | break; 17 | default: 18 | res.setHeader('Allow', 'DELETE'); 19 | res.status(405).json({ 20 | error: { message: `Method ${method} Not Allowed` }, 21 | }); 22 | } 23 | } catch (error: any) { 24 | const message = error.message || 'Something went wrong'; 25 | const status = error.status || 500; 26 | 27 | res.status(status).json({ error: { message } }); 28 | } 29 | } 30 | 31 | // Delete an API key 32 | const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { 33 | const teamMember = await throwIfNoTeamAccess(req, res); 34 | throwIfNotAllowed(teamMember, 'team_api_key', 'delete'); 35 | 36 | const { apiKeyId } = req.query as { apiKeyId: string }; 37 | 38 | await deleteApiKey(apiKeyId); 39 | 40 | res.json({ data: {} }); 41 | }; 42 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/api-keys/index.ts: -------------------------------------------------------------------------------- 1 | import { createApiKey, fetchApiKeys } from 'models/apiKey'; 2 | import { throwIfNoTeamAccess } from 'models/team'; 3 | import { throwIfNotAllowed } from 'models/user'; 4 | import type { NextApiRequest, NextApiResponse } from 'next'; 5 | 6 | export default async function handler( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | const { method } = req; 11 | 12 | try { 13 | switch (method) { 14 | case 'GET': 15 | await handleGET(req, res); 16 | break; 17 | case 'POST': 18 | await handlePOST(req, res); 19 | break; 20 | default: 21 | res.setHeader('Allow', 'GET, POST'); 22 | res.status(405).json({ 23 | error: { message: `Method ${method} Not Allowed` }, 24 | }); 25 | } 26 | } catch (error: any) { 27 | const message = error.message || 'Something went wrong'; 28 | const status = error.status || 500; 29 | 30 | res.status(status).json({ error: { message } }); 31 | } 32 | } 33 | 34 | // Get API keys 35 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 36 | const teamMember = await throwIfNoTeamAccess(req, res); 37 | throwIfNotAllowed(teamMember, 'team_api_key', 'read'); 38 | 39 | const apiKeys = await fetchApiKeys(teamMember.teamId); 40 | 41 | res.json({ data: apiKeys }); 42 | }; 43 | 44 | // Create an API key 45 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 46 | const teamMember = await throwIfNoTeamAccess(req, res); 47 | throwIfNotAllowed(teamMember, 'team_api_key', 'create'); 48 | 49 | const { name } = JSON.parse(req.body) as { name: string }; 50 | 51 | const apiKey = await createApiKey({ 52 | name, 53 | teamId: teamMember.teamId, 54 | }); 55 | 56 | res.status(201).json({ data: { apiKey } }); 57 | }; 58 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/directory-sync.ts: -------------------------------------------------------------------------------- 1 | import env from '@/lib/env'; 2 | import jackson from '@/lib/jackson'; 3 | import { sendAudit } from '@/lib/retraced'; 4 | import { throwIfNoTeamAccess } from 'models/team'; 5 | import { throwIfNotAllowed } from 'models/user'; 6 | import type { NextApiRequest, NextApiResponse } from 'next'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const { method } = req; 13 | 14 | try { 15 | switch (method) { 16 | case 'GET': 17 | await handleGET(req, res); 18 | break; 19 | case 'POST': 20 | await handlePOST(req, res); 21 | break; 22 | default: 23 | res.setHeader('Allow', 'GET, POST'); 24 | res.status(405).json({ 25 | error: { message: `Method ${method} Not Allowed` }, 26 | }); 27 | } 28 | } catch (error: any) { 29 | const message = error.message || 'Something went wrong'; 30 | const status = error.status || 500; 31 | 32 | res.status(status).json({ error: { message } }); 33 | } 34 | } 35 | 36 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 37 | const teamMember = await throwIfNoTeamAccess(req, res); 38 | throwIfNotAllowed(teamMember, 'team_dsync', 'read'); 39 | 40 | const { directorySync } = await jackson(); 41 | 42 | const { data, error } = await directorySync.directories.getByTenantAndProduct( 43 | teamMember.teamId, 44 | env.product 45 | ); 46 | 47 | if (error) { 48 | throw error; 49 | } 50 | 51 | res.status(200).json({ data }); 52 | }; 53 | 54 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 55 | const teamMember = await throwIfNoTeamAccess(req, res); 56 | throwIfNotAllowed(teamMember, 'team_dsync', 'create'); 57 | 58 | const { name, provider } = req.body; 59 | 60 | const { directorySync } = await jackson(); 61 | 62 | const { data, error } = await directorySync.directories.create({ 63 | name, 64 | type: provider, 65 | tenant: teamMember.teamId, 66 | product: env.product, 67 | }); 68 | 69 | if (error) { 70 | throw error; 71 | } 72 | 73 | sendAudit({ 74 | action: 'dsync.connection.create', 75 | crud: 'c', 76 | user: teamMember.user, 77 | team: teamMember.team, 78 | }); 79 | 80 | res.status(201).json({ data }); 81 | }; 82 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/index.ts: -------------------------------------------------------------------------------- 1 | import { sendAudit } from '@/lib/retraced'; 2 | import { 3 | deleteTeam, 4 | getTeam, 5 | throwIfNoTeamAccess, 6 | updateTeam, 7 | } from 'models/team'; 8 | import { throwIfNotAllowed } from 'models/user'; 9 | import type { NextApiRequest, NextApiResponse } from 'next'; 10 | 11 | export default async function handler( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | const { method } = req; 16 | 17 | try { 18 | switch (method) { 19 | case 'GET': 20 | await handleGET(req, res); 21 | break; 22 | case 'PUT': 23 | await handlePUT(req, res); 24 | break; 25 | case 'DELETE': 26 | await handleDELETE(req, res); 27 | break; 28 | default: 29 | res.setHeader('Allow', 'GET, PUT, DELETE'); 30 | res.status(405).json({ 31 | error: { message: `Method ${method} Not Allowed` }, 32 | }); 33 | } 34 | } catch (error: any) { 35 | const message = error.message || 'Something went wrong'; 36 | const status = error.status || 500; 37 | 38 | res.status(status).json({ error: { message } }); 39 | } 40 | } 41 | 42 | // Get a team by slug 43 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 44 | const teamMember = await throwIfNoTeamAccess(req, res); 45 | throwIfNotAllowed(teamMember, 'team', 'read'); 46 | 47 | const team = await getTeam({ id: teamMember.teamId }); 48 | 49 | res.status(200).json({ data: team }); 50 | }; 51 | 52 | // Update a team 53 | const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => { 54 | const teamMember = await throwIfNoTeamAccess(req, res); 55 | throwIfNotAllowed(teamMember, 'team', 'update'); 56 | 57 | const updatedTeam = await updateTeam(teamMember.team.slug, { 58 | name: req.body.name, 59 | slug: req.body.slug, 60 | domain: req.body.domain, 61 | }); 62 | 63 | sendAudit({ 64 | action: 'team.update', 65 | crud: 'u', 66 | user: teamMember.user, 67 | team: teamMember.team, 68 | }); 69 | 70 | res.status(200).json({ data: updatedTeam }); 71 | }; 72 | 73 | // Delete a team 74 | const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { 75 | const teamMember = await throwIfNoTeamAccess(req, res); 76 | throwIfNotAllowed(teamMember, 'team', 'delete'); 77 | 78 | await deleteTeam({ id: teamMember.teamId }); 79 | 80 | sendAudit({ 81 | action: 'team.delete', 82 | crud: 'd', 83 | user: teamMember.user, 84 | team: teamMember.team, 85 | }); 86 | 87 | res.status(200).json({ data: {} }); 88 | }; 89 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/permissions.ts: -------------------------------------------------------------------------------- 1 | import { permissions } from '@/lib/permissions'; 2 | import { throwIfNoTeamAccess } from 'models/team'; 3 | import type { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | try { 10 | switch (req.method) { 11 | case 'GET': 12 | await handleGET(req, res); 13 | break; 14 | default: 15 | res.setHeader('Allow', 'GET'); 16 | res.status(405).json({ 17 | error: { message: `Method ${req.method} Not Allowed` }, 18 | }); 19 | } 20 | } catch (error: any) { 21 | const message = error.message || 'Something went wrong'; 22 | const status = error.status || 500; 23 | 24 | res.status(status).json({ error: { message } }); 25 | } 26 | } 27 | 28 | // Get permissions for a team for the current user 29 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 30 | const teamRole = await throwIfNoTeamAccess(req, res); 31 | 32 | res.json({ data: permissions[teamRole.role] }); 33 | }; 34 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/saml.ts: -------------------------------------------------------------------------------- 1 | import env from '@/lib/env'; 2 | import jackson from '@/lib/jackson'; 3 | import { sendAudit } from '@/lib/retraced'; 4 | import { throwIfNoTeamAccess } from 'models/team'; 5 | import { throwIfNotAllowed } from 'models/user'; 6 | import type { NextApiRequest, NextApiResponse } from 'next'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const { method } = req; 13 | 14 | try { 15 | switch (method) { 16 | case 'GET': 17 | await handleGET(req, res); 18 | break; 19 | case 'POST': 20 | await handlePOST(req, res); 21 | break; 22 | case 'DELETE': 23 | await handleDELETE(req, res); 24 | break; 25 | default: 26 | res.setHeader('Allow', 'GET, POST, DELETE'); 27 | res.status(405).json({ 28 | error: { message: `Method ${method} Not Allowed` }, 29 | }); 30 | } 31 | } catch (err: any) { 32 | const message = err.message || 'Something went wrong'; 33 | const status = err.status || 500; 34 | 35 | res.status(status).json({ error: { message } }); 36 | } 37 | } 38 | 39 | // Get the SAML connection for the team. 40 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 41 | const teamMember = await throwIfNoTeamAccess(req, res); 42 | throwIfNotAllowed(teamMember, 'team_sso', 'read'); 43 | 44 | const { apiController } = await jackson(); 45 | 46 | const connections = await apiController.getConnections({ 47 | tenant: teamMember.teamId, 48 | product: env.product, 49 | }); 50 | 51 | const response = { 52 | connections, 53 | issuer: env.saml.issuer, 54 | acs: `${env.appUrl}${env.saml.path}`, 55 | }; 56 | 57 | res.json({ data: response }); 58 | }; 59 | 60 | // Create a SAML connection for the team. 61 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 62 | const teamMember = await throwIfNoTeamAccess(req, res); 63 | throwIfNotAllowed(teamMember, 'team_sso', 'create'); 64 | 65 | const { metadataUrl, encodedRawMetadata } = req.body; 66 | 67 | const { apiController } = await jackson(); 68 | 69 | const connection = await apiController.createSAMLConnection({ 70 | encodedRawMetadata, 71 | metadataUrl, 72 | defaultRedirectUrl: env.saml.callback, 73 | redirectUrl: env.saml.callback, 74 | tenant: teamMember.teamId, 75 | product: env.product, 76 | }); 77 | 78 | sendAudit({ 79 | action: 'sso.connection.create', 80 | crud: 'c', 81 | user: teamMember.user, 82 | team: teamMember.team, 83 | }); 84 | 85 | res.status(201).json({ data: connection }); 86 | }; 87 | 88 | const handleDELETE = async (req: NextApiRequest, res: NextApiResponse) => { 89 | const teamMember = await throwIfNoTeamAccess(req, res); 90 | throwIfNotAllowed(teamMember, 'team_sso', 'delete'); 91 | 92 | const { clientID, clientSecret } = req.query as { 93 | clientID: string; 94 | clientSecret: string; 95 | }; 96 | 97 | const { apiController } = await jackson(); 98 | 99 | await apiController.deleteConnections({ clientID, clientSecret }); 100 | 101 | sendAudit({ 102 | action: 'sso.connection.delete', 103 | crud: 'c', 104 | user: teamMember.user, 105 | team: teamMember.team, 106 | }); 107 | 108 | res.json({ data: {} }); 109 | }; 110 | -------------------------------------------------------------------------------- /pages/api/teams/[slug]/webhooks/[endpointId].ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from '@/lib/errors'; 2 | import { sendAudit } from '@/lib/retraced'; 3 | import { findOrCreateApp, findWebhook, updateWebhook } from '@/lib/svix'; 4 | import { throwIfNoTeamAccess } from 'models/team'; 5 | import { throwIfNotAllowed } from 'models/user'; 6 | import type { NextApiRequest, NextApiResponse } from 'next'; 7 | import { EndpointIn } from 'svix'; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | const { method } = req; 14 | 15 | try { 16 | switch (method) { 17 | case 'GET': 18 | await handleGET(req, res); 19 | break; 20 | case 'PUT': 21 | await handlePUT(req, res); 22 | break; 23 | default: 24 | res.setHeader('Allow', 'GET, PUT'); 25 | res.status(405).json({ 26 | error: { message: `Method ${method} Not Allowed` }, 27 | }); 28 | } 29 | } catch (err: any) { 30 | const message = err.message || 'Something went wrong'; 31 | const status = err.status || 500; 32 | 33 | res.status(status).json({ error: { message } }); 34 | } 35 | } 36 | 37 | // Get a Webhook 38 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 39 | const teamMember = await throwIfNoTeamAccess(req, res); 40 | throwIfNotAllowed(teamMember, 'team_webhook', 'read'); 41 | 42 | const { endpointId } = req.query as { 43 | endpointId: string; 44 | }; 45 | 46 | const app = await findOrCreateApp(teamMember.team.name, teamMember.team.id); 47 | 48 | if (!app) { 49 | throw new ApiError(200, 'Bad request.'); 50 | } 51 | 52 | const webhook = await findWebhook(app.id, endpointId as string); 53 | 54 | res.status(200).json({ data: webhook }); 55 | }; 56 | 57 | // Update a Webhook 58 | const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => { 59 | const teamMember = await throwIfNoTeamAccess(req, res); 60 | throwIfNotAllowed(teamMember, 'team_webhook', 'update'); 61 | 62 | const { endpointId } = req.query as { 63 | endpointId: string; 64 | }; 65 | 66 | const { name, url, eventTypes } = req.body; 67 | 68 | const app = await findOrCreateApp(teamMember.team.name, teamMember.team.id); 69 | 70 | if (!app) { 71 | throw new ApiError(200, 'Bad request.'); 72 | } 73 | 74 | const data: EndpointIn = { 75 | description: name, 76 | url, 77 | version: 1, 78 | }; 79 | 80 | if (eventTypes.length > 0) { 81 | data['filterTypes'] = eventTypes; 82 | } 83 | 84 | const webhook = await updateWebhook(app.id, endpointId, data); 85 | 86 | sendAudit({ 87 | action: 'webhook.update', 88 | crud: 'u', 89 | user: teamMember.user, 90 | team: teamMember.team, 91 | }); 92 | 93 | res.status(200).json({ data: webhook }); 94 | }; 95 | -------------------------------------------------------------------------------- /pages/api/teams/index.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from '@/lib/common'; 2 | import { ApiError } from '@/lib/errors'; 3 | import { getSession } from '@/lib/session'; 4 | import { createTeam, getTeams, isTeamExists } from 'models/team'; 5 | import type { NextApiRequest, NextApiResponse } from 'next'; 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | const { method } = req; 12 | 13 | try { 14 | switch (method) { 15 | case 'GET': 16 | await handleGET(req, res); 17 | break; 18 | case 'POST': 19 | await handlePOST(req, res); 20 | break; 21 | default: 22 | res.setHeader('Allow', 'GET, POST'); 23 | res.status(405).json({ 24 | error: { message: `Method ${method} Not Allowed` }, 25 | }); 26 | } 27 | } catch (error: any) { 28 | const message = error.message || 'Something went wrong'; 29 | const status = error.status || 500; 30 | 31 | res.status(status).json({ error: { message } }); 32 | } 33 | } 34 | 35 | // Get teams 36 | const handleGET = async (req: NextApiRequest, res: NextApiResponse) => { 37 | const session = await getSession(req, res); 38 | 39 | const teams = await getTeams(session?.user.id as string); 40 | 41 | res.status(200).json({ data: teams }); 42 | }; 43 | 44 | // Create a team 45 | const handlePOST = async (req: NextApiRequest, res: NextApiResponse) => { 46 | const { name } = req.body; 47 | 48 | const session = await getSession(req, res); 49 | const slug = slugify(name); 50 | 51 | if (await isTeamExists([{ slug }])) { 52 | throw new ApiError(400, 'A team with the name already exists.'); 53 | } 54 | 55 | const team = await createTeam({ 56 | userId: session?.user?.id as string, 57 | name, 58 | slug, 59 | }); 60 | 61 | res.status(200).json({ data: team }); 62 | }; 63 | -------------------------------------------------------------------------------- /pages/api/users.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import { getSession } from '@/lib/session'; 3 | import type { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | const { method } = req; 10 | 11 | try { 12 | switch (method) { 13 | case 'PUT': 14 | await handlePUT(req, res); 15 | break; 16 | default: 17 | res.setHeader('Allow', 'PUT'); 18 | res.status(405).json({ 19 | error: { message: `Method ${method} Not Allowed` }, 20 | }); 21 | } 22 | } catch (error: any) { 23 | const message = error.message || 'Something went wrong'; 24 | const status = error.status || 500; 25 | 26 | res.status(status).json({ error: { message } }); 27 | } 28 | } 29 | 30 | const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => { 31 | const session = await getSession(req, res); 32 | 33 | const user = await prisma.user.update({ 34 | where: { id: session?.user.id }, 35 | data: req.body, 36 | }); 37 | 38 | res.status(200).json({ data: user }); 39 | }; 40 | -------------------------------------------------------------------------------- /pages/auth/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import { AuthLayout } from '@/components/layouts'; 2 | import { InputWithLabel } from '@/components/shared'; 3 | import { getAxiosError } from '@/lib/common'; 4 | import axios from 'axios'; 5 | import { useFormik } from 'formik'; 6 | import type { 7 | GetServerSidePropsContext, 8 | InferGetServerSidePropsType, 9 | } from 'next'; 10 | import { useSession } from 'next-auth/react'; 11 | import { useTranslation } from 'next-i18next'; 12 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 13 | import Link from 'next/link'; 14 | import { useRouter } from 'next/router'; 15 | import type { ReactElement } from 'react'; 16 | import { Button } from 'react-daisyui'; 17 | import toast from 'react-hot-toast'; 18 | import type { ApiResponse, NextPageWithLayout } from 'types'; 19 | import * as Yup from 'yup'; 20 | 21 | const ForgotPassword: NextPageWithLayout< 22 | InferGetServerSidePropsType 23 | > = () => { 24 | const { status } = useSession(); 25 | const router = useRouter(); 26 | const { t } = useTranslation('common'); 27 | 28 | if (status === 'authenticated') { 29 | router.push('/dashboard'); 30 | } 31 | 32 | const formik = useFormik({ 33 | initialValues: { 34 | email: '', 35 | }, 36 | validationSchema: Yup.object().shape({ 37 | email: Yup.string().required().email(), 38 | }), 39 | onSubmit: async (values) => { 40 | try { 41 | await axios.post('/api/auth/forgot-password', { 42 | ...values, 43 | }); 44 | 45 | formik.resetForm(); 46 | toast.success(t('password-reset-link-sent')); 47 | } catch (error: any) { 48 | toast.error(getAxiosError(error)); 49 | } 50 | }, 51 | }); 52 | 53 | return ( 54 | <> 55 |
56 |
57 |
58 | 67 |
68 |
69 | 79 |
80 |
81 |
82 |

83 | {t('already-have-an-account')} 84 | 88 |  {t('sign-in')} 89 | 90 |

91 | 92 | ); 93 | }; 94 | 95 | ForgotPassword.getLayout = function getLayout(page: ReactElement) { 96 | return {page}; 97 | }; 98 | 99 | export const getServerSideProps = async ( 100 | context: GetServerSidePropsContext 101 | ) => { 102 | const { locale }: GetServerSidePropsContext = context; 103 | 104 | return { 105 | props: { 106 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 107 | }, 108 | }; 109 | }; 110 | 111 | export default ForgotPassword; 112 | -------------------------------------------------------------------------------- /pages/auth/join.tsx: -------------------------------------------------------------------------------- 1 | import GithubButton from '@/components/auth/GithubButton'; 2 | import GoogleButton from '@/components/auth/GoogleButton'; 3 | import Join from '@/components/auth/Join'; 4 | import JoinWithInvitation from '@/components/auth/JoinWithInvitation'; 5 | import { AuthLayout } from '@/components/layouts'; 6 | import { getParsedCookie } from '@/lib/cookie'; 7 | import { inferSSRProps } from '@/lib/inferSSRProps'; 8 | import { GetServerSidePropsContext } from 'next'; 9 | import { useSession } from 'next-auth/react'; 10 | import { useTranslation } from 'next-i18next'; 11 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 12 | import Link from 'next/link'; 13 | import { useRouter } from 'next/router'; 14 | import { type ReactElement, useEffect } from 'react'; 15 | import toast from 'react-hot-toast'; 16 | import type { NextPageWithLayout } from 'types'; 17 | 18 | const Signup: NextPageWithLayout> = ({ 19 | inviteToken, 20 | next, 21 | }) => { 22 | const router = useRouter(); 23 | const { status } = useSession(); 24 | const { t } = useTranslation('common'); 25 | 26 | const { error } = router.query; 27 | 28 | useEffect(() => { 29 | if (error) { 30 | toast.error(t(error)); 31 | } 32 | }, [router.query]); 33 | 34 | if (status === 'authenticated') { 35 | router.push('/'); 36 | } 37 | 38 | return ( 39 | <> 40 |
41 | {inviteToken ? ( 42 | 43 | ) : ( 44 | 45 | )} 46 |
or
47 |
48 | 49 | 50 |
51 |
52 | 53 |

54 | {t('already-have-an-account')} 55 | 59 |  {t('sign-in')} 60 | 61 |

62 | 63 | ); 64 | }; 65 | 66 | Signup.getLayout = function getLayout(page: ReactElement) { 67 | return ( 68 | 72 | {page} 73 | 74 | ); 75 | }; 76 | 77 | export const getServerSideProps = async ( 78 | context: GetServerSidePropsContext 79 | ) => { 80 | const { req, res, locale }: GetServerSidePropsContext = context; 81 | 82 | const cookieParsed = getParsedCookie(req, res); 83 | 84 | return { 85 | props: { 86 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 87 | inviteToken: cookieParsed.token, 88 | next: cookieParsed.url ?? '/auth/login', 89 | }, 90 | }; 91 | }; 92 | 93 | export default Signup; 94 | -------------------------------------------------------------------------------- /pages/auth/reset-password/[token].tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordForm } from '@/components/auth'; 2 | import { AuthLayout } from '@/components/layouts'; 3 | import type { 4 | GetServerSidePropsContext, 5 | } from 'next'; 6 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 7 | import { ReactElement } from 'react'; 8 | import type { NextPageWithLayout } from 'types'; 9 | 10 | const ResetPasswordPage: NextPageWithLayout = () => { 11 | return ; 12 | }; 13 | 14 | ResetPasswordPage.getLayout = function getLayout(page: ReactElement) { 15 | return ( 16 | 17 | {page} 18 | 19 | ); 20 | }; 21 | 22 | export const getServerSideProps = async ( 23 | context: GetServerSidePropsContext 24 | ) => { 25 | const { locale }: GetServerSidePropsContext = context; 26 | 27 | return { 28 | props: { 29 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 30 | }, 31 | }; 32 | }; 33 | 34 | export default ResetPasswordPage; 35 | -------------------------------------------------------------------------------- /pages/auth/verify-email-token.tsx: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | import type { GetServerSidePropsContext } from 'next'; 3 | import type { ReactElement } from 'react'; 4 | 5 | const VerifyEmailToken = () => { 6 | return <>; 7 | }; 8 | 9 | VerifyEmailToken.getLayout = function getLayout(page: ReactElement) { 10 | return <>{page}; 11 | }; 12 | 13 | export const getServerSideProps = async ({ 14 | query, 15 | }: GetServerSidePropsContext) => { 16 | const { token } = query as { token: string }; 17 | 18 | if (!token) { 19 | return { 20 | notFound: true, 21 | }; 22 | } 23 | 24 | const verificationToken = await prisma.verificationToken.findFirst({ 25 | where: { 26 | token, 27 | }, 28 | }); 29 | 30 | if (!verificationToken) { 31 | return { 32 | redirect: { 33 | destination: '/auth/login?error=token-not-found', 34 | permanent: false, 35 | }, 36 | }; 37 | } 38 | 39 | if(new Date() > verificationToken.expires) { 40 | return { 41 | redirect: { 42 | destination: '/auth/login?error=token-expired', 43 | permanent: false, 44 | }, 45 | }; 46 | } 47 | 48 | await Promise.allSettled([ 49 | prisma.user.update({ 50 | where: { 51 | email: verificationToken?.identifier, 52 | }, 53 | data: { 54 | emailVerified: new Date(), 55 | }, 56 | }), 57 | 58 | prisma.verificationToken.delete({ 59 | where: { 60 | token, 61 | }, 62 | }), 63 | ]); 64 | 65 | return { 66 | redirect: { 67 | destination: '/auth/login?success=email-verified', 68 | permanent: false, 69 | }, 70 | }; 71 | }; 72 | 73 | export default VerifyEmailToken; 74 | -------------------------------------------------------------------------------- /pages/auth/verify-email.tsx: -------------------------------------------------------------------------------- 1 | import { AuthLayout } from '@/components/layouts'; 2 | import { GetServerSidePropsContext } from 'next'; 3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 4 | import type { ReactElement } from 'react'; 5 | 6 | const VerifyEmail = () => { 7 | return <> 8 | }; 9 | 10 | VerifyEmail.getLayout = function getLayout(page: ReactElement) { 11 | return ( 12 | 13 | {page} 14 | 15 | ); 16 | }; 17 | 18 | export const getServerSideProps = async ( 19 | context: GetServerSidePropsContext 20 | ) => { 21 | const { locale }: GetServerSidePropsContext = context; 22 | 23 | return { 24 | props: { 25 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 26 | }, 27 | }; 28 | }; 29 | 30 | export default VerifyEmail; 31 | -------------------------------------------------------------------------------- /pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/shared'; 2 | import useTeams from 'hooks/useTeams'; 3 | import { GetServerSidePropsContext } from 'next'; 4 | import { useSession } from 'next-auth/react'; 5 | import { useTranslation } from 'next-i18next'; 6 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 7 | import { useRouter } from 'next/router'; 8 | import type { NextPageWithLayout } from 'types'; 9 | 10 | const Dashboard: NextPageWithLayout = () => { 11 | const router = useRouter(); 12 | const { teams } = useTeams(); 13 | const { t } = useTranslation('common'); 14 | const { data: session } = useSession(); 15 | 16 | if (teams) { 17 | if (teams.length > 0) { 18 | router.push(`/teams/${teams[0].slug}/settings`); 19 | } else { 20 | router.push('teams?newTeam=true'); 21 | } 22 | } 23 | 24 | return ( 25 | 26 | 27 |
28 |

29 | {`${t('hi')}, ${session?.user.name} ${t( 30 | 'you-have-logged-in-using' 31 | )} ${session?.user.email}`} 32 |

33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export async function getStaticProps({ locale }: GetServerSidePropsContext) { 40 | return { 41 | props: { 42 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 43 | }, 44 | }; 45 | } 46 | 47 | export default Dashboard; 48 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import FAQSection from '@/components/defaultLanding/FAQSection'; 2 | import FeatureSection from '@/components/defaultLanding/FeatureSection'; 3 | import HeroSection from '@/components/defaultLanding/HeroSection'; 4 | import PricingSection from '@/components/defaultLanding/PricingSection'; 5 | import { GetServerSidePropsContext } from 'next'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 8 | import Link from 'next/link'; 9 | import type { ReactElement } from 'react'; 10 | import type { NextPageWithLayout } from 'types'; 11 | 12 | const Home: NextPageWithLayout = () => { 13 | const { t } = useTranslation('common'); 14 | 15 | return ( 16 |
17 |
18 |
19 | 20 | BoxyHQ 21 | 22 |
23 |
24 | 32 |
33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 | ); 43 | }; 44 | 45 | export async function getStaticProps({ locale }: GetServerSidePropsContext) { 46 | return { 47 | props: { 48 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 49 | // Will be passed to the page component as props 50 | }, 51 | }; 52 | } 53 | 54 | Home.getLayout = function getLayout(page: ReactElement) { 55 | return <>{page}; 56 | }; 57 | 58 | export default Home; 59 | -------------------------------------------------------------------------------- /pages/settings/account.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPageWithLayout } from "types"; 2 | import type { GetServerSidePropsContext } from "next"; 3 | import { serverSideTranslations } from "next-i18next/serverSideTranslations"; 4 | import { getSession } from "@/lib/session"; 5 | import { getUserBySession } from "models/user"; 6 | import { inferSSRProps } from "@/lib/inferSSRProps"; 7 | import { UpdateAccount } from "@/components/account"; 8 | 9 | const Account: NextPageWithLayout> = ({ 10 | user, 11 | }) => { 12 | if (!user) { 13 | return null; 14 | } 15 | 16 | return ; 17 | }; 18 | 19 | export const getServerSideProps = async ( 20 | context: GetServerSidePropsContext 21 | ) => { 22 | const session = await getSession(context.req, context.res); 23 | const user = await getUserBySession(session); 24 | 25 | const { locale }: GetServerSidePropsContext = context; 26 | 27 | return { 28 | props: { 29 | ...(locale ? await serverSideTranslations(locale, ["common"]) : {}), 30 | user: JSON.parse(JSON.stringify(user)), 31 | }, 32 | }; 33 | }; 34 | 35 | export default Account; 36 | -------------------------------------------------------------------------------- /pages/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import { UpdatePassword } from '@/components/account'; 2 | import type { GetServerSidePropsContext } from 'next'; 3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 4 | 5 | const Password = () => { 6 | return ; 7 | }; 8 | 9 | export const getServerSideProps = async ( 10 | context: GetServerSidePropsContext 11 | ) => { 12 | const { locale }: GetServerSidePropsContext = context; 13 | 14 | return { 15 | props: { 16 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 17 | }, 18 | }; 19 | }; 20 | 21 | export default Password; 22 | -------------------------------------------------------------------------------- /pages/teams/[slug]/api-keys.tsx: -------------------------------------------------------------------------------- 1 | import APIKeysContainer from '@/components/apiKey/APIKeysContainer'; 2 | import { GetServerSidePropsContext } from 'next'; 3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 4 | 5 | const APIKeys = () => { 6 | return ; 7 | }; 8 | 9 | export async function getServerSideProps({ 10 | locale, 11 | }: GetServerSidePropsContext) { 12 | return { 13 | props: { 14 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 15 | }, 16 | }; 17 | } 18 | 19 | export default APIKeys; 20 | -------------------------------------------------------------------------------- /pages/teams/[slug]/audit-logs.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/shared'; 2 | import { Error, Loading } from '@/components/shared'; 3 | import { TeamTab } from '@/components/team'; 4 | import env from '@/lib/env'; 5 | import { inferSSRProps } from '@/lib/inferSSRProps'; 6 | import { getViewerToken } from '@/lib/retraced'; 7 | import { getSession } from '@/lib/session'; 8 | import useCanAccess from 'hooks/useCanAccess'; 9 | import useTeam from 'hooks/useTeam'; 10 | import { getTeamMember } from 'models/team'; 11 | import { throwIfNotAllowed } from 'models/user'; 12 | import { GetServerSidePropsContext } from 'next'; 13 | import { useTranslation } from 'next-i18next'; 14 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 15 | import dynamic from 'next/dynamic'; 16 | import type { NextPageWithLayout } from 'types'; 17 | 18 | interface RetracedEventsBrowserProps { 19 | host: string; 20 | auditLogToken: string; 21 | header: string; 22 | } 23 | 24 | const RetracedEventsBrowser = dynamic( 25 | () => import('@retracedhq/logs-viewer'), 26 | { 27 | ssr: false, 28 | } 29 | ); 30 | 31 | const Events: NextPageWithLayout> = ({ 32 | auditLogToken, 33 | retracedHost, 34 | error, 35 | }) => { 36 | const { t } = useTranslation('common'); 37 | const { canAccess } = useCanAccess(); 38 | const { isLoading, isError, team } = useTeam(); 39 | 40 | if (isLoading) { 41 | return ; 42 | } 43 | 44 | if (isError || error) { 45 | return ; 46 | } 47 | 48 | if (!team) { 49 | return ; 50 | } 51 | 52 | return ( 53 | <> 54 | 55 | 56 | 57 | {canAccess('team_audit_log', ['read']) && auditLogToken && ( 58 | 63 | )} 64 | 65 | 66 | 67 | ); 68 | }; 69 | 70 | export async function getServerSideProps(context: GetServerSidePropsContext) { 71 | const { locale, req, res, query } = context; 72 | 73 | const session = await getSession(req, res); 74 | const teamMember = await getTeamMember( 75 | session?.user.id as string, 76 | query.slug as string 77 | ); 78 | 79 | try { 80 | throwIfNotAllowed(teamMember, 'team_audit_log', 'read'); 81 | 82 | const auditLogToken = await getViewerToken( 83 | teamMember.team.id, 84 | session?.user.id as string 85 | ); 86 | 87 | return { 88 | props: { 89 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 90 | error: null, 91 | auditLogToken, 92 | retracedHost: env.retraced.url, 93 | }, 94 | }; 95 | } catch (error: any) { 96 | return { 97 | props: { 98 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 99 | error: { 100 | message: error.message, 101 | }, 102 | auditLogToken: null, 103 | retracedHost: null, 104 | }, 105 | }; 106 | } 107 | } 108 | 109 | export default Events; 110 | -------------------------------------------------------------------------------- /pages/teams/[slug]/directory-sync.tsx: -------------------------------------------------------------------------------- 1 | import { CreateDirectory, Directory } from '@/components/directorySync'; 2 | import { Card } from '@/components/shared'; 3 | import { Error, Loading } from '@/components/shared'; 4 | import { TeamTab } from '@/components/team'; 5 | import useDirectory from 'hooks/useDirectory'; 6 | import useTeam from 'hooks/useTeam'; 7 | import { GetServerSidePropsContext } from 'next'; 8 | import { useTranslation } from 'next-i18next'; 9 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 10 | import { useRouter } from 'next/router'; 11 | import { useState } from 'react'; 12 | import { Button } from 'react-daisyui'; 13 | import type { NextPageWithLayout } from 'types'; 14 | 15 | const DirectorySync: NextPageWithLayout = () => { 16 | const router = useRouter(); 17 | const { slug } = router.query as { slug: string }; 18 | 19 | const [visible, setVisible] = useState(false); 20 | const { isLoading, isError, team } = useTeam(); 21 | const { directories } = useDirectory(slug); 22 | const { t } = useTranslation('common'); 23 | 24 | if (isLoading) { 25 | return ; 26 | } 27 | 28 | if (isError) { 29 | return ; 30 | } 31 | 32 | if (!team) { 33 | return ; 34 | } 35 | 36 | const directory = 37 | directories && directories.length > 0 ? directories[0] : null; 38 | 39 | return ( 40 | <> 41 | 42 | 43 | 44 |
45 |

{t('provision')}

46 | {directory === null ? ( 47 | 55 | ) : ( 56 | 65 | )} 66 |
67 | 68 |
69 |
70 | 71 | 72 | ); 73 | }; 74 | 75 | export async function getServerSideProps({ 76 | locale, 77 | }: GetServerSidePropsContext) { 78 | return { 79 | props: { 80 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 81 | }, 82 | }; 83 | } 84 | 85 | export default DirectorySync; 86 | -------------------------------------------------------------------------------- /pages/teams/[slug]/members.tsx: -------------------------------------------------------------------------------- 1 | import { InviteMember, PendingInvitations } from '@/components/invitation'; 2 | import { Error, Loading } from '@/components/shared'; 3 | import { Members, TeamTab } from '@/components/team'; 4 | import useTeam from 'hooks/useTeam'; 5 | import { GetServerSidePropsContext } from 'next'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 8 | import { useState } from 'react'; 9 | import { Button } from 'react-daisyui'; 10 | import type { NextPageWithLayout } from 'types'; 11 | 12 | const TeamMembers: NextPageWithLayout = () => { 13 | const { t } = useTranslation('common'); 14 | const [visible, setVisible] = useState(false); 15 | const { isLoading, isError, team } = useTeam(); 16 | 17 | if (isLoading) { 18 | return ; 19 | } 20 | 21 | if (isError) { 22 | return ; 23 | } 24 | 25 | if (!team) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 |
33 |
34 | 44 |
45 | 46 |
47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export async function getServerSideProps({ 54 | locale, 55 | }: GetServerSidePropsContext) { 56 | return { 57 | props: { 58 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 59 | }, 60 | }; 61 | } 62 | 63 | export default TeamMembers; 64 | -------------------------------------------------------------------------------- /pages/teams/[slug]/products.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSidePropsContext } from 'next'; 2 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 3 | import type { NextPageWithLayout } from 'types'; 4 | 5 | const Products: NextPageWithLayout = () => { 6 | return ( 7 |
8 |

9 | This is just a placeholder for the products page. 10 |

11 |
12 | ); 13 | }; 14 | 15 | export async function getServerSideProps({ locale }: GetServerSidePropsContext) { 16 | return { 17 | props: { 18 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 19 | }, 20 | }; 21 | } 22 | 23 | export default Products; 24 | -------------------------------------------------------------------------------- /pages/teams/[slug]/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Error, Loading } from '@/components/shared'; 2 | import { AccessControl } from '@/components/shared/AccessControl'; 3 | import { RemoveTeam, TeamSettings, TeamTab } from '@/components/team'; 4 | import useTeam from 'hooks/useTeam'; 5 | import type { GetServerSidePropsContext } from 'next'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 8 | import type { NextPageWithLayout } from 'types'; 9 | 10 | const Settings: NextPageWithLayout = () => { 11 | const { t } = useTranslation('common'); 12 | const { isLoading, isError, team } = useTeam(); 13 | 14 | if (isLoading) { 15 | return ; 16 | } 17 | 18 | if (isError) { 19 | return ; 20 | } 21 | 22 | if (!team) { 23 | return ; 24 | } 25 | 26 | return ( 27 | <> 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export async function getServerSideProps({ 38 | locale, 39 | }: GetServerSidePropsContext) { 40 | return { 41 | props: { 42 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 43 | }, 44 | }; 45 | } 46 | 47 | export default Settings; 48 | -------------------------------------------------------------------------------- /pages/teams/[slug]/webhooks.tsx: -------------------------------------------------------------------------------- 1 | import { Error, Loading } from '@/components/shared'; 2 | import { TeamTab } from '@/components/team'; 3 | import { CreateWebhook, Webhooks } from '@/components/webhook'; 4 | import useTeam from 'hooks/useTeam'; 5 | import { GetServerSidePropsContext } from 'next'; 6 | import { useTranslation } from 'next-i18next'; 7 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 8 | import { useState } from 'react'; 9 | import { Button } from 'react-daisyui'; 10 | import type { NextPageWithLayout } from 'types'; 11 | 12 | const WebhookList: NextPageWithLayout = () => { 13 | const { t } = useTranslation('common'); 14 | const [visible, setVisible] = useState(false); 15 | const { isLoading, isError, team } = useTeam(); 16 | 17 | if (isLoading) { 18 | return ; 19 | } 20 | 21 | if (isError) { 22 | return ; 23 | } 24 | 25 | if (!team) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 |
33 |
34 | 44 |
45 | 46 |
47 | 48 | 49 | ); 50 | }; 51 | 52 | export async function getServerSideProps({ 53 | locale, 54 | }: GetServerSidePropsContext) { 55 | return { 56 | props: { 57 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 58 | }, 59 | }; 60 | } 61 | 62 | export default WebhookList; 63 | -------------------------------------------------------------------------------- /pages/teams/index.tsx: -------------------------------------------------------------------------------- 1 | import { CreateTeam, Teams } from '@/components/team'; 2 | import { GetServerSidePropsContext } from 'next'; 3 | import { useTranslation } from 'next-i18next'; 4 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 5 | import { useRouter } from 'next/router'; 6 | import { useEffect, useState } from 'react'; 7 | import { Button } from 'react-daisyui'; 8 | import type { NextPageWithLayout } from 'types'; 9 | 10 | const AllTeams: NextPageWithLayout = () => { 11 | const [visible, setVisible] = useState(false); 12 | 13 | const router = useRouter(); 14 | const { t } = useTranslation('common'); 15 | 16 | const { newTeam } = router.query as { newTeam: string }; 17 | 18 | useEffect(() => { 19 | if (newTeam) { 20 | setVisible(true); 21 | } 22 | }, [router.query]); 23 | 24 | return ( 25 | <> 26 |
27 |

{t('all-teams')}

28 | 38 |
39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export async function getStaticProps({ locale }: GetServerSidePropsContext) { 46 | return { 47 | props: { 48 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 49 | }, 50 | }; 51 | } 52 | 53 | export default AllTeams; 54 | -------------------------------------------------------------------------------- /pages/teams/switch.tsx: -------------------------------------------------------------------------------- 1 | import { AuthLayout } from '@/components/layouts'; 2 | import { getSession } from '@/lib/session'; 3 | import { deleteCookie } from 'cookies-next'; 4 | import { getTeams } from 'models/team'; 5 | import type { 6 | GetServerSidePropsContext, 7 | InferGetServerSidePropsType, 8 | } from 'next'; 9 | import { useSession } from 'next-auth/react'; 10 | import { useTranslation } from 'next-i18next'; 11 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; 12 | import { useRouter } from 'next/router'; 13 | import { type ReactElement, useEffect } from 'react'; 14 | import toast from 'react-hot-toast'; 15 | import type { NextPageWithLayout } from 'types'; 16 | 17 | const Organizations: NextPageWithLayout< 18 | InferGetServerSidePropsType 19 | > = ({ teams }) => { 20 | const router = useRouter(); 21 | const { t } = useTranslation('common'); 22 | const { status } = useSession(); 23 | 24 | if (status === 'unauthenticated') { 25 | router.push('/auth/login'); 26 | } 27 | 28 | useEffect(() => { 29 | if (teams === null) { 30 | toast.error(t('no-active-team')); 31 | return; 32 | } 33 | 34 | router.push(`/dashboard`); 35 | }); 36 | 37 | return ( 38 | <> 39 |
40 |

{t('choose-team')}

41 |
42 |
43 | 44 | ); 45 | }; 46 | 47 | Organizations.getLayout = function getLayout(page: ReactElement) { 48 | return {page}; 49 | }; 50 | 51 | export const getServerSideProps = async ( 52 | context: GetServerSidePropsContext 53 | ) => { 54 | const { req, res, locale }: GetServerSidePropsContext = context; 55 | 56 | const session = await getSession(req, res); 57 | 58 | deleteCookie('pending-invite', { req, res }); 59 | 60 | const teams = await getTeams(session?.user.id as string); 61 | 62 | return { 63 | props: { 64 | ...(locale ? await serverSideTranslations(locale, ['common']) : {}), 65 | teams: JSON.parse(JSON.stringify(teams)), 66 | }, 67 | }; 68 | }; 69 | 70 | export default Organizations; 71 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | projects: [ 5 | { 6 | name: 'chromium', 7 | use: { ...devices['Desktop Chrome'] }, 8 | }, 9 | { 10 | name: 'firefox', 11 | use: { ...devices['Desktop Firefox'] }, 12 | }, 13 | ], 14 | reporter: [ 15 | [ 16 | 'html', 17 | { 18 | outputFolder: 'report', 19 | open: 'never', 20 | }, 21 | ], 22 | ], 23 | webServer: { 24 | command: 'npm run start', 25 | url: 'http://localhost:4002', 26 | }, 27 | use: { 28 | headless: true, 29 | ignoreHTTPSErrors: true, 30 | baseURL: 'http://localhost:4002', 31 | video: 'off', 32 | }, 33 | }; 34 | export default config; 35 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20230703084002_add_api_keys/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "ApiKey" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "teamId" TEXT NOT NULL, 6 | "hashedKey" TEXT NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "expiresAt" TIMESTAMP(3), 10 | "lastUsedAt" TIMESTAMP(3), 11 | 12 | CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); 17 | 18 | -- AddForeignKey 19 | ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; 20 | -------------------------------------------------------------------------------- /prisma/migrations/20230703084117_add_index_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX IF EXISTS "TeamMember_userId_key"; 3 | 4 | -- CreateIndex 5 | CREATE INDEX IF NOT EXISTS "TeamMember_userId_idx" ON "TeamMember"("userId"); -------------------------------------------------------------------------------- /prisma/migrations/20230720113226_make_password_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/saas-starter-kit/a3c5989f4ce5f968a60825cfc68aa72f026ee79f/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/saas-starter-kit/a3c5989f4ce5f968a60825cfc68aa72f026ee79f/public/logo.png -------------------------------------------------------------------------------- /public/saas-starter-kit-poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/saas-starter-kit/a3c5989f4ce5f968a60825cfc68aa72f026ee79f/public/saas-starter-kit-poster.png -------------------------------------------------------------------------------- /public/user-default-profile.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steven-tey/saas-starter-kit/a3c5989f4ce5f968a60825cfc68aa72f026ee79f/public/user-default-profile.jpeg -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | color-scheme: dark light; 7 | } 8 | 9 | html, 10 | body { 11 | padding: 0; 12 | margin: 0; 13 | font-family: 14 | -apple-system, 15 | BlinkMacSystemFont, 16 | Segoe UI, 17 | Roboto, 18 | Oxygen, 19 | Ubuntu, 20 | Cantarell, 21 | Fira Sans, 22 | Droid Sans, 23 | Helvetica Neue, 24 | sans-serif; 25 | } 26 | 27 | a { 28 | color: inherit; 29 | text-decoration: none; 30 | } 31 | 32 | * { 33 | box-sizing: border-box; 34 | } 35 | 36 | @layer base { 37 | input { 38 | @apply rounded !important; 39 | } 40 | } 41 | 42 | @layer components { 43 | #__next { 44 | @apply h-full; 45 | } 46 | 47 | .btn { 48 | @apply rounded normal-case; 49 | } 50 | 51 | .modal-box { 52 | @apply rounded; 53 | } 54 | } 55 | 56 | .btn-md { 57 | @apply min-h-8 h-10; 58 | } 59 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | darkMode: 'class', 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx}', 6 | './components/**/*.{js,ts,jsx,tsx}', 7 | 'node_modules/daisyui/dist/**/*.js', 8 | ], 9 | daisyui: { 10 | themes: ['corporate'], 11 | }, 12 | plugins: [ 13 | require('@tailwindcss/forms'), 14 | require('@tailwindcss/typography'), 15 | require('daisyui'), 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /tests/e2e/auth/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('Should navigate to login page', async ({ page }) => { 4 | page.goto('/'); 5 | await expect(page).toHaveURL('/auth/login'); 6 | }); 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node"], 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "baseUrl": ".", 19 | "noImplicitAny": false, 20 | "paths": { 21 | "@/lib/*": ["lib/*"], 22 | "@/components/*": ["components/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next-auth.d.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /types/base.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma } from '@prisma/client'; 2 | 3 | export type ApiError = { 4 | code?: string; 5 | message: string; 6 | values: { [key: string]: string }; 7 | }; 8 | 9 | export type ApiResponse = 10 | | { 11 | data: T; 12 | error: never; 13 | } 14 | | { 15 | data: never; 16 | error: ApiError; 17 | }; 18 | 19 | export type Role = 'owner' | 'member'; 20 | 21 | export type SPSAMLConfig = { 22 | issuer: string; 23 | acs: string; 24 | }; 25 | 26 | export type TeamWithMemberCount = Prisma.TeamGetPayload<{ 27 | include: { 28 | _count: { 29 | select: { members: true }; 30 | }; 31 | }; 32 | }>; 33 | 34 | export type WebookFormSchema = { 35 | name: string; 36 | url: string; 37 | eventTypes: string[]; 38 | }; 39 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base'; 2 | export * from './next'; 3 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-use-before-define 2 | import type { Role } from '@prisma/client'; 3 | import type { DefaultSession } from 'next-auth'; 4 | 5 | declare module 'next-auth' { 6 | /** 7 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 8 | */ 9 | interface Session { 10 | user: DefaultSession['user'] & { 11 | id: string; 12 | roles: { teamId: string; role: Role }[]; 13 | }; 14 | } 15 | 16 | interface Profile { 17 | requested: { 18 | tenant: string; 19 | }; 20 | roles: string[]; 21 | groups: string[]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /types/next.ts: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { Session } from 'next-auth'; 3 | import { AppProps } from 'next/app'; 4 | import { ReactElement, ReactNode } from 'react'; 5 | 6 | export type AppPropsWithLayout = AppProps & { 7 | Component: NextPageWithLayout; 8 | pageProps: { 9 | session?: Session; 10 | }; 11 | }; 12 | 13 | export type NextPageWithLayout

> = NextPage

& { 14 | getLayout?: (page: ReactElement) => ReactNode; 15 | }; 16 | --------------------------------------------------------------------------------