diff --git a/apps/app/src/actions/organization/accept-invitation.ts b/apps/app/src/actions/organization/accept-invitation.ts index a507fbf6d..21bc214a6 100644 --- a/apps/app/src/actions/organization/accept-invitation.ts +++ b/apps/app/src/actions/organization/accept-invitation.ts @@ -69,7 +69,6 @@ export const completeInvitation = authActionClientWithoutOrg where: { userId: user.id, organizationId: invitation.organizationId, - deactivated: false, }, }); @@ -90,6 +89,16 @@ export const completeInvitation = authActionClientWithoutOrg }, }); + if (existingMembership.deactivated) { + await db.member.update({ + where: { id: existingMembership.id }, + data: { + deactivated: false, + role: invitation.role, + }, + }); + } + // Server redirect to the organization's root redirect(`/${invitation.organizationId}/`); } diff --git a/apps/app/src/actions/tasks.ts b/apps/app/src/actions/tasks.ts new file mode 100644 index 000000000..5ca53b41b --- /dev/null +++ b/apps/app/src/actions/tasks.ts @@ -0,0 +1,26 @@ +'use server'; + +import { addYears } from 'date-fns'; +import { createSafeActionClient } from 'next-safe-action'; +import { cookies } from 'next/headers'; +import { z } from 'zod'; + +const schema = z.object({ + view: z.enum(['categories', 'list']), + orgId: z.string(), +}); + +export const updateTaskViewPreference = createSafeActionClient() + .inputSchema(schema) + .action(async ({ parsedInput }) => { + const cookieStore = await cookies(); + + cookieStore.set({ + name: `task-view-preference-${parsedInput.orgId}`, + value: parsedInput.view, + expires: addYears(new Date(), 1), + }); + + return { success: true }; + }); + diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts new file mode 100644 index 000000000..0634cef8b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/checkMemberStatus.ts @@ -0,0 +1,86 @@ +'use server'; + +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import { headers } from 'next/headers'; + +export const checkMemberStatus = async ({ + email, + organizationId, +}: { + email: string; + organizationId: string; +}) => { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.session) { + throw new Error('Authentication required.'); + } + + const currentUserId = session.session.userId; + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: organizationId, + userId: currentUserId, + deactivated: false, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + throw new Error("You don't have permission to reactivate members."); + } + + // Find the user by email + const user = await db.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + }); + + if (!user) { + // User doesn't exist yet + return { success: true, memberExists: false, isActive: false, reactivated: false }; + } + + // Check if there's a member for this user and organization (active or deactivated) + const existingMember = await db.member.findFirst({ + where: { + userId: user.id, + organizationId, + }, + }); + + if (!existingMember) { + // Member doesn't exist + return { success: true, memberExists: false, isActive: false, reactivated: false }; + } + + if (existingMember.deactivated) { + return { + success: true, + memberExists: true, + isActive: true, + reactivated: true, + memberId: existingMember.id, + }; + } + + // Member exists and is already active + return { + success: true, + memberExists: true, + isActive: true, + reactivated: false, + memberId: existingMember.id, + }; + } catch (error) { + console.error('Error checking member status:', error); + throw error; + } +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts new file mode 100644 index 000000000..0d9b4d66f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/sendInvitationEmail.ts @@ -0,0 +1,85 @@ +'use server'; + +import { auth } from '@/utils/auth'; +import { sendInviteMemberEmail } from '@comp/email/lib/invite-member'; +import { db } from '@db'; +import { headers } from 'next/headers'; + +export const sendInvitationEmailToExistingMember = async ({ + email, + organizationId, + roles, +}: { + email: string; + organizationId: string; + roles: string[]; +}) => { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.session) { + throw new Error('Authentication required.'); + } + + const currentUserId = session.session.userId; + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: organizationId, + userId: currentUserId, + deactivated: false, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + throw new Error("You don't have permission to send invitations."); + } + + // Get organization name + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!organization) { + throw new Error('Organization not found.'); + } + + // Generate invitation using Better Auth + // Note: This might fail if member already exists, so we'll create invitation manually + const invitation = await db.invitation.create({ + data: { + email: email.toLowerCase(), + organizationId, + role: roles.length === 1 ? roles[0] : roles.join(','), + status: 'pending', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + inviterId: currentUserId, + }, + }); + + // Generate invite link + const isLocalhost = process.env.NODE_ENV === 'development'; + const protocol = isLocalhost ? 'http' : 'https'; + + const betterAuthUrl = process.env.NEXT_PUBLIC_BETTER_AUTH_URL; + const isDevEnv = betterAuthUrl?.includes('dev.trycomp.ai'); + const isProdEnv = betterAuthUrl?.includes('app.trycomp.ai'); + + const domain = isDevEnv ? 'dev.trycomp.ai' : isProdEnv ? 'app.trycomp.ai' : 'localhost:3000'; + const inviteLink = `${protocol}://${domain}/invite/${invitation.id}`; + + // Send the invitation email + await sendInviteMemberEmail({ + inviteeEmail: email.toLowerCase(), + inviteLink, + organizationName: organization.name, + }); + + return { success: true }; + } catch (error) { + console.error('Error sending invitation email:', error); + throw error; + } +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index aa9d5ff5d..bc04368b9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -32,6 +32,8 @@ import { import { Input } from '@comp/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import { addEmployeeWithoutInvite } from '../actions/addEmployeeWithoutInvite'; +import { checkMemberStatus } from '../actions/checkMemberStatus'; +import { sendInvitationEmailToExistingMember } from '../actions/sendInvitationEmail'; import { MultiRoleCombobox } from './MultiRoleCombobox'; // --- Constants for Roles --- @@ -169,11 +171,26 @@ export function InviteMembersModal({ roles: invite.roles, }); } else { - // Use authClient to send the invitation - await authClient.organization.inviteMember({ + // Check member status and reactivate if needed + const memberStatus = await checkMemberStatus({ email: invite.email.toLowerCase(), - role: invite.roles.length === 1 ? invite.roles[0] : invite.roles, + organizationId, }); + + if (memberStatus.memberExists && memberStatus.isActive) { + // Member already exists and is active - send invitation email manually + await sendInvitationEmailToExistingMember({ + email: invite.email.toLowerCase(), + organizationId, + roles: invite.roles, + }); + } else { + // Member doesn't exist - use authClient to send the invitation + await authClient.organization.inviteMember({ + email: invite.email.toLowerCase(), + role: invite.roles.length === 1 ? invite.roles[0] : invite.roles, + }); + } } successCount++; } catch (error) { @@ -331,10 +348,26 @@ export function InviteMembersModal({ roles: validRoles, }); } else { - await authClient.organization.inviteMember({ + // Check member status and reactivate if needed + const memberStatus = await checkMemberStatus({ email: email.toLowerCase(), - role: validRoles, + organizationId, }); + + if (memberStatus.memberExists && memberStatus.isActive) { + // Member already exists and is active - send invitation email manually + await sendInvitationEmailToExistingMember({ + email: email.toLowerCase(), + organizationId, + roles: validRoles, + }); + } else { + // Member doesn't exist - use authClient to send the invitation + await authClient.organization.inviteMember({ + email: email.toLowerCase(), + role: validRoles, + }); + } } successCount++; } catch (error) { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/SearchInput.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/SearchInput.tsx index 223e90ed5..d5188a2a3 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/SearchInput.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/SearchInput.tsx @@ -16,7 +16,7 @@ export const SearchInput = forwardRef( ref={ref} type="text" placeholder={placeholder} - className={`h-9 w-[280px] border border-input bg-background pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md ${className}`} + className={`h-9 w-full border border-input bg-background pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md ${className}`} {...props} /> diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx index 79d23ea54..d51d84f9e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx @@ -1,5 +1,7 @@ 'use client'; +import { updateTaskViewPreference } from '@/actions/tasks'; +import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { Separator } from '@comp/ui/separator'; import type { Member, Task, User } from '@db'; @@ -7,7 +9,7 @@ import { Check, Circle, FolderTree, List, Plus, XCircle } from 'lucide-react'; import Image from 'next/image'; import { useParams } from 'next/navigation'; import { useQueryState } from 'nuqs'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { CreateTaskSheet } from './CreateTaskSheet'; import { ModernTaskList } from './ModernTaskList'; import { SearchInput } from './SearchInput'; @@ -25,6 +27,7 @@ export function TaskList({ tasks: initialTasks, members, controls, + activeTab, }: { tasks: (Task & { controls: { id: string; name: string }[]; @@ -44,6 +47,7 @@ export function TaskList({ })[]; members: (Member & { user: User })[]; controls: { id: string; name: string }[]; + activeTab: 'categories' | 'list'; }) { const params = useParams(); const orgId = params.orgId as string; @@ -51,30 +55,17 @@ export function TaskList({ const [statusFilter, setStatusFilter] = useQueryState('status'); const [assigneeFilter, setAssigneeFilter] = useQueryState('assignee'); const [createTaskOpen, setCreateTaskOpen] = useQueryState('create-task'); + const [currentTab, setCurrentTab] = useState<'categories' | 'list'>(activeTab); - // Initialize with default, load from localStorage after hydration - const [activeTab, setActiveTab] = useState<'categories' | 'list'>('categories'); - const lastLoadedOrgId = useRef(null); - - // Load saved preference from localStorage after client-side hydration + // Sync activeTab prop with state when it changes useEffect(() => { - // Reset and load preference when orgId changes or on initial load - if (lastLoadedOrgId.current !== orgId) { - const saved = localStorage.getItem(`task-view-preference-${orgId}`); - if (saved === 'categories' || saved === 'list') { - setActiveTab(saved); - } else { - // Reset to default if no saved preference exists for this org - setActiveTab('categories'); - } - lastLoadedOrgId.current = orgId; - } - }, [orgId]); - - // Save preference to localStorage when user changes it (not on initial load) - const handleTabChange = (tab: 'categories' | 'list') => { - setActiveTab(tab); - localStorage.setItem(`task-view-preference-${orgId}`, tab); + setCurrentTab(activeTab); + }, [activeTab]); + + const handleTabChange = async (value: string) => { + const newTab = value as 'categories' | 'list'; + setCurrentTab(newTab); + await updateTaskViewPreference({ view: newTab, orgId }); }; const eligibleAssignees = useMemo(() => { @@ -268,20 +259,22 @@ export function TaskList({ return (
{/* Header */} -
+

Tasks

Manage and track your compliance tasks

- + + New Task +
{/* Analytics Dashboard */} @@ -524,171 +517,162 @@ export function TaskList({ {/* Unified Control Module */} -
- {/* Search */} - setSearchQuery(e.target.value)} - /> - - {/* Divider */} -
- - {/* Status Filter */} - - - {/* Assignee Filter */} - - - {/* Spacer */} -
- - {/* View Toggle */} -
- - -
+ +
+ {/* Filters */} +
+
+ setSearchQuery(e.target.value)} + /> +
- {/* Result Count */} - {(searchQuery || statusFilter || assigneeFilter) && ( -
-
-
- {filteredTasks.length} {filteredTasks.length === 1 ? 'result' : 'results'} +
+ + {/* Status + Assignee */} +
+ + +
+ {/* Result Count */} + {(searchQuery || statusFilter || assigneeFilter) && ( +
+ {filteredTasks.length} {filteredTasks.length === 1 ? 'result' : 'results'} +
+ )}
- )} -
- {/* Content */} -
- {activeTab === 'categories' ? ( + {/* Tabs - visible on all screens */} + + + + Categories + + + + List + + +
+ - ) : ( + + - )} -
+ +
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx index d14e25b5c..65a0bff4f 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx @@ -1,7 +1,7 @@ import { auth } from '@/utils/auth'; import { db, Role } from '@db'; import { Metadata } from 'next'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { TaskList } from './components/TaskList'; export async function generateMetadata(): Promise { @@ -28,9 +28,14 @@ export default async function TasksPage({ const members = await getMembersWithMetadata(); const controls = await getControls(); + // Read tab preference from cookie (server-side, no hydration issues) + const cookieStore = await cookies(); + const savedView = cookieStore.get(`task-view-preference-${orgId}`)?.value; + const activeTab = savedView === 'categories' || savedView === 'list' ? savedView : 'categories'; + return (
- +
); } @@ -57,27 +62,27 @@ const getTasks = async () => { name: true, }, }, - evidenceAutomations: { + evidenceAutomations: { + select: { + id: true, + isEnabled: true, + name: true, + runs: { + orderBy: { + createdAt: 'desc', + }, + take: 3, select: { - id: true, - isEnabled: true, - name: true, - runs: { - orderBy: { - createdAt: 'desc', - }, - take: 3, - select: { - status: true, - success: true, - evaluationStatus: true, - createdAt: true, - triggeredBy: true, - runDuration: true, - }, - }, + status: true, + success: true, + evaluationStatus: true, + createdAt: true, + triggeredBy: true, + runDuration: true, }, }, + }, + }, }, orderBy: [{ status: 'asc' }, { title: 'asc' }], }); diff --git a/bun.lock b/bun.lock index 045121eda..6aa0283dd 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "comp", diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 91bcb1486..662d04eb3 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -20,7 +20,7 @@ function TabsList({ className, ...props }: React.ComponentProps