mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-02 08:46:38 +00:00
basic invite user functional
This commit is contained in:
224
src/app/[orgId]/settings/users/components/InviteUserForm.tsx
Normal file
224
src/app/[orgId]/settings/users/components/InviteUserForm.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
120
src/app/invite/InviteStatusCard.tsx
Normal file
120
src/app/invite/InviteStatusCard.tsx
Normal 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
77
src/app/invite/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
|
||||
type Step = "org" | "site" | "resources";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user