diff --git a/server/db/schema.ts b/server/db/schema.ts index ba52fff7..4a24e488 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -210,13 +210,13 @@ export const userInvites = sqliteTable("userInvites", { inviteId: text("inviteId").primaryKey(), orgId: text("orgId") .notNull() - .references(() => orgs.orgId), + .references(() => orgs.orgId, { onDelete: "cascade" }), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), tokenHash: text("token").notNull(), roleId: integer("roleId") .notNull() - .references(() => roles.roleId), + .references(() => roles.roleId, { onDelete: "cascade" }), }); export type Org = InferSelectModel; diff --git a/server/routers/external.ts b/server/routers/external.ts index e123ddd5..1b13f058 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -72,8 +72,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, user.inviteUser -); -authenticated.post("/org/:orgId/accept-invite", user.acceptInvite); +); // maybe make this /invite/create instead +authenticated.post("/invite/accept", user.acceptInvite); authenticated.get( "/resource/:resourceId/roles", diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index bccdf146..119ab3bd 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -9,13 +9,17 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { isWithinExpirationDate } from "oslo"; const acceptInviteBodySchema = z.object({ token: z.string(), inviteId: z.string(), }); -export type AcceptInviteResponse = {}; +export type AcceptInviteResponse = { + accepted: boolean; + orgId: string; +}; export async function acceptInvite( req: Request, @@ -50,6 +54,12 @@ export async function acceptInvite( ); } + if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invite has expired") + ); + } + const validToken = await verify(existingInvite[0].tokenHash, token, { memoryCost: 19456, timeCost: 2, @@ -79,6 +89,15 @@ export async function acceptInvite( ); } + if (existingUser[0].email !== existingInvite[0].email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invite is not for this user" + ) + ); + } + let roleId: number; // get the role to make sure it exists const existingRole = await db @@ -109,7 +128,7 @@ export async function acceptInvite( await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId)); return response(res, { - data: {}, + data: { accepted: true, orgId: existingInvite[0].orgId }, success: true, error: false, message: "Invite accepted", diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 16db4d63..bcbff537 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -24,6 +24,8 @@ const inviteUserBodySchema = z.object({ validHours: z.number().gt(0).lte(168), }); +export type InviteUserBody = z.infer; + export type InviteUserResponse = { inviteLink: string; expiresAt: number; @@ -112,7 +114,7 @@ export async function inviteUser( roleId, }); - const inviteLink = `${config.app.base_url}/invite/${inviteId}-${token}`; + const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`; return response(res, { data: { diff --git a/src/app/[orgId]/settings/users/components/InviteUserForm.tsx b/src/app/[orgId]/settings/users/components/InviteUserForm.tsx new file mode 100644 index 00000000..1823f91b --- /dev/null +++ b/src/app/[orgId]/settings/users/components/InviteUserForm.tsx @@ -0,0 +1,224 @@ +"use client"; + +import api from "@app/api"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@app/components/ui/select"; +import { useToast } from "@app/hooks/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useParams } from "next/navigation"; +import CopyTextBox from "@app/components/CopyTextBox"; + +const formSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + validForHours: z.string(), + roleId: z.string(), +}); + +export default function InviteUserForm() { + const { toast } = useToast(); + const { orgId } = useParams(); + + const [inviteLink, setInviteLink] = useState(null); + const [loading, setLoading] = useState(false); + const [expiresInDays, setExpiresInDays] = useState(1); + + const roles = [ + { roleId: 1, name: "Super User" }, + { roleId: 2, name: "Admin" }, + { roleId: 3, name: "Power User" }, + { roleId: 4, name: "User" }, + { roleId: 5, name: "Guest" }, + ]; + + const validFor = [ + { hours: 24, name: "1 day" }, + { hours: 48, name: "2 days" }, + { hours: 72, name: "3 days" }, + { hours: 96, name: "4 days" }, + { hours: 120, name: "5 days" }, + { hours: 144, name: "6 days" }, + { hours: 168, name: "7 days" }, + ]; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + validForHours: "24", + roleId: "4", + }, + }); + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleId: parseInt(values.roleId), + validHours: parseInt(values.validForHours), + } as InviteUserBody + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to invite user", + description: + e.response?.data?.message || + "An error occurred while inviting the user.", + }); + }); + + if (res && res.status === 200) { + setInviteLink(res.data.data.inviteLink); + toast({ + variant: "default", + title: "User invited", + description: "The user has been successfully invited.", + }); + + setExpiresInDays(parseInt(values.validForHours) / 24); + } + + setLoading(false); + } + + return ( + <> + {!inviteLink && ( +
+ + ( + + Email + + + + + + )} + /> + ( + + Role + + + + )} + /> + ( + + Valid For + + + + )} + /> +
+ +
+ + + )} + + {inviteLink && ( +
+

+ The user has been successfully invited. They must access + the link below to accept the invitation. +

+

+ The invite will expire in{" "} + + {expiresInDays}{" "} + {expiresInDays === 1 ? "day" : "days"} + + . +

+ {/* */} +
+ )} + + ); +} diff --git a/src/app/[orgId]/settings/users/components/UsersTable.tsx b/src/app/[orgId]/settings/users/components/UsersTable.tsx index 1276b5d7..2922249f 100644 --- a/src/app/[orgId]/settings/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/users/components/UsersTable.tsx @@ -10,6 +10,15 @@ import { import { Button } from "@app/components/ui/button"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { UsersDataTable } from "./UsersDataTable"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@app/components/ui/dialog"; +import { useState } from "react"; +import InviteUserForm from "./InviteUserForm"; export type UserRow = { id: string; @@ -60,13 +69,29 @@ type UsersTableProps = { }; export default function UsersTable({ users }: UsersTableProps) { + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + return ( - { - console.log("Invite user"); - }} - /> + <> + + + + Invite User + + + + + + { + setIsInviteModalOpen(true); + }} + /> + ); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 48a3fd4d..88d9fcad 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; -export default async function Page( - props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - } -) { +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { const searchParams = await props.searchParams; const user = await verifySession(); @@ -21,7 +19,14 @@ export default async function Page(

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

diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 54509286..c5088423 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; -export default async function Page( - props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - } -) { +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { const searchParams = await props.searchParams; const user = await verifySession(); @@ -21,7 +19,14 @@ export default async function Page(

Already have an account?{" "} - + Log in

diff --git a/src/app/invite/InviteStatusCard.tsx b/src/app/invite/InviteStatusCard.tsx new file mode 100644 index 00000000..185cc994 --- /dev/null +++ b/src/app/invite/InviteStatusCard.tsx @@ -0,0 +1,120 @@ +"use client"; + +import api from "@app/api"; +import { Button } from "@app/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@app/components/ui/card"; +import { XCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; + +type InviteStatusCardProps = { + type: "rejected" | "wrong_user" | "user_does_not_exist"; + token: string; +}; + +export default function InviteStatusCard({ + type, + token, +}: InviteStatusCardProps) { + const router = useRouter(); + + async function goToLogin() { + await api.post("/auth/logout", {}); + router.push(`/auth/login?redirect=/invite?token=${token}`); + } + + async function goToSignup() { + await api.post("/auth/logout", {}); + router.push(`/auth/signup?redirect=/invite?token=${token}`); + } + + function renderBody() { + if (type === "rejected") { + return ( +
+

+ We're sorry, but it looks like the invite you're trying + to access has not been accepted or is no longer valid. +

+
    +
  • The invite may have expired
  • +
  • The invite might have been revoked
  • +
  • There could be a typo in the invite link
  • +
+
+ ); + } else if (type === "wrong_user") { + return ( +
+

+ We're sorry, but it looks like the invite you're trying + to access is not for this user. +

+

+ Please make sure you're logged in as the correct user. +

+
+ ); + } else if (type === "user_does_not_exist") { + return ( +
+

+ We're sorry, but it looks like the invite you're trying + to access is not for a user that exists. +

+

+ Please create an account first. +

+
+ ); + } + } + + function renderFooter() { + if (type === "rejected") { + return ( + + ); + } else if (type === "wrong_user") { + return ( + + ); + } else if (type === "user_does_not_exist") { + return ; + } + } + + return ( +
+ + +
+
+ + Invite Not Accepted + +
+ {renderBody()} + + + {renderFooter()} + +
+
+ ); +} diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx new file mode 100644 index 00000000..e8d83af8 --- /dev/null +++ b/src/app/invite/page.tsx @@ -0,0 +1,77 @@ +import { internal } from "@app/api"; +import { authCookieHeader } from "@app/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { AcceptInviteResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import InviteStatusCard from "./InviteStatusCard"; + +export default async function InvitePage(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const params = await props.searchParams; + + const tokenParam = params.token as string; + + if (!tokenParam) { + redirect("/"); + } + + const user = await verifySession(); + + if (!user) { + redirect(`/auth/login?redirect=/invite?token=${params.token}`); + } + + const parts = tokenParam.split("-"); + if (parts.length !== 2) { + return ( + <> +

Invalid Invite

+

The invite link is invalid.

+ + ); + } + + const inviteId = parts[0]; + const token = parts[1]; + + let error = ""; + const res = await internal + .post>( + `/invite/accept`, + { + inviteId, + token, + }, + await authCookieHeader() + ) + .catch((e) => { + error = e.response?.data?.message; + console.log(error); + }); + + if (res && res.status === 200) { + redirect(`/${res.data.data.orgId}`); + } + + function cardType() { + if (error.includes("Invite is not for this user")) { + return "wrong_user"; + } else if ( + error.includes( + "User does not exist. Please create an account first." + ) + ) { + return "user_does_not_exist"; + } else { + return "rejected"; + } + } + + return ( + <> + + + ); +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 897501a1..62093157 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -14,6 +14,7 @@ import { CardHeader, CardTitle, } from "@app/components/ui/card"; +import CopyTextBox from "@app/components/CopyTextBox"; type Step = "org" | "site" | "resources"; diff --git a/src/components/ButtonWithLoading.tsx b/src/components/ButtonWithLoading.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/CopyTextBox.tsx b/src/components/CopyTextBox.tsx new file mode 100644 index 00000000..72350a53 --- /dev/null +++ b/src/components/CopyTextBox.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Copy, Check } from "lucide-react"; + +export default function CopyTextBox({ text = "", wrapText = false }) { + const [isCopied, setIsCopied] = useState(false); + const textRef = useRef(null); + + const copyToClipboard = async () => { + if (textRef.current) { + try { + await navigator.clipboard.writeText( + textRef.current.textContent || "" + ); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error("Failed to copy text: ", err); + } + } + }; + + return ( +
+
+                {text}
+            
+ +
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 28b2fa4c..ee7eda11 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,15 +1,17 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: + "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: @@ -31,26 +33,41 @@ const buttonVariants = cva( size: "default", }, } -) +); export interface ButtonProps extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean + VariantProps { + asChild?: boolean; + loading?: boolean; // Add loading prop } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + ( + { + className, + variant, + size, + asChild = false, + loading = false, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : "button"; return ( - ) + > + {loading && } + {props.children} + + ); } -) -Button.displayName = "Button" +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 01ff19c7..7d24311b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,122 +1,122 @@ -"use client" +"use client"; -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = "DialogHeader" +
+); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = "DialogFooter" +
+); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogPortal, - DialogOverlay, - DialogClose, - DialogTrigger, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +};