diff --git a/apps/app/src/actions/people/create-employee-action.ts b/apps/app/src/actions/people/create-employee-action.ts index 094797288c..5ad7eba710 100644 --- a/apps/app/src/actions/people/create-employee-action.ts +++ b/apps/app/src/actions/people/create-employee-action.ts @@ -1,9 +1,9 @@ "use server"; import { db } from "@bubba/db"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { authActionClient } from "../safe-action"; import { createEmployeeSchema } from "../schema"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import type { ActionResponse } from "../types"; const DEFAULT_TASKS = [ @@ -58,6 +58,23 @@ export const createEmployeeAction = authActionClient }, }); + const portalUser = await db.portalUser.create({ + data: { + id: employee.id, + name, + email, + organizationId: user.organizationId, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + employees: { + connect: { + id: employee.id, + }, + }, + }, + }); + // Create or get the required task definitions first and store their IDs const requiredTasks = await Promise.all( DEFAULT_TASKS.map(async (task) => { diff --git a/apps/app/src/components/magic-link.tsx b/apps/app/src/components/magic-link.tsx index 76d112131d..4c4dffb19b 100644 --- a/apps/app/src/components/magic-link.tsx +++ b/apps/app/src/components/magic-link.tsx @@ -110,7 +110,7 @@ export function MagicLinkSignIn({ className }: Props) { + diff --git a/apps/portal/src/app/actions/logout.ts b/apps/portal/src/app/actions/logout.ts new file mode 100644 index 0000000000..88a7ba9226 --- /dev/null +++ b/apps/portal/src/app/actions/logout.ts @@ -0,0 +1,11 @@ +"use server"; + +import { createSafeActionClient } from "next-safe-action"; +import { headers } from "next/headers"; +import { auth } from "../lib/auth"; + +export const logout = createSafeActionClient().action(async () => { + await auth.api.signOut({ + headers: await headers(), + }); +}); diff --git a/apps/portal/src/app/components/header.tsx b/apps/portal/src/app/components/header.tsx new file mode 100644 index 0000000000..813e9abf3f --- /dev/null +++ b/apps/portal/src/app/components/header.tsx @@ -0,0 +1,31 @@ +import { UserMenu } from "@/app/components/user-menu"; +import { getI18n } from "@/app/locales/server"; +import { Skeleton } from "@bubba/ui/skeleton"; +import { headers } from "next/headers"; +import { Suspense } from "react"; +import { auth } from "../lib/auth"; +import { MobileMenu } from "./mobile-menu"; + +export async function Header() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const t = await getI18n(); + + return ( +
+ + +
+
Employee Portal
+
+ +
+ }> + + +
+
+ ); +} diff --git a/apps/portal/src/app/components/logout.tsx b/apps/portal/src/app/components/logout.tsx new file mode 100644 index 0000000000..0b9a8ced7d --- /dev/null +++ b/apps/portal/src/app/components/logout.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useI18n } from "@/app/locales/client"; +import { DropdownMenuItem } from "@bubba/ui/dropdown-menu"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { authClient } from "../lib/auth-client"; + +export function Logout() { + const t = useI18n(); + const [isLoading, setLoading] = useState(false); + const router = useRouter(); + + const handleLogout = async () => { + setLoading(true); + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + router.push("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/login"); // redirect to login page + }, + }, + }); + setLoading(false); + }; + + return ( + + {isLoading ? "Loading..." : t("user_menu.sign_out")} + + ); +} diff --git a/apps/portal/src/app/components/main-menu.tsx b/apps/portal/src/app/components/main-menu.tsx new file mode 100644 index 0000000000..83933d1bad --- /dev/null +++ b/apps/portal/src/app/components/main-menu.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useI18n } from "@/app/locales/client"; +import { Button } from "@bubba/ui/button"; +import { cn } from "@bubba/ui/cn"; +import { Icons } from "@bubba/ui/icons"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@bubba/ui/tooltip"; +import { useClickAway } from "@uidotdev/usehooks"; +import { Reorder, motion, useMotionValue } from "framer-motion"; +import { useAction } from "next-safe-action/hooks"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { useLongPress } from "use-long-press"; + +const icons = { + "/": () => , +}; + +interface ItemProps { + item: { path: string; name: string; disabled: boolean }; + isActive: boolean; + onSelect?: () => void; + disabled: boolean; +} + +const Item = ({ item, isActive, onSelect, disabled }: ItemProps) => { + const Icon = icons[item.path as keyof typeof icons]; + const linkDisabled = disabled || item.disabled; + + return ( + + {linkDisabled ? ( +
+ Coming +
+ ) : ( + onSelect?.()}> + + + + +
+ + {item.name} +
+
+
+
+ + {item.name} + +
+ + )} +
+ ); +}; + +const listVariant = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.04, + }, + }, +}; + +const itemVariant = { + hidden: { opacity: 0 }, + show: { opacity: 1 }, +}; + +type Props = { + initialItems?: { path: string; name: string; disabled: boolean }[]; + onSelect?: () => void; +}; + +export function MainMenu({ initialItems, onSelect }: Props) { + const t = useI18n(); + + const defaultItems = [ + { + path: "/", + name: "Dashboard", + disabled: false, + }, + ]; + + const [items, setItems] = useState(initialItems ?? defaultItems); + const pathname = usePathname(); + const part = pathname?.split("/")[1]; + + const hiddenItems = defaultItems.filter( + (item) => !items.some((i) => i.path === item.path), + ); + + const onReorder = ( + items: { + path: string; + name: string; + disabled: boolean; + }[], + ) => { + setItems(items); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/portal/src/app/components/mobile-menu.tsx b/apps/portal/src/app/components/mobile-menu.tsx new file mode 100644 index 0000000000..e43c7dc2db --- /dev/null +++ b/apps/portal/src/app/components/mobile-menu.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Button } from "@bubba/ui/button"; +import { Icons } from "@bubba/ui/icons"; +import { Sheet, SheetContent } from "@bubba/ui/sheet"; +import { useState } from "react"; +import { MainMenu } from "./main-menu"; + +export function MobileMenu() { + const [isOpen, setOpen] = useState(false); + + return ( + +
+ +
+ +
+ +
+ + setOpen(false)} /> +
+
+ ); +} diff --git a/apps/portal/src/app/components/otp.tsx b/apps/portal/src/app/components/otp.tsx index aa9014fc76..f6b97f9f08 100644 --- a/apps/portal/src/app/components/otp.tsx +++ b/apps/portal/src/app/components/otp.tsx @@ -6,7 +6,7 @@ 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 { ArrowRight, Loader2, Mail } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -88,13 +88,14 @@ export function OtpSignIn({ className }: Props) { diff --git a/apps/portal/src/app/components/sidebar.tsx b/apps/portal/src/app/components/sidebar.tsx new file mode 100644 index 0000000000..7a4b371549 --- /dev/null +++ b/apps/portal/src/app/components/sidebar.tsx @@ -0,0 +1,21 @@ +import { Icons } from "@bubba/ui/icons"; +import { cookies } from "next/headers"; +import Link from "next/link"; +import { MainMenu } from "./main-menu"; + +export async function Sidebar() { + return ( + + ); +} diff --git a/apps/portal/src/app/components/user-menu.tsx b/apps/portal/src/app/components/user-menu.tsx new file mode 100644 index 0000000000..1ced7d0b28 --- /dev/null +++ b/apps/portal/src/app/components/user-menu.tsx @@ -0,0 +1,43 @@ +import { getI18n } from "@/app/locales/server"; +import { Avatar, AvatarFallback, AvatarImageNext } from "@bubba/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bubba/ui/dropdown-menu"; +import type { auth } from "../lib/auth"; +import { Logout } from "./logout"; + +export async function UserMenu({ + session, +}: { session: Awaited> }) { + const t = await getI18n(); + return ( + + + + {session?.user?.image && ( + + )} + + + {session?.user?.name?.charAt(0)?.toUpperCase()} + + + + + + + + + + + ); +} diff --git a/apps/portal/src/app/lib/auth-client.ts b/apps/portal/src/app/lib/auth-client.ts index 07b2fdc03f..b484464df1 100644 --- a/apps/portal/src/app/lib/auth-client.ts +++ b/apps/portal/src/app/lib/auth-client.ts @@ -6,5 +6,5 @@ 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 index 90e3b78d01..b07fff7574 100644 --- a/apps/portal/src/app/lib/auth.ts +++ b/apps/portal/src/app/lib/auth.ts @@ -12,6 +12,12 @@ export const auth = betterAuth({ }), user: { modelName: "PortalUser", + additionalFields: { + organizationId: { + type: "string", + required: false, + } + }, }, session: { modelName: "PortalSession", diff --git a/apps/portal/src/app/locales/en.ts b/apps/portal/src/app/locales/en.ts index 6f531020b8..4763c1daed 100644 --- a/apps/portal/src/app/locales/en.ts +++ b/apps/portal/src/app/locales/en.ts @@ -1,19 +1,32 @@ export default { auth: { title: "Employee Portal", - description: "Enter your email and one time password to continue", + description: "Enter your email address to receive a one time password.", 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", + button: "Continue", }, }, + powered_by: { + title: "Comp AI - OSS Compliance Platform", + description: + "Get SOC 2, ISO 27001, and GDPR compliant in weeks, not months. Open source, instant sign up, free trial.", + learn_more: "Start Free Trial & Get Compliant", + learn_more_link: "https://trycomp.ai", + }, + user_menu: { + sign_out: "Sign Out", + }, not_found: { title: "Not Found", description: "The page you are looking for does not exist.", return: "Return to the home page", }, + sidebar: { + dashboard: "Employee Portal Overview", + }, } as const; diff --git a/apps/portal/src/app/locales/es.ts b/apps/portal/src/app/locales/es.ts index 1e86c2d705..fba3266930 100644 --- a/apps/portal/src/app/locales/es.ts +++ b/apps/portal/src/app/locales/es.ts @@ -1,19 +1,32 @@ export default { auth: { title: "Portal del Empleado", - description: "Ingresa tu correo electrónico y contraseña de un solo uso para continuar", + description: "Ingresa tu correo electrónico para recibir una contraseña de un solo uso.", options: "Más opciones", email: { otp_sent: "Contraseña de un solo uso enviada", otp_description: "Revisa tu correo electrónico para ver la contraseña de un solo uso", otp_try_again: "Intentar de nuevo", placeholder: "Tu correo electrónico del trabajo", - button: "Obtener contraseña de un solo uso", + button: "Continuar", }, }, + powered_by: { + title: "Comp AI - Plataforma de Cumplimiento OSS", + description: + "Obtén el cumplimiento de SOC 2, ISO 27001 y GDPR en semanas, no meses. Código abierto, registro instantáneo, prueba gratuita.", + learn_more: "Comienza la Prueba Gratuita y Obtén Cumplimiento", + learn_more_link: "https://trycomp.ai", + }, + user_menu: { + sign_out: "Cerrar Sesión", + }, not_found: { title: "No Encontrado", - description: "La página que buscas no existe.", + description: "La página que estás buscando no existe.", return: "Volver a la página principal", }, + sidebar: { + dashboard: "Vista General del Portal del Empleado", + }, } as const; diff --git a/apps/portal/src/app/locales/fr.ts b/apps/portal/src/app/locales/fr.ts index d55ec0f965..7c2d32d872 100644 --- a/apps/portal/src/app/locales/fr.ts +++ b/apps/portal/src/app/locales/fr.ts @@ -1,19 +1,32 @@ export default { auth: { - title: "Portail des Employés", - description: "Entrez votre email et votre mot de passe à usage unique pour continuer", + title: "Portail Employé", + description: "Saisissez votre adresse e-mail pour recevoir un mot de passe à usage unique.", options: "Plus d'options", email: { otp_sent: "Mot de passe à usage unique envoyé", - otp_description: "Vérifiez votre email pour le mot de passe à usage unique", + otp_description: "Vérifiez votre e-mail pour le mot de passe à usage unique", otp_try_again: "Réessayer", - placeholder: "Votre email professionnel", - button: "Obtenir un mot de passe à usage unique", + placeholder: "Votre e-mail professionnel", + button: "Continuer", }, }, + powered_by: { + title: "Comp AI - Plateforme de Conformité OSS", + description: + "Obtenez la conformité SOC 2, ISO 27001 et RGPD en quelques semaines, pas en mois. Open source, inscription instantanée, essai gratuit.", + learn_more: "Commencer l'essai gratuit et devenir conforme", + learn_more_link: "https://trycomp.ai", + }, + user_menu: { + sign_out: "Se déconnecter", + }, not_found: { title: "Page Non Trouvée", description: "La page que vous recherchez n'existe pas.", return: "Retourner à la page d'accueil", }, + sidebar: { + dashboard: "Vue d'ensemble du portail employé", + }, } as const; diff --git a/apps/portal/src/app/locales/no.ts b/apps/portal/src/app/locales/no.ts index 4516f2b68e..4bfe74915e 100644 --- a/apps/portal/src/app/locales/no.ts +++ b/apps/portal/src/app/locales/no.ts @@ -1,19 +1,32 @@ export default { auth: { title: "Ansattportal", - description: "Skriv inn e-post og engangspassord for å fortsette", + description: "Skriv inn e-postadressen din for å motta et engangspassord.", options: "Flere alternativer", email: { otp_sent: "Engangspassord sendt", otp_description: "Sjekk e-posten din for engangspassordet", otp_try_again: "Prøv igjen", - placeholder: "Din jobbmail", - button: "Få engangspassord", + placeholder: "Din jobb-e-post", + button: "Fortsett", }, }, + powered_by: { + title: "Comp AI - OSS Compliance Platform", + description: + "Bli SOC 2, ISO 27001 og GDPR-kompatibel på uker, ikke måneder. Åpen kildekode, umiddelbar registrering, gratis prøveperiode.", + learn_more: "Start gratis prøveperiode og bli kompatibel", + learn_more_link: "https://trycomp.ai", + }, + user_menu: { + sign_out: "Logg ut", + }, not_found: { - title: "Ikke Funnet", + title: "Ikke funnet", description: "Siden du leter etter eksisterer ikke.", - return: "Gå tilbake til forsiden", + return: "Gå tilbake til hjemmesiden", + }, + sidebar: { + dashboard: "Oversikt over ansattportal", }, } as const; diff --git a/apps/portal/src/app/locales/pt.ts b/apps/portal/src/app/locales/pt.ts index fe2625b264..ef35fa7e90 100644 --- a/apps/portal/src/app/locales/pt.ts +++ b/apps/portal/src/app/locales/pt.ts @@ -1,19 +1,32 @@ export default { auth: { title: "Portal do Funcionário", - description: "Digite seu e-mail e senha única para continuar", + description: "Digite seu endereço de e-mail para receber uma senha única.", options: "Mais opções", email: { otp_sent: "Senha única enviada", otp_description: "Verifique seu e-mail para ver a senha única", otp_try_again: "Tentar novamente", - placeholder: "Seu e-mail do trabalho", - button: "Obter senha única", + placeholder: "Seu e-mail profissional", + button: "Continuar", }, }, + powered_by: { + title: "Comp AI - Plataforma de Conformidade OSS", + description: + "Obtenha conformidade com SOC 2, ISO 27001 e GDPR em semanas, não meses. Código aberto, inscrição instantânea, teste gratuito.", + learn_more: "Comece o Teste Gratuito e Obtenha Conformidade", + learn_more_link: "https://trycomp.ai", + }, + user_menu: { + sign_out: "Sair", + }, not_found: { title: "Não Encontrado", description: "A página que você está procurando não existe.", - return: "Voltar à página inicial", + return: "Voltar para a página inicial", + }, + sidebar: { + dashboard: "Visão Geral do Portal do Funcionário", }, } as const; diff --git a/apps/portal/src/middleware.ts b/apps/portal/src/middleware.ts index 947340bf96..60b056d24b 100644 --- a/apps/portal/src/middleware.ts +++ b/apps/portal/src/middleware.ts @@ -36,6 +36,17 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(url); } + if (session?.user?.organizationId && newUrl.pathname === "/unauthorized") { + const url = new URL("/", request.url); + + return NextResponse.redirect(url); + } + + if (session && !session.user.organizationId && newUrl.pathname !== "/unauthorized") { + const url = new URL("/unauthorized", request.url); + + return NextResponse.redirect(url); + } return response; } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 22c0da49bf..5afc0839a5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -143,6 +143,7 @@ model Organization { members OrganizationMember[] OrganizationPolicy OrganizationPolicy[] OrganizationCategory OrganizationCategory[] + PortalUser PortalUser[] @@index([stripeCustomerId]) } @@ -1016,6 +1017,8 @@ model PortalUser { sessions PortalSession[] accounts PortalAccount[] employees Employee[] + organizationId String? + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@unique([email]) @@map("portal_user") diff --git a/packages/email/components/footer.tsx b/packages/email/components/footer.tsx index bd3f47713c..d8a501a0b1 100644 --- a/packages/email/components/footer.tsx +++ b/packages/email/components/footer.tsx @@ -1,29 +1,21 @@ -import { - Column, - Hr, - Img, - Link, - Row, - Section, - Text, -} from "@react-email/components"; +import { Hr, Link, Section, Text } from "@react-email/components"; export function Footer() { return (

- + + Open Source Compliance,{" "} - Compliance made easy. + Comp AI + . - - - Bubba AI, Inc. - 2261 Market Street, San Francisco, CA 94114 - - + + Bubba AI, Inc. 2261 Market Street, San Francisco, CA 94114 +
); } diff --git a/packages/email/emails/otp.tsx b/packages/email/emails/otp.tsx index 1db4e21934..bac5a4ec21 100644 --- a/packages/email/emails/otp.tsx +++ b/packages/email/emails/otp.tsx @@ -60,16 +60,25 @@ export const OTPVerificationEmail = ({ email, otp }: Props) => { - Your one-time password for Comp AI + Hey there, +
+
+ Your one-time password for Comp AI is: {otp}. Please do not share + this code with anyone.
- {otp} + + If you did not request this password, you can safely ignore this + email. For support, please reach out to your IT / Security + department. +
- this login link was intended for{" "} - {email}.{" "} + This one time password was intended for{" "} + {email} and expires in + 10 minutes.