basic invite user functional

This commit is contained in:
Milo Schwartz
2024-11-02 23:46:08 -04:00
parent a6bb8f5bb1
commit a6baebb216
15 changed files with 684 additions and 137 deletions

View File

@@ -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<string | null>(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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
validForHours: "24",
roleId: "4",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/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 && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter an email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>Valid For</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map((option) => (
<SelectItem
key={option.hours}
value={option.hours.toString()}
>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-center">
<Button
type="submit"
loading={loading}
disabled={inviteLink !== null}
>
Invite User
</Button>
</div>
</form>
</Form>
)}
{inviteLink && (
<div className="max-w-md">
<p className="mb-4">
The user has been successfully invited. They must access
the link below to accept the invitation.
</p>
<p className="mb-4">
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1 ? "day" : "days"}
</b>
.
</p>
{/* <CopyTextBox text={inviteLink} wrapText={false} /> */}
</div>
)}
</>
);
}

View File

@@ -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 (
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
console.log("Invite user");
}}
/>
<>
<Dialog
open={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
</DialogHeader>
<InviteUserForm />
</DialogContent>
</Dialog>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
setIsInviteModalOpen(true);
}}
/>
</>
);
}

View File

@@ -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(
<p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "}
<Link href="/auth/signup" className="underline">
<Link
href={
!searchParams.redirect
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
}
className="underline"
>
Sign up
</Link>
</p>

View File

@@ -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(
<p className="text-center text-muted-foreground mt-4">
Already have an account?{" "}
<Link href="/auth/login" className="underline">
<Link
href={
!searchParams.redirect
? `/auth/login`
: `/auth/login?redirect=${searchParams.redirect}`
}
className="underline"
>
Log in
</Link>
</p>

View File

@@ -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 (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access has not been accepted or is no longer valid.
</p>
<ul className="list-disc list-inside text-sm space-y-2">
<li>The invite may have expired</li>
<li>The invite might have been revoked</li>
<li>There could be a typo in the invite link</li>
</ul>
</div>
);
} else if (type === "wrong_user") {
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for this user.
</p>
<p className="text-center">
Please make sure you're logged in as the correct user.
</p>
</div>
);
} else if (type === "user_does_not_exist") {
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for a user that exists.
</p>
<p className="text-center">
Please create an account first.
</p>
</div>
);
}
}
function renderFooter() {
if (type === "rejected") {
return (
<Button
onClick={() => {
router.push("/");
}}
>
Go home
</Button>
);
} else if (type === "wrong_user") {
return (
<Button onClick={goToLogin}>Login in as different user</Button>
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>Create an account</Button>;
}
}
return (
<div className="p-3 md:mt-32 flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
<XCircle
className="w-10 h-10 text-red-600"
aria-hidden="true"
/>
</div>
<CardTitle className="text-center text-2xl font-bold">
Invite Not Accepted
</CardTitle>
</CardHeader>
<CardContent>{renderBody()}</CardContent>
<CardFooter className="flex justify-center space-x-4">
{renderFooter()}
</CardFooter>
</Card>
</div>
);
}

77
src/app/invite/page.tsx Normal file
View File

@@ -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 (
<>
<h1>Invalid Invite</h1>
<p>The invite link is invalid.</p>
</>
);
}
const inviteId = parts[0];
const token = parts[1];
let error = "";
const res = await internal
.post<AxiosResponse<AcceptInviteResponse>>(
`/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 (
<>
<InviteStatusCard type={cardType()} token={tokenParam} />
</>
);
}

View File

@@ -14,6 +14,7 @@ import {
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox";
type Step = "org" | "site" | "resources";