diff --git a/auth.config.ts b/auth.config.ts deleted file mode 100644 index bb62a0d..0000000 --- a/auth.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NextAuthConfig } from "next-auth"; -import { NextResponse } from "next/server"; - -export const authConfig = { - pages: { - signIn: "/auth/login", - }, - callbacks: { - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isAuthRoute = nextUrl.pathname.startsWith("/auth"); - const isPublicRoute = nextUrl.pathname.startsWith("/public"); - if (isAuthRoute) { - if (isLoggedIn) { - return Response.redirect(new URL(`/teams`, nextUrl)); - } - return false; - } - - if (!isLoggedIn && !isPublicRoute) { - return Response.redirect(new URL("/auth/login", nextUrl)); - } - return true; - }, - }, - providers: [], -} satisfies NextAuthConfig; diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index a339353..42ca743 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,8 +1,6 @@ import { Inbox, Users, - User, - UserPlus, Files, GitPullRequest, LogOut, @@ -23,10 +21,8 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarHeader, - SidebarFooter, } from "@/components/ui/sidebar"; import SignOutModal from "@/components/auth/sign-out-modal"; -import { NetworkStatusIndicator } from "@/components/ui/network-status-indicator"; import { useRouter } from "next/router"; const data = { @@ -59,8 +55,7 @@ const data = { icon: Files, }, { - title: "Logout", - url: "/api/auth/signout", + title: "Sign Out", icon: LogOut, }, ], @@ -115,7 +110,7 @@ export function AppSidebar() { {item.items.map((subItem) => ( - {subItem.title === "Logout" ? ( + {subItem.title === "Sign Out" ? ( -
- {errorMessage && ( - <> - -

{errorMessage}

- - )} -
- - - ); -} diff --git a/components/auth/sign-in-form.tsx b/components/auth/sign-in-form.tsx new file mode 100644 index 0000000..d4257b8 --- /dev/null +++ b/components/auth/sign-in-form.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { + AtSymbolIcon, + KeyIcon, + ExclamationCircleIcon, +} from "@heroicons/react/24/outline"; +import { ArrowRightIcon } from "@heroicons/react/20/solid"; +import { Button } from "../ui/button"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import { toast } from "react-hot-toast"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { schemaSignIn } from "@/lib/schemas/sign-in.schema"; +import { z } from "zod"; +import { signIn } from "next-auth/react"; + +type FormData = z.infer; + +export function SignInForm() { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(schemaSignIn), + defaultValues: { + email: "", + password: "", + }, + }); + + const onSubmit = async (data: FormData) => { + try { + setIsLoading(true); + + const result = await signIn("credentials", { + email: data.email, + password: data.password, + redirect: false, + callbackUrl: "/teams", + }); + + if (result?.error) { + toast.error("Invalid email or password"); + return; + } + toast.success("Sign in successful!"); + router.push("/teams"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Something went wrong" + ); + console.error("Sign in error:", error); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Please sign in to continue.

+
+
+ +
+ + +
+ {form.formState.errors.email && ( +
+ +

{form.formState.errors.email.message}

+
+ )} +
+
+ +
+ + +
+ {form.formState.errors.password && ( +
+ +

{form.formState.errors.password.message}

+
+ )} +
+
+ +
+
+ ); +} diff --git a/components/auth/sign-out-modal.tsx b/components/auth/sign-out-modal.tsx index 9c744c4..71734f4 100644 --- a/components/auth/sign-out-modal.tsx +++ b/components/auth/sign-out-modal.tsx @@ -30,10 +30,10 @@ const SignOutModal = ({ children }: SignOutModalProps) => { await signOut({ redirect: false, - callbackUrl: "/auth/login", + callbackUrl: "/auth/sign-in", }); - router.push("/auth/login"); + router.push("/auth/sign-in"); } catch (error) { console.error("Error during sign out:", error); } finally { diff --git a/components/auth/register-form.tsx b/components/auth/sign-up-form.tsx similarity index 95% rename from components/auth/register-form.tsx rename to components/auth/sign-up-form.tsx index 48486da..016e487 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/sign-up-form.tsx @@ -1,17 +1,12 @@ -import { - AtSymbolIcon, - KeyIcon, - ExclamationCircleIcon, - UserIcon, -} from "@heroicons/react/24/outline"; +import { AtSymbolIcon, KeyIcon, UserIcon } from "@heroicons/react/24/outline"; import { ArrowRightIcon } from "@heroicons/react/20/solid"; import { Button } from "../ui/button"; -import { register } from "@/lib/auth/actions"; +import { signUp } from "@/lib/auth/actions"; import { useState } from "react"; import { useRouter } from "next/router"; import toast from "react-hot-toast"; -export function RegisterForm() { +export function SignUpForm() { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -21,7 +16,7 @@ export function RegisterForm() { const formData = new FormData(e.currentTarget); try { - const { error } = await register(formData); + const { error } = await signUp(formData); if (error) { toast.error(error); @@ -30,7 +25,7 @@ export function RegisterForm() { } toast.success("Account created successfully! Please log in."); - await router.push("/auth/login"); + await router.push("/auth/sign-in"); } catch (error) { toast.error("An error occurred while creating your account"); setIsLoading(false); diff --git a/components/board/board.tsx b/components/board/board.tsx index f101ca1..4dad82b 100644 --- a/components/board/board.tsx +++ b/components/board/board.tsx @@ -22,7 +22,7 @@ import { ShapeColorPalette } from "@/components/board/components/shape-color-pal import { KonvaEventObject } from "konva/lib/Node"; import { Text } from "konva/lib/shapes/Text"; -export default function Board({}: BoardProps) { +export default function Board() { const clientSyncService = useClientSync(); const docUrl = clientSyncService.getDocUrl() as AnyDocumentId; const [localDoc] = useDocument(docUrl); diff --git a/components/board/hooks/use-board-interactions.ts b/components/board/hooks/use-board-interactions.ts index 4872c4c..ffd8cd2 100644 --- a/components/board/hooks/use-board-interactions.ts +++ b/components/board/hooks/use-board-interactions.ts @@ -1,6 +1,5 @@ -import { useCallback, useContext, useRef, useEffect } from "react"; +import { useCallback, useContext, useRef } from "react"; import { KonvaEventObject } from "konva/lib/Node"; -import { BoardMode } from "@/types/board"; import { useDrawing } from "./use-drawing"; import { useDragging } from "./use-dragging"; import { useErasing } from "./use-erasing"; @@ -57,7 +56,7 @@ export const useBoardInteractions = () => { (e: KeyboardEvent) => { if (mode !== "selecting") return; - if (e.key === "Delete" || e.key === "Backspace") { + if (e.key === "Delete") { e.preventDefault(); handleDelete(); } diff --git a/components/teams/change-role-dialog.tsx b/components/teams/change-role-dialog.tsx new file mode 100644 index 0000000..f2555c9 --- /dev/null +++ b/components/teams/change-role-dialog.tsx @@ -0,0 +1,126 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { TeamMemberWithRelations } from "@/lib/services/team/team-service"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import { toast } from "sonner"; + +export default function ChangeRoleDialog({ + member, + teamId, + currentUserRole, + adminsCount, +}: { + member: TeamMemberWithRelations; + teamId: string; + currentUserRole: string; + adminsCount: number; +}) { + const [open, setOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(member.role.name); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleChangeRole = async () => { + if (selectedRole === member.role.name) { + setOpen(false); + return; + } + + if ( + member.role.name === "Admin" && + selectedRole !== "Admin" && + adminsCount <= 1 + ) { + toast.error("Cannot remove the last admin of the team"); + return; + } + + setIsLoading(true); + try { + const response = await fetch( + `/api/teams/${teamId}/members/${member.id}/role`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ role: selectedRole }), + } + ); + + if (!response.ok) { + throw new Error("Failed to change role"); + } + + toast.success("Role changed successfully"); + router.reload(); + setOpen(false); + } catch (error) { + toast.error("Failed to change role"); + } finally { + setIsLoading(false); + } + }; + + if (currentUserRole !== "Admin") return null; + + return ( + + + + + + + Change Member Role + + Change the role for {member.user.name} + + +
+ +
+ + + + +
+
+ ); +} diff --git a/components/teams/delete-member-dialog.tsx b/components/teams/delete-member-dialog.tsx new file mode 100644 index 0000000..f053b9a --- /dev/null +++ b/components/teams/delete-member-dialog.tsx @@ -0,0 +1,87 @@ +import { Button } from "../ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; +import { toast } from "react-hot-toast"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import { TeamMemberWithRelations } from "@/lib/services/team/team-service"; + +export default function DeleteMemberDialog({ + member, + teamId, +}: { + member: TeamMemberWithRelations; + teamId: string; +}) { + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(null); + const [open, setOpen] = useState(false); + + const handleDeleteMember = async (memberId: string) => { + try { + setIsDeleting(memberId); + const response = await fetch( + `/api/teams/${teamId}/members/${memberId}/delete`, + { + method: "DELETE", + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || "Failed to delete member"); + } + + toast.success("Member deleted successfully"); + router.reload(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete member" + ); + } finally { + setIsDeleting(null); + setOpen(false); + } + }; + + return ( + + + + + + + Delete team member + + Are you sure you want to remove {member.user.name} from the team? + This action cannot be undone. + + + + Cancel + handleDeleteMember(member.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + + ); +} diff --git a/components/teams/invitations-list.tsx b/components/teams/invitations-list.tsx index 8e1da2a..52708d7 100644 --- a/components/teams/invitations-list.tsx +++ b/components/teams/invitations-list.tsx @@ -62,16 +62,11 @@ export function InvitationsList({ action === "accept" ? "accepted" : "rejected" } successfully` ); - - router.push("/profile/invitations"); } catch (error) { console.error(`Error ${action}ing invitation:`, error); - toast.error( - error instanceof Error - ? error.message - : `Failed to ${action} invitation` - ); + toast.error(`Failed to ${action} invitation`); } finally { + router.push("/profile/invitations"); setProcessingInvitations((prev) => ({ ...prev, [invitationId]: false })); } }; diff --git a/components/teams/leave-team-dialog.tsx b/components/teams/leave-team-dialog.tsx new file mode 100644 index 0000000..6fcfaff --- /dev/null +++ b/components/teams/leave-team-dialog.tsx @@ -0,0 +1,80 @@ +import { Button } from "../ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "../ui/alert-dialog"; +import { toast } from "react-hot-toast"; +import { useState } from "react"; +import { useRouter } from "next/router"; +import { deleteArchivedBoards } from "@/lib/utils/indexeddb-garbage-collector"; + +interface LeaveTeamDialogProps { + teamId: string; + teamName: string; +} + +export function LeaveTeamDialog({ teamId, teamName }: LeaveTeamDialogProps) { + const router = useRouter(); + const [isLeaving, setIsLeaving] = useState(false); + const [open, setOpen] = useState(false); + + const handleLeaveTeam = async () => { + try { + setIsLeaving(true); + const response = await fetch(`/api/teams/${teamId}/leave`, { + method: "POST", + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || "Failed to leave team"); + } + + toast.success("Successfully left the team"); + router.push("/teams"); + await deleteArchivedBoards(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to leave team" + ); + } finally { + setIsLeaving(false); + setOpen(false); + } + }; + + return ( + + + + + + + Leave team + + Are you sure you want to leave {teamName}? You will lose access to + all boards and resources associated with this team. + + + + Cancel + + Leave Team + + + + + ); +} diff --git a/components/teams/members-list.tsx b/components/teams/members-list.tsx index 2b6fdda..927bd48 100644 --- a/components/teams/members-list.tsx +++ b/components/teams/members-list.tsx @@ -15,24 +15,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { User } from "@prisma/client"; - -interface TeamMemberWithRelations { - id: string; - teamId: string; - userId: string; - roleId: string; - user: User; - role: { - id: string; - name: string; - }; -} +import DeleteMemberDialog from "./delete-member-dialog"; +import ChangeRoleDialog from "./change-role-dialog"; +import { TeamMemberWithRelations } from "@/lib/services/team/team-service"; export function MembersList({ members, + userRole, + teamId, + userId, }: { members: TeamMemberWithRelations[]; + userRole: string; + teamId: string; + userId: string; }) { const getInitials = (name: string) => { return name @@ -53,6 +49,13 @@ export function MembersList({ } }; + const isAdmin = userRole === "Admin"; + const adminsCount = members.filter( + (member) => member.role.name === "Admin" + ).length; + + const isOnlyAdmin = adminsCount === 1; + return (
@@ -64,12 +67,13 @@ export function MembersList({
- +
User Email Role + {isAdmin && Actions} @@ -101,6 +105,22 @@ export function MembersList({ {member.role.name} + +
+ {isAdmin && member.userId !== userId && ( + + )} + {isAdmin && + (member.role.name !== "Admin" || !isOnlyAdmin) && ( + + )} +
+
))}
diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..57760f2 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..6e637f7 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx index 416c921..2a5846d 100644 --- a/components/ui/sidebar.tsx +++ b/components/ui/sidebar.tsx @@ -440,7 +440,7 @@ const SidebarGroupLabel = React.forwardRef< return ( svg]:size-4 [&>svg]:shrink-0", @@ -461,11 +461,10 @@ const SidebarGroupAction = React.forwardRef< return ( svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. "after:absolute after:-inset-2 after:md:hidden", "group-data-[collapsible=icon]:hidden", className @@ -562,7 +561,7 @@ const SidebarMenuButton = React.forwardRef< const button = ( svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. "after:absolute after:-inset-2 after:md:hidden", "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", @@ -721,7 +719,7 @@ const SidebarMenuSubButton = React.forwardRef< return ( { } } -export async function getTeam(id: string): Promise { - try { - const team = await prisma.team.findUnique({ - where: { id }, - }); - return team; - } catch (e) { - console.error(e); - return null; - } -} - -export async function getTeamMembers( - teamId: string -): Promise { - try { - const members = await prisma.teamMember.findMany({ - where: { teamId }, - include: { - user: true, - role: true, - }, - }); - - return members; - } catch (e) { - console.error(e); - return null; - } -} - export async function getInvitations( email: string ): Promise { diff --git a/lib/auth/actions.ts b/lib/auth/actions.ts index 6643357..180de30 100644 --- a/lib/auth/actions.ts +++ b/lib/auth/actions.ts @@ -1,30 +1,6 @@ -import { signIn } from "next-auth/react"; - -export async function authenticate(formData: FormData) { - try { - const result = await signIn("credentials", { - email: formData.get("email"), - password: formData.get("password"), - redirect: false, - callbackUrl: "/teams", - }); - - if (result?.error) { - return { - error: result.error || "Invalid email or password", - isLoading: false, - }; - } - - return { error: "", isLoading: false }; - } catch (error) { - return { error: "An error occurred. Please try again.", isLoading: false }; - } -} - -export async function register(formData: FormData) { +export async function signUp(formData: FormData) { try { - const response = await fetch("/api/auth/register", { + const response = await fetch("/api/auth/sign-up", { method: "POST", body: JSON.stringify({ email: formData.get("email"), diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 552c32d..35c8037 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -43,10 +43,10 @@ export const authOptions: NextAuthOptions = { }), ], pages: { - signIn: "/auth/login", + signIn: "/auth/sign-in", error: "/auth/error", - signOut: "/auth/logout", - newUser: "/auth/register", + + newUser: "/auth/sign-up", }, session: { strategy: "jwt", diff --git a/lib/automerge-server.ts b/lib/automerge-server.ts index b8d0269..b8680aa 100644 --- a/lib/automerge-server.ts +++ b/lib/automerge-server.ts @@ -30,6 +30,6 @@ export async function createAutomergeServer( peerId: `storage-server-${hostname}`, }; - const repo = new Repo(config); + const repo = new Repo(config as any); return repo; } diff --git a/lib/middleware/with-team-role-page.ts b/lib/middleware/with-team-role-page.ts index b2957b2..5244c53 100644 --- a/lib/middleware/with-team-role-page.ts +++ b/lib/middleware/with-team-role-page.ts @@ -27,7 +27,7 @@ export type TeamRolePageOptions = { /** * Redirect URL if authentication fails - * Default: '/auth/login' + * Default: '/auth/sign-in' */ authRedirect?: string; @@ -49,7 +49,7 @@ const defaultOptions: Required> & { }, resourceType: "team", role: "Member", - authRedirect: "/auth/login", + authRedirect: "/auth/sign-in", forbiddenRedirect: "/teams", }; diff --git a/lib/schemas/login.schema.ts b/lib/schemas/login.schema.ts deleted file mode 100644 index edd84b7..0000000 --- a/lib/schemas/login.schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -"use server"; -import { z } from "zod"; - -/** - * Schema for validating login form data using Zod. - * - */ -export const schemaLogin = z.object({ - /** - * Validates the email field. - * - Must be a string. - * - Must be a valid email address. - */ - email: z.string().email({ message: "Invalid email address" }), - - /** - * Validates the password field. - * - Must be a string. - * - Must be at least 6 characters long. - */ - password: z - .string() - .min(6, { message: "Password must be at least 6 characters long" }), -}); diff --git a/lib/schemas/sign-in.schema.ts b/lib/schemas/sign-in.schema.ts new file mode 100644 index 0000000..aa4eaa8 --- /dev/null +++ b/lib/schemas/sign-in.schema.ts @@ -0,0 +1,22 @@ +"use server"; +import { z } from "zod"; + +/** + * @file sign-in.schema.ts + * @description Schema for validating sign-in form data using Zod. + */ +export const schemaSignIn = z.object({ + /** + * Validates the email field. + * - Must be a string. + * - Must be a valid email address. + */ + email: z.string().email("Invalid email address"), + + /** + * Validates the password field. + * - Must be a string. + * - Must be at least 8 characters long. + */ + password: z.string().min(8, "Password must be at least 8 characters"), +}); diff --git a/lib/schemas/register.schema.ts b/lib/schemas/sign-up.schema.ts similarity index 93% rename from lib/schemas/register.schema.ts rename to lib/schemas/sign-up.schema.ts index db40dee..c4cbed3 100644 --- a/lib/schemas/register.schema.ts +++ b/lib/schemas/sign-up.schema.ts @@ -2,10 +2,10 @@ import { z } from "zod"; /** - * Schema for validating login form data using Zod. + * Schema for validating sign-up form data using Zod. * */ -export const schemaRegister = z.object({ +export const schemaSignUp = z.object({ /** * Validates the email field. * - Must be a string. diff --git a/lib/services/client-doc/client-doc-service.ts b/lib/services/client-doc/client-doc-service.ts index b8eb476..9481ed6 100644 --- a/lib/services/client-doc/client-doc-service.ts +++ b/lib/services/client-doc/client-doc-service.ts @@ -165,6 +165,7 @@ export class ClientSyncService implements IClientSyncService { try { this.networkAdapter.disconnect(); + // @ts-ignore this.networkAdapter.emit("close"); } catch (error) { console.error("Error disconnecting from server", error); diff --git a/lib/services/client-doc/types.ts b/lib/services/client-doc/types.ts index 9b22ada..f2a3a88 100644 --- a/lib/services/client-doc/types.ts +++ b/lib/services/client-doc/types.ts @@ -1,5 +1,5 @@ -import { KonvaNodeSchema, LayerSchema } from "@/types/KonvaNodeSchema"; -import { DocHandle } from "@automerge/automerge-repo"; +import { LayerSchema } from "@/types/KonvaNodeSchema"; +import { DocHandle, Repo } from "@automerge/automerge-repo"; export interface ClientSyncStatus { isOnline: boolean; @@ -9,19 +9,19 @@ export interface ClientSyncStatus { } export interface IClientSyncService { + initializeRepo(): Promise | null>; getDocUrl(): string; - // getSyncStatus(): ClientSyncStatus; - // onStatusChange(callback: (status: ClientSyncStatus) => void): void; - // getIsLocalSynced(): boolean; - // getMergeResult(): KonvaNodeSchema & Record; - // applyLocalChanges(changes: Uint8Array[]): void; - // discardLocalChanges(): void; - updateServerData(docUrl: string): void; + createLocalDocFromServerDoc(): Promise | null>; + setOnline(online: boolean): void; + canConnect(): boolean; + getRepo(): Repo | null; connect(): void; disconnect(): void; - canConnect(): boolean; - deleteDoc(): void; - initializeRepo(): Promise; + getDocUrl(): string; + updateServerData(docUrl: string): void; createLocalDocFromServerDoc(): Promise | null>; + syncLocalRepo(): Promise; setOnline(online: boolean): void; + getActiveUsers(): Promise; + deleteDoc(): void; } diff --git a/lib/services/team/team-service.ts b/lib/services/team/team-service.ts index f0da480..0a4c224 100644 --- a/lib/services/team/team-service.ts +++ b/lib/services/team/team-service.ts @@ -5,8 +5,8 @@ import { Team as PrismaTeam, TeamInvitation as PrismaTeamInvitation, TeamMember as PrismaTeamMember, - TeamInvitationStatus, Board, + User, } from "@prisma/client"; export class TeamServiceError extends Error { @@ -78,6 +78,17 @@ export interface TeamInvitationWithTeam extends PrismaTeamInvitation { }; } +export interface TeamMemberWithRelations { + id: string; + teamId: string; + userId: string; + roleId: string; + user: User; + role: { + id: string; + name: string; + }; +} export class TeamService { static async getTeamBoards(teamId: string): Promise { return await prisma.board.findMany({ @@ -425,4 +436,90 @@ export class TeamService { }); return memberTeams.map((member) => member.team); } + + /** + * Delete a member from a team + * @throws {TeamServiceError} If the member is the last admin of the team + */ + static async deleteMember( + teamId: string, + memberId: string + ): Promise { + try { + const member = await prisma.teamMember.findUnique({ + where: { id: memberId }, + include: { role: true }, + }); + + if (!member) { + throw new TeamServiceError(`Team member with ID ${memberId} not found`); + } + const membersCount = await prisma.teamMember.count({ + where: { teamId }, + }); + if (membersCount <= 1) { + throw new TeamServiceError("Cannot delete the last member of the team"); + } + if (member.role.name === "Admin") { + const adminCount = await prisma.teamMember.count({ + where: { + teamId, + role: { + name: "Admin", + }, + }, + }); + + if (adminCount <= 1) { + throw new TeamServiceError( + "Cannot delete the last admin of the team" + ); + } + } + + await prisma.teamMember.delete({ + where: { id: memberId }, + }); + + return true; + } catch (error) { + if (error instanceof TeamServiceError) { + throw error; + } + + throw new TeamServiceError( + `Failed to delete team member: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + static async getMember(memberId: string): Promise { + const member = await prisma.teamMember.findUnique({ + where: { id: memberId }, + include: { role: true, user: true }, + }); + if (!member) { + throw new TeamServiceError(`Team member with ID ${memberId} not found`); + } + return member as PrismaTeamMember; + } + + static async getTeamMembers(teamId: string): Promise { + try { + const members = await prisma.teamMember.findMany({ + where: { teamId }, + include: { + user: true, + role: true, + }, + }); + + return members; + } catch (e) { + console.error(e); + return []; + } + } } diff --git a/lib/utils/mail/index.ts b/lib/utils/mail/index.ts index ed48579..8fcf12e 100644 --- a/lib/utils/mail/index.ts +++ b/lib/utils/mail/index.ts @@ -65,7 +65,7 @@ export async function sendInvitationEmail( If you already have an account, visit: ${siteUrl}/teams - If you need to create an account: ${siteUrl}/register?email=${encodeURIComponent( + If you need to create an account: ${siteUrl}/sign-up?email=${encodeURIComponent( to )} diff --git a/lib/utils/mail/templates/invite.html b/lib/utils/mail/templates/invite.html index d0fbada..4c8734f 100644 --- a/lib/utils/mail/templates/invite.html +++ b/lib/utils/mail/templates/invite.html @@ -99,8 +99,8 @@

Team: {{teamName}}

Go to CollaBoard -

If you don't have an account yet, you can register here:

- If you don't have an account yet, you can sign up here:

+
Create an Account diff --git a/middleware.ts b/middleware.ts index 6f4f4e4..616ea7b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,8 +3,8 @@ import type { NextRequest } from "next/server"; import { getToken } from "next-auth/jwt"; const publicPaths = [ - "/auth/login", - "/auth/register", + "/auth/sign-in", + "/auth/sign-up", "/auth/error", "/auth/verify", "/api/auth", @@ -39,7 +39,7 @@ export async function middleware(request: NextRequest) { if (!token && !isPublicPath(pathname)) { const url = request.nextUrl.clone(); - url.pathname = "/auth/login"; + url.pathname = "/auth/sign-in"; return NextResponse.redirect(url); } diff --git a/package-lock.json b/package-lock.json index 7f0f942..47f80ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,22 @@ "name": "collaboard", "version": "0.1.0", "dependencies": { - "@automerge/automerge": "^2.2.8", - "@automerge/automerge-repo": "^1.1.12", + "@automerge/automerge": "2.2.8", + "@automerge/automerge-repo": "1.1.12", "@automerge/automerge-repo-network-websocket": "^1.2.1", - "@automerge/automerge-repo-react-hooks": "^1.1.12", - "@automerge/automerge-repo-storage-indexeddb": "^1.1.12", + "@automerge/automerge-repo-react-hooks": "1.1.12", + "@automerge/automerge-repo-storage-indexeddb": "1.1.12", "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.10.0", "@kessl/next-flash": "^1.0.3", "@prisma/client": "^5.18.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", @@ -46,6 +48,7 @@ "next-auth": "^4.24.11", "nodemailer": "^6.10.0", "or": "^0.2.0", + "prisma": "^6.5.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", @@ -1257,67 +1260,101 @@ } } }, - "node_modules/@prisma/debug": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.18.0.tgz", - "integrity": "sha512-f+ZvpTLidSo3LMJxQPVgAxdAjzv5OpzAo/eF8qZqbwvgi2F5cTOI9XCpdRzJYA0iGfajjwjOKKrVq64vkxEfUw==", + "node_modules/@prisma/config": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.5.0.tgz", + "integrity": "sha512-sOH/2Go9Zer67DNFLZk6pYOHj+rumSb0VILgltkoxOjYnlLqUpHPAN826vnx8HigqnOCxj9LRhT6U7uLiIIWgw==", "license": "Apache-2.0", - "optional": true, - "peer": true + "dependencies": { + "esbuild": ">=0.12 <1", + "esbuild-register": "3.6.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.5.0.tgz", + "integrity": "sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ==", + "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.18.0.tgz", - "integrity": "sha512-ofmpGLeJ2q2P0wa/XaEgTnX/IsLnvSp/gZts0zjgLNdBhfuj2lowOOPmDcfKljLQUXMvAek3lw5T01kHmCG8rg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.5.0.tgz", + "integrity": "sha512-FVPQYHgOllJklN9DUyujXvh3hFJCY0NX86sDmBErLvoZjy2OXGiZ5FNf3J/C4/RZZmCypZBYpBKEhx7b7rEsdw==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@prisma/debug": "5.18.0", - "@prisma/engines-version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "@prisma/fetch-engine": "5.18.0", - "@prisma/get-platform": "5.18.0" + "@prisma/debug": "6.5.0", + "@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", + "@prisma/fetch-engine": "6.5.0", + "@prisma/get-platform": "6.5.0" } }, "node_modules/@prisma/engines-version": { - "version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169.tgz", - "integrity": "sha512-a/+LpJj8vYU3nmtkg+N3X51ddbt35yYrRe8wqHTJtYQt7l1f8kjIBcCs6sHJvodW/EK5XGvboOiwm47fmNrbgg==", - "license": "Apache-2.0", - "optional": true, - "peer": true + "version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60.tgz", + "integrity": "sha512-iK3EmiVGFDCmXjSpdsKGNqy9hOdLnvYBrJB61far/oP03hlIxrb04OWmDjNTwtmZ3UZdA5MCvI+f+3k2jPTflQ==", + "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.18.0.tgz", - "integrity": "sha512-I/3u0x2n31rGaAuBRx2YK4eB7R/1zCuayo2DGwSpGyrJWsZesrV7QVw7ND0/Suxeo/vLkJ5OwuBqHoCxvTHpOg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.5.0.tgz", + "integrity": "sha512-3LhYA+FXP6pqY8FLHCjewyE8pGXXJ7BxZw2rhPq+CZAhvflVzq4K8Qly3OrmOkn6wGlz79nyLQdknyCG2HBTuA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@prisma/debug": "5.18.0", - "@prisma/engines-version": "5.18.0-25.4c784e32044a8a016d99474bd02a3b6123742169", - "@prisma/get-platform": "5.18.0" + "@prisma/debug": "6.5.0", + "@prisma/engines-version": "6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60", + "@prisma/get-platform": "6.5.0" } }, "node_modules/@prisma/get-platform": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.18.0.tgz", - "integrity": "sha512-Tk+m7+uhqcKDgnMnFN0lRiH7Ewea0OEsZZs9pqXa7i3+7svS3FSCqDBCaM9x5fmhhkufiG0BtunJVDka+46DlA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.5.0.tgz", + "integrity": "sha512-xYcvyJwNMg2eDptBYFqFLUCfgi+wZLcj6HDMsj0Qw0irvauG4IKmkbywnqwok0B+k+W+p+jThM2DKTSmoPCkzw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@prisma/debug": "5.18.0" + "@prisma/debug": "6.5.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", @@ -1815,6 +1852,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", @@ -2363,9 +2443,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3746,6 +3826,18 @@ "@esbuild/win32-x64": "0.23.1" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6803,21 +6895,31 @@ "license": "MIT" }, "node_modules/prisma": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.18.0.tgz", - "integrity": "sha512-+TrSIxZsh64OPOmaSgVPH7ALL9dfU0jceYaMJXsNrTkFHO7/3RANi5K2ZiPB1De9+KDxCWn7jvRq8y8pvk+o9g==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.5.0.tgz", + "integrity": "sha512-yUGXmWqv5F4PByMSNbYFxke/WbnyTLjnJ5bKr8fLkcnY7U5rU9rUTh/+Fja+gOrRxEgtCbCtca94IeITj4j/pg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@prisma/engines": "5.18.0" + "@prisma/config": "6.5.0", + "@prisma/engines": "6.5.0" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=16.13" + "node": ">=18.18" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/prop-types": { diff --git a/package.json b/package.json index f9c713c..d43cf6f 100644 --- a/package.json +++ b/package.json @@ -9,20 +9,22 @@ "lint": "next lint" }, "dependencies": { - "@automerge/automerge": "^2.2.8", - "@automerge/automerge-repo": "^1.1.12", + "@automerge/automerge": "2.2.8", + "@automerge/automerge-repo": "1.1.12", "@automerge/automerge-repo-network-websocket": "^1.2.1", - "@automerge/automerge-repo-react-hooks": "^1.1.12", - "@automerge/automerge-repo-storage-indexeddb": "^1.1.12", + "@automerge/automerge-repo-react-hooks": "1.1.12", + "@automerge/automerge-repo-storage-indexeddb": "1.1.12", "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.10.0", "@kessl/next-flash": "^1.0.3", "@prisma/client": "^5.18.0", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", @@ -47,6 +49,7 @@ "next-auth": "^4.24.11", "nodemailer": "^6.10.0", "or": "^0.2.0", + "prisma": "^6.5.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", diff --git a/pages/api/auth/login.ts b/pages/api/auth/login.ts deleted file mode 100644 index 6dc5d3f..0000000 --- a/pages/api/auth/login.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { schemaLogin } from "@/lib/schemas/login.schema"; -import { signIn } from "next-auth/react"; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { - if (req.method !== "POST") { - return res.status(405).json({ error: "Method not allowed" }); - } - - try { - const parsedData = schemaLogin.parse(req.body); - - const result = await signIn("credentials", { - email: parsedData.email, - password: parsedData.password, - redirect: false, - callbackUrl: "/dashboard", - }); - - if (result?.error) { - return res.status(401).json({ error: "Invalid email or password" }); - } - - return res.status(200).json({ success: true }); - } catch (error) { - return res - .status(500) - .json({ error: "An error occurred. Please try again." }); - } -} diff --git a/pages/api/auth/register.ts b/pages/api/auth/register.ts index 765f71b..1960c3d 100644 --- a/pages/api/auth/register.ts +++ b/pages/api/auth/register.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { schemaRegister } from "@/lib/schemas/register.schema"; +import { schemaSignUp } from "@/lib/schemas/sign-up.schema"; import { prisma } from "@/db/prisma"; import { hash } from "bcryptjs"; @@ -12,7 +12,7 @@ export default async function handler( } try { - const parsedData = schemaRegister.parse(req.body); + const parsedData = schemaSignUp.parse(req.body); const emailExists = await prisma.user.findUnique({ where: { email: parsedData.email }, diff --git a/pages/api/auth/sign-up.ts b/pages/api/auth/sign-up.ts new file mode 100644 index 0000000..1960c3d --- /dev/null +++ b/pages/api/auth/sign-up.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { schemaSignUp } from "@/lib/schemas/sign-up.schema"; +import { prisma } from "@/db/prisma"; +import { hash } from "bcryptjs"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const parsedData = schemaSignUp.parse(req.body); + + const emailExists = await prisma.user.findUnique({ + where: { email: parsedData.email }, + }); + + if (emailExists) { + return res + .status(400) + .json({ error: "Account with this email already exists" }); + } + + if (parsedData.password !== parsedData.confirmPassword) { + return res.status(400).json({ error: "Passwords do not match" }); + } + + const usernameExists = await prisma.user.findUnique({ + where: { username: parsedData.username }, + }); + + if (usernameExists) { + return res + .status(400) + .json({ error: "Account with this username already exists" }); + } + + const hashedPassword = await hash(parsedData.password, 10); + await prisma.user.create({ + data: { + email: parsedData.email, + passwordHash: hashedPassword, + name: parsedData.name, + surname: parsedData.surname, + username: parsedData.username, + }, + }); + + return res.status(200).json({ success: true }); + } catch (error) { + return res + .status(500) + .json({ error: "An error occurred. Please try again." }); + } +} diff --git a/pages/api/auth/signout.ts b/pages/api/auth/signout.ts deleted file mode 100644 index c182e38..0000000 --- a/pages/api/auth/signout.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { getSession } from "next-auth/react"; - -/** - * API endpoint for signing out a user - * - * @param req - The Next.js API request - * @param res - The Next.js API response - * @returns A JSON response indicating success or failure - */ -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { - if (req.method !== "POST") { - return res.status(405).json({ - success: false, - message: "Method not allowed. Use POST instead.", - }); - } - - try { - const session = await getSession({ req }); - - if (!session) { - return res.status(401).json({ - success: false, - message: "Not authenticated", - }); - } - - const cookieOptions = { - expires: new Date(0), - path: "/", - httpOnly: true, - sameSite: "lax" as const, - }; - - res.setHeader("Set-Cookie", [ - `next-auth.session-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax`, - `next-auth.callback-url=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax`, - `next-auth.csrf-token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Lax`, - ]); - - return res.status(200).json({ - success: true, - message: "Successfully signed out", - }); - } catch (error) { - console.error("Sign out error:", error); - return res.status(500).json({ - success: false, - message: "An error occurred during sign out", - }); - } -} diff --git a/pages/api/invitations/[id]/accept.ts b/pages/api/invitations/[id]/accept.ts index 5a0d330..d5395c0 100644 --- a/pages/api/invitations/[id]/accept.ts +++ b/pages/api/invitations/[id]/accept.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import { TeamService } from "@/lib/services/team/team-service"; -import { withCollaboardApi, ApiResponse } from "@/lib/middleware"; +import { withApi, ApiResponse } from "@/lib/middleware"; /** * API endpoint for accepting a team invitation @@ -31,7 +31,7 @@ async function handler( }); } -export default withCollaboardApi(handler, { +export default withApi(handler, { methods: ["POST"], requireAuth: true, }); diff --git a/pages/api/invitations/[id]/reject.ts b/pages/api/invitations/[id]/reject.ts index c878eab..63a8bc2 100644 --- a/pages/api/invitations/[id]/reject.ts +++ b/pages/api/invitations/[id]/reject.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import { TeamService } from "@/lib/services/team/team-service"; -import { withCollaboardApi } from "@/lib/middleware"; +import { withApi } from "@/lib/middleware"; import { ApiResponse } from "@/lib/middleware/with-api-auth"; /** @@ -32,7 +32,7 @@ async function handler( }); } -export default withCollaboardApi(handler, { +export default withApi(handler, { methods: ["POST"], requireAuth: true, }); diff --git a/pages/api/teams/[id]/leave.ts b/pages/api/teams/[id]/leave.ts new file mode 100644 index 0000000..000f916 --- /dev/null +++ b/pages/api/teams/[id]/leave.ts @@ -0,0 +1,45 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { withTeamRoleApi } from "@/lib/middleware"; +import { TeamService } from "@/lib/services/team/team-service"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth/auth"; +import { prisma } from "@/db/prisma"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const teamId = req.query.id as string; + const session = await getServerSession(req, res, authOptions); + + if (!session) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const userId = session.user.id; + const member = await prisma.teamMember.findFirst({ + where: { + teamId, + userId, + }, + }); + + if (!member) { + return res.status(404).json({ message: "Member not found" }); + } + + await TeamService.deleteMember(teamId, member.id); + return res.status(200).json({ message: "Successfully left the team" }); + } catch (error) { + if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: "Internal server error" }); + } +} + +export default withTeamRoleApi(handler, { + methods: ["POST"], + requireAuth: true, + resourceType: "team", + role: ["Admin", "Member"], + getResourceId: (req) => req.query.id as string, +}); diff --git a/pages/api/teams/[id]/members/[memberId]/delete.ts b/pages/api/teams/[id]/members/[memberId]/delete.ts new file mode 100644 index 0000000..74a5ba3 --- /dev/null +++ b/pages/api/teams/[id]/members/[memberId]/delete.ts @@ -0,0 +1,35 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { withTeamRoleApi } from "@/lib/middleware"; +import { TeamService } from "@/lib/services/team/team-service"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth/auth"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const teamId = req.query.id as string; + const memberId = req.query.memberId as string; + + const userMember = await TeamService.getMember(memberId); + + const sessionUser = await getServerSession(req, res, authOptions); + if (userMember.userId === sessionUser?.user.id) { + return res.status(400).json({ message: "You cannot delete yourself" }); + } + + await TeamService.deleteMember(teamId, memberId); + return res.status(200).json({ message: "Member deleted successfully" }); + } catch (error) { + if (error instanceof Error) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: "Internal server error" }); + } +} + +export default withTeamRoleApi(handler, { + methods: ["DELETE"], + requireAuth: true, + resourceType: "team", + role: ["Admin"], + getResourceId: (req) => req.query.id as string, +}); diff --git a/pages/api/teams/[id]/members/[memberId]/role.ts b/pages/api/teams/[id]/members/[memberId]/role.ts new file mode 100644 index 0000000..51f26fb --- /dev/null +++ b/pages/api/teams/[id]/members/[memberId]/role.ts @@ -0,0 +1,72 @@ +import { prisma } from "@/db/prisma"; +import { TeamServiceError } from "@/lib/services/team/team-service"; +import { withTeamRoleApi } from "@/lib/middleware"; +import { NextApiRequest, NextApiResponse } from "next"; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "PATCH") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const teamId = req.query.id as string; + const memberId = req.query.memberId as string; + const { role: newRoleName } = req.body; + + try { + const member = await prisma.teamMember.findUnique({ + where: { id: memberId }, + include: { role: true }, + }); + + if (!member) { + return res.status(404).json({ message: "Member not found" }); + } + + if (member.role.name === "Admin" && newRoleName !== "Admin") { + const adminCount = await prisma.teamMember.count({ + where: { + teamId, + role: { + name: "Admin", + }, + }, + }); + + if (adminCount <= 1) { + throw new TeamServiceError("Cannot remove the last admin of the team"); + } + } + + const newRole = await prisma.teamRole.findFirst({ + where: { name: newRoleName }, + }); + + if (!newRole) { + return res.status(400).json({ message: "Invalid role" }); + } + + const updatedMember = await prisma.teamMember.update({ + where: { id: memberId }, + data: { roleId: newRole.id }, + include: { + user: true, + role: true, + }, + }); + + return res.status(200).json(updatedMember); + } catch (error) { + if (error instanceof TeamServiceError) { + return res.status(400).json({ message: error.message }); + } + return res.status(500).json({ message: "Internal server error" }); + } +} + +export default withTeamRoleApi(handler, { + methods: ["PATCH"], + requireAuth: true, + resourceType: "team", + role: ["Admin"], + getResourceId: (req) => req.query.id as string, +}); diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx deleted file mode 100644 index 14fb1e6..0000000 --- a/pages/auth/login.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import AuthLayout from "@/components/layouts/auth-layout"; -import { LoginForm } from "@/components/auth/login-form"; -import Link from "next/link"; - -const LoginPage = () => { - return ( - -
-
-
-
-
- -

- Don't have an account?{" "} - - Sign up - -

-
-
-
- ); -}; - -export default LoginPage; diff --git a/pages/auth/sign-in.tsx b/pages/auth/sign-in.tsx new file mode 100644 index 0000000..9a7fef3 --- /dev/null +++ b/pages/auth/sign-in.tsx @@ -0,0 +1,27 @@ +import { SignInForm } from "@/components/auth/sign-in-form"; +import Head from "next/head"; +import Link from "next/link"; + +const SignInPage = () => { + return ( + <> + + Sign in - CollaBoard + + +
+
+ +

+ Don't have an account?{" "} + + Sign up + +

+
+
+ + ); +}; + +export default SignInPage; diff --git a/pages/auth/register.tsx b/pages/auth/sign-up.tsx similarity index 57% rename from pages/auth/register.tsx rename to pages/auth/sign-up.tsx index b9c9c1a..c636bb9 100644 --- a/pages/auth/register.tsx +++ b/pages/auth/sign-up.tsx @@ -1,20 +1,17 @@ import AuthLayout from "@/components/layouts/auth-layout"; -import { RegisterForm } from "@/components/auth/register-form"; +import { SignUpForm } from "@/components/auth/sign-up-form"; import Link from "next/link"; -export default function RegisterPage() { +export default function SignUpPage() { return ( - +
- +

Already have an account?{" "} - - Login + + Sign in

diff --git a/pages/boards/[id]/index.tsx b/pages/boards/[id]/index.tsx index 02d16a2..657754e 100644 --- a/pages/boards/[id]/index.tsx +++ b/pages/boards/[id]/index.tsx @@ -15,7 +15,11 @@ export default function BoardPage({ boardId, docUrl }: BoardPageProps) { const getServerSidePropsFunc: GetServerSideProps = async ({ params }) => { const boardId = params?.id as string; const board = await BoardService.getBoardById(boardId); - + if (!board) { + return { + notFound: true, + }; + } return { props: { boardId, diff --git a/pages/index.tsx b/pages/index.tsx index 12d6cb9..57f980a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,4 +1,3 @@ -import { SyncService } from "@/lib/services/client-doc/client-doc-service"; import Head from "next/head"; import { useSession } from "next-auth/react"; import { useEffect } from "react"; diff --git a/pages/profile/invitations.tsx b/pages/profile/invitations.tsx index dd48517..4ac095f 100644 --- a/pages/profile/invitations.tsx +++ b/pages/profile/invitations.tsx @@ -1,8 +1,8 @@ import { getInvitations } from "@/db/data"; -import { TeamInvitation } from "@prisma/client"; import { GetServerSideProps } from "next"; import { getSession } from "next-auth/react"; import { InvitationsList } from "@/components/teams/invitations-list"; + export default function Invitations({ invitations }: { invitations: string }) { const parsedInvitations = JSON.parse(invitations); return ( @@ -11,6 +11,7 @@ export default function Invitations({ invitations }: { invitations: string }) {
); } + export const getServerSideProps: GetServerSideProps = async ({ req, params, @@ -20,7 +21,7 @@ export const getServerSideProps: GetServerSideProps = async ({ if (!session?.user || !session.user.email) { return { redirect: { - destination: "/auth/login", + destination: "/auth/sign-in", permanent: false, }, }; diff --git a/pages/teams/[id]/boards.tsx b/pages/teams/[id]/boards.tsx index 03c7fff..b4bfe63 100644 --- a/pages/teams/[id]/boards.tsx +++ b/pages/teams/[id]/boards.tsx @@ -1,10 +1,10 @@ import { GetServerSideProps } from "next"; -import { getTeamBoards, getTeam } from "@/db/data"; import { BoardCards } from "@/components/boards/board-cards"; import { getSession } from "next-auth/react"; import { CreateBoardDialog } from "@/components/boards/create-board-dialog"; import { TeamService } from "@/lib/services/team/team-service"; import { withTeamRolePage } from "@/lib/middleware"; +import { getTeamBoards } from "@/db/data"; interface BoardsPageProps { boards: string; team: string; @@ -39,7 +39,7 @@ const getServerSidePropsFunc: GetServerSideProps = async (context) => { const teamId = context.params?.id as string; const userRole = await TeamService.getUserTeamRole(session!.user.id, teamId); - const team = await getTeam(teamId); + const team = await TeamService.getTeamById(teamId); if (!team) { return { diff --git a/pages/teams/[id]/members.tsx b/pages/teams/[id]/members.tsx index 348654d..37cd496 100644 --- a/pages/teams/[id]/members.tsx +++ b/pages/teams/[id]/members.tsx @@ -1,5 +1,4 @@ import { GetServerSideProps } from "next"; -import { getTeamMembers, getTeam } from "@/db/data"; import { MembersList } from "@/components/teams/members-list"; import { getSession } from "next-auth/react"; import { TeamService } from "@/lib/services/team/team-service"; @@ -10,16 +9,19 @@ interface MembersPageProps { members: string; team: string; userRole: string; + userId: string; } export default function MembersPage({ members, team, userRole, + userId, }: MembersPageProps) { const parsedTeam = JSON.parse(team); const parsedMembers = JSON.parse(members); const parsedUserRole = JSON.parse(userRole); + const parsedUserId = JSON.parse(userId); return (
@@ -27,7 +29,14 @@ export default function MembersPage({
- {parsedMembers && } + {parsedMembers && ( + + )}
); } @@ -36,16 +45,16 @@ const getServerSidePropsFunc: GetServerSideProps = async (context) => { const session = await getSession(context); const teamId = context.params?.id as string; - const team = await getTeam(teamId); + const team = await TeamService.getTeamById(teamId); const userRole = await TeamService.getUserTeamRole(session!.user.id, teamId); - - const members = await getTeamMembers(teamId); + const members = await TeamService.getTeamMembers(teamId); return { props: { members: JSON.stringify(members), team: JSON.stringify(team), userRole: JSON.stringify(userRole), + userId: JSON.stringify(session!.user.id), }, }; }; diff --git a/pages/teams/[id]/settings.tsx b/pages/teams/[id]/settings.tsx index b541426..a321bac 100644 --- a/pages/teams/[id]/settings.tsx +++ b/pages/teams/[id]/settings.tsx @@ -1,3 +1,107 @@ -export default function SettingsPage() { - return
Settings
; +import { GetServerSideProps } from "next"; +import { getSession } from "next-auth/react"; +import { TeamService } from "@/lib/services/team/team-service"; +import { withTeamRolePage } from "@/lib/middleware"; +import { LeaveTeamDialog } from "@/components/teams/leave-team-dialog"; +import { Card, CardContent } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +interface SettingsPageProps { + team: string; + userRole: string; + members: string; } + +export default function SettingsPage({ + team, + userRole, + members, +}: SettingsPageProps) { + const parsedTeam = JSON.parse(team); + const parsedUserRole = JSON.parse(userRole); + const parsedMembers = JSON.parse(members); + + const isAdmin = parsedUserRole === "Admin"; + const adminsCount = parsedMembers.filter( + (member: any) => member.role.name === "Admin" + ).length; + + if (adminsCount === 1 && isAdmin) { + return ( +
+
+

{parsedTeam.name} - Settings

+
+
+ + +
+ +
+
+

Leave Team

+

+ You are the only admin of this team. You cannot leave the + team. +

+
+
+
+
+
+
+
+ ); + } + return ( +
+
+

{parsedTeam.name} - Settings

+
+ +
+ + +
+ +
+
+

Leave Team

+

+ Remove yourself from this team +

+
+ +
+
+
+
+
+
+ ); +} + +const getServerSidePropsFunc: GetServerSideProps = async (context) => { + const session = await getSession(context); + + const teamId = context.params?.id as string; + const team = await TeamService.getTeamById(teamId); + const userRole = await TeamService.getUserTeamRole(session!.user.id, teamId); + const members = await TeamService.getTeamMembers(teamId); + + return { + props: { + team: JSON.stringify(team), + userRole: JSON.stringify(userRole), + members: JSON.stringify(members), + }, + }; +}; + +export const getServerSideProps = withTeamRolePage(getServerSidePropsFunc, { + resourceType: "team", + role: ["Admin", "Member"], +}); diff --git a/tsconfig.json b/tsconfig.json index f870842..4b76514 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ES2015", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/types/globals.d.ts b/types/globals.d.ts index f19ce02..07866fa 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -19,3 +19,12 @@ declare module "*.jpeg" { const content: StaticImageData; export default content; } + +declare module "*.wasm" { + const content: any; + export default content; +} + +declare module "@automerge/automerge" { + export * from "@automerge/automerge"; +}