From 8cf06c6ea559158b828fbfb5c7b0104f1dbd2839 Mon Sep 17 00:00:00 2001 From: Matthias Nannt Date: Sat, 26 Nov 2022 11:55:57 +0100 Subject: [PATCH] add api key functionality to formbricks HQ --- .env.docker | 2 +- .env.example | 2 +- apps/hq/next.config.js | 10 +- apps/hq/package.json | 8 +- apps/hq/src/app/LoadingSpinner.tsx | 7 + apps/hq/src/app/Logo.jsx | 46 ++--- apps/hq/src/app/SessionProvider.tsx | 7 + apps/hq/src/app/{forms => app}/layout.tsx | 91 +++++----- apps/hq/src/app/app/me/settings/page.tsx | 160 ++++++++++++++++++ apps/hq/src/app/app/page.tsx | 7 + apps/hq/src/app/auth/signin/SigninForm.tsx | 28 +-- apps/hq/src/app/auth/signup/SignupForm.tsx | 22 ++- apps/hq/src/app/auth/verify/SignIn.tsx | 2 +- apps/hq/src/app/forms/page.tsx | 3 - apps/hq/src/app/layout.tsx | 12 +- apps/hq/src/components/Modal.tsx | 49 ++++++ apps/hq/src/images/avatar-placeholder.png | Bin 0 -> 2011 bytes apps/hq/src/lib/apiHelper.ts | 25 +++ apps/hq/src/lib/apiKeys.ts | 62 +++++++ apps/hq/src/lib/email.ts | 16 +- apps/hq/src/lib/session.ts | 14 ++ apps/hq/src/lib/token.ts | 15 ++ apps/hq/src/lib/utils.ts | 79 +++++++++ apps/hq/src/pages/api/auth/[...nextauth].ts | 43 ++++- apps/hq/src/pages/api/users/index.tsx | 13 ++ .../api/users/me/api-keys/[apiKeyId]/index.ts | 51 ++++++ .../src/pages/api/users/me/api-keys/index.tsx | 52 ++++++ docker-compose.dev.yml | 2 +- docker-compose.yml | 2 +- .../20221115091649_init/migration.sql | 70 -------- .../20221126102828_init/migration.sql | 136 +++++++++++++++ packages/database/prisma/schema.prisma | 92 ++++++++-- packages/ui/src/Button.tsx | 2 +- pnpm-lock.yaml | 157 ++++++++++++++++- turbo.json | 1 + 35 files changed, 1077 insertions(+), 211 deletions(-) create mode 100644 apps/hq/src/app/LoadingSpinner.tsx create mode 100644 apps/hq/src/app/SessionProvider.tsx rename apps/hq/src/app/{forms => app}/layout.tsx (77%) create mode 100644 apps/hq/src/app/app/me/settings/page.tsx create mode 100644 apps/hq/src/app/app/page.tsx delete mode 100644 apps/hq/src/app/forms/page.tsx create mode 100644 apps/hq/src/components/Modal.tsx create mode 100644 apps/hq/src/images/avatar-placeholder.png create mode 100644 apps/hq/src/lib/apiHelper.ts create mode 100644 apps/hq/src/lib/apiKeys.ts create mode 100644 apps/hq/src/lib/session.ts create mode 100644 apps/hq/src/lib/token.ts create mode 100644 apps/hq/src/lib/utils.ts create mode 100644 apps/hq/src/pages/api/users/me/api-keys/[apiKeyId]/index.ts create mode 100644 apps/hq/src/pages/api/users/me/api-keys/index.tsx delete mode 100644 packages/database/prisma/migrations/20221115091649_init/migration.sql create mode 100644 packages/database/prisma/migrations/20221126102828_init/migration.sql diff --git a/.env.docker b/.env.docker index d2787daafa..fa435a4fea 100644 --- a/.env.docker +++ b/.env.docker @@ -14,7 +14,7 @@ NEXTAUTH_URL=http://localhost:3000 # This should always be localhost:3000 (or whatever port your app is running on) NEXTAUTH_URL_INTERNAL=http://localhost:3000 -DATABASE_URL='postgresql://postgres:postgres@postgres:5432/formbricks?schema=public' +DATABASE_URL='postgresql://postgres:postgres@postgres:5432/postgres?schema=public' ################ # Mail Setup # diff --git a/.env.example b/.env.example index 62f695a150..b2fe0a58ff 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ NEXTAUTH_URL=http://localhost:3000 # This should always be localhost:3000 (or whatever port your app is running on) NEXTAUTH_URL_INTERNAL=http://localhost:3000 -DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' +DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres?schema=public' # For Docker Compose Production Setup use this Database URL: # DATABASE_URL='postgresql://postgres:postgres@postgres:5432/formbricks?schema=public' diff --git a/apps/hq/next.config.js b/apps/hq/next.config.js index da150cd84b..8072815530 100644 --- a/apps/hq/next.config.js +++ b/apps/hq/next.config.js @@ -12,6 +12,14 @@ module.exports = { appDir: true, serverComponentsExternalPackages: ["@prisma/client"], }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + ], + }, webpack: (config) => { config.externals = [...(config.externals || []), "@prisma/client"]; // Important: return the modified config @@ -21,7 +29,7 @@ module.exports = { return [ { source: "/", - destination: "/forms/", + destination: "/app/", permanent: false, }, ]; diff --git a/apps/hq/package.json b/apps/hq/package.json index bf30c9f477..0ab09da438 100644 --- a/apps/hq/package.json +++ b/apps/hq/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@formbricks/hq", - "version": "1.0.0", + "version": "0.0.0", "scripts": { "dev": "next dev", "build": "next build", @@ -9,10 +9,12 @@ "lint": "next lint" }, "dependencies": { + "@formbricks/react": "workspace:*", "@formbricks/ui": "workspace:*", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", "bcryptjs": "^2.4.3", + "date-fns": "^2.29.3", "jsonwebtoken": "^8.5.1", "next": "^13.0.5", "next-auth": "^4.17.0", @@ -20,7 +22,9 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^4.6.0", - "react-toastify": "^9.1.1" + "react-loader-spinner": "^5.3.4", + "react-toastify": "^9.1.1", + "swr": "^1.3.0" }, "devDependencies": { "@formbricks/database": "workspace:*", diff --git a/apps/hq/src/app/LoadingSpinner.tsx b/apps/hq/src/app/LoadingSpinner.tsx new file mode 100644 index 0000000000..f29dbec739 --- /dev/null +++ b/apps/hq/src/app/LoadingSpinner.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { TailSpin } from "react-loader-spinner"; + +export default function LoadingSpinner() { + return ; +} diff --git a/apps/hq/src/app/Logo.jsx b/apps/hq/src/app/Logo.jsx index 742ae2c83f..0afe42daf2 100644 --- a/apps/hq/src/app/Logo.jsx +++ b/apps/hq/src/app/Logo.jsx @@ -51,24 +51,24 @@ export function Logo(props) { @@ -79,7 +79,7 @@ export function Logo(props) { - + - + colorInterpolationFilters="sRGB"> + - + colorInterpolationFilters="sRGB"> + @@ -122,8 +122,8 @@ export function Logo(props) { width="87.5471" height="87.5471" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB"> - + colorInterpolationFilters="sRGB"> + @@ -134,8 +134,8 @@ export function Logo(props) { x2="-0.00218275" y2="110.964" gradientUnits="userSpaceOnUse"> - - + + - - + + - - + + - - + + - - + + - - + + diff --git a/apps/hq/src/app/SessionProvider.tsx b/apps/hq/src/app/SessionProvider.tsx new file mode 100644 index 0000000000..cee9a186f1 --- /dev/null +++ b/apps/hq/src/app/SessionProvider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SessionProvider, SessionProviderProps } from "next-auth/react"; + +export default function ClientSessionProvider(props: SessionProviderProps) { + return ; +} diff --git a/apps/hq/src/app/forms/layout.tsx b/apps/hq/src/app/app/layout.tsx similarity index 77% rename from apps/hq/src/app/forms/layout.tsx rename to apps/hq/src/app/app/layout.tsx index 1c78d4bdd7..33dcb20583 100644 --- a/apps/hq/src/app/forms/layout.tsx +++ b/apps/hq/src/app/app/layout.tsx @@ -3,25 +3,19 @@ import { Disclosure, Menu, Transition } from "@headlessui/react"; import { Bars3Icon, BellIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { signOut, useSession } from "next-auth/react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { Fragment } from "react"; +import { Logo } from "../Logo"; +import AvatarPlaceholder from "@/images/avatar-placeholder.png"; +import Link from "next/link"; +import LoadingSpinner from "../LoadingSpinner"; -const user = { - name: "Tom Cook", - email: "tom@example.com", - imageUrl: - "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80", -}; const navigation = [ - { name: "Dashboard", href: "#", current: true }, + /* { name: "Forms", href: "#", current: true }, { name: "Team", href: "#", current: false }, { name: "Projects", href: "#", current: false }, - { name: "Calendar", href: "#", current: false }, -]; -const userNavigation = [ - { name: "Your Profile", onClick: () => {} }, - { name: "Settings", onClick: () => {} }, - { name: "Sign out", onClick: () => signOut() }, + { name: "Calendar", href: "#", current: false }, */ ]; function classNames(...classes) { @@ -29,11 +23,24 @@ function classNames(...classes) { } export default function ProjectsLayout({ children }) { - const { data: session, status } = useSession(); const router = useRouter(); + const userNavigation = [ + { + name: "Settings", + onClick: () => { + router.push("/app/me/settings"); + }, + }, + { name: "Sign out", onClick: () => signOut() }, + ]; + const { data: session, status } = useSession(); if (status === "loading") { - return
Loading
; + return ( +
+ +
+ ); } if (!session) { router.push(`/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`); @@ -49,16 +56,9 @@ export default function ProjectsLayout({ children }) {
- Your Company - Your Company + + +
{navigation.map((item) => ( @@ -78,19 +78,25 @@ export default function ProjectsLayout({ children }) {
- + */} {/* Profile dropdown */}
- + Open user menu - + Avatar placeholder
{item.name} @@ -155,18 +161,22 @@ export default function ProjectsLayout({ children }) {
- + profile picture
-
{user.name}
-
{user.email}
+
{session.user.name}
+
{session.user.email}
- + */}
{userNavigation.map((item) => ( @@ -186,17 +196,8 @@ export default function ProjectsLayout({ children }) {
-
-
-

Dashboard

-
-
-
- {/* Replace with your content */} -
{children}
- {/* /End replace */} -
+
{children}
diff --git a/apps/hq/src/app/app/me/settings/page.tsx b/apps/hq/src/app/app/me/settings/page.tsx new file mode 100644 index 0000000000..eb213534b3 --- /dev/null +++ b/apps/hq/src/app/app/me/settings/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import LoadingSpinner from "@/app/LoadingSpinner"; +import Modal from "@/components/Modal"; +import { createApiKey, deleteApiKey, useApiKeys } from "@/lib/apiKeys"; +import { convertDateString, convertDateTimeString } from "@/lib/utils"; +import { Form, Submit, Text } from "@formbricks/react"; +import { Button } from "@formbricks/ui"; +import { useState } from "react"; + +export default function MeSettingsPage() { + const { apiKeys, mutateApiKeys, isLoadingApiKeys } = useApiKeys(); + const [openNewApiKeyModal, setOpenNewApiKeyModal] = useState(false); + + return ( +
+
+

Account Settings

+
+ {/* Payment details */} +
+
+
+
+
+
+

Personal API Keys

+

+ These keys allow full access to your personal account through the API, as if you were + logged in. Try not to keep disused keys around. If you have any suspicion that one of + these may be compromised, delete it and use a new one. +

+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + {isLoadingApiKeys ? ( + + ) : apiKeys.length === 0 ? ( + + + + ) : ( + apiKeys.map((apiKey) => ( + + + + + + + + )) + )} + +
+ Label + + Value + + Last Used + + Created + + Edit +
+ You don't have any API Keys yet +
+ {apiKey.label} + + {apiKey.apiKey || secret} + + {convertDateTimeString(apiKey.lastUsed)} + + {convertDateTimeString(apiKey.createdAt)} + + +
+
+
+
+
+
+
+
+
+ {openNewApiKeyModal && ( + +

+ Create a Personal API Key +

+
+
{ + const apiKey = await createApiKey({ label: data.label }); + mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false); + setOpenNewApiKeyModal(false); + }}> + +

+ Key value will only ever be shown once, immediately after creation. Copy it to your destination + right away. +

+
+ + +
+ +
+ )} +
+ ); +} diff --git a/apps/hq/src/app/app/page.tsx b/apps/hq/src/app/app/page.tsx new file mode 100644 index 0000000000..b6418aea15 --- /dev/null +++ b/apps/hq/src/app/app/page.tsx @@ -0,0 +1,7 @@ +export default function ProjectsPage() { + return ( +
+

Dashboard

+
+ ); +} diff --git a/apps/hq/src/app/auth/signin/SigninForm.tsx b/apps/hq/src/app/auth/signin/SigninForm.tsx index d242c06d15..c149612bc4 100644 --- a/apps/hq/src/app/auth/signin/SigninForm.tsx +++ b/apps/hq/src/app/auth/signin/SigninForm.tsx @@ -33,11 +33,7 @@ export const SigninForm = ({ callbackUrl, error }) => {
)} -
+
)} -
- - + {process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && ( + <> +
+ + {" "} + + )} ); diff --git a/apps/hq/src/app/auth/signup/SignupForm.tsx b/apps/hq/src/app/auth/signup/SignupForm.tsx index 51c6f75ccd..7018e84b0d 100644 --- a/apps/hq/src/app/auth/signup/SignupForm.tsx +++ b/apps/hq/src/app/auth/signup/SignupForm.tsx @@ -133,15 +133,19 @@ export const SignupForm = () => {
)}
-
- - + {process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && ( + <> +
+ + + + )} ); diff --git a/apps/hq/src/app/auth/verify/SignIn.tsx b/apps/hq/src/app/auth/verify/SignIn.tsx index e0156a4fa7..779a970587 100644 --- a/apps/hq/src/app/auth/verify/SignIn.tsx +++ b/apps/hq/src/app/auth/verify/SignIn.tsx @@ -8,7 +8,7 @@ export const SignIn = ({ token }) => { if (token) { signIn("token", { token: token, - callbackUrl: `/projects`, + callbackUrl: `/app`, }); } }, [token]); diff --git a/apps/hq/src/app/forms/page.tsx b/apps/hq/src/app/forms/page.tsx deleted file mode 100644 index 6f9814305b..0000000000 --- a/apps/hq/src/app/forms/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ProjectsPage() { - return
Projects
; -} diff --git a/apps/hq/src/app/layout.tsx b/apps/hq/src/app/layout.tsx index 7a324e5a91..c68ba0e3b6 100644 --- a/apps/hq/src/app/layout.tsx +++ b/apps/hq/src/app/layout.tsx @@ -1,16 +1,16 @@ -"use client"; - import "@/styles/globals.css"; import "@/styles/toastify.css"; // include styles from the ui package -import { SessionProvider } from "next-auth/react"; +import SessionProvider from "./SessionProvider"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - + + + Formbricks HQ + + {children} diff --git a/apps/hq/src/components/Modal.tsx b/apps/hq/src/components/Modal.tsx new file mode 100644 index 0000000000..ca9c4043ec --- /dev/null +++ b/apps/hq/src/components/Modal.tsx @@ -0,0 +1,49 @@ +/* This example requires Tailwind CSS v2.0+ */ +import { Dialog, Transition } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { Fragment } from "react"; + +export default function Modal({ open, setOpen, children }) { + return ( + + + +
+ + +
+
+ + +
+ +
+
{children}
+
+
+
+
+
+
+ ); +} diff --git a/apps/hq/src/images/avatar-placeholder.png b/apps/hq/src/images/avatar-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..09892098aa943af5fe95e7dd434a07fe03e7ccd1 GIT binary patch literal 2011 zcmV<12PF83P)D!y%g4vZ*x1<2%*?Q`u)x5;+S=OH)zz}HvdGBD zw6wIs!oteR%FWHqrlzLJ$;r^r(7(UGt*x!9s;a}o!>FjJ#>U3Iy}i%R&$YF+x3{;h zuCCtR-nO>3udlDMv9Z$9(zv*|ySuyA*4EqG+ZlWNivR!y*-1n}RCr$O+*gj>NDPM2 zpNco<96RTn9rwSi8F&oCZFlz#Qq{xgJ4+x4LZrx&UjP6A00000000000000000000 z0000000000h)13Iv~gIg9X6)(&Ma2$kko6>k^LchuGJIe1?9VT%ih&$-<6da;(GtY zRygU`V`YKSGPJdX%aOK)>O)}bd#Gv~*uA$6-S28s*y-CA`a9YL;;WKvrF0c*ZKyul z)*h=`7wH^W2Elqsjqd}?Xz*Ptp%PkVVMQxo5?Bs`i9Upe<)onxU}?Eo>Ro7Cj@nv* ze0H1D=vvOYdIRQ`ySe^?YRPg}s_G9&F3o|+E;KJf72K|Qva4ddGB4O%8ki>o6?HI`i*6#+w#5|Y?<y|LwN+|wJ-DOs*c9lZ(lz;YDS^$r~880p|x z??Sy~IVsik0el`?F2Y>=KFtcZ1R zaGELyc&axa?H!NJ`jhg5v~%-y@6vvAx%c&^lgbGsy=v!Q)n20Y000000000000000 z0000001(G=0_9$3(l{U9f4Ydqf&CDSql-`X!}G?Z(<>_@q?M!EmqlnRgo`h=qe`mH zp^F!L#dKMP=0-j66q)X!m2_UKU< z?=gcl`PrjD-Z_UQ!Oo*l-Y$R@ac4on7M&O-j9L_~HG+QG8x@#l(9NwQst~Om6uTU# z#^D0B?jkA@-9e`x4^-yi2NWuOs^gbXXX{ZRZwp;qmr8Y=vc4+;?DuSiV=6Wdp+vB! zYWn~xgnKHt51~MOqTGjPD@PoaW3S(TV1qAdpLGPU;{}n+CNZ&z1&l zAk)yBhI<>K$Szko#25&!NK0f~qZEphQ8{ z3aIf)#a^Mvnrf|~$}^REhB95M)P*|TX3iCPq&kn#$aR|+5*6uFl|Ix;s7M0Ewp3#a z)kYpw@J3LsOsPOxLcOd<@p>5y2p=fiLkJUGpQ800tayqjR`dizEyTFw96Gb|` z!YX_?aV~Q~zz%VD}8+~-=U|TkA>Od+oZ}G}nbJRjS zi+DxREJj@nPrRDb5Owl07n$q)ia(q8)f$NXg*KhY?6EB-U9U5WqIxO65S z|C%@HT<{BxaK1b6FYI^o5WlAp7RhA6Kem`8MTkGt$mX+;7JpOgV>ZtqfL8IK*!Dm6 zzQ>>P{QXCNTMXj=+Nnb$0D%|)f;hy>mx(1hlm3f?BA8dZ4V=5bdfd6DDtbSk-BC3w tPksUb00000000000000000000fEW70#Mag`cL)Fg002ovPDHLkV1gNB=am2e literal 0 HcmV?d00001 diff --git a/apps/hq/src/lib/apiHelper.ts b/apps/hq/src/lib/apiHelper.ts new file mode 100644 index 0000000000..7e30f09795 --- /dev/null +++ b/apps/hq/src/lib/apiHelper.ts @@ -0,0 +1,25 @@ +import { createHash } from "crypto"; +import { prisma } from "@formbricks/database"; + +export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex"); + +export const hasOwnership = async (model, session, id) => { + try { + const entity = await prisma[model].findUnique({ + where: { id: id }, + include: { + user: { + select: { email: true }, + }, + }, + }); + if (entity.user.email === session.user.email) { + return true; + } else { + return false; + } + } catch (e) { + console.error(`can't verify ownership: ${e}`); + return false; + } +}; diff --git a/apps/hq/src/lib/apiKeys.ts b/apps/hq/src/lib/apiKeys.ts new file mode 100644 index 0000000000..05c2da196d --- /dev/null +++ b/apps/hq/src/lib/apiKeys.ts @@ -0,0 +1,62 @@ +import useSWR from "swr"; +import { fetcher } from "@/lib/utils"; + +export const useApiKeys = () => { + const { data, error, mutate } = useSWR(`/api/users/me/api-keys/`, fetcher); + + return { + apiKeys: data, + isLoadingApiKeys: !error && !data, + isErrorApiKeys: error, + mutateApiKeys: mutate, + }; +}; + +export const useApiKey = (id: string) => { + const { data, error, mutate } = useSWR(`/api/users/me/api-keys/${id}/`, fetcher); + + return { + apiKey: data, + isLoadingApiKey: !error && !data, + isErrorApiKey: error, + mutateApiKey: mutate, + }; +}; + +export const persistApiKey = async (apiKey) => { + try { + await fetch(`/api/users/me/api-keys/${apiKey.id}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(apiKey), + }); + } catch (error) { + console.error(error); + } +}; + +export const createApiKey = async (apiKey = {}) => { + try { + const res = await fetch(`/api/users/me/api-keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(apiKey), + }); + return await res.json(); + } catch (error) { + console.error(error); + throw Error(`createApiKey: unable to create api-key: ${error.message}`); + } +}; + +export const deleteApiKey = async (apiKey) => { + try { + const res = await fetch(`/api/users/me/api-keys/${apiKey.id}`, { + method: "DELETE", + }); + return await res.json(); + } catch (error) { + console.error(error); + throw Error(`deleteApiKey: unable to delete api-key: ${error.message}`); + } +}; diff --git a/apps/hq/src/lib/email.ts b/apps/hq/src/lib/email.ts index 4ec8f5ae8b..b4d9c87526 100644 --- a/apps/hq/src/lib/email.ts +++ b/apps/hq/src/lib/email.ts @@ -21,7 +21,7 @@ export const sendEmail = async (emailData: sendEmailData) => { // debug: true, }); const emailDefaults = { - from: `Cargoship <${process.env.MAIL_FROM || "noreply@formbricks.com"}>`, + from: `Formbricks <${process.env.MAIL_FROM || "noreply@formbricks.com"}>`, }; await transporter.sendMail({ ...emailDefaults, ...emailData }); }; @@ -36,14 +36,14 @@ export const sendVerificationEmail = async (user) => { }/auth/verification-requested?email=${encodeURIComponent(user.email)}`; await sendEmail({ to: user.email, - subject: "Welcome to Cargoship", - html: `Welcome to Cargoship!

To verify your email address and start using Cargoship please click this link:
+ subject: "Welcome to Formbricks", + html: `Welcome to Formbricks!

To verify your email address and start using Formbricks please click this link:
${verifyLink}

The link is valid for one day. If it has expired please request a new token here:
${verificationRequestLink}

- Your Cargoship Team`, + Your Formbricks Team`, }); }; @@ -56,7 +56,7 @@ export const sendForgotPasswordEmail = async (user) => { )}`; await sendEmail({ to: user.email, - subject: "Reset your Cargoship password", + subject: "Reset your Formbricks password", html: `You have requested a link to change your password. You can do this through the link below:
${verifyLink}

@@ -64,16 +64,16 @@ export const sendForgotPasswordEmail = async (user) => {
Your password won't change until you access the link above and create a new one.

- Your Cargoship Team`, + Your Formbricks Team`, }); }; export const sendPasswordResetNotifyEmail = async (user) => { await sendEmail({ to: user.email, - subject: "Your Cargoship password has been changed", + subject: "Your Formbricks password has been changed", html: `We're contacting you to notify you that your password has been changed.

- Your Cargoship Team`, + Your Formbricks Team`, }); }; diff --git a/apps/hq/src/lib/session.ts b/apps/hq/src/lib/session.ts new file mode 100644 index 0000000000..42dc8948b2 --- /dev/null +++ b/apps/hq/src/lib/session.ts @@ -0,0 +1,14 @@ +import { Session } from "next-auth"; + +export async function getSession(cookie: string): Promise { + const response = await fetch(`${process.env.NEXTAUTH_URL}/api/auth/session`, { + headers: { cookie }, + }); + + if (!response?.ok) { + return null; + } + + const session = await response.json(); + return Object.keys(session).length > 0 ? session : null; +} diff --git a/apps/hq/src/lib/token.ts b/apps/hq/src/lib/token.ts new file mode 100644 index 0000000000..808f3a6781 --- /dev/null +++ b/apps/hq/src/lib/token.ts @@ -0,0 +1,15 @@ +import { cookies } from "next/headers"; +import { decode } from "next-auth/jwt"; + +if (!process.env.NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is missing"); +} + +export const getToken = async () => { + return await decode({ + token: cookies() + .getAll() + .find((cookie) => cookie.name.includes("next-auth.session-token"))?.value, + secret: process.env.NEXTAUTH_SECRET as string, + }); +}; diff --git a/apps/hq/src/lib/utils.ts b/apps/hq/src/lib/utils.ts new file mode 100644 index 0000000000..184c717b78 --- /dev/null +++ b/apps/hq/src/lib/utils.ts @@ -0,0 +1,79 @@ +import intlFormat from "date-fns/intlFormat"; +import { formatDistance } from "date-fns"; + +export const fetcher = async (url) => { + const res = await fetch(url); + + // If the status code is not in the range 200-299, + // we still try to parse and throw it. + if (!res.ok) { + const error: any = new Error("An error occurred while fetching the data."); + // Attach extra info to the error object. + error.info = await res.json(); + error.status = res.status; + throw error; + } + + return res.json(); +}; + +export const convertDateString = (dateString: string) => { + if (!dateString) { + return dateString; + } + const date = new Date(dateString); + return intlFormat( + date, + { + year: "numeric", + month: "long", + day: "numeric", + }, + { + locale: "en", + } + ); +}; + +export const convertDateTimeString = (dateString: string) => { + if (!dateString) { + return dateString; + } + const date = new Date(dateString); + return intlFormat( + date, + { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }, + { + locale: "en", + } + ); +}; + +export const convertTimeString = (dateString: string) => { + const date = new Date(dateString); + return intlFormat( + date, + { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }, + { + locale: "en", + } + ); +}; + +export const timeSince = (dateString: string) => { + const date = new Date(dateString); + return formatDistance(date, new Date(), { + addSuffix: true, + }); +}; diff --git a/apps/hq/src/pages/api/auth/[...nextauth].ts b/apps/hq/src/pages/api/auth/[...nextauth].ts index d6363105f4..5ecc30733c 100644 --- a/apps/hq/src/pages/api/auth/[...nextauth].ts +++ b/apps/hq/src/pages/api/auth/[...nextauth].ts @@ -126,13 +126,31 @@ export const authOptions: NextAuthOptions = { }), ], callbacks: { - async signIn({ user, account, profile, email, credentials }: any) { - console.log("user", JSON.stringify(account, null, 2)); - console.log("account", JSON.stringify(account, null, 2)); - console.log("profile", JSON.stringify(profile, null, 2)); - console.log(JSON.stringify(user)); + async jwt({ token, user, account }) { + const existingUser = await prisma.user.findFirst({ + where: { email: token.email! }, + select: { + id: true, + }, + }); - if (account.provider === "credentials") { + if (!existingUser) { + return token; + } + + return { + ...existingUser, + ...token, + }; + }, + async session({ session, token, user }) { + // @ts-ignore + session.user.id = token.id; + + return session; + }, + async signIn({ user, account }: any) { + if (account.provider === "credentials" || account.provider === "token") { if (!user.emailVerified && process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") { return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`; } @@ -206,6 +224,19 @@ export const authOptions: NextAuthOptions = { accounts: { create: [{ ...account }], }, + teams: { + create: [ + { + accepted: true, + role: "OWNER", + team: { + create: { + name: `${user.name}'s Team`, + }, + }, + }, + ], + }, }, }); diff --git a/apps/hq/src/pages/api/users/index.tsx b/apps/hq/src/pages/api/users/index.tsx index c8e543b8d6..f1b74439f2 100644 --- a/apps/hq/src/pages/api/users/index.tsx +++ b/apps/hq/src/pages/api/users/index.tsx @@ -20,6 +20,19 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) const userData = await prisma.user.create({ data: { ...user, + teams: { + create: [ + { + accepted: true, + role: "OWNER", + team: { + create: { + name: `${user.name}'s Team`, + }, + }, + }, + ], + }, }, }); if (process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") await sendVerificationEmail(userData); diff --git a/apps/hq/src/pages/api/users/me/api-keys/[apiKeyId]/index.ts b/apps/hq/src/pages/api/users/me/api-keys/[apiKeyId]/index.ts new file mode 100644 index 0000000000..d141180c86 --- /dev/null +++ b/apps/hq/src/pages/api/users/me/api-keys/[apiKeyId]/index.ts @@ -0,0 +1,51 @@ +import type { NextApiResponse, NextApiRequest } from "next"; +import { getSession } from "next-auth/react"; +import { hasOwnership } from "@/lib/apiHelper"; +import { prisma } from "@formbricks/database"; +import { unstable_getServerSession } from "next-auth"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; + +export default async function handle(req: NextApiRequest, res: NextApiResponse) { + // Check Authentication + const session = await unstable_getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + const apiKeyId = parseInt(req.query.apiKeyId.toString()); + + if (isNaN(apiKeyId)) { + return res.status(400).json({ message: "Invalid id" }); + } + + const ownership = await hasOwnership("apiKey", session, apiKeyId); + if (!ownership) { + return res.status(401).json({ message: "You are not authorized to access this apiKey" }); + } + + // GET /api/users/me/api-keys/:apiKeyId + // Get apiKey with specific id + if (req.method === "GET") { + const apiKey = await prisma.apiKey.findUnique({ + where: { + id: apiKeyId, + }, + }); + if (apiKey === null) return res.status(404).json({ error: "not found" }); + return res.json(apiKey); + } + // DELETE /api/users/me/api-keys/:apiKeyId + // Deletes an existing apiKey + // Required fields in body: - + // Optional fields in body: - + else if (req.method === "DELETE") { + const prismaRes = await prisma.apiKey.delete({ + where: { id: apiKeyId }, + }); + return res.json(prismaRes); + } + // Unknown HTTP Method + else { + throw new Error(`The HTTP ${req.method} method is not supported by this route.`); + } +} diff --git a/apps/hq/src/pages/api/users/me/api-keys/index.tsx b/apps/hq/src/pages/api/users/me/api-keys/index.tsx new file mode 100644 index 0000000000..ed3797435a --- /dev/null +++ b/apps/hq/src/pages/api/users/me/api-keys/index.tsx @@ -0,0 +1,52 @@ +import { hashApiKey } from "@/lib/apiHelper"; +import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { prisma } from "@formbricks/database"; +import { randomBytes } from "crypto"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { unstable_getServerSession } from "next-auth"; +import { getSession } from "next-auth/react"; + +export default async function handle(req: NextApiRequest, res: NextApiResponse) { + // Check Authentication + const session = await unstable_getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + // GET /api/users/[userId]/api-keys/ + // Gets all ApiKeys of a user + if (req.method === "GET") { + const session = await getSession({ req }); + const apiKeys = await prisma.apiKey.findMany({ + where: { + user: { email: session.user.email }, + }, + }); + return res.json(apiKeys); + } + // POST /api/users/[userId]/api-keys/ + // Creates a ApiKey + // Required fields in body: - + // Optional fields in body: note + else if (req.method === "POST") { + const apiKey = req.body; + + const key = randomBytes(16).toString("hex"); + + const session = await getSession({ req }); + // create form in database + const result = await prisma.apiKey.create({ + data: { + ...apiKey, + hashedKey: hashApiKey(key), + user: { connect: { email: session?.user?.email } }, + }, + }); + res.json({ ...result, apiKey: key }); + } + + // Unknown HTTP Method + else { + throw new Error(`The HTTP ${req.method} method is not supported by this route.`); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0cd17fd099..6121c895dd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,7 +5,7 @@ services: volumes: - postgres:/var/lib/postgresql/data environment: - - POSTGRES_DB=formbricks + - POSTGRES_DB=postgres - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres ports: diff --git a/docker-compose.yml b/docker-compose.yml index dc87194483..f798ea34e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: hostr: build: context: . - dockerfile: ./apps/web/Dockerfile + dockerfile: ./apps/hq/Dockerfile depends_on: - postgres ports: diff --git a/packages/database/prisma/migrations/20221115091649_init/migration.sql b/packages/database/prisma/migrations/20221115091649_init/migration.sql deleted file mode 100644 index 45e82c8cbf..0000000000 --- a/packages/database/prisma/migrations/20221115091649_init/migration.sql +++ /dev/null @@ -1,70 +0,0 @@ --- CreateEnum -CREATE TYPE "MembershipRole" AS ENUM ('MEMBER', 'ADMIN', 'OWNER'); - --- CreateEnum -CREATE TYPE "IdentityProvider" AS ENUM ('EMAIL', 'GITHUB'); - --- CreateTable -CREATE TABLE "Project" ( - "id" SERIAL NOT NULL, - "teamId" INTEGER NOT NULL, - - CONSTRAINT "Project_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Team" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - - CONSTRAINT "Team_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Membership" ( - "teamId" INTEGER NOT NULL, - "userId" INTEGER NOT NULL, - "accepted" BOOLEAN NOT NULL DEFAULT false, - "role" "MembershipRole" NOT NULL, - - CONSTRAINT "Membership_pkey" PRIMARY KEY ("userId","teamId") -); - --- CreateTable -CREATE TABLE "Account" ( - "id" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "access_token" TEXT, - "refresh_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "users" ( - "id" SERIAL NOT NULL, - "name" TEXT, - "email" TEXT NOT NULL, - "email_verified" TIMESTAMP(3), - "password" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - "identityProvider" "IdentityProvider" NOT NULL DEFAULT 'EMAIL', - "identityProviderAccountId" TEXT, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/packages/database/prisma/migrations/20221126102828_init/migration.sql b/packages/database/prisma/migrations/20221126102828_init/migration.sql new file mode 100644 index 0000000000..32ed206861 --- /dev/null +++ b/packages/database/prisma/migrations/20221126102828_init/migration.sql @@ -0,0 +1,136 @@ +-- CreateEnum +CREATE TYPE "PipelineType" AS ENUM ('WEBHOOK', 'EMAIL_NOTIFICATION'); + +-- CreateEnum +CREATE TYPE "MembershipRole" AS ENUM ('MEMBER', 'ADMIN', 'OWNER'); + +-- CreateEnum +CREATE TYPE "IdentityProvider" AS ENUM ('EMAIL', 'GITHUB'); + +-- CreateTable +CREATE TABLE "Pipeline" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + "type" "PipelineType" NOT NULL, + "formId" INTEGER NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "Pipeline_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "teamId" INTEGER NOT NULL, + "data" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Form" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "teamId" INTEGER NOT NULL, + "schema" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "Form_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Submission" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "teamId" INTEGER, + "formId" INTEGER NOT NULL, + "customerId" INTEGER NOT NULL, + "data" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Team" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Membership" ( + "teamId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "accepted" BOOLEAN NOT NULL DEFAULT false, + "role" "MembershipRole" NOT NULL, + + CONSTRAINT "Membership_pkey" PRIMARY KEY ("userId","teamId") +); + +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "label" TEXT, + "hashedKey" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "userId" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "access_token" TEXT, + "refresh_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "name" TEXT, + "email" TEXT NOT NULL, + "email_verified" TIMESTAMP(3), + "password" TEXT, + "identityProvider" "IdentityProvider" NOT NULL DEFAULT 'EMAIL', + "identityProviderAccountId" TEXT, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_id_key" ON "ApiKey"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 11aa4a954b..20137a4df8 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -12,17 +12,66 @@ generator client { previewFeatures = ["referentialIntegrity"] } -model Project { - id Int @id @default(autoincrement()) - organization Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - teamId Int +enum PipelineType { + WEBHOOK + EMAIL_NOTIFICATION +} + +model Pipeline { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + type PipelineType + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId Int + enabled Boolean @default(false) + config Json @default("{}") +} + +model Customer { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int + Submissions Submission[] + data Json @default("{}") +} + +model Form { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int + schema Json @default("{}") + submission Submission[] + Pipeline Pipeline[] +} + +model Submission { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + Team Team? @relation(fields: [teamId], references: [id]) + teamId Int? + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId Int + customer Customer @relation(fields: [customerId], references: [id]) + customerId Int + data Json @default("{}") } model Team { - id Int @id @default(autoincrement()) - name String - members Membership[] - projects Project[] + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + members Membership[] + forms Form[] + Customer Customer[] + Submission Submission[] } enum MembershipRole { @@ -42,24 +91,36 @@ model Membership { @@id([userId, teamId]) } +model ApiKey { + id Int @id @unique @default(autoincrement()) + createdAt DateTime @default(now()) + lastUsedAt DateTime? + label String? + hashedKey String @unique() + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int +} + enum IdentityProvider { EMAIL GITHUB } model Account { - id String @id @default(cuid()) - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int type String provider String providerAccountId String - access_token String? @db.Text - refresh_token String? @db.Text + access_token String? @db.Text + refresh_token String? @db.Text expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? @db.Text session_state String? @@unique([provider, providerAccountId]) @@ -67,16 +128,17 @@ model Account { model User { id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") name String? email String @unique emailVerified DateTime? @map(name: "email_verified") password String? - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") identityProvider IdentityProvider @default(EMAIL) identityProviderAccountId String? teams Membership[] accounts Account[] + ApiKey ApiKey[] @@map(name: "users") } diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx index 886f68f606..2599c00bf8 100644 --- a/packages/ui/src/Button.tsx +++ b/packages/ui/src/Button.tsx @@ -43,7 +43,7 @@ export const Button = forwardRef`, otherwise it's a `