mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-21 11:15:13 +02:00
basic invite user functional
This commit is contained in:
parent
a6bb8f5bb1
commit
a6baebb216
15 changed files with 684 additions and 137 deletions
|
@ -210,13 +210,13 @@ export const userInvites = sqliteTable("userInvites", {
|
||||||
inviteId: text("inviteId").primaryKey(),
|
inviteId: text("inviteId").primaryKey(),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
expiresAt: integer("expiresAt").notNull(),
|
expiresAt: integer("expiresAt").notNull(),
|
||||||
tokenHash: text("token").notNull(),
|
tokenHash: text("token").notNull(),
|
||||||
roleId: integer("roleId")
|
roleId: integer("roleId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.roleId),
|
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
|
|
|
@ -72,8 +72,8 @@ authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
user.inviteUser
|
user.inviteUser
|
||||||
);
|
); // maybe make this /invite/create instead
|
||||||
authenticated.post("/org/:orgId/accept-invite", user.acceptInvite);
|
authenticated.post("/invite/accept", user.acceptInvite);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
|
|
|
@ -9,13 +9,17 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
|
||||||
const acceptInviteBodySchema = z.object({
|
const acceptInviteBodySchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
inviteId: z.string(),
|
inviteId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AcceptInviteResponse = {};
|
export type AcceptInviteResponse = {
|
||||||
|
accepted: boolean;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function acceptInvite(
|
export async function acceptInvite(
|
||||||
req: Request,
|
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, {
|
const validToken = await verify(existingInvite[0].tokenHash, token, {
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
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;
|
let roleId: number;
|
||||||
// get the role to make sure it exists
|
// get the role to make sure it exists
|
||||||
const existingRole = await db
|
const existingRole = await db
|
||||||
|
@ -109,7 +128,7 @@ export async function acceptInvite(
|
||||||
await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId));
|
await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId));
|
||||||
|
|
||||||
return response<AcceptInviteResponse>(res, {
|
return response<AcceptInviteResponse>(res, {
|
||||||
data: {},
|
data: { accepted: true, orgId: existingInvite[0].orgId },
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Invite accepted",
|
message: "Invite accepted",
|
||||||
|
|
|
@ -24,6 +24,8 @@ const inviteUserBodySchema = z.object({
|
||||||
validHours: z.number().gt(0).lte(168),
|
validHours: z.number().gt(0).lte(168),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type InviteUserBody = z.infer<typeof inviteUserBodySchema>;
|
||||||
|
|
||||||
export type InviteUserResponse = {
|
export type InviteUserResponse = {
|
||||||
inviteLink: string;
|
inviteLink: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
@ -112,7 +114,7 @@ export async function inviteUser(
|
||||||
roleId,
|
roleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteLink = `${config.app.base_url}/invite/${inviteId}-${token}`;
|
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
|
||||||
|
|
||||||
return response<InviteUserResponse>(res, {
|
return response<InviteUserResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|
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 { Button } from "@app/components/ui/button";
|
||||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
import { UsersDataTable } from "./UsersDataTable";
|
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 = {
|
export type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -60,13 +69,29 @@ type UsersTableProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ users }: UsersTableProps) {
|
export default function UsersTable({ users }: UsersTableProps) {
|
||||||
|
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={isInviteModalOpen}
|
||||||
|
onOpenChange={setIsInviteModalOpen}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invite User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<InviteUserForm />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<UsersDataTable
|
<UsersDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={users}
|
data={users}
|
||||||
inviteUser={() => {
|
inviteUser={() => {
|
||||||
console.log("Invite user");
|
setIsInviteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page(
|
export default async function Page(props: {
|
||||||
props: {
|
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}
|
}) {
|
||||||
) {
|
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const user = await verifySession();
|
const user = await verifySession();
|
||||||
|
|
||||||
|
@ -21,7 +19,14 @@ export default async function Page(
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
Don't have an account?{" "}
|
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
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page(
|
export default async function Page(props: {
|
||||||
props: {
|
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}
|
}) {
|
||||||
) {
|
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const user = await verifySession();
|
const user = await verifySession();
|
||||||
|
|
||||||
|
@ -21,7 +19,14 @@ export default async function Page(
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
Already have an account?{" "}
|
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
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</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,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
|
||||||
type Step = "org" | "site" | "resources";
|
type Step = "org" | "site" | "resources";
|
||||||
|
|
||||||
|
|
0
src/components/ButtonWithLoading.tsx
Normal file
0
src/components/ButtonWithLoading.tsx
Normal file
52
src/components/CopyTextBox.tsx
Normal file
52
src/components/CopyTextBox.tsx
Normal file
|
@ -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<HTMLPreElement>(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 (
|
||||||
|
<div className="relative w-full border rounded-md">
|
||||||
|
<pre
|
||||||
|
ref={textRef}
|
||||||
|
className={`p-4 pr-16 text-sm w-full ${
|
||||||
|
wrapText
|
||||||
|
? "whitespace-pre-wrap break-words"
|
||||||
|
: "overflow-x-auto"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<code className="block w-full">{text}</code>
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 z-10"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,15 +1,17 @@
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
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(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
|
@ -31,26 +33,41 @@ const buttonVariants = cva(
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
|
loading?: boolean; // Add loading prop
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ 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 (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={loading || props.disabled} // Disable button when loading
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
)
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{props.children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react"
|
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<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({
|
||||||
className,
|
className,
|
||||||
|
@ -64,8 +64,8 @@ const DialogHeader = ({
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogHeader.displayName = "DialogHeader"
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({
|
||||||
className,
|
className,
|
||||||
|
@ -78,8 +78,8 @@ const DialogFooter = ({
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DialogFooter.displayName = "DialogFooter"
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -119,4 +119,4 @@ export {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue