diff --git a/apps/app/package.json b/apps/app/package.json index 16614ca233..69dff64985 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@ai-sdk/openai": "^1.1.12", + "@ai-sdk/provider": "^1.0.7", "@browserbasehq/sdk": "^2.3.0", "@bubba/notifications": "workspace:*", "@date-fns/tz": "^1.2.0", diff --git a/apps/app/src/actions/integrations/delete-integration-connection.ts b/apps/app/src/actions/integrations/delete-integration-connection.ts index e8a79344a5..2dc5bc4236 100644 --- a/apps/app/src/actions/integrations/delete-integration-connection.ts +++ b/apps/app/src/actions/integrations/delete-integration-connection.ts @@ -3,7 +3,6 @@ "use server"; import { db } from "@bubba/db"; -import { Nango } from "@nangohq/node"; import { revalidatePath } from "next/cache"; import { authActionClient } from "../safe-action"; import { deleteIntegrationConnectionSchema } from "../schema"; @@ -21,10 +20,6 @@ export const deleteIntegrationConnectionAction = authActionClient const { integrationId } = parsedInput; const { user } = ctx; - const nango = new Nango({ - secretKey: process.env.NANGO_SECRET_KEY as string, - }); - const integration = await db.organizationIntegrations.findUnique({ where: { name: integrationId.toLowerCase(), diff --git a/apps/app/src/actions/integrations/retrieve-integration-session-token.ts b/apps/app/src/actions/integrations/retrieve-integration-session-token.ts index 930379c8bd..f8311bb78c 100644 --- a/apps/app/src/actions/integrations/retrieve-integration-session-token.ts +++ b/apps/app/src/actions/integrations/retrieve-integration-session-token.ts @@ -2,7 +2,6 @@ "use server"; -import { Nango } from "@nangohq/node"; import { authActionClient } from "../safe-action"; import { createIntegrationSchema } from "../schema"; @@ -19,24 +18,8 @@ export const retrieveIntegrationSessionTokenAction = authActionClient const { integrationId } = parsedInput; const { user } = ctx; - const nango = new Nango({ - secretKey: process.env.NANGO_SECRET_KEY as string, - }); - - const response = await nango.createConnectSession({ - end_user: { - id: user.id, - email: user.email || undefined, - display_name: user.name || undefined, - }, - organization: { - id: user.organizationId, - }, - allowed_integrations: [integrationId], - }); - return { success: true, - sessionToken: response.data.token, + sessionToken: "123", }; }); diff --git a/apps/app/src/components/integrations/integrations-card.tsx b/apps/app/src/components/integrations/integrations-card.tsx index 5753b895c7..cb1ac26f2a 100644 --- a/apps/app/src/components/integrations/integrations-card.tsx +++ b/apps/app/src/components/integrations/integrations-card.tsx @@ -10,7 +10,6 @@ import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; import { ScrollArea } from "@bubba/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader } from "@bubba/ui/sheet"; -import Nango from "@nangohq/frontend"; import { useAction } from "next-safe-action/hooks"; import { useRouter } from "next/navigation"; import { parseAsBoolean, parseAsString, useQueryStates } from "nuqs"; @@ -46,11 +45,6 @@ export function IntegrationsCard({ category: string; installedSettings: Record; }) { - const nango = new Nango({ - width: 400, - height: 600, - }); - const router = useRouter(); const [params, setParams] = useQueryStates({ @@ -83,21 +77,6 @@ export function IntegrationsCard({ const res = await retrieveIntegrationSessionToken.executeAsync({ integrationId: id, }); - - const connect = nango.openConnectUI({ - onEvent: async (event) => { - if (event.type === "close") { - setLoading(false); - } else if (event.type === "connect") { - toast.success("Integration connected successfully"); - router.replace("/integrations"); - } - }, - }); - - if (res?.data?.sessionToken) { - connect.setSessionToken(res.data.sessionToken); - } } catch (error) { console.error("Connection error:", error); toast.error("Failed to connect integration"); diff --git a/apps/portal/.gitignore b/apps/portal/.gitignore new file mode 100644 index 0000000000..5ef6a52078 --- /dev/null +++ b/apps/portal/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/portal/README.md b/apps/portal/README.md new file mode 100644 index 0000000000..e215bc4ccf --- /dev/null +++ b/apps/portal/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/apps/portal/next.config.ts b/apps/portal/next.config.ts new file mode 100644 index 0000000000..93c632a021 --- /dev/null +++ b/apps/portal/next.config.ts @@ -0,0 +1,31 @@ +import "./src/env.mjs"; + +const config = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + }, + async rewrites() { + return [ + { + source: "/ingest/static/:path*", + destination: "https://us-assets.i.posthog.com/static/:path*", + }, + { + source: "/ingest/:path*", + destination: "https://us.i.posthog.com/:path*", + }, + { + source: "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/ingest/decide", + destination: "https://us.i.posthog.com/decide", + }, + ]; + }, + skipTrailingSlashRedirect: true, +}; + +export default config; diff --git a/apps/portal/package.json b/apps/portal/package.json new file mode 100644 index 0000000000..f6fb5f5db6 --- /dev/null +++ b/apps/portal/package.json @@ -0,0 +1,27 @@ +{ + "name": "portal", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack -p 3002", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "better-auth": "^1.1.18", + "next": "15.1.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-otp-input": "^3.1.1" + }, + "devDependencies": { + "@bubba/ui": "workspace:*", + "@types/node": "^22.13.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "postcss": "^8.5.2", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} diff --git a/apps/portal/postcss.config.mjs b/apps/portal/postcss.config.mjs new file mode 100644 index 0000000000..1a69fd2a45 --- /dev/null +++ b/apps/portal/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/portal/public/apple-touch-icon.png b/apps/portal/public/apple-touch-icon.png new file mode 100644 index 0000000000..145bc4070d Binary files /dev/null and b/apps/portal/public/apple-touch-icon.png differ diff --git a/apps/portal/public/favicon-96x96.png b/apps/portal/public/favicon-96x96.png new file mode 100644 index 0000000000..20a5bd2d6c Binary files /dev/null and b/apps/portal/public/favicon-96x96.png differ diff --git a/apps/portal/public/favicon.ico b/apps/portal/public/favicon.ico new file mode 100644 index 0000000000..712b9754e8 Binary files /dev/null and b/apps/portal/public/favicon.ico differ diff --git a/apps/portal/public/favicon.svg b/apps/portal/public/favicon.svg new file mode 100644 index 0000000000..4b7f78ce0f --- /dev/null +++ b/apps/portal/public/favicon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/portal/public/fonts/GeneralSans-Variable.ttf b/apps/portal/public/fonts/GeneralSans-Variable.ttf new file mode 100644 index 0000000000..65b0dabc1b Binary files /dev/null and b/apps/portal/public/fonts/GeneralSans-Variable.ttf differ diff --git a/apps/portal/public/fonts/GeneralSans-VariableItalic.ttf b/apps/portal/public/fonts/GeneralSans-VariableItalic.ttf new file mode 100644 index 0000000000..e9450be74c Binary files /dev/null and b/apps/portal/public/fonts/GeneralSans-VariableItalic.ttf differ diff --git a/apps/portal/public/site.webmanifest b/apps/portal/public/site.webmanifest new file mode 100644 index 0000000000..351bbacff9 --- /dev/null +++ b/apps/portal/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Comp AI", + "short_name": "Comp AI", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/apps/portal/public/web-app-manifest-192x192.png b/apps/portal/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000..1cc01ee0db Binary files /dev/null and b/apps/portal/public/web-app-manifest-192x192.png differ diff --git a/apps/portal/public/web-app-manifest-512x512.png b/apps/portal/public/web-app-manifest-512x512.png new file mode 100644 index 0000000000..caae06827b Binary files /dev/null and b/apps/portal/public/web-app-manifest-512x512.png differ diff --git a/apps/portal/src/app/[locale]/(app)/page.tsx b/apps/portal/src/app/[locale]/(app)/page.tsx new file mode 100644 index 0000000000..cc4353d1e8 --- /dev/null +++ b/apps/portal/src/app/[locale]/(app)/page.tsx @@ -0,0 +1,55 @@ +import { auth } from "@/app/lib/auth"; +import { db } from "@bubba/db"; +import { unstable_cache } from "next/cache"; +import { headers } from "next/headers"; + +export default async function Home() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return null; + } + + const user = await getUser(session.user.email); + + return ( +
+
+ {user?.id && user?.organization?.id && ( +
+

Welcome to the portal

+ +

You are logged in as {user.email}

+ +

+ You work for {user.organization.name} and your employee ID is{" "} + {user.id} +

+
+ )} +
+
+ ); +} + +export const getUser = unstable_cache(async (email: string) => { + "use server"; + + const employee_data = await db.employee.findFirst({ + where: { + email, + }, + include: { + organization: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + return employee_data; +}); diff --git a/apps/portal/src/app/[locale]/(public)/auth/page.tsx b/apps/portal/src/app/[locale]/(public)/auth/page.tsx new file mode 100644 index 0000000000..f6f2c7f111 --- /dev/null +++ b/apps/portal/src/app/[locale]/(public)/auth/page.tsx @@ -0,0 +1,46 @@ +import { OtpSignIn } from "@/app/components/otp"; +import { getI18n } from "@/app/locales/server"; +import type { Metadata } from "next"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Login | Comp AI", +}; + +export default async function Page() { + const t = await getI18n(); + + const defaultSignInOptions = ( +
+ +
+ ); + + return ( + <> +
+
+
+
+
+ +

Comp AI

+ +
+

{t("auth.title")}

+
+ + {t("auth.description")} + +
+
+ +
+ {defaultSignInOptions} +
+
+
+
+ + ); +} diff --git a/apps/portal/src/app/[locale]/(public)/layout.tsx b/apps/portal/src/app/[locale]/(public)/layout.tsx new file mode 100644 index 0000000000..79e340bc8f --- /dev/null +++ b/apps/portal/src/app/[locale]/(public)/layout.tsx @@ -0,0 +1,7 @@ +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/apps/portal/src/app/[locale]/layout.tsx b/apps/portal/src/app/[locale]/layout.tsx new file mode 100644 index 0000000000..8dc2ef278e --- /dev/null +++ b/apps/portal/src/app/[locale]/layout.tsx @@ -0,0 +1,108 @@ +import { cn } from "@bubba/ui/cn"; +import "@bubba/ui/globals.css"; +import { env } from "@/env.mjs"; +import { initializeServer } from "@bubba/analytics/src/server"; +import { GeistMono } from "geist/font/mono"; +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Toaster } from "sonner"; +import { Providers } from "./providers"; + +export const metadata: Metadata = { + metadataBase: new URL("https://portal.trycomp.ai"), + title: "Comp AI | Employee Portal", + description: "Enter your email and one time password to continue", + twitter: { + title: "Comp AI | Employee Portal", + description: "Enter your email and one time password to continue", + images: [ + { + url: "https://cdn.trycomp.ai/opengraph-image.jpg", + width: 800, + height: 600, + }, + { + url: "https://cdn.trycomp.ai/opengraph-image.jpg", + width: 1800, + height: 1600, + }, + ], + }, + openGraph: { + title: "Comp AI | Employee Portal", + description: "Enter your email and one time password to continue", + url: "https://portal.trycomp.ai", + siteName: "Comp AI", + images: [ + { + url: "https://cdn.trycomp.ai/opengraph-image.jpg", + width: 800, + height: 600, + }, + { + url: "https://cdn.trycomp.ai/opengraph-image.jpg", + width: 1800, + height: 1600, + }, + ], + locale: "en_US", + type: "website", + }, +}; + +export const viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: [ + { media: "(prefers-color-scheme: light)" }, + { media: "(prefers-color-scheme: dark)" }, + ], +}; + +const font = localFont({ + src: "/../../../public/fonts/GeneralSans-Variable.ttf", + display: "swap", + variable: "--font-general-sans", +}); + +export const preferredRegion = ["auto"]; +export const maxDuration = 5; + +if (env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST) { + initializeServer({ + apiKey: env.NEXT_PUBLIC_POSTHOG_KEY, + apiHost: env.NEXT_PUBLIC_POSTHOG_HOST, + }); +} + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const params = await props.params; + + const { locale } = params; + + const { children } = props; + + return ( + + + + +
{children}
+
+
+ + + + ); +} diff --git a/apps/portal/src/app/[locale]/not-found.tsx b/apps/portal/src/app/[locale]/not-found.tsx new file mode 100644 index 0000000000..397616538d --- /dev/null +++ b/apps/portal/src/app/[locale]/not-found.tsx @@ -0,0 +1,16 @@ +import { getI18n } from "@/app/locales/server"; +import Link from "next/link"; + +export default async function NotFound() { + const t = await getI18n(); + + return ( +
+

{t("not_found.title")}

+

{t("not_found.description")}

+ + {t("not_found.return")} + +
+ ); +} diff --git a/apps/portal/src/app/[locale]/providers.tsx b/apps/portal/src/app/[locale]/providers.tsx new file mode 100644 index 0000000000..ffa318ae8e --- /dev/null +++ b/apps/portal/src/app/[locale]/providers.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { I18nProviderClient } from "@/app/locales/client"; +import { env } from "@/env.mjs"; +import { Analytics, AnalyticsProvider } from "@bubba/analytics"; +import { ThemeProvider } from "next-themes"; +import { useEffect } from "react"; +import type { ReactNode } from "react"; + +type ProviderProps = { + children: ReactNode; + locale: string; +}; + +export function Providers({ children, locale }: ProviderProps) { + const hasAnalyticsKeys = + env.NEXT_PUBLIC_POSTHOG_KEY && env.NEXT_PUBLIC_POSTHOG_HOST; + + useEffect(() => { + if (hasAnalyticsKeys) { + Analytics.init({ + apiKey: env.NEXT_PUBLIC_POSTHOG_KEY!, + apiHost: env.NEXT_PUBLIC_POSTHOG_HOST!, + }); + } + }, [hasAnalyticsKeys]); + + return ( + + + {hasAnalyticsKeys ? ( + + {children} + + ) : ( + children + )} + + + ); +} diff --git a/apps/portal/src/app/actions/login.ts b/apps/portal/src/app/actions/login.ts new file mode 100644 index 0000000000..c09a18cea3 --- /dev/null +++ b/apps/portal/src/app/actions/login.ts @@ -0,0 +1,23 @@ +"use server"; + +import { createSafeActionClient } from "next-safe-action"; +import { z } from "zod"; +import { auth } from "../lib/auth"; + +export const login = createSafeActionClient() + .schema(z.object({ + otp: z.string(), + email: z.string().email(), + })) + .action(async ({ parsedInput }) => { + await auth.api.signInEmailOTP({ + body: { + email: parsedInput.email, + otp: parsedInput.otp, + }, + }); + + return { + success: true, + }; + }); diff --git a/apps/portal/src/app/api/auth/[...all]/route.ts b/apps/portal/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000000..6322ff3e80 --- /dev/null +++ b/apps/portal/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/app/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); \ No newline at end of file diff --git a/apps/portal/src/app/components/otp-form.tsx b/apps/portal/src/app/components/otp-form.tsx new file mode 100644 index 0000000000..098d3b687f --- /dev/null +++ b/apps/portal/src/app/components/otp-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@bubba/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { login } from "../actions/login"; +import { OtpStyledInput } from "./otp-input"; + +const INPUT_LENGTH = 6; + +const otpFormSchema = z.object({ + email: z.string().email(), + otp: z.string().min(INPUT_LENGTH, "OTP is required"), +}); + +type OtpFormValues = z.infer; + +interface OtpFormProps { + email: string; +} + +export function OtpForm({ email }: OtpFormProps) { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(otpFormSchema), + defaultValues: { + email, + otp: "", + }, + }); + + const { execute, isExecuting } = useAction(login, { + onSuccess: () => { + toast.success("OTP verified"); + router.push("/"); + }, + onError: (error) => { + toast.error(error.error.serverError as string); + }, + }); + + const onSubmit = async (formData: OtpFormValues) => { + try { + setIsLoading(true); + + await execute({ + otp: formData.otp, + email: formData.email, + }); + } catch (error) { + toast.error("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + + + + + + )} + /> + + + + ); +} diff --git a/apps/portal/src/app/components/otp-input.tsx b/apps/portal/src/app/components/otp-input.tsx new file mode 100644 index 0000000000..3926e03140 --- /dev/null +++ b/apps/portal/src/app/components/otp-input.tsx @@ -0,0 +1,34 @@ +import { cn } from "@bubba/ui/cn"; +import { Input } from "@bubba/ui/input"; +import React from "react"; +import OtpInput, { type OTPInputProps } from "react-otp-input"; + +type OtpOptions = Omit; + +type OtpStyledInputProps = { + className?: string; +} & OtpOptions; + +/** + * Otp input Docs: {@link: https://shadcn-extension.vercel.app/docs/otp-input} + */ + +export const OtpStyledInput = ({ + className, + ...props +}: OtpStyledInputProps) => { + return ( + ( + + )} + containerStyle={`flex justify-center items-center flex-wrap text-2xl font-bold ${ + props.renderSeparator ? "gap-1" : "gap-x-3 gap-y-2" + }`} + /> + ); +}; diff --git a/apps/portal/src/app/components/otp.tsx b/apps/portal/src/app/components/otp.tsx new file mode 100644 index 0000000000..aa9014fc76 --- /dev/null +++ b/apps/portal/src/app/components/otp.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useI18n } from "@/app/locales/client"; +import { Button } from "@bubba/ui/button"; +import { cn } from "@bubba/ui/cn"; +import { Form, FormControl, FormField, FormItem } from "@bubba/ui/form"; +import { Input } from "@bubba/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Mail } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { authClient } from "../lib/auth-client"; +import { OtpForm } from "./otp-form"; + +const formSchema = z.object({ + email: z.string().email(), +}); + +type Props = { + className?: string; +}; + +export function OtpSignIn({ className }: Props) { + const t = useI18n(); + const [isLoading, setLoading] = useState(false); + const [isSent, setSent] = useState(false); + const [_email, setEmail] = useState(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit({ email }: z.infer) { + setLoading(true); + setEmail(email); + + const { data, error } = await authClient.emailOtp.sendVerificationOtp({ + email: email, + type: "sign-in", + }); + + if (error) { + setLoading(false); + toast.error(error.message); + } + + setSent(true); + setLoading(false); + } + + if (isSent) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ( + + + + + + )} + /> + + +
+
+ + ); +} diff --git a/apps/portal/src/app/global-error.tsx b/apps/portal/src/app/global-error.tsx new file mode 100644 index 0000000000..030d39083f --- /dev/null +++ b/apps/portal/src/app/global-error.tsx @@ -0,0 +1,13 @@ +"use client"; + +import NextError from "next/error"; + +export default function GlobalError() { + return ( + + + + + + ); +} diff --git a/apps/portal/src/app/lib/auth-client.ts b/apps/portal/src/app/lib/auth-client.ts new file mode 100644 index 0000000000..07b2fdc03f --- /dev/null +++ b/apps/portal/src/app/lib/auth-client.ts @@ -0,0 +1,10 @@ +import { env } from "@/env.mjs" +import { createAuthClient } from "better-auth/client" +import { emailOTPClient } from "better-auth/client/plugins" + +export const authClient = createAuthClient({ + baseUrl: env.NEXT_PUBLIC_BETTER_AUTH_URL, + plugins: [ + emailOTPClient() + ] +}) \ No newline at end of file diff --git a/apps/portal/src/app/lib/auth.ts b/apps/portal/src/app/lib/auth.ts new file mode 100644 index 0000000000..ecd3ee9bce --- /dev/null +++ b/apps/portal/src/app/lib/auth.ts @@ -0,0 +1,40 @@ +import { db } from "@bubba/db"; +import { OTPVerificationEmail } from "@bubba/email/emails/otp" +import { sendEmail } from "@bubba/email/lib/resend" +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { emailOTPClient } from "better-auth/client/plugins" +import { nextCookies } from "better-auth/next-js"; +import { emailOTP } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: prismaAdapter(db, { + provider: "postgresql", + }), + user: { + modelName: "PortalUser", + }, + session: { + modelName: "PortalSession", + }, + account: { + modelName: "PortalAccount", + }, + verification: { + modelName: "PortalVerification", + }, + plugins: [ + nextCookies(), + emailOTP({ + otpLength: 6, + expiresIn: 10 * 60, + async sendVerificationOTP({ email, otp }) { + await sendEmail({ + to: email, + subject: "One-Time Password for Comp AI", + react: OTPVerificationEmail({ email, otp }), + }); + } + }) + ] +}); diff --git a/apps/portal/src/app/lib/resend.ts b/apps/portal/src/app/lib/resend.ts new file mode 100644 index 0000000000..4538716f22 --- /dev/null +++ b/apps/portal/src/app/lib/resend.ts @@ -0,0 +1,4 @@ +import { env } from "@/env.mjs"; +import { Resend } from "resend"; + +export const resend = new Resend(env.RESEND_API_KEY); diff --git a/apps/portal/src/app/locales/client.ts b/apps/portal/src/app/locales/client.ts new file mode 100644 index 0000000000..3bc4a7b8c5 --- /dev/null +++ b/apps/portal/src/app/locales/client.ts @@ -0,0 +1,25 @@ +"use client"; + +import { createI18nClient } from "next-international/client"; + +export const { + useI18n, + useScopedI18n, + I18nProviderClient, + useCurrentLocale, + useChangeLocale, +} = createI18nClient({ + en: () => import("./en"), + es: () => import("./es"), + fr: () => import("./fr"), + no: () => import("./no"), + pt: () => import("./pt"), +}); + +export const languages = { + en: "English", + es: "Español", + fr: "Français", + no: "Norsk", + pt: "Português", +} as const; diff --git a/apps/portal/src/app/locales/en.ts b/apps/portal/src/app/locales/en.ts new file mode 100644 index 0000000000..6f531020b8 --- /dev/null +++ b/apps/portal/src/app/locales/en.ts @@ -0,0 +1,19 @@ +export default { + auth: { + title: "Employee Portal", + description: "Enter your email and one time password to continue", + options: "More options", + email: { + otp_sent: "One time password sent", + otp_description: "Check your email for the one time password", + otp_try_again: "Try again", + placeholder: "Your work email", + button: "Get one time password", + }, + }, + not_found: { + title: "Not Found", + description: "The page you are looking for does not exist.", + return: "Return to the home page", + }, +} as const; diff --git a/apps/portal/src/app/locales/es.ts b/apps/portal/src/app/locales/es.ts new file mode 100644 index 0000000000..dcce0c7bfb --- /dev/null +++ b/apps/portal/src/app/locales/es.ts @@ -0,0 +1,3 @@ +export default { + +} as const; diff --git a/apps/portal/src/app/locales/fr.ts b/apps/portal/src/app/locales/fr.ts new file mode 100644 index 0000000000..dcce0c7bfb --- /dev/null +++ b/apps/portal/src/app/locales/fr.ts @@ -0,0 +1,3 @@ +export default { + +} as const; diff --git a/apps/portal/src/app/locales/no.ts b/apps/portal/src/app/locales/no.ts new file mode 100644 index 0000000000..dcce0c7bfb --- /dev/null +++ b/apps/portal/src/app/locales/no.ts @@ -0,0 +1,3 @@ +export default { + +} as const; diff --git a/apps/portal/src/app/locales/pt.ts b/apps/portal/src/app/locales/pt.ts new file mode 100644 index 0000000000..dcce0c7bfb --- /dev/null +++ b/apps/portal/src/app/locales/pt.ts @@ -0,0 +1,3 @@ +export default { + +} as const; diff --git a/apps/portal/src/app/locales/server.ts b/apps/portal/src/app/locales/server.ts new file mode 100644 index 0000000000..d4352a17bd --- /dev/null +++ b/apps/portal/src/app/locales/server.ts @@ -0,0 +1,9 @@ +import { createI18nServer } from "next-international/server"; + +export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer({ + en: () => import("./en"), + es: () => import("./es"), + fr: () => import("./fr"), + no: () => import("./no"), + pt: () => import("./pt"), +}); diff --git a/apps/portal/src/env.mjs b/apps/portal/src/env.mjs new file mode 100644 index 0000000000..d4a7eeaad8 --- /dev/null +++ b/apps/portal/src/env.mjs @@ -0,0 +1,27 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + BETTER_AUTH_SECRET: z.string(), + BETTER_AUTH_URL: z.string(), + RESEND_API_KEY: z.string(), + }, + + client: { + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), + NEXT_PUBLIC_BETTER_AUTH_URL: z.string(), + }, + + runtimeEnv: { + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_BETTER_AUTH_URL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + RESEND_API_KEY: process.env.RESEND_API_KEY, + }, + + skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION, +}); diff --git a/apps/portal/src/middleware.ts b/apps/portal/src/middleware.ts new file mode 100644 index 0000000000..947340bf96 --- /dev/null +++ b/apps/portal/src/middleware.ts @@ -0,0 +1,47 @@ +import type { auth } from "@/app/lib/auth"; +import { betterFetch } from "@better-fetch/fetch"; +import { createI18nMiddleware } from "next-international/middleware"; +import { type NextRequest, NextResponse } from "next/server"; + +const I18nMiddleware = createI18nMiddleware({ + locales: ["en", "es", "fr", "no", "pt"], + defaultLocale: "en", + urlMappingStrategy: "rewrite", +}); + +type Session = typeof auth.$Infer.Session; + +export async function middleware(request: NextRequest) { + const response = I18nMiddleware(request); + const nextUrl = request.nextUrl; + + const { data: session } = await betterFetch("/api/auth/get-session", { + baseURL: request.nextUrl.origin, + headers: { + cookie: request.headers.get("cookie") || "", + }, + }); + + const pathnameLocale = nextUrl.pathname.split("/", 2)?.[1]; + + const pathnameWithoutLocale = pathnameLocale + ? nextUrl.pathname.slice(pathnameLocale.length + 1) + : nextUrl.pathname; + + const newUrl = new URL(pathnameWithoutLocale || "/", request.url); + + if (!session && newUrl.pathname !== "/auth") { + const url = new URL("/auth", request.url); + + return NextResponse.redirect(url); + } + + + return response; +} + +export const config = { + matcher: [ + "/((?!api|_next/static|_next/image|favicon.ico|monitoring|ingest).*)", + ], +}; diff --git a/apps/portal/tailwind.config.ts b/apps/portal/tailwind.config.ts new file mode 100644 index 0000000000..89b98bad8b --- /dev/null +++ b/apps/portal/tailwind.config.ts @@ -0,0 +1,11 @@ +import baseConfig from "@bubba/ui/tailwind.config"; +import type { Config } from "tailwindcss"; + +export default { + content: [ + "./src/**/*.{ts,tsx}", + "../../packages/ui/src/**/*.{ts,tsx}", + "../../packages/invoice/src/**/*.{ts,tsx}", + ], + presets: [baseConfig], +} satisfies Config; diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json new file mode 100644 index 0000000000..c1334095f8 --- /dev/null +++ b/apps/portal/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/bun.lock b/bun.lock index 2a4a7b8096..6cb29c61f5 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "version": "0.1.0", "dependencies": { "@ai-sdk/openai": "^1.1.12", + "@ai-sdk/provider": "^1.0.7", "@browserbasehq/sdk": "^2.3.0", "@bubba/notifications": "workspace:*", "@date-fns/tz": "^1.2.0", @@ -92,6 +93,26 @@ "typescript": "^5.7.3", }, }, + "apps/portal": { + "name": "portal", + "version": "0.1.0", + "dependencies": { + "better-auth": "^1.1.18", + "next": "15.1.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-otp-input": "^3.1.1", + }, + "devDependencies": { + "@bubba/ui": "workspace:*", + "@types/node": "^22.13.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "postcss": "^8.5.2", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + }, + }, "apps/web": { "name": "web", "version": "0.1.0", @@ -288,7 +309,7 @@ "packages": { "@ai-sdk/openai": ["@ai-sdk/openai@0.0.66", "", { "dependencies": { "@ai-sdk/provider": "0.0.24", "@ai-sdk/provider-utils": "1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-V4XeDnlNl5/AY3GB3ozJUjqnBLU5pK3DacKTbCNH3zH8/MggJoH6B8wRGdLUPVFMcsMz60mtvh4DC9JsIVFrKw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@0.0.24", "", { "dependencies": { "json-schema": "0.4.0" } }, "sha512-XMsNGJdGO+L0cxhhegtqZ8+T6nn4EoShS819OvCgI2kLbYTIvk0GWFGD0AXJmxkxs3DrpsJxKAFukFR7bvTkgQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@1.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@1.0.20", "", { "dependencies": { "@ai-sdk/provider": "0.0.24", "eventsource-parser": "1.1.2", "nanoid": "3.3.6", "secure-json-parse": "2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-ngg/RGpnA00eNOWEtXHenpX1MsM2QshQh4QJFjUfwcqHpM5kTfG7je7Rc3HcEDP+OkRVv2GF+X4fC1Vfcnl8Ow=="], @@ -344,6 +365,10 @@ "@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], + "@better-auth/utils": ["@better-auth/utils@0.2.3", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-Ap1GaSmo6JYhJhxJOpUB0HobkKPTNzfta+bLV89HfpyCAHN7p8ntCrmNFHNAVD0F6v0mywFVEUg1FUhNCc81Rw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.12", "", {}, "sha512-B3bfloI/2UBQWIATRN6qmlORrvx3Mp0kkNjmXLv0b+DtbtR+pP4/I5kQA/rDUv+OReLywCCldf6co4LdDmh8JA=="], + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], @@ -490,6 +515,8 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.13", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], @@ -550,6 +577,8 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], @@ -612,6 +641,10 @@ "@next/third-parties": ["@next/third-parties@15.1.7", "", { "dependencies": { "third-party-capital": "1.0.20" }, "peerDependencies": { "next": "^13.0.0 || ^14.0.0 || ^15.0.0", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" } }, "sha512-wDAh/gW+WFT7jPYmGEGtNeLC74D8+hu3S6YJOpcfC/6uBMSC7yjh0QW1/RPYKziVnjqe6O4jR0AhbN0uuS8CZw=="], + "@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="], + + "@noble/hashes": ["@noble/hashes@1.7.1", "", {}, "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -702,6 +735,16 @@ "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-8U2TIj59cRlSXTX2d0mzUKP7whfWGFMzTeC3qPgAbccXFrPNZLaDhpNEdG5U2QZ/tBv/IHlCJ8s+KYXpJeop6w=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.3.15", "", { "dependencies": { "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "asn1js": "^3.0.5", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg=="], + "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -910,6 +953,10 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.1.0", "", {}, "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.1.1", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8" } }, "sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA=="], + "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], "@slack/logger": ["@slack/logger@4.0.0", "", { "dependencies": { "@types/node": ">=18.0.0" } }, "sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA=="], @@ -1216,6 +1263,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.5", "", { "dependencies": { "pvtsutils": "^1.3.2", "pvutils": "^1.1.3", "tslib": "^2.4.0" } }, "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], @@ -1266,6 +1315,10 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "better-auth": ["better-auth@1.1.18", "", { "dependencies": { "@better-auth/utils": "0.2.3", "@better-fetch/fetch": "1.1.12", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "0.3.3", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.27.4", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-cU/u7/qlSiR/OO1O53gh/bHvxyB53DE+5ZOw9DtpDI8IyIQUBqDnQ06MOwNtj8JwjXHcMFNSNd5u/W8DED0bJw=="], + + "better-call": ["better-call@0.3.3", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "uncrypto": "^0.1.3", "zod": "^3.24.1" } }, "sha512-N4lDVm0NGmFfDJ0XMQ4O83Zm/3dPlvIQdxvwvgSLSkjFX5PM4GUYSVAuxNzXN27QZMHDkrJTWUqxBrm4tPC3eA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -1480,6 +1533,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -2006,6 +2061,8 @@ "ky": ["ky@1.7.5", "", {}, "sha512-HzhziW6sc5m0pwi5M196+7cEBtbt0lCYi67wNsiwMUmz833wloE0gbzJPWKs1gliFKQb34huItDQX97LyOdPdA=="], + "kysely": ["kysely@0.27.5", "", {}, "sha512-s7hZHcQeSNKpzCkHRm8yA+0JPLjncSWnjb+2TIElwS2JAqYr+Kv3Ess+9KFfJS0C1xcQ1i9NkNHpWwCYpHMWsA=="], + "languine": ["languine@3.0.6", "", { "dependencies": { "@clack/prompts": "^0.10.0", "@trigger.dev/sdk": "3.3.13", "@trpc/client": "11.0.0-rc.700", "@trpc/server": "11.0.0-rc.700", "@types/xml2js": "^0.4.14", "chalk": "^5.4.1", "csv-parse": "^5.6.0", "csv-stringify": "^6.5.2", "dedent": "^1.5.3", "dotenv": "^16.4.7", "fast-glob": "^3.3.3", "fast-xml-parser": "^4.5.1", "gettext-parser": "^8.0.0", "jsdom": "^26.0.0", "json2php": "^0.0.12", "json5": "^2.2.3", "jsonrepair": "^3.11.2", "open": "^10.1.0", "php-parser": "^3.1.5", "plist": "^3.1.0", "preferred-pm": "^4.1.1", "properties": "^1.2.1", "rambda": "^9.4.2", "simple-git": "^3.27.0", "superjson": "^2.2.2", "table-layout": "^4.1.1", "xliff": "^6.2.1", "xml2js": "^0.6.2", "yaml": "^2.7.0", "zod": "^3.24.1" }, "bin": { "languine": "dist/index.js" } }, "sha512-iqc1Nnw0SqzLfWkgMnBApR4EwRoJu7zJTuuNp7owV/6duq3dxNao6LT3QDQXs1qoQBuZg5tTHoN9gUd6Efw6PA=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -2214,6 +2271,8 @@ "nanoid": ["nanoid@5.1.0", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-zDAl/llz8Ue/EblwSYwdxGBYfj46IM1dhjVi8dyp9LQffoIGxJEAHj2oeZ4uNcgycSRcQ83CnfcZqEJzVDLcDw=="], + "nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -2380,6 +2439,8 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], + "portal": ["portal@workspace:apps/portal"], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="], @@ -2500,6 +2561,10 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.3", "", {}, "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -2536,6 +2601,8 @@ "react-number-format": ["react-number-format@5.4.3", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VCY5hFg/soBighAoGcdE+GagkJq0230qN6jcS5sp8wQX1qy1fYN/RX7/BXkrs0oyzzwqR8/+eSUrqXbGeywdUQ=="], + "react-otp-input": ["react-otp-input@3.1.1", "", { "peerDependencies": { "react": ">=16.8.6 || ^17.0.0 || ^18.0.0", "react-dom": ">=16.8.6 || ^17.0.0 || ^18.0.0" } }, "sha512-bjPavgJ0/Zmf/AYi4onj8FbH93IjeD+e8pWwxIJreDEWsU1ILR5fs8jEJmMGWSBe/yyvPP6X/W6Mk9UkOCkTPw=="], + "react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="], "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], @@ -2606,6 +2673,8 @@ "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], "run-applescript": ["run-applescript@7.0.0", "", {}, "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A=="], @@ -3068,6 +3137,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@0.0.24", "", { "dependencies": { "json-schema": "0.4.0" } }, "sha512-XMsNGJdGO+L0cxhhegtqZ8+T6nn4EoShS819OvCgI2kLbYTIvk0GWFGD0AXJmxkxs3DrpsJxKAFukFR7bvTkgQ=="], + + "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@0.0.24", "", { "dependencies": { "json-schema": "0.4.0" } }, "sha512-XMsNGJdGO+L0cxhhegtqZ8+T6nn4EoShS819OvCgI2kLbYTIvk0GWFGD0AXJmxkxs3DrpsJxKAFukFR7bvTkgQ=="], + "@ai-sdk/provider-utils/nanoid": ["nanoid@3.3.6", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="], "@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "0.0.26", "eventsource-parser": "^1.1.2", "nanoid": "^3.3.7", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ=="], @@ -3462,12 +3535,8 @@ "cmdk/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], - "comp.ai/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@1.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g=="], - "comp.ai/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.1.8", "", { "dependencies": { "@ai-sdk/provider": "1.0.7", "eventsource-parser": "^3.0.0", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-1j9niMUAFlCBdYRYJr1yoB5kwZcRFBVuBiL1hhrf0ONFNrDiJYA6F+gROOuP16NHhezMfTo60+GeeV1xprHFjg=="], - "comp.ai/ai/@ai-sdk/provider": ["@ai-sdk/provider@1.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-q1PJEZ0qD9rVR+8JFEd01/QM++csMT5UVwYXSN2u54BrVw/D8TZLTeg2FEfKK00DgAx0UtWd8XOhhwITP9BT5g=="], - "comp.ai/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.1.8", "", { "dependencies": { "@ai-sdk/provider": "1.0.7", "eventsource-parser": "^3.0.0", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.0.0" }, "optionalPeers": ["zod"] }, "sha512-1j9niMUAFlCBdYRYJr1yoB5kwZcRFBVuBiL1hhrf0ONFNrDiJYA6F+gROOuP16NHhezMfTo60+GeeV1xprHFjg=="], "comp.ai/ai/@ai-sdk/react": ["@ai-sdk/react@1.1.16", "", { "dependencies": { "@ai-sdk/provider-utils": "2.1.8", "@ai-sdk/ui-utils": "1.1.14", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.0.0" }, "optionalPeers": ["react", "zod"] }, "sha512-4Jx1piCte2+YoDd6ZdwM0Mw29046edw7MMNICImCPv2s7sfwFwe4c1t8waA4PYRefuETmzheqjh80kafQYJf8g=="], diff --git a/packages/db/prisma/migrations/20250218153849_employee_portal/migration.sql b/packages/db/prisma/migrations/20250218153849_employee_portal/migration.sql new file mode 100644 index 0000000000..a0547bd1fa --- /dev/null +++ b/packages/db/prisma/migrations/20250218153849_employee_portal/migration.sql @@ -0,0 +1,75 @@ +-- AlterTable +ALTER TABLE "Employee" ADD COLUMN "linkId" TEXT; + +-- CreateTable +CREATE TABLE "portal_user" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "portal_user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "portal_session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "portal_session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "portal_account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "portal_account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "portal_verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "portal_verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "portal_user_email_key" ON "portal_user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "portal_session_token_key" ON "portal_session"("token"); + +-- AddForeignKey +ALTER TABLE "Employee" ADD CONSTRAINT "Employee_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "portal_user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "portal_session" ADD CONSTRAINT "portal_session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "portal_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "portal_account" ADD CONSTRAINT "portal_account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "portal_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index bb10b6a561..4998843c0a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -38,8 +38,6 @@ enum RequirementType { training } -// Authentication and Organization - model Account { id String @id @default(cuid()) userId String @@ -829,6 +827,9 @@ model Employee { userId String? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + linkId String? + link PortalUser? @relation(fields: [linkId], references: [id], onDelete: SetNull) + employeeTasks EmployeeTask[] RiskTaskAssignment RiskTaskAssignment[] VendorTaskAssignment VendorTaskAssignment[] @@ -973,3 +974,66 @@ model OrganizationCategory { @@unique([id]) @@index([organizationId]) } + +// Employee Portal + +model PortalUser { + id String @id + name String + email String + emailVerified Boolean + image String? + createdAt DateTime + updatedAt DateTime + sessions PortalSession[] + accounts PortalAccount[] + employees Employee[] + + @@unique([email]) + @@map("portal_user") +} + +model PortalSession { + id String @id + expiresAt DateTime + token String + createdAt DateTime + updatedAt DateTime + ipAddress String? + userAgent String? + userId String + user PortalUser @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@map("portal_session") +} + +model PortalAccount { + id String @id + accountId String + providerId String + userId String + user PortalUser @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime + updatedAt DateTime + + @@map("portal_account") +} + +model PortalVerification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime? + updatedAt DateTime? + + @@map("portal_verification") +} \ No newline at end of file diff --git a/packages/email/emails/otp.tsx b/packages/email/emails/otp.tsx new file mode 100644 index 0000000000..1db4e21934 --- /dev/null +++ b/packages/email/emails/otp.tsx @@ -0,0 +1,86 @@ +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; +import { Logo } from "../components/logo"; + +interface Props { + email: string; + otp: string; +} + +export const OTPVerificationEmail = ({ email, otp }: Props) => { + return ( + + + + + + + + + One-Time Password for Comp AI + + + + + + Your one-time password for Comp AI + + + + Your one-time password for Comp AI + + + {otp} + +
+
+ + this login link was intended for{" "} + {email}.{" "} + +
+ +
+ +