diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/@sidebar/OrganizationSidebar.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/@sidebar/OrganizationSidebar.tsx index baca2915..c4977d75 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/@sidebar/OrganizationSidebar.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/@sidebar/OrganizationSidebar.tsx @@ -7,7 +7,7 @@ import { Suspense } from 'react'; import { DesktopSidebarFallback } from '@/components/SidebarComponents/SidebarFallback'; import { SwitcherAndToggle } from '@/components/SidebarComponents/SidebarLogo'; -import { Activity, FileText, GitCompare, Home, Layers, MessageCircle, Settings, Shield } from 'lucide-react'; +import { Activity, FileText, GitCompare, Home, Layers, MessageCircle, Settings, Shield, Users } from 'lucide-react'; async function OrganizationSidebarInternal({ organizationId, @@ -41,6 +41,11 @@ async function OrganizationSidebarInternal({ href={`/org/${organizationId}/projects`} icon={} /> + } + /> } /> + } + /> { + return ( +
+ {[...Array(quantity)].map((_, i) => ( + + + + + + ))} +
+ ) +} + +export default TeamsLoadingFallback diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/page.tsx index 0bfe3a27..a22a48fe 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/page.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/page.tsx @@ -1,9 +1,11 @@ import { ProjectsCardList } from "@/components/Projects/ProjectsCardList"; import { Search } from "@/components/Search"; +import { TeamsCardList } from "@/components/Teams/TeamsCardList"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getOrganizationTitle } from "@/data/user/organizations"; import { getProjects } from "@/data/user/projects"; +import { getTeams } from "@/data/user/teams"; import { organizationParamSchema, projectsfilterSchema @@ -16,6 +18,7 @@ import type { z } from "zod"; import { DashboardClientWrapper } from "./DashboardClientWrapper"; import { DashboardLoadingFallback } from "./DashboardLoadingFallback"; import ProjectsLoadingFallback from "./ProjectsLoadingFallback"; +import TeamsLoadingFallback from "./TeamsLoadingFallback"; async function Projects({ organizationId, @@ -26,10 +29,26 @@ async function Projects({ }) { const projects = await getProjects({ organizationId, + teamId: null, ...filters, }); return ; } +async function Teams({ + organizationId, + filters, +}: { + organizationId: string; + filters: z.infer; +}) { + const teams = await getTeams({ + organizationId, + ...filters, + }); + return ; +} + + export type DashboardProps = { params: { organizationId: string }; @@ -81,6 +100,35 @@ async function Dashboard({ params, searchParams }: DashboardProps) { + + + Recent Teams +
+ + +
+
+ + }> + + {validatedSearchParams.query && ( +

+ Searching for{" "} + {validatedSearchParams.query} +

+ )} +
+
+ +
); } diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/create/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/create/page.tsx index 337634d6..4312148d 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/create/page.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/create/page.tsx @@ -1,5 +1,6 @@ import CreateProjectForm from "@/components/CreateProjectForm"; import { getOrganizationRepos } from "@/data/user/repos"; +import { getTeamsInOrganization } from "@/data/user/teams"; import { organizationParamSchema } from "@/utils/zod-schemas/params"; import type { Metadata } from "next"; @@ -14,11 +15,16 @@ export default async function CreateProjectPage({ params: unknown; }) { const { organizationId } = organizationParamSchema.parse(params); - const repositories = await getOrganizationRepos(organizationId); + const [repositories, fullTeams] = await Promise.all([ + getOrganizationRepos(organizationId), + getTeamsInOrganization(organizationId) + ]); + + const teams = fullTeams.map(({ id, name }) => ({ id, name })); return (
- +
); } \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/page.tsx index 891ad52c..c77e596d 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/page.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/projects/page.tsx @@ -5,8 +5,8 @@ import { T } from "@/components/ui/Typography"; import { Button } from "@/components/ui/button"; import { getProjects, getProjectsTotalCount } from "@/data/user/projects"; import { - organizationParamSchema, - projectsfilterSchema + projectsfilterSchema, + teamParamSchema } from "@/utils/zod-schemas/params"; import { Plus } from "lucide-react"; import type { Metadata } from "next"; @@ -15,13 +15,14 @@ import { Suspense } from "react"; import type { DashboardProps } from "../page"; import { OrganizationProjectsTable } from "./OrganizationProjectsTable"; -async function ProjectsTableWithPagination({ +export async function ProjectsTableWithPagination({ organizationId, + teamId, searchParams, -}: { organizationId: string; searchParams: unknown }) { +}: { organizationId: string; teamId: number | null; searchParams: unknown }) { const filters = projectsfilterSchema.parse(searchParams); const [projects, totalPages] = await Promise.all([ - getProjects({ ...filters, organizationId }), + getProjects({ ...filters, organizationId, teamId }), getProjectsTotalCount({ ...filters, organizationId }), ]); @@ -42,7 +43,7 @@ export default async function Page({ params, searchParams, }: DashboardProps) { - const { organizationId } = organizationParamSchema.parse(params); + const { organizationId, teamId } = teamParamSchema.parse(params); const filters = projectsfilterSchema.parse(searchParams); return ( @@ -78,6 +79,7 @@ export default async function Page({ > diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/OrganizationTeamsTable.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/OrganizationTeamsTable.tsx new file mode 100644 index 00000000..9362d10b --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/OrganizationTeamsTable.tsx @@ -0,0 +1,118 @@ +"use client"; +import { T } from '@/components/ui/Typography'; +import { Card } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { Tables } from '@/lib/database.types'; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table'; +import moment from 'moment'; +import Link from 'next/link'; +import { useState } from 'react'; + +type Team = Tables<'teams'>; + +type Props = { + teams: Team[]; +}; + +export function OrganizationTeamsTable({ teams }: Props) { + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( + + {row.getValue('name')} + + ), + }, + { + accessorKey: 'created_at', + header: 'Created At', + cell: ({ row }) => { + const date = moment(row.getValue('created_at') as string); + return date.format('MMM DD YYYY, HH:mm [hrs]'); + }, + }, + { + accessorKey: 'by', + header: 'By', + cell: ({ row }) => row.getValue('by') || 'unknown', + }, + ]; + + const [sorting, setSorting] = useState([]); + const table = useReactTable({ + data: teams, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + state: { + sorting, + }, + }); + + if (teams.length === 0) { + return ( + +
+ No teams found +
+
+ ); + } + + return ( +
+ + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/TeamsTableWithPagination.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/TeamsTableWithPagination.tsx new file mode 100644 index 00000000..7c2fab36 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/TeamsTableWithPagination.tsx @@ -0,0 +1,22 @@ +import { Pagination } from "@/components/Pagination"; +import { getTeams, getTeamsTotalCount } from "@/data/user/teams"; +import { projectsfilterSchema } from "@/utils/zod-schemas/params"; +import { OrganizationTeamsTable } from "./OrganizationTeamsTable"; + +export async function TeamsTableWithPagination({ + organizationId, + searchParams, +}: { organizationId: string; searchParams: unknown }) { + const filters = projectsfilterSchema.parse(searchParams); + const [teams, totalPages] = await Promise.all([ + getTeams({ ...filters, organizationId }), + getTeamsTotalCount({ ...filters, organizationId }), + ]); + + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/page.tsx new file mode 100644 index 00000000..17b3ccf6 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/(specific-organization-pages)/teams/page.tsx @@ -0,0 +1,60 @@ +import { CreateTeamDialog } from "@/components/CreateTeamDialog"; +import { PageHeading } from "@/components/PageHeading"; +import { Search } from "@/components/Search"; +import { T } from "@/components/ui/Typography"; +import { + organizationParamSchema, + projectsfilterSchema +} from "@/utils/zod-schemas/params"; +import type { Metadata } from "next"; +import { Suspense } from "react"; +import type { DashboardProps } from "../page"; +import { TeamsTableWithPagination } from "./TeamsTableWithPagination"; + +export const metadata: Metadata = { + title: "Teams", + description: "You can create teams within your organization.", +}; + +export default async function Page({ + params, + searchParams, +}: DashboardProps) { + const { organizationId } = organizationParamSchema.parse(params); + const filters = projectsfilterSchema.parse(searchParams); + + return ( +
+ +
+
+ + {filters.query && ( +

+ Searching for {filters.query} +

+ )} +
+ + +
+ { + + Loading teams... + + } + > + + + } +
+ ); +} diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/page.tsx new file mode 100644 index 00000000..34e630e4 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/page.tsx @@ -0,0 +1,45 @@ +// https://github.com/vercel/next.js/issues/58272 +import { T } from '@/components/ui/Typography'; +import { getTeamNameById } from '@/data/user/teams'; +import TeamIcon from 'lucide-react/dist/esm/icons/folder'; +import Link from 'next/link'; + +import { Suspense } from 'react'; +import { z } from 'zod'; + +const paramsSchema = z.object({ + organizationId: z.string(), + teamId: z.coerce.number(), +}); + +async function Title({ teamId }: { teamId: number }) { + const title = await getTeamNameById(teamId); + return ( +
+ + {title} +
+ Team +
+
+ ); +} + +export default async function OrganizationNavbar({ + params, +}: { + params: unknown; +}) { + const { organizationId, teamId } = paramsSchema.parse(params); + return ( +
+ + + Loading...}> + + </Suspense> + </span> + </Link> + </div> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/settings/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/settings/page.tsx new file mode 100644 index 00000000..0f875ab0 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@navbar/settings/page.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/utils/cn'; +import { ArrowLeftIcon } from '@radix-ui/react-icons'; +import Link from 'next/link'; +import { z } from 'zod'; + +const paramsSchema = z.object({ + organizationId: z.string(), + teamId: z.coerce.number(), +}); + +export default function OrganizationSettingsNavbar({ + params, +}: { + params: unknown; +}) { + const { organizationId, teamId } = paramsSchema.parse(params); + return ( + <div className={cn('hidden lg:block', 'relative ')}> + <Link + className="flex gap-1.5 py-1.5 px-3 cursor-pointer items-center group rounded-md transition hover:cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-800" + href={`/org/${organizationId}/team/${teamId}`} + > + <ArrowLeftIcon className="w-4 h-4 text-gray-500 dark:text-slate-400 group-hover:text-gray-700 dark:group-hover:text-slate-300" /> + <p className="text-gray-500 dark:text-slate-400 group-hover:text-gray-700 dark:group-hover:text-slate-300 text-sm font-normal"> + Back to Team + </p> + </Link> + </div> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/TeamSidebar.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/TeamSidebar.tsx new file mode 100644 index 00000000..339a6999 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/TeamSidebar.tsx @@ -0,0 +1,83 @@ +import { DesktopSidebarFallback } from '@/components/SidebarComponents/SidebarFallback'; +import { SwitcherAndToggle } from '@/components/SidebarComponents/SidebarLogo'; +import { SidebarLink } from '@/components/SidebarLink'; +import { fetchSlimOrganizations } from '@/data/user/organizations'; +import { getOrganizationOfTeam } from '@/data/user/teams'; +import { cn } from '@/utils/cn'; +import { Activity, FileText, GitCompare, Home, Layers, MessageCircle, Settings, Shield, Users } from 'lucide-react'; +import { Suspense } from 'react'; + +async function TeamSidebarInternal({ organizationId }: { organizationId: string }) { + const slimOrganizations = await fetchSlimOrganizations(); + + return ( + <div + className={cn( + 'flex flex-col h-full', + 'lg:px-3 lg:py-4', + )} + > + + <div className="flex justify-between items-center mb-4"> + <SwitcherAndToggle organizationId={organizationId} slimOrganizations={slimOrganizations} /> + </div> + + <div className="flex flex-col gap-0"> + <SidebarLink + label="Home" + href={`/org/${organizationId}`} + icon={<Home className="size-4 text-foreground" />} + /> + <SidebarLink + label="Projects" + href={`/org/${organizationId}/projects`} + icon={<Layers className="size-4 text-foreground" />} + /> + <SidebarLink + label="Teams" + href={`/org/${organizationId}/teams`} + icon={<Users className="size-4 text-foreground" />} + /> + <SidebarLink + label="Activity" + href={`/org/${organizationId}/activity`} + icon={<Activity className="size-4 text-foreground" />} + /> + <SidebarLink + label="Policies" + href={`/org/${organizationId}/policies`} + icon={<Shield className="size-4 text-foreground" />} + /> + <SidebarLink + label="Drift" + href={`/org/${organizationId}/drift`} + icon={<GitCompare className="size-4 text-foreground" />} + /> + <SidebarLink + label="Docs" + href={`/org/${organizationId}/docs`} + icon={<FileText className="size-4 text-foreground" />} + /> + <SidebarLink + label="Admin" + href={`/org/${organizationId}/admin`} + icon={<Settings className="size-4 text-foreground" />} + /> + <SidebarLink + label="Ask in Slack" + href="#" + icon={<MessageCircle className="size-4 text-foreground" />} + /> + </div> + </div> + ); +} + +export async function TeamSidebar({ teamId }: { teamId: number }) { + const organizationId = await getOrganizationOfTeam(teamId); + return ( + <Suspense fallback={<DesktopSidebarFallback />}> + <TeamSidebarInternal organizationId={organizationId} /> + </Suspense> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/default.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/default.tsx new file mode 100644 index 00000000..599e81de --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/@sidebar/default.tsx @@ -0,0 +1,3 @@ +import { TeamSidebar } from './TeamSidebar'; + +export default TeamSidebar; diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/TeamProjectsTableWithPagination.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/TeamProjectsTableWithPagination.tsx new file mode 100644 index 00000000..f13c7b4e --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/TeamProjectsTableWithPagination.tsx @@ -0,0 +1,23 @@ +import { Pagination } from "@/components/Pagination"; +import { getProjects, getProjectsTotalCount } from "@/data/user/projects"; +import { projectsfilterSchema } from "@/utils/zod-schemas/params"; +import { OrganizationProjectsTable } from "../../../(specific-organization-pages)/projects/OrganizationProjectsTable"; + +export async function TeamProjectsWithPagination({ + organizationId, + teamId, + searchParams, +}: { organizationId: string; teamId: number | null; searchParams: unknown }) { + const filters = projectsfilterSchema.parse(searchParams); + const [projects, totalPages] = await Promise.all([ + getProjects({ ...filters, organizationId, teamId }), + getProjectsTotalCount({ ...filters, organizationId }), + ]); + + return ( + <> + <OrganizationProjectsTable projects={projects} /> + <Pagination totalPages={totalPages} /> + </> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/layout.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/layout.tsx new file mode 100644 index 00000000..9acd3d3b --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/layout.tsx @@ -0,0 +1,55 @@ + +import { ApplicationLayoutShell } from '@/components/ApplicationLayoutShell'; +import { InternalNavbar } from '@/components/NavigationMenu/InternalNavbar'; +import SettingsIcon from 'lucide-react/dist/esm/icons/settings'; +import Link from 'next/link'; +import { ReactNode, Suspense } from 'react'; +import { z } from 'zod'; +import { TeamSidebar } from './@sidebar/TeamSidebar'; + +const paramsSchema = z.object({ + teamId: z.coerce.number(), + organizationId: z.string(), +}); + +export default async function Layout({ + children, + params, + navbar, +}: { + children: ReactNode; + + navbar: ReactNode; +} & { + params: { + teamId: string; + }; +}) { + const parsedParams = paramsSchema.parse(params); + const { teamId, organizationId } = parsedParams; + return ( + <ApplicationLayoutShell sidebar={<TeamSidebar teamId={teamId} />}> + <div> + <InternalNavbar> + <div className="flex w-full justify-between items-center"> + <Suspense>{navbar}</Suspense> + <div className="flex items-center space-x-2"> + <Link + className="flex gap-1.5 py-1.5 px-3 cursor-pointer items-center group rounded-md transition hover:cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-800" + href={`/org/${organizationId}/team/${teamId}/settings`} + > + <SettingsIcon className="w-4 h-4 text-gray-500 dark:text-slate-400 group-hover:text-gray-700 dark:group-hover:text-slate-300" /> + <p className="text-gray-500 dark:text-slate-400 group-hover:text-gray-700 dark:group-hover:text-slate-300 text-sm font-normal"> + Team settings + </p> + </Link> + </div> + </div> + </InternalNavbar> + <div className="relative flex-1 h-auto mt-6 w-full overflow-auto"> + <div className="px-6 space-y-6 pb-10">{children}</div> + </div> + </div> + </ApplicationLayoutShell> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/page.tsx new file mode 100644 index 00000000..ee699a8a --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/page.tsx @@ -0,0 +1,72 @@ + +import { PageHeading } from '@/components/PageHeading'; +import { Search } from '@/components/Search'; +import { Button } from '@/components/ui/button'; +import { T } from '@/components/ui/Typography'; +import { projectsfilterSchema } from '@/utils/zod-schemas/params'; +import { Plus } from 'lucide-react'; +import Link from 'next/link'; +import { Suspense } from 'react'; +import { z } from 'zod'; +import { ProjectsTableWithPagination } from '../../../(specific-organization-pages)/projects/page'; + +const paramsSchema = z.object({ + teamId: z.coerce.number(), + organizationId: z.string(), +}); + +export default async function TeamPage({ + params, + searchParams +}: { + params: { + teamId: string; + }; + searchParams: unknown +}) { + const parsedParams = paramsSchema.parse(params); + const filters = projectsfilterSchema.parse(searchParams); + const { teamId, organizationId } = parsedParams; + return ( + <div className="flex flex-col space-y-4 max-w-5xl mt-2"> + <PageHeading + title="Team" + subTitle="You can create projects within team, or within your organization." + /> + <div className="flex justify-between gap-2"> + <div className="md:w-1/3"> + <Search placeholder="Search project" /> + {filters.query && ( + <p className="text-sm ml-2 mt-4"> + Searching for <span className="font-bold">{filters.query}</span> + </p> + )} + </div> + <div className="flex space-x-4"> + <Link href={`/org/${organizationId}/projects/create`}> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + Create Project + </Button> + </Link> + </div> + </div> + { + <Suspense + fallback={ + <T.P className="text-muted-foreground my-6"> + Loading team... + </T.P> + } + > + <ProjectsTableWithPagination + organizationId={organizationId} + teamId={teamId} + searchParams={searchParams} + /> + </Suspense> + } + </div> + ); +} + diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/AddUserToTeamDialog.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/AddUserToTeamDialog.tsx new file mode 100644 index 00000000..878f154b --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/AddUserToTeamDialog.tsx @@ -0,0 +1,178 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { T } from '@/components/ui/Typography'; +// convert the imports above into modularized imports +// import Check from 'lucide-react/dist/esm/icons/check'; +import { Label } from '@/components/ui/label'; +import { addUserToTeamAction } from '@/data/user/teams'; +import { useSAToastMutation } from '@/hooks/useSAToastMutation'; +import { Table } from '@/types'; +import { zodResolver } from '@hookform/resolvers/zod'; +import AddUserIcon from 'lucide-react/dist/esm/icons/user-plus'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { OrganizationUsersSelect } from './OrganizationUsersSelect'; +import { ProjectTeamMemberRoleSelect } from './ProjectTeamMemberRoleSelect'; + +const addUserSchema = z.object({ + userId: z.string(), + role: z.enum(['readonly', 'member', 'admin']), +}); + +type AddUserFormType = z.infer<typeof addUserSchema>; + +type AddableMember = Table<'organization_members'> & { + user_profiles: Table<'user_profiles'>; +}; + +export const AddUserToTeamDialog = ({ + organizationId, + teamId, + addableMembers, +}: { + organizationId: string; + teamId: number; + addableMembers: Array<AddableMember>; +}) => { + const [open, setOpen] = useState(false); + + const { control, formState, handleSubmit } = useForm<AddUserFormType>({ + defaultValues: { + role: 'member', + }, + resolver: zodResolver(addUserSchema), + }); + + const addUserMutation = useSAToastMutation( + async (data: AddUserFormType) => { + return await addUserToTeamAction({ + userId: data.userId, + organizationId, + teamId, + role: data.role, + }); + }, + { + loadingMessage: 'Adding user to team...', + successMessage: 'User added to team successfully!', + errorMessage: (error) => `Failed to add user: ${error.message}`, + onSuccess: (data) => { + if (data.status === 'success') { + setOpen(false); + console.log('Added user:', data.data); + } + }, + onError: (error) => { + console.error('Error adding user to team:', error); + // You can handle specific errors here if needed + }, + } + ); + + const users = addableMembers.map((member) => ({ + value: member.user_profiles.id, + label: member.user_profiles.full_name ?? `User ${member.user_profiles.id}`, + })); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button size="default"> + <AddUserIcon className="mr-2 w-5 h-5" /> Add User + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[425px]"> + <form + onSubmit={handleSubmit((data) => addUserMutation.mutate(data))} + > + <DialogHeader> + <div className="p-3 w-fit bg-gray-200/50 dark:bg-gray-700/40 rounded-lg"> + <AddUserIcon className="w-6 h-6" /> + </div> + <div className="p-1 mb-4"> + <DialogTitle className="text-lg">Add to Team</DialogTitle> + <DialogDescription className="text-base"> + Add user to team + </DialogDescription> + </div> + </DialogHeader> + <div className="flex flex-col gap-6 mt-4 mb-4"> + {!users.length ? ( + <T.Subtle>Loading...</T.Subtle> + ) : ( + <Controller + control={control} + name="userId" + render={({ field }) => { + const selectedUser = users.find( + (user) => user.value === field.value, + ); + return ( + <OrganizationUsersSelect + value={selectedUser} + onChange={(newUser) => { + field.onChange(newUser.value); + }} + users={users} + /> + ); + }} + ></Controller> + )} + <Controller + control={control} + name="role" + render={({ field }) => { + return ( + <div className="flex flex-col space-y-2 justify-start w-full mb-4"> + <Label className="text-muted-foreground"> + Select a role + </Label> + <ProjectTeamMemberRoleSelect + isLoading={addUserMutation.isLoading} + value={field.value} + onChange={(newRole) => { + field.onChange(newRole); + }} + /> + </div> + ); + }} + ></Controller> + </div> + <DialogFooter> + <Button + type="button" + variant={'outline'} + onClick={() => { + setOpen(false); + }} + className="w-full" + > + Cancel + </Button> + <Button + type="submit" + disabled={!formState.isValid} + variant="default" + className="w-full" + > + Yes, add to team + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +}; \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ChangeRole.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ChangeRole.tsx new file mode 100644 index 00000000..e6e22dc6 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ChangeRole.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { updateTeamRole } from '@/data/user/teams'; +import { useSAToastMutation } from '@/hooks/useSAToastMutation'; +import { Enum } from '@/types'; +import { ProjectTeamMemberRoleSelect } from './ProjectTeamMemberRoleSelect'; + +export function ChangeRole({ + role, + userId, + teamId, +}: { + role: Enum<'project_team_member_role'>; + userId: string; + teamId: number; +}) { + const changeRole = useSAToastMutation(updateTeamRole, { + loadingMessage: 'Changing role...', + successMessage: 'Role changed!', + errorMessage: 'Failed to change role', + }); + + return ( + <ProjectTeamMemberRoleSelect + value={role} + isLoading={changeRole.isLoading} + onChange={(newRole) => { + changeRole.mutate({ + userId, + role: newRole, + teamId, + }); + }} + /> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/OrganizationUsersSelect.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/OrganizationUsersSelect.tsx new file mode 100644 index 00000000..0e1503a9 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/OrganizationUsersSelect.tsx @@ -0,0 +1,82 @@ +import { Label } from '@/components/ui/label'; +import { useTheme } from 'next-themes'; +import Select from 'react-select'; + +const darkThemeStyles = { + control: (styles) => ({ + ...styles, + backgroundColor: '#111827', // slate-950 from Tailwind's color palette + borderColor: '#374151', // gray-600 from Tailwind's color palette + color: '#D1D5DB', // slate-200 from Tailwind's color palette + '&:hover': { + borderColor: '#4B5563', // slate-600 from Tailwind's color palette + }, + }), + option: (styles, { isFocused, isSelected }) => ({ + ...styles, + backgroundColor: isSelected + ? '#374151' // slate-700 from Tailwind's color palette + : isFocused + ? '#111827' // slate-950 from Tailwind's color palette + : '#1F2937', // slate-900 from Tailwind's color palette + color: '#D1D5DB', // slate-200 from Tailwind's color palette + }), + singleValue: (styles) => ({ + ...styles, + color: '#D1D5DB', // slate-200 from Tailwind's color palette + }), + multiValue: (styles) => ({ + ...styles, + backgroundColor: '#374151', // slate-700 from Tailwind's color palette + }), + multiValueLabel: (styles) => ({ + ...styles, + color: '#D1D5DB', // slate-200 from Tailwind's color palette + }), + multiValueRemove: (styles) => ({ + ...styles, + color: '#D1D5DB', // slate-200 from Tailwind's color palette + '&:hover': { + backgroundColor: '#F87171', // red-400 from Tailwind's color palette + color: '#D1D5DB', // slate-200 from Tailwind's color palette + }, + }), + clearIndicator: (styles) => ({ + ...styles, + color: '#D1D5DB', // slate-200 from Tailwind's color palette + }), + menu: (styles) => ({ + ...styles, + backgroundColor: '#1F2937', // slate-900 from Tailwind's color palette + }), +}; + +type Option = { + value: string; + label: string; +}; + +export const OrganizationUsersSelect = ({ + users, + value, + onChange, +}: { + users: Array<Option>; + value?: Option; + onChange: (value: Option) => void; +}) => { + const { theme } = useTheme(); + + return ( + <div> + <Label className="text-muted-foreground">Select user</Label> + <Select + value={value} + onChange={onChange} + options={users} + className="mt-1" + styles={theme === 'dark' ? darkThemeStyles : {}} + /> + </div> + ); +}; \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ProjectTeamMemberRoleSelect.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ProjectTeamMemberRoleSelect.tsx new file mode 100644 index 00000000..92c5b0e8 --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/ProjectTeamMemberRoleSelect.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Enum } from '@/types'; + +type ProjectTeamMemberRoleSelectProps = { + value: Enum<'project_team_member_role'>; + onChange: (value: Enum<'project_team_member_role'>) => void; + isLoading: boolean; +}; + +// typeguard to narrow string to Enum<'project_team_member_role'> +function isTeamMemberRole( + value: string, +): value is Enum<'project_team_member_role'> { + return ['admin', 'member', 'readonly'].includes(value); +} + +export function ProjectTeamMemberRoleSelect({ + value, + onChange, + isLoading = false, +}: ProjectTeamMemberRoleSelectProps) { + return ( + <Select + disabled={isLoading} + value={value} + onValueChange={(value) => { + if (!isTeamMemberRole(value)) { + throw new Error('Invalid team member role'); + } + onChange(value); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="Select a role" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectLabel>Roles</SelectLabel> + <SelectItem value="admin">Admin</SelectItem> + <SelectItem value="member">Member</SelectItem> + <SelectItem value="readonly">Read Only Member</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/RemoveUserDialog.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/RemoveUserDialog.tsx new file mode 100644 index 00000000..6675e18e --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/RemoveUserDialog.tsx @@ -0,0 +1,84 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { removeUserFromTeam } from '@/data/user/teams'; +import { useSAToastMutation } from '@/hooks/useSAToastMutation'; +import TrashIcon from 'lucide-react/dist/esm/icons/trash'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +type Props = { + teamId: number; + userId: string; + isSameUser: boolean; +}; + +export const RemoveUserDialog = ({ isSameUser, teamId, userId }: Props) => { + const [open, setOpen] = useState(false); + const router = useRouter(); + const removeUserMutation = useSAToastMutation(removeUserFromTeam, { + loadingMessage: 'Removing user from team...', + successMessage: 'User removed from team!', + errorMessage: 'Failed to remove user from team', + onSuccess: () => { + if (isSameUser) { + router.push(`/dashboard`); + } + }, + }); + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="destructive" size="default"> + <TrashIcon size={16} className="stroke-2" /> + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle> + {isSameUser ? 'Leave Team' : 'Remove User From Team'} + </DialogTitle> + <DialogDescription> + {isSameUser + ? 'Are you sure you want to leave this team?' + : 'Are you sure you want to remove this user from the team?'} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + disabled={removeUserMutation.isLoading} + variant="destructive" + onClick={() => { + removeUserMutation.mutate({ + teamId, + userId, + }); + setOpen(false); + }} + > + Yes + </Button> + <Button + disabled={removeUserMutation.isLoading} + type="button" + variant="outline" + onClick={() => { + setOpen(false); + }} + > + Cancel + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/TeamMembers.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/TeamMembers.tsx new file mode 100644 index 00000000..c1af2b3d --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/TeamMembers.tsx @@ -0,0 +1,226 @@ +import { T } from '@/components/ui/Typography'; +import { + Table as ShadcnTable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + getOrganizationAdmins, + getOrganizationTitle, +} from '@/data/user/organizations'; +import { + getAddableMembers, + getCanLoggedInUserManageTeam, + getTeamMembersByTeamId, +} from '@/data/user/teams'; +import { serverGetLoggedInUser } from '@/utils/server/serverGetLoggedInUser'; +import Link from 'next/link'; +import { Suspense } from 'react'; +import { AddUserToTeamDialog } from './AddUserToTeamDialog'; +import { ChangeRole } from './ChangeRole'; +import { RemoveUserDialog } from './RemoveUserDialog'; + +async function AddUserToTeam({ + organizationId, + teamId, +}: { + organizationId: string; + teamId: number; +}) { + const addableMembers = await getAddableMembers({ + organizationId, + teamId, + }); + + if (addableMembers.length === 0) { + return ( + <div> + <T.H3>Want to invite a member to this team? </T.H3> + <T.Subtle> + No more users to add. Invite users to the organization first{' '} + <Link + className="text-blue-500 underline cursor-pointer" + href={`/org/${organizationId}/settings/members`} + > + here + </Link> + . + </T.Subtle> + </div> + ); + } + + return ( + <AddUserToTeamDialog + organizationId={organizationId} + teamId={teamId} + addableMembers={addableMembers} + /> + ); +} + +export async function AutomaticTeamAdmins({ + organizationId, +}: { + organizationId: string; +}) { + const teamAdmins = await getOrganizationAdmins(organizationId); + const organizationTitle = await getOrganizationTitle(organizationId); + const autoTeamAdminList = teamAdmins.map((admin, index) => { + const userProfile = Array.isArray(admin.user_profiles) + ? admin.user_profiles[0] + : admin.user_profiles; + if (!userProfile) { + throw new Error('userProfile is undefined'); + } + return { + rowNo: index + 1, + name: userProfile.full_name, + role: admin.member_role, + id: admin.id, + }; + }); + + return ( + <div className="space-y-2"> + <div className="space-y-2 max-w-2xl"> + <T.H2>Team Admins</T.H2> + <T.Subtle> + Below are organization admins of the organization{' '} + <strong>{organizationTitle} </strong>. As they are organization + admins, they are automatically team admins of all teams in the + organization. + </T.Subtle> + </div> + + <div className="overflow-hidden shadow border sm:rounded-lg mt-8 max-w-2xl"> + <ShadcnTable> + <TableHeader> + <TableRow> + <TableHead scope="col">User</TableHead>{' '} + <TableHead scope="col">Name</TableHead> + <TableHead scope="col">Role</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {autoTeamAdminList.map((member, index) => { + return ( + <TableRow key={member.id}> + <TableCell>{member.rowNo}</TableCell>{' '} + <TableCell>{member.name}</TableCell> + <TableCell className="uppercase">{member.role}</TableCell> + </TableRow> + ); + })} + </TableBody> + </ShadcnTable> + </div> + </div> + ); +} + +export async function TeamMembers({ + teamId, + organizationId, +}: { + teamId: number; + organizationId: string; +}) { + const teamMembers = await getTeamMembersByTeamId(teamId); + const loggedInUser = await serverGetLoggedInUser(); + const { canUserManageTeam } = await getCanLoggedInUserManageTeam( + organizationId, + teamId, + ); + const teamMemberList = teamMembers.map((member, index) => { + const userProfile = Array.isArray(member.user_profiles) + ? member.user_profiles[0] + : member.user_profiles; + if (!userProfile) { + throw new Error('userProfile is undefined'); + } + return { + rowNo: index + 1, + name: userProfile.full_name, + role: member.role, + id: member.id, + userId: userProfile.id, + }; + }); + return ( + <div className="space-y-8"> + <div> + <div className="space-y-2 max-w-lg"> + <T.H2>Team Members</T.H2> + {teamMemberList.length ? ( + <T.Subtle> + Below are team members who have been manually added to the team. + </T.Subtle> + ) : ( + <T.Subtle>There are no team members in this team.</T.Subtle> + )} + </div> + + {teamMemberList.length ? ( + <div className="overflow-hidden shadow border sm:rounded-lg mt-8 max-w-xl"> + <ShadcnTable> + <TableHeader> + <TableRow> + <TableHead scope="col">User</TableHead>{' '} + <TableHead scope="col">Name</TableHead> + {canUserManageTeam ? ( + <> + <TableHead scope="col">Change Role</TableHead> + <TableHead scope="col">Actions</TableHead> + </> + ) : ( + <TableHead scope="col">Role</TableHead> + )} + </TableRow> + </TableHeader> + <TableBody> + {teamMemberList.map((member, index) => { + return ( + <TableRow key={member.id}> + <TableCell>{member.rowNo}</TableCell>{' '} + <TableCell>{member.name}</TableCell> + {canUserManageTeam ? ( + <> + <TableCell> + <ChangeRole + userId={member.userId} + teamId={teamId} + role={member.role} + /> + </TableCell> + <TableCell> + <RemoveUserDialog + isSameUser={member.userId === loggedInUser.id} + teamId={teamId} + userId={member.userId} + /> + </TableCell> + </> + ) : ( + <TableCell>{member.role}</TableCell> + )} + </TableRow> + ); + })} + </TableBody> + </ShadcnTable> + </div> + ) : null} + </div> + + {canUserManageTeam ? ( + <Suspense fallback={<T.P>Loading...</T.P>}> + <AddUserToTeam organizationId={organizationId} teamId={teamId} /> + </Suspense> + ) : null} + </div> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/page.tsx new file mode 100644 index 00000000..cf71b81c --- /dev/null +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/org/[organizationId]/team/[teamId]/(specific-team-pages)/settings/page.tsx @@ -0,0 +1,29 @@ +import { T } from '@/components/ui/Typography'; +import { Suspense } from 'react'; +import { z } from 'zod'; +import { AutomaticTeamAdmins, TeamMembers } from './TeamMembers'; + +const paramsSchema = z.object({ + teamId: z.coerce.number(), + organizationId: z.string(), +}); + +export default async function TeamSettingsPage({ + params, +}: { + params: unknown; +}) { + const parsedParams = paramsSchema.parse(params); + const { teamId, organizationId } = parsedParams; + + return ( + <div className="space-y-16"> + <Suspense fallback={<T.P>Loading...</T.P>}> + <AutomaticTeamAdmins organizationId={organizationId} /> + </Suspense> + <Suspense fallback={<T.P>Loading...</T.P>}> + <TeamMembers teamId={teamId} organizationId={organizationId} /> + </Suspense> + </div> + ); +} \ No newline at end of file diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/ApprovalControls.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/ApprovalControls.tsx index d81dde5e..f24a2ca4 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/ApprovalControls.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/ApprovalControls.tsx @@ -2,22 +2,32 @@ import { getLoggedInUserOrganizationRole, + getNormalizedOrganizationSubscription, getSlimOrganizationById, } from '@/data/user/organizations'; import { getSlimProjectById } from '@/data/user/projects'; +import { getLoggedInUserTeamRole, getSlimTeamById } from '@/data/user/teams'; import { ApprovalControlActions } from './ApprovalControlActions'; async function fetchData(projectId: string) { const projectByIdData = await getSlimProjectById(projectId); - const [organizationData, organizationRole] = await Promise.all([ - getSlimOrganizationById(projectByIdData.organization_id), - getLoggedInUserOrganizationRole(projectByIdData.organization_id), - ]); + const [organizationData, maybeTeamData, organizationRole, teamRole] = + await Promise.all([ + getSlimOrganizationById(projectByIdData.organization_id), + projectByIdData.team_id ? getSlimTeamById(projectByIdData.team_id) : null, + getLoggedInUserOrganizationRole(projectByIdData.organization_id), + projectByIdData.team_id + ? getLoggedInUserTeamRole(projectByIdData.team_id) + : null, + getNormalizedOrganizationSubscription(projectByIdData.organization_id), + ]); return { projectByIdData, organizationRole, + teamRole, organizationData, + maybeTeamData, }; } @@ -26,9 +36,14 @@ export async function ApprovalControls({ projectId }: { projectId: string }) { const isOrganizationManager = data.organizationRole === 'admin' || data.organizationRole === 'owner'; const isTopLevelProject = !data.projectByIdData.team_id; - const canManage = isOrganizationManager; + const maybeTeamRole = data.teamRole; + const canManage = isTopLevelProject + ? isOrganizationManager + : maybeTeamRole === 'admin' || isOrganizationManager; - const canOnlyEdit = data.organizationRole === 'member'; + const canOnlyEdit = isTopLevelProject + ? data.organizationRole === 'member' + : maybeTeamRole === 'member'; return ( <ApprovalControlActions diff --git a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/settings/page.tsx b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/settings/page.tsx index 84a5945e..cf858820 100644 --- a/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/settings/page.tsx +++ b/src/app/(dynamic-pages)/(authenticated-pages)/(application-pages)/project/[projectSlug]/(specific-project-pages)/settings/page.tsx @@ -7,9 +7,11 @@ import ProjectSettings from '../ProjectSettings'; export default async function ProjectSettingsPage({ params }: { params: unknown }) { const { projectSlug } = projectSlugParamSchema.parse(params); - const projectData = await getSlimProjectBySlug(projectSlug); - const project = await getProjectById(projectData.id); - const repository = await getRepoDetails(project.repo_id); + const [projectData, project, repository] = await Promise.all([ + getSlimProjectBySlug(projectSlug), + getProjectById((await getSlimProjectBySlug(projectSlug)).id), + getRepoDetails((await getProjectById((await getSlimProjectBySlug(projectSlug)).id)).repo_id) + ]); return ( <div className="flex flex-col space-y-4 max-w-5xl mt-2"> <ProjectSettings project={project} repositoryName={repository.repo_full_name} /> diff --git a/src/components/CreateProjectForm.tsx b/src/components/CreateProjectForm.tsx index cb1d3824..2871e535 100644 --- a/src/components/CreateProjectForm.tsx +++ b/src/components/CreateProjectForm.tsx @@ -11,7 +11,7 @@ import { generateSlug } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { motion } from "framer-motion"; -import { AlertCircle, Github } from "lucide-react"; +import { AlertCircle, Github, Users } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { FormEvent, useState } from 'react'; @@ -29,6 +29,7 @@ const createProjectFormSchema = z.object({ terraformDir: z.string().min(1, "Terraform working directory is required"), labels: z.array(z.string()), managedState: z.boolean().default(true), + teamId: z.number().int().positive().nullable(), }); type CreateProjectFormData = z.infer<typeof createProjectFormSchema>; @@ -38,12 +39,18 @@ type Repository = { repo_full_name: string | null; }; +type Team = { + id: number; + name: string; +}; + type CreateProjectFormProps = { organizationId: string; repositories: Repository[]; + teams: Team[]; }; -export default function CreateProjectForm({ organizationId, repositories }: CreateProjectFormProps) { +export default function CreateProjectForm({ organizationId, repositories, teams }: CreateProjectFormProps) { const router = useRouter(); const { control, handleSubmit, formState: { errors } } = useForm<CreateProjectFormData>({ @@ -54,6 +61,7 @@ export default function CreateProjectForm({ organizationId, repositories }: Crea terraformDir: "", managedState: true, labels: [], + teamId: null, }, }); @@ -62,6 +70,7 @@ export default function CreateProjectForm({ organizationId, repositories }: Crea const slug = generateSlug(data.name); return await createProjectAction({ organizationId, + teamId: data.teamId, name: data.name, slug, repoId: data.repository, @@ -218,6 +227,60 @@ export default function CreateProjectForm({ organizationId, repositories }: Crea )} </CardContent> </MotionCard> + <MotionCard + className="mb-6" + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.2 }} + > + <CardHeader className="flex flex-col"> + <CardTitle className="text-lg">Select a Team</CardTitle> + <CardDescription className="text-sm text-muted-foreground">Choose the team for your project</CardDescription> + </CardHeader> + <CardContent> + {teams.length > 0 ? ( + <Controller + name="teamId" + control={control} + render={({ field }) => ( + <div className="relative"> + <Select onValueChange={(value) => field.onChange(parseInt(value))} value={field.value?.toString() || ""}> + <SelectTrigger className={`w-full ${errors.teamId ? 'border-destructive' : ''}`}> + <SelectValue placeholder="Select a team" /> + </SelectTrigger> + <SelectContent> + {teams.map((team) => ( + <SelectItem key={team.id} value={team.id.toString()}> + <div className="flex items-center"> + <Users className="mr-2 h-4 w-4" /> + <span>{team.name}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + {errors.teamId && ( + <div className="flex items-center mt-1 text-destructive"> + <AlertCircle className="h-4 w-4 mr-1" /> + <span className="text-sm">{errors.teamId.message}</span> + </div> + )} + </div> + )} + /> + ) : ( + <div className="text-center py-8"> + <div className="bg-muted/50 rounded-full p-4 inline-block"> + <Users className="mx-auto size-8 text-muted-foreground" /> + </div> + <T.H4 className="mb-1 mt-4">No Teams Found</T.H4> + <T.P className="text-muted-foreground mb-4"> + It looks like there are no teams available. + </T.P> + </div> + )} + </CardContent> + </MotionCard> <MotionCard className="mb-6" diff --git a/src/components/CreateTeamDialog.tsx b/src/components/CreateTeamDialog.tsx new file mode 100644 index 00000000..43cf3da2 --- /dev/null +++ b/src/components/CreateTeamDialog.tsx @@ -0,0 +1,112 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { createTeamAction } from '@/data/user/teams'; +import { useSAToastMutation } from '@/hooks/useSAToastMutation'; +import { PlusIcon } from '@radix-ui/react-icons'; +import UsersIcon from 'lucide-react/dist/esm/icons/users'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +type CreateTeamDialogProps = { + organizationId: string; +}; + +export function CreateTeamDialog({ organizationId }: CreateTeamDialogProps) { + const [teamTitle, setTeamTitle] = useState<string>(''); + const [open, setOpen] = useState(false); + const router = useRouter(); + const { mutate, isLoading } = useSAToastMutation( + async () => await createTeamAction(organizationId, teamTitle), + { + loadingMessage: 'Creating team...', + successMessage: 'Team created!', + errorMessage: 'Failed to create team.', + onSuccess: (data) => { + if (data.status === 'success') { + setOpen(false); + router.push(`/org/${organizationId}/team/${data.data.id}`); + } + }, + }, + ); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + mutate(); + }; + + return ( + <> + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <PlusIcon className='mr-2' /> Create team + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <div className="p-3 w-fit bg-gray-200/50 dark:bg-gray-700/40 mb-2 rounded-lg"> + <UsersIcon className=" w-6 h-6" /> + </div> + <div className="p-1"> + <DialogTitle className="text-lg">Create Team</DialogTitle> + <DialogDescription className="text-base mt-0"> + Collaborate with your team members on your project. + </DialogDescription> + </div> + </DialogHeader> + <form onSubmit={handleSubmit}> + <div className="mb-8"> + <Label className="text-muted-foreground">Team Name</Label> + <Input + value={teamTitle} + onChange={(event) => { + setTeamTitle(event.target.value); + }} + required + className="mt-1.5 shadow appearance-none border h-11 rounded-lg w-full py-2 px-3 focus:ring-0 text-gray-700 dark:text-gray-300 leading-tight focus:outline-none focus:shadow-outline text-base" + id="name" + type="text" + placeholder="Team Name" + disabled={isLoading} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + disabled={isLoading} + className="w-full" + onClick={() => { + setOpen(false); + }} + > + Cancel + </Button> + <Button + type="submit" + variant="default" + className="w-full" + disabled={isLoading} + > + {isLoading ? 'Creating Team...' : 'Create Team'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + </> + ); +} \ No newline at end of file diff --git a/src/components/Projects/ProjectsTable.tsx b/src/components/Projects/ProjectsTable.tsx new file mode 100644 index 00000000..9f6fd48e --- /dev/null +++ b/src/components/Projects/ProjectsTable.tsx @@ -0,0 +1,102 @@ +'use client'; +import { Badge } from '@/components/ui/badge'; +import { + Table as ShadcnTable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { T } from '@/components/ui/Typography'; +import { Table } from '@/types'; +import Check from 'lucide-react/dist/esm/icons/check'; +import Pen from 'lucide-react/dist/esm/icons/pen-tool'; +import ThumbsUp from 'lucide-react/dist/esm/icons/thumbs-up'; +import Timer from 'lucide-react/dist/esm/icons/timer'; +import moment from 'moment'; +import Link from 'next/link'; + +export const ProjectsTable = ({ + projects, +}: { + projects: Table<'projects'>[]; +}) => { + if (projects.length === 0) { + return ( + <T.P className="text-muted-foreground my-6"> + 🔍 No matching projects found. + </T.P> + ); + } + return ( + <div className="mt-6 flow-root"> + <div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div className="inline-block min-w-full align-middle sm:px-6 lg:px-8"> + {/* <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg"></div> */} + {/* <div className="flex rounded-lg bg-clip-border border border-gray-200 max-w-[1296px] overflow-hidden"> */} + <div className="border rounded-lg shadow-sm overflow-hidden"> + <ShadcnTable> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Project Status</TableHead> + <TableHead>Created on</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {projects.map((project) => ( + <TableRow key={project.id}> + <TableCell> + <Link + href={`/project/${project.id}`} + className=" font-medium underline underline-offset-4 " + > + {project.name} + </Link> + </TableCell> + + <TableCell> + {/* Add your project status rendering logic here */} + {project.project_status === 'completed' ? ( + <Badge variant="default"> + <Check size={16} className="mr-1" /> + Completed + </Badge> + ) : project.project_status === 'pending_approval' ? ( + <Badge variant="outline"> + <Pen size={16} className="mr-1" /> + Pending Approval + </Badge> + ) : project.project_status === 'approved' ? ( + <Badge variant="secondary"> + <ThumbsUp size={16} className="mr-1" /> + Approved + </Badge> + ) : project.project_status === 'draft' ? ( + <Badge variant="default"> + <Timer size={16} className="mr-1" /> + Draft + </Badge> + ) : ( + <Badge> + <Timer size={16} /> + <T.P> + {String(project.project_status).replace('_', ' ')} + </T.P> + </Badge> + )} + </TableCell> + <TableCell> + {moment(project.created_at).format('LLL')} + </TableCell> + </TableRow> + ))} + </TableBody> + </ShadcnTable> + </div> + </div> + </div> + </div> + ); +}; \ No newline at end of file diff --git a/src/components/Teams/TeamsCardList.tsx b/src/components/Teams/TeamsCardList.tsx new file mode 100644 index 00000000..c5083388 --- /dev/null +++ b/src/components/Teams/TeamsCardList.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Table } from "@/types"; +import { format } from "date-fns"; +import { motion } from "framer-motion"; +import { CalendarDays, Link as LinkIcon } from "lucide-react"; +import Link from "next/link"; + +const MotionCard = motion(Card); +const MotionCardContent = motion(CardContent); + +const cardVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, +}; + +const contentVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 10 }, + visible: { opacity: 1, y: 0 }, +}; + +export const TeamsCardList = ({ + teams, +}: { + teams: Table<"teams">[]; +}) => { + if (teams.length === 0) { + return ( + <p className="text-muted-foreground my-6"> + 🔍 No matching teams found. + </p> + ); + } + + return ( + <ScrollArea className="w-full"> + <div className="flex space-x-4 pb-4"> + {teams.slice(0, 5).map((team, index) => ( + <MotionCard + key={team.id} + className="w-[300px] shadow-sm" + variants={cardVariants} + initial="hidden" + animate="visible" + transition={{ duration: 0.3, delay: index * 0.1 }} + > + <Link href={`/org/${team.organization_id}/team/${team.id}`} className="block p-4"> + <MotionCardContent className="p-0 space-y-3" variants={contentVariants} initial="hidden" animate="visible"> + <motion.div className="flex justify-between items-center" variants={itemVariants}> + <span className="text-xs text-muted-foreground">ID: {team.id}</span> + </motion.div> + <motion.h2 className="text-lg font-semibold" variants={itemVariants}>{team.name}</motion.h2> + <motion.div className="flex items-center text-xs text-muted-foreground" variants={itemVariants}> + <CalendarDays className="mr-1 h-3 w-3" /> + <span>Created: {format(new Date(team.created_at ?? Date.now()), "dd MMM yyyy")}</span> + </motion.div> + <motion.div className="text-xs text-muted-foreground flex items-center" variants={itemVariants}> + <LinkIcon className="mr-1 h-3 w-3" /> + <span className="truncate">/{team.id}</span> + </motion.div> + </MotionCardContent> + </Link> + </MotionCard> + ))} + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + ); +}; + +function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} \ No newline at end of file diff --git a/src/data/user/projects.tsx b/src/data/user/projects.tsx index 6f59c3f1..ce433ee4 100644 --- a/src/data/user/projects.tsx +++ b/src/data/user/projects.tsx @@ -70,6 +70,7 @@ export const createProjectAction = async ({ terraformWorkingDir, managedState, labels, + teamId, }: { organizationId: string; name: string; @@ -78,6 +79,7 @@ export const createProjectAction = async ({ terraformWorkingDir: string; managedState: boolean; labels: string[]; + teamId: number | null; }): Promise<SAPayload<Tables<"projects">>> => { "use server"; const supabaseClient = createSupabaseUserServerActionClient(); @@ -87,6 +89,7 @@ export const createProjectAction = async ({ organization_id: organizationId, name, slug, + team_id: teamId, repo_id: repoId, terraform_working_dir: terraformWorkingDir, is_managing_state: managedState, @@ -100,20 +103,23 @@ export const createProjectAction = async ({ .select("*") .single(); - console.log('createProjectAction', project); - if (error) { - console.log('createProjectAction', error); return { status: 'error', message: error.message, }; } + if (teamId) { + revalidatePath(`/org/[organizationId]/team/[teamId]`, "layout"); + } else { + revalidatePath(`/org/[organizationId]`, "layout"); + revalidatePath(`/org/[organizationId]/projects/`, "layout"); + } + + - revalidatePath(`/org/[organizationId]`, "layout"); - revalidatePath(`/org/[organizationId]/projects/`, "layout"); return { status: 'success', @@ -238,6 +244,7 @@ export const markProjectAsCompletedAction = async (projectId: string): Promise<S export const getProjects = async ({ organizationId, + teamId, query = "", page = 1, limit = 5, @@ -245,6 +252,7 @@ export const getProjects = async ({ query?: string; page?: number; organizationId: string; + teamId: number | null; limit?: number; }) => { const zeroIndexedPage = page - 1; @@ -255,6 +263,13 @@ export const getProjects = async ({ .eq("organization_id", organizationId) .range(zeroIndexedPage * limit, (zeroIndexedPage + 1) * limit - 1); + // Add team filter + if (teamId !== null) { + supabaseQuery = supabaseQuery.eq('team_id', teamId); + } else { + supabaseQuery = supabaseQuery.is('team_id', null); + } + if (query) { supabaseQuery = supabaseQuery.ilike("name", `%${query}%`); } diff --git a/src/data/user/teams.ts b/src/data/user/teams.ts new file mode 100644 index 00000000..3e939e9b --- /dev/null +++ b/src/data/user/teams.ts @@ -0,0 +1,400 @@ +'use server'; +import { supabaseAdminClient } from '@/supabase-clients/admin/supabaseAdminClient'; +import { createSupabaseUserServerActionClient } from '@/supabase-clients/user/createSupabaseUserServerActionClient'; +import { createSupabaseUserServerComponentClient } from '@/supabase-clients/user/createSupabaseUserServerComponentClient'; +import { Enum, SAPayload, Table } from '@/types'; +import { serverGetLoggedInUser } from '@/utils/server/serverGetLoggedInUser'; +import { revalidatePath } from 'next/cache'; +import { + getLoggedInUserOrganizationRole, + getOrganizationAdmins, + getTeamMembersInOrganization, +} from './organizations'; + +export async function getSlimTeamById(teamId: number) { + const supabaseClient = createSupabaseUserServerComponentClient(); + const { data, error } = await supabaseClient + .from('teams') + .select('id,name,organization_id') + .eq('id', teamId) + .single(); + if (error) { + throw error; + } + return data; +} + +export const getTeamsInOrganization = async ( + organizationId: string, +): Promise<Table<'teams'>[]> => { + const supabaseClient = createSupabaseUserServerComponentClient(); + + const { data, error } = await supabaseClient + .from('teams') + .select('*') + .eq('organization_id', organizationId) + .order('created_at', { ascending: true }); + + if (error) { + throw error; + } + + if (!data) { + throw new Error('No teams found for organization'); + } + return data; +}; + +export const getTeams = async ({ + organizationId, + query = '', + page = 1, + limit = 5, +}: { + query?: string; + page?: number; + organizationId: string; + limit?: number; +}) => { + const zeroIndexedPage = page - 1; + const supabase = createSupabaseUserServerComponentClient(); + let supabaseQuery = supabase + .from('teams') + .select('*') + .eq('organization_id', organizationId) + .range(zeroIndexedPage * limit, (zeroIndexedPage + 1) * limit - 1); + + if (query) { + supabaseQuery = supabaseQuery.ilike('name', `%${query}%`); + } + + const { data, error } = await supabaseQuery.order('created_at', { + ascending: false, + }); + + if (error) { + throw error; + } + + return data; +}; + +export const getTeamsTotalCount = async ({ + organizationId, + query = '', + page = 1, + limit = 5, +}: { + organizationId: string; + query?: string; + page?: number; + limit?: number; +}) => { + const zeroIndexedPage = page - 1; + let supabaseQuery = supabaseAdminClient + .from('teams') + .select('id', { + count: 'exact', + head: true, + }) + .eq('organization_id', organizationId) + .range(zeroIndexedPage * limit, (zeroIndexedPage + 1) * limit - 1); + + if (query) { + supabaseQuery = supabaseQuery.ilike('name', `%${query}%`); + } + + const { count, error } = await supabaseQuery.order('created_at', { + ascending: false, + }); + + if (error) { + throw error; + } + + if (!count) { + return 0; + } + + return Math.ceil(count / limit) ?? 0; +}; + +export const createTeamAction = async ( + organizationId: string, + name: string, +): Promise< + SAPayload<{ + created_at: string | null; + id: number; + name: string; + organization_id: string; + }> +> => { + const supabaseClient = createSupabaseUserServerComponentClient(); + const user = await serverGetLoggedInUser(); + + const { data, error } = await supabaseClient + .from('teams') + .insert({ + name, + organization_id: organizationId, + }) + .select('*') + .single(); + + if (error) { + throw error; + } + + await addUserToTeamAction({ + userId: user.id, + teamId: data.id, + role: 'admin', + organizationId, + }); + + revalidatePath(`/org/${organizationId}`); + + return { + status: 'success', + data, + }; +}; + +export async function getOrganizationOfTeam(teamId: number) { + const supabaseClient = createSupabaseUserServerComponentClient(); + const { data, error } = await supabaseClient + .from('teams') + .select('organization_id') + .eq('id', teamId) + .single(); + if (error) { + throw error; + } + return data.organization_id; +} + +export const getUserTeamRole = async ( + userId: string, + teamId: number, +): Promise<Enum<'project_team_member_role'> | null> => { + const supabase = createSupabaseUserServerComponentClient(); + const { data, error } = await supabase + .from('team_members') + .select('*') + .eq('user_id', userId) + .eq('team_id', teamId); + + const row = data?.[0]; + + if (error) { + throw error; + } + + return row?.role ?? null; +}; + +export const getLoggedInUserTeamRole = async (teamId: number) => { + const user = await serverGetLoggedInUser(); + return getUserTeamRole(user.id, teamId); +}; + +export const getTeamById = async (teamId: number) => { + const supabase = createSupabaseUserServerComponentClient(); + const { data, error } = await supabase + .from('teams') + .select('*') + .eq('id', teamId) + .single(); + + if (error) { + throw error; + } + + return data; +}; + +export const getTeamNameById = async (teamId: number) => { + const supabase = createSupabaseUserServerComponentClient(); + const { data, error } = await supabase + .from('teams') + .select('name') + .eq('id', teamId) + .single(); + + if (error) { + throw error; + } + + return data.name; +}; + +export const getTeamMembersByTeamId = async (teamId: number) => { + const supabase = createSupabaseUserServerComponentClient(); + const { data, error } = await supabase + .from('team_members') + .select('*, user_profiles(*)') + .eq('team_id', teamId); + + if (error) { + throw error; + } + + return data.map((member) => { + const { user_profiles, ...rest } = member; + if (!user_profiles) { + throw new Error('No user profile found for member'); + } + return { + ...rest, + user_profiles: user_profiles, + }; + }); +}; + +export const getCanLoggedInUserManageTeam = async ( + organizationId: string, + teamId: number, +) => { + const [teamRole, orgRole] = await Promise.all([ + getLoggedInUserTeamRole(teamId), + getLoggedInUserOrganizationRole(organizationId), + ]); + + let canUserManageTeam = false; + + if (teamRole === 'admin' || orgRole === 'owner' || orgRole === 'admin') { + canUserManageTeam = true; + } + return { + canUserManageTeam, + teamRole, + orgRole, + }; +}; + +export const updateTeamRole = async ({ + userId, + teamId, + role, +}: { + userId: string; + teamId: number; + role: Enum<'project_team_member_role'>; +}): Promise<SAPayload> => { + const supabase = createSupabaseUserServerActionClient(); + const organizationId = await getOrganizationOfTeam(teamId); + const { data, error } = await supabase + .from('team_members') + .update({ role: role }) + .eq('user_id', userId) + .eq('team_id', teamId); + + if (error) { + throw error; + } + + revalidatePath(`/org/${organizationId}/team/${teamId}`); + + return { + status: 'success', + }; +}; + +export const removeUserFromTeam = async ({ + userId, + teamId, +}: { + userId: string; + teamId: number; +}): Promise<SAPayload> => { + const supabase = createSupabaseUserServerActionClient(); + const organizationId = await getOrganizationOfTeam(teamId); + + const { data, error } = await supabase + .from('team_members') + .delete() + .eq('user_id', userId) + .eq('team_id', teamId); + + if (error) { + throw error; + } + revalidatePath(`/org/${organizationId}/team/${teamId}`); + return { + status: 'success', + }; +}; + +export const getAddableMembers = async ({ + organizationId, + teamId, +}: { + organizationId: string; + teamId: number; +}) => { + const [orgMembers, teamMembers, admins] = await Promise.all([ + getTeamMembersInOrganization(organizationId), + getTeamMembersByTeamId(teamId), + getOrganizationAdmins(organizationId), + ]); + + return orgMembers.filter((member) => { + const isMember = teamMembers.find( + (teamMember) => teamMember.user_profiles.id === member.user_profiles.id, + ); + const isAdmin = admins.find( + (admin) => admin.user_profiles.id === member.user_profiles.id, + ); + return !isMember && !isAdmin; + }); +}; + +export const addUserToTeamAction = async ({ + userId, + teamId, + role, + organizationId, +}: { + userId: string; + organizationId: string; + teamId: number; + role: Enum<'project_team_member_role'>; +}): Promise< + SAPayload<{ + id: number; + user_id: string; + role: Enum<'project_team_member_role'>; + team_id: number; + }> +> => { + const supabase = createSupabaseUserServerComponentClient(); + const rowCount = await supabase + .from('team_members') + .select('id') + .eq('user_id', userId) + .eq('team_id', teamId); + + if (rowCount.error || rowCount.data?.length > 0) { + throw new Error('User already in team'); + } + + const { data, error } = await supabase + .from('team_members') + .insert({ + user_id: userId, + role: role, + team_id: teamId, + }) + .select('*') + .single(); + + if (error) { + throw error; + } + revalidatePath(`/organorgization/${organizationId}/team/${teamId}`); + return { + status: 'success', + data, + }; +}; diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index fbf4a5f0..577d0477 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -1458,6 +1458,59 @@ export type Database = { }, ] } + team_members: { + Row: { + created_at: string | null + id: number + role: Database["public"]["Enums"]["project_team_member_role"] + team_id: number + user_id: string + } + Insert: { + created_at?: string | null + id?: number + role?: Database["public"]["Enums"]["project_team_member_role"] + team_id: number + user_id: string + } + Update: { + created_at?: string | null + id?: number + role?: Database["public"]["Enums"]["project_team_member_role"] + team_id?: number + user_id?: string + } + Relationships: [ + { + foreignKeyName: "team_members_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "user_profiles" + referencedColumns: ["id"] + }, + ] + } + teams: { + Row: { + created_at: string | null + id: number + name: string + organization_id: string + } + Insert: { + created_at?: string | null + id?: number + name: string + organization_id: string + } + Update: { + created_at?: string | null + id?: number + name?: string + organization_id?: string + } + Relationships: [] + } user_api_keys: { Row: { created_at: string @@ -1780,12 +1833,19 @@ export type Database = { member_id: string }[] } - get_organization_id_by_team_id: { - Args: { - p_id: number - } - Returns: string - } + get_organization_id_by_team_id: + | { + Args: { + p_id: number + } + Returns: string + } + | { + Args: { + p_id: number + } + Returns: string + } get_organization_id_for_project_id: { Args: { project_id: string @@ -1808,12 +1868,28 @@ export type Database = { organization_id: string }[] } + get_team_admins_by_team_id: { + Args: { + team_id: number + } + Returns: { + user_id: string + }[] + } get_team_id_for_project_id: { Args: { project_id: string } Returns: number } + get_team_members_team_id: { + Args: { + team_id: number + } + Returns: { + user_id: string + }[] + } increment_credits: { Args: { org_id: string diff --git a/src/utils/parseNotification.ts b/src/utils/parseNotification.ts index 2b0563ef..9946284e 100644 --- a/src/utils/parseNotification.ts +++ b/src/utils/parseNotification.ts @@ -76,7 +76,7 @@ export const parseNotification = ( return { title: 'Accepted invitation to join organization', description: `${notification.userFullName} has accepted your invitation to join your organization`, - href: `/organization/${notification.organizationId}/settings/members`, + href: `/org/${notification.organizationId}/settings/members`, image: 'UserCheck', type: notification.type, actionType: 'link', diff --git a/src/utils/zod-schemas/params.ts b/src/utils/zod-schemas/params.ts index d8956607..930e6088 100644 --- a/src/utils/zod-schemas/params.ts +++ b/src/utils/zod-schemas/params.ts @@ -4,6 +4,11 @@ export const organizationParamSchema = z.object({ organizationId: z.string().uuid(), }); +export const teamParamSchema = z.object({ + teamId: z.coerce.number().optional(), + organizationId: z.string().uuid(), +}); + export const organizationSlugParamSchema = z.object({ organizationSlug: z.string(), }); @@ -13,6 +18,11 @@ export const projectsfilterSchema = z.object({ query: z.string().optional(), }); +export const teamFilterSchema = z.object({ + page: z.coerce.number().optional(), + query: z.string().optional(), +}); + export const projectParamSchema = z.object({ projectId: z.string().uuid(), }); diff --git a/supabase/migrations/20240731075720_add_teams.sql b/supabase/migrations/20240731075720_add_teams.sql new file mode 100644 index 00000000..d97aa8fe --- /dev/null +++ b/supabase/migrations/20240731075720_add_teams.sql @@ -0,0 +1,16 @@ +CREATE TABLE "public"."team_members" ( + "id" bigint generated by DEFAULT AS identity NOT NULL, + "created_at" timestamp WITH time zone DEFAULT NOW(), + "user_id" uuid NOT NULL REFERENCES "public"."user_profiles"("id") ON DELETE CASCADE, + "role" project_team_member_role NOT NULL DEFAULT 'member'::project_team_member_role, + "team_id" bigint NOT NULL +); + +CREATE TABLE "public"."teams" ( + "id" bigint generated by DEFAULT AS identity NOT NULL, + "created_at" timestamp WITH time zone DEFAULT NOW(), + "organization_id" uuid NOT NULL, + "name" text NOT NULL +); + +ALTER TABLE "public"."teams" enable ROW LEVEL SECURITY; \ No newline at end of file diff --git a/supabase/migrations/20240731081047_add_team_database_functions.sql b/supabase/migrations/20240731081047_add_team_database_functions.sql new file mode 100644 index 00000000..51169860 --- /dev/null +++ b/supabase/migrations/20240731081047_add_team_database_functions.sql @@ -0,0 +1,90 @@ + +-- Get organization of a team +-- This function is used to get an organization of a team +CREATE OR REPLACE FUNCTION public.get_organization_id_by_team_id(p_id integer) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER AS $function$ +DECLARE v_organization_id UUID; +BEGIN +SELECT organization_id INTO v_organization_id +FROM teams +WHERE id = p_id; +RETURN v_organization_id; +EXCEPTION +WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'No organization found for the provided id: %', +p_id; +END; +$function$; +REVOKE ALL ON FUNCTION public.get_organization_id_by_team_id(p_id integer) +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_organization_id_by_team_id(p_id integer) +FROM ANON; + + +CREATE OR REPLACE FUNCTION public.get_organization_id_by_team_id(p_id bigint) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER AS $function$ +DECLARE v_organization_id UUID; +BEGIN +SELECT organization_id INTO v_organization_id +FROM teams +WHERE id = p_id; +RETURN v_organization_id; +EXCEPTION +WHEN NO_DATA_FOUND THEN RAISE EXCEPTION 'No organization found for the provided id: %', +p_id; +END; +$function$; +REVOKE ALL ON FUNCTION public.get_organization_id_by_team_id(p_id bigint) +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_organization_id_by_team_id(p_id bigint) +FROM ANON; + +-- Get organizations for user +-- This function is used to get all organizations that a user is a member of +CREATE OR REPLACE FUNCTION public.get_organizations_for_user(user_id uuid) RETURNS TABLE(organization_id uuid) LANGUAGE plpgsql SECURITY DEFINER AS $function$ BEGIN RETURN QUERY +SELECT o.id AS organization_id +FROM organizations o + JOIN organization_members ot ON o.id = ot.organization_id +WHERE ot.member_id = user_id; +END; +$function$; +REVOKE ALL ON FUNCTION public.get_organizations_for_user +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_organizations_for_user +FROM ANON; +-- Get project admins of a team +-- This function is used to get all admins of a team +CREATE OR REPLACE FUNCTION public.get_team_admins_by_team_id(team_id bigint) RETURNS TABLE(user_id uuid) LANGUAGE plpgsql SECURITY DEFINER AS $function$ BEGIN RETURN QUERY +SELECT team_members.user_id +FROM team_members +WHERE team_members.team_id = $1 + AND role = 'admin'; +END; +$function$; +REVOKE ALL ON FUNCTION public.get_team_admins_by_team_id +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_team_admins_by_team_id +FROM ANON; +-- Get project members of a team +-- This function is used to get all members of a team +CREATE OR REPLACE FUNCTION public.get_team_members_team_id(team_id bigint) RETURNS TABLE(user_id uuid) LANGUAGE plpgsql SECURITY DEFINER AS $function$ BEGIN RETURN QUERY +SELECT team_members.user_id +FROM team_members +WHERE team_members.team_id = $1; +END; +$function$; +REVOKE ALL ON FUNCTION public.get_team_members_team_id +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_team_members_team_id +FROM ANON; + +CREATE OR REPLACE FUNCTION get_team_id_for_project_id(project_id UUID) RETURNS INT8 AS $$ +DECLARE team_id INT8; +BEGIN +SELECT p.team_id INTO team_id +FROM projects p +WHERE p.id = project_id; +RETURN team_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +REVOKE ALL ON FUNCTION public.get_team_id_for_project_id +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_team_id_for_project_id +FROM ANON; \ No newline at end of file diff --git a/supabase/migrations/20240731082359_add_team_policies.sql b/supabase/migrations/20240731082359_add_team_policies.sql new file mode 100644 index 00000000..bfb3b134 --- /dev/null +++ b/supabase/migrations/20240731082359_add_team_policies.sql @@ -0,0 +1,187 @@ +-- Drop the policy for reading project comments +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'project_comments' + AND policyname = 'All organization members of a project can read project comments' + ) THEN + DROP POLICY "All organization members of a project can read project comments" ON "public"."project_comments"; + END IF; +END $$; + +-- Drop the policy for inserting project comments +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'project_comments' + AND policyname = 'All organization members of a project can make project comments' + ) THEN + DROP POLICY "All organization members of a project can make project comments" ON "public"."project_comments"; + END IF; +END $$; + + +CREATE policy "Enable delete for org admins only" ON "public"."teams" AS permissive FOR DELETE TO authenticated USING ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(teams.organization_id) AS get_organization_admin_ids + ) + ) +); + +CREATE policy "Enable insert for org admins only" ON "public"."teams" AS permissive FOR +INSERT TO authenticated WITH CHECK ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(teams.organization_id) AS get_organization_admin_ids + ) + ) + ); + + + +CREATE policy "Enable read access for org admins or team members" ON "public"."teams" AS permissive FOR +SELECT TO authenticated USING ( + ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(teams.organization_id) AS get_organization_admin_ids + ) + ) + OR ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_team_members_team_id(teams.id) AS get_team_members_team_id + ) + ) + ) + ); + + +CREATE policy "Enable update for org admins" ON "public"."teams" AS permissive FOR +UPDATE TO authenticated USING ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(teams.organization_id) AS get_organization_admin_ids + ) + ) + ) WITH CHECK ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(teams.organization_id) AS get_organization_admin_ids + ) + ) + ); + + +CREATE policy "Enable read access for all team members" ON "public"."projects" AS permissive FOR +SELECT TO authenticated USING ( + ( + (organization_id IS NULL) + OR ( + (team_id IS NULL) + AND ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_member_ids(projects.organization_id) AS get_organization_member_ids + ) + ) + ) + OR ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_team_members_team_id(projects.team_id) AS get_team_members_team_id + ) + ) + OR ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(projects.organization_id) AS get_organization_admin_ids + ) + ) + ) + ); + + +CREATE policy "All team members can read organizations" ON "public"."organizations" AS permissive FOR +SELECT TO authenticated USING ( + ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_member_ids(organizations.id) AS get_organization_member_ids + ) + ) + OR ( + id IN ( + SELECT get_invited_organizations_for_user_v2( + ( + SELECT auth.uid() + ), + ((auth.jwt()->>'email'::text))::character varying + ) AS get_invited_organizations_for_user_v2 + ) + ) + ) + ); + +CREATE policy "All team members of a project can make project comments" ON "public"."project_comments" AS permissive FOR +INSERT TO authenticated WITH CHECK ( + ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(get_organization_id_for_project_id(project_id)) AS get_organization_admin_ids + ) + ) + OR ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_team_members_team_id(get_team_id_for_project_id(project_id)) AS get_team_members_team_id + ) + ) + ) + ); + + CREATE policy "All team members of a project can read project comments" ON "public"."project_comments" AS permissive FOR +SELECT TO authenticated USING ( + ( + ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_organization_admin_ids(get_organization_id_for_project_id(project_id)) AS get_organization_admin_ids + ) + ) + OR ( + ( + SELECT auth.uid() + ) IN ( + SELECT get_team_members_team_id(get_team_id_for_project_id(project_id)) AS get_team_members_team_id + ) + ) + ) + ); \ No newline at end of file