diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..cff8f30860 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + push: + branches: + - release + +permissions: + contents: write # Allow check out and commit changes (version, changelog) + issues: write # Allow commenting on issues/PRs + pull-requests: write # Allow commenting on issues/PRs + id-token: write # Needed for provenance + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Fetch all history so semantic-release can analyze commits + fetch-depth: 0 + # Use a token that has permission to push to the repository + # Either a PAT stored in GH_TOKEN or the default GITHUB_TOKEN + token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' # Or your preferred Node.js version + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + # with: + # bun-version: latest # Optional: specify a bun version + + - name: Install dependencies + run: bun install --frozen-lockfile # Use --frozen-lockfile in CI + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Uncomment if publishing to npm + run: npx semantic-release \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100755 new mode 100644 index 916cde3adc..51d9c37223 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,11 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -export NVM_DIR="$HOME/.nvm" -. "$NVM_DIR/nvm.sh" - -export PATH=$PATH:$HOME/.nvm/versions/node/$(nvm current)/bin - -npx --version - -npx commitlint --edit $1 +npx commitlint --edit $1 \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index c5ceb12fd5..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -export NVM_DIR="$HOME/.nvm" -. "$NVM_DIR/nvm.sh" - -export PATH=$PATH:$HOME/.nvm/versions/node/$(nvm current)/bin:$HOME/.bun/bin - - -echo Node version: $(node -v) -echo Bun version: $(bun -v) - -bun run test diff --git a/.husky/pre-push b/.husky/pre-push index 9778c7e722..f1aa4cc1ff 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,10 +1,8 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - branch_name=$(git symbolic-ref --short HEAD) -pattern="^(claudio|mariano|alex)/leap-.*$" +pattern="^[a-zA-Z]+/comp-.*$" if [[ ! $branch_name =~ $pattern ]]; then echo "Branch name '$branch_name' does not follow the naming convention." + echo "Branch names should follow the pattern: firstname/comp-*" exit 1 fi \ No newline at end of file diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx new file mode 100644 index 0000000000..f2f14316ad --- /dev/null +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/NoAccessMessage.tsx @@ -0,0 +1,18 @@ +import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; +import { AlertTriangle } from "lucide-react"; + +interface NoAccessMessageProps { + message?: string; +} + +export function NoAccessMessage({ message }: NoAccessMessageProps) { + return ( + + + Access Denied + + {message ?? "You do not have access to the employee portal with this account, or you are not currently assigned to an organization. Please contact your administrator if you believe this is an error."} + + + ); +} \ No newline at end of file diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx new file mode 100644 index 0000000000..0493692e48 --- /dev/null +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/OrganizationDashboard.tsx @@ -0,0 +1,61 @@ +import { db } from "@comp/db"; +import type { + Member, + User, + Policy, + EmployeeTrainingVideoCompletion, + Organization +} from "@prisma/client"; +import { EmployeeTasksList } from "./EmployeeTasksList"; + +// Define the type for the member prop passed from Overview +interface MemberWithUserOrg extends Member { + user: User; + organization: Organization; +} + +interface OrganizationDashboardProps { + organizationId: string; + member: MemberWithUserOrg; // Pass the full member object for user info etc. +} + +export async function OrganizationDashboard({ organizationId, member }: OrganizationDashboardProps) { + + // Fetch policies specific to the selected organization + const policies = await db.policy.findMany({ + where: { + organizationId: organizationId, + isRequiredToSign: true, // Keep original logic for required policies + }, + }); + + // Fetch training video completions specific to the member + // Note: The original fetched *all* completions for the member, regardless of org + // If videos are org-specific, the schema/query might need adjustment + const trainingVideos = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: member.id, + // Add organizationId filter if EmployeeTrainingVideoCompletion has it + // organizationId: organizationId, + }, + // Include video details if needed by EmployeeTasksList + // include: { trainingVideo: true } + }); + + // Display welcome message and tasks + return ( +
+
+ {/* Use organization name if available and needed */} +

Organization: {member.organization.name}

+

Welcome back, {member.user.name}

+

Please complete the following tasks for {member.organization.name}:

+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx b/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx index 4412cb1bee..daeaa7b75a 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/components/Overview.tsx @@ -1,91 +1,136 @@ import { auth } from "@/app/lib/auth"; import { db } from "@comp/db"; -import { cache } from "react"; +// Import types directly from @prisma/client +import type { + Member, + User, + Policy, + EmployeeTrainingVideoCompletion, + Organization, +} from "@prisma/client"; import { headers } from "next/headers"; -import { EmployeeTasksList } from "./EmployeeTasksList"; - -export async function Overview() { - const policies = await getPolicies(); - const trainingVideos = await getTrainingVideos(); - const member = await getMember(); - - return ( -
-
-

Welcome back, {member.user.name}

-

Please complete the following tasks

-
- -
- ); +import { redirect } from "next/navigation"; +// Removed EmployeeTasksList import as it's not used directly here +import { NoAccessMessage } from "./NoAccessMessage"; +// Removed OrganizationSelector import +import { OrganizationDashboard } from "./OrganizationDashboard"; + +// Define the type for the member prop including the user and organization relations +interface MemberWithUserOrg extends Member { + user: User; + organization: Organization; } -const getMember = cache(async () => { +// Removed OverviewProps interface and searchParams prop +// export async function Overview({ searchParams }: OverviewProps) { +export async function Overview() { const session = await auth.api.getSession({ headers: await headers(), }); if (!session?.user) { - throw new Error("Unauthorized"); + redirect("/login"); // Or appropriate login/auth route } - const member = await db.member.findFirst({ + // Fetch all memberships for the user, including organization details + const memberships = await db.member.findMany({ where: { userId: session.user.id, - role: "employee", + // We might want to filter by role if needed, but let's see all memberships first + // role: "employee", // Keep commented unless needed }, include: { user: true, + organization: true, // Include organization details }, }); - if (!member) { - throw new Error("Unauthorized"); + // Case 1: No memberships found + if (memberships.length === 0) { + return ; } - return member; -}); - -const getPolicies = cache(async () => { - const member = await getMember(); - const organizationId = member.organizationId; + // Filter memberships to only those with valid organization data + const validMemberships = memberships.filter( + (member): member is MemberWithUserOrg & { organization: Organization } => + Boolean(member.organization) + ); - if (!organizationId) { - throw new Error("Unauthorized"); + // If after filtering, there are no valid memberships with organizations + if (validMemberships.length === 0) { + // This case might indicate memberships exist but lack organization links + console.warn("User has memberships but none with associated organizations.", { userId: session.user.id }); + return ; } - const policies = await db.policy.findMany({ - where: { - organizationId, - isRequiredToSign: true, - }, - }); - return policies; -}); + // Render a dashboard for each valid membership + return ( +
{/* Added a wrapper div with spacing */} + {validMemberships.map((member) => ( + + ))} +
+ ); + + // Removed the logic for OrganizationSelector and single/selected org handling + /* + // Extract unique organizations + const organizations = memberships.reduce((acc, member) => { + if (member.organization && !acc.some(org => org.id === member.organizationId)) { + acc.push(member.organization); + } + return acc; + }, [] as Organization[]); + + + const selectedOrgId = searchParams?.orgId as string | undefined; -const getTrainingVideos = cache(async () => { - const member = await getMember(); + // Case 2: Multiple organizations, and none selected yet OR selected is invalid + if (organizations.length > 1) { + const isValidSelection = selectedOrgId && organizations.some(org => org.id === selectedOrgId); - if (!member) { - throw new Error("Unauthorized"); + if (!isValidSelection) { + // If multiple orgs and no valid selection, show selector + return ; + } + // If valid selection, proceed to find member and render dashboard (handled below) } - const organizationId = member.organizationId; + // Case 3: Exactly one organization OR multiple orgs with a valid selection + let targetOrgId: string | undefined = undefined; + let targetMember: MemberWithUserOrg | undefined = undefined; + + if (organizations.length === 1) { + targetOrgId = organizations[0].id; + // Find the specific membership for this single organization + targetMember = memberships.find(m => m.organizationId === targetOrgId); + } else if (selectedOrgId) { + // Already validated that selectedOrgId is one of the user's orgs + targetOrgId = selectedOrgId; + targetMember = memberships.find(m => m.organizationId === targetOrgId); + } - if (!organizationId) { - throw new Error("Unauthorized"); + // If we have a target organization and member, render the dashboard + if (targetOrgId && targetMember) { + // We need the full MemberWithUserOrg type here potentially + // Ensure targetMember is correctly typed if OrganizationDashboard expects more + return ; } - const trainingVideos = await db.employeeTrainingVideoCompletion.findMany({ - where: { - memberId: member.id, - }, - }); + // Fallback case (should ideally not be reached with the logic above) + // If multiple orgs but somehow didn't render selector or dashboard + if (organizations.length > 1) { + return ; + } - return trainingVideos; -}); + // If single org but couldn't find member (data inconsistency?) + // Or some other unexpected state + console.error("Unexpected state in Overview component", { userId: session.user.id, memberships }); + return ; // Or a more specific error + */ +} diff --git a/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx b/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx index 1b20eac612..f3af69c550 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/layout.tsx @@ -9,10 +9,12 @@ export default async function Layout({ const t = await getI18n(); return ( -
+ <> -
{children}
-
+
+ {children} +
+ ); } diff --git a/apps/portal/src/app/[locale]/(app)/(home)/page.tsx b/apps/portal/src/app/[locale]/(app)/(home)/page.tsx index 159c056853..4b1985cb0e 100644 --- a/apps/portal/src/app/[locale]/(app)/(home)/page.tsx +++ b/apps/portal/src/app/[locale]/(app)/(home)/page.tsx @@ -2,16 +2,26 @@ import { getI18n } from "@/app/locales/server"; import type { Metadata } from "next"; import { setStaticParamsLocale } from "next-international/server"; import { Overview } from "./components/Overview"; +import { Suspense } from 'react' + +interface HomePageProps { + params: Promise<{ locale: string }> + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +} + +export default function HomePage({ params, searchParams }: HomePageProps) { -export default async function Portal({ - params, -}: { - params: Promise<{ locale: string }>; -}) { - const { locale } = await params; - setStaticParamsLocale(locale); - return ; + return ( +
+ {/* Add loading states later if Overview becomes complex */} + Loading overview...
}> + {/* Pass searchParams to Overview */} + + + {/* Other home page sections can go here */} + + ) } export async function generateMetadata({ diff --git a/apps/portal/src/app/[locale]/(app)/layout.tsx b/apps/portal/src/app/[locale]/(app)/layout.tsx index 6d3bf83d4a..bf4dbc5eaf 100644 --- a/apps/portal/src/app/[locale]/(app)/layout.tsx +++ b/apps/portal/src/app/[locale]/(app)/layout.tsx @@ -18,12 +18,16 @@ export default async function Layout({ } return ( -
+
-
-
-
{children}
+
+
+
+
+
+ {children} +
); diff --git a/apps/portal/src/app/components/header.tsx b/apps/portal/src/app/components/header.tsx index b6da9287f1..65698406f0 100644 --- a/apps/portal/src/app/components/header.tsx +++ b/apps/portal/src/app/components/header.tsx @@ -5,7 +5,7 @@ import { MobileMenu } from "./mobile-menu"; export async function Header() { return ( -
+
diff --git a/apps/portal/src/app/components/logout.tsx b/apps/portal/src/app/components/logout.tsx index 2ac96dfc44..84afcd23c8 100644 --- a/apps/portal/src/app/components/logout.tsx +++ b/apps/portal/src/app/components/logout.tsx @@ -16,7 +16,7 @@ export function Logout() { await authClient.signOut({ fetchOptions: { onSuccess: () => { - router.push("/login"); // redirect to login page + router.push("/auth"); // Redirect to /auth instead of /login }, }, }); diff --git a/apps/portal/src/app/components/otp-input.tsx b/apps/portal/src/app/components/otp-input.tsx index 617d1ba6b3..26c330c34d 100644 --- a/apps/portal/src/app/components/otp-input.tsx +++ b/apps/portal/src/app/components/otp-input.tsx @@ -20,13 +20,19 @@ export const OtpStyledInput = ({ return ( ( )} - containerStyle={`flex justify-center items-center flex-wrap text-2xl font-bold ${ + containerStyle={`flex justify-center items-center text-2xl font-bold ${ props.renderSeparator ? "gap-1" : "gap-x-3 gap-y-2" }`} /> diff --git a/apps/portal/src/app/components/sidebar.tsx b/apps/portal/src/app/components/sidebar.tsx index 187be13f66..c89dfaf547 100644 --- a/apps/portal/src/app/components/sidebar.tsx +++ b/apps/portal/src/app/components/sidebar.tsx @@ -1,19 +1,14 @@ import { Icons } from "@comp/ui/icons"; -import { cookies } from "next/headers"; import Link from "next/link"; import { MainMenu } from "./main-menu"; export async function Sidebar() { return ( -