Skip to content

Commit 333d161

Browse files
committed
feat: Add invite user support
1 parent 93049e8 commit 333d161

File tree

18 files changed

+3973
-109
lines changed

18 files changed

+3973
-109
lines changed
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { AdminCard } from "@/components/admin/AdminCard";
12
import BackgroundJobs from "@/components/admin/BackgroundJobs";
23

34
export default function BackgroundJobsPage() {
4-
return <BackgroundJobs />;
5+
return (
6+
<AdminCard>
7+
<BackgroundJobs />
8+
</AdminCard>
9+
);
510
}

apps/web/app/admin/layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { redirect } from "next/navigation";
2-
import { AdminCard } from "@/components/admin/AdminCard";
32
import { AdminNotices } from "@/components/admin/AdminNotices";
43
import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
54
import Sidebar from "@/components/shared/sidebar/Sidebar";
@@ -54,7 +53,7 @@ export default async function AdminLayout({
5453
>
5554
<div className="flex flex-col gap-1">
5655
<AdminNotices />
57-
<AdminCard>{children}</AdminCard>
56+
{children}
5857
</div>
5958
</SidebarLayout>
6059
);
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { AdminCard } from "@/components/admin/AdminCard";
12
import ServerStats from "@/components/admin/ServerStats";
23

34
export default function AdminOverviewPage() {
4-
return <ServerStats />;
5+
return (
6+
<AdminCard>
7+
<ServerStats />
8+
</AdminCard>
9+
);
510
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { redirect } from "next/navigation";
2+
import InviteAcceptForm from "@/components/invite/InviteAcceptForm";
3+
import KarakeepLogo from "@/components/KarakeepIcon";
4+
import { getServerAuthSession } from "@/server/auth";
5+
6+
interface InvitePageProps {
7+
params: {
8+
token: string;
9+
};
10+
}
11+
12+
export default async function InvitePage({ params }: InvitePageProps) {
13+
const session = await getServerAuthSession();
14+
if (session) {
15+
redirect("/");
16+
}
17+
18+
return (
19+
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
20+
<div className="w-full max-w-md space-y-8">
21+
<div className="flex items-center justify-center">
22+
<KarakeepLogo height={80} />
23+
</div>
24+
<InviteAcceptForm token={params.token} />
25+
</div>
26+
</div>
27+
);
28+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { ActionButton } from "@/components/ui/action-button";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogHeader,
10+
DialogTitle,
11+
DialogTrigger,
12+
} from "@/components/ui/dialog";
13+
import {
14+
Form,
15+
FormControl,
16+
FormField,
17+
FormItem,
18+
FormLabel,
19+
FormMessage,
20+
} from "@/components/ui/form";
21+
import { Input } from "@/components/ui/input";
22+
import { toast } from "@/components/ui/use-toast";
23+
import { api } from "@/lib/trpc";
24+
import { zodResolver } from "@hookform/resolvers/zod";
25+
import { TRPCClientError } from "@trpc/client";
26+
import { useForm } from "react-hook-form";
27+
import { z } from "zod";
28+
29+
const createInviteSchema = z.object({
30+
email: z.string().email("Please enter a valid email address"),
31+
});
32+
33+
interface CreateInviteDialogProps {
34+
children: React.ReactNode;
35+
}
36+
37+
export default function CreateInviteDialog({
38+
children,
39+
}: CreateInviteDialogProps) {
40+
const [open, setOpen] = useState(false);
41+
const [errorMessage, setErrorMessage] = useState("");
42+
43+
const form = useForm<z.infer<typeof createInviteSchema>>({
44+
resolver: zodResolver(createInviteSchema),
45+
defaultValues: {
46+
email: "",
47+
},
48+
});
49+
50+
const invalidateInvitesList = api.useUtils().invites.list.invalidate;
51+
const createInviteMutation = api.invites.create.useMutation({
52+
onSuccess: () => {
53+
toast({
54+
description: "Invite sent successfully",
55+
});
56+
invalidateInvitesList();
57+
setOpen(false);
58+
form.reset();
59+
setErrorMessage("");
60+
},
61+
onError: (e) => {
62+
if (e instanceof TRPCClientError) {
63+
setErrorMessage(e.message);
64+
} else {
65+
setErrorMessage("Failed to send invite");
66+
}
67+
},
68+
});
69+
70+
return (
71+
<Dialog open={open} onOpenChange={setOpen}>
72+
<DialogTrigger asChild>{children}</DialogTrigger>
73+
<DialogContent className="sm:max-w-md">
74+
<DialogHeader>
75+
<DialogTitle>Send User Invitation</DialogTitle>
76+
<DialogDescription>
77+
Send an invitation to a new user to join Karakeep. They&apos;ll
78+
receive an email with instructions to create their account and will
79+
be assigned the &quot;user&quot; role.
80+
</DialogDescription>
81+
</DialogHeader>
82+
83+
<Form {...form}>
84+
<form
85+
onSubmit={form.handleSubmit(async (value) => {
86+
setErrorMessage("");
87+
await createInviteMutation.mutateAsync(value);
88+
})}
89+
className="space-y-4"
90+
>
91+
{errorMessage && (
92+
<p className="text-sm text-destructive">{errorMessage}</p>
93+
)}
94+
95+
<FormField
96+
control={form.control}
97+
name="email"
98+
render={({ field }) => (
99+
<FormItem>
100+
<FormLabel>Email Address</FormLabel>
101+
<FormControl>
102+
<Input
103+
type="email"
104+
placeholder="[email protected]"
105+
{...field}
106+
/>
107+
</FormControl>
108+
<FormMessage />
109+
</FormItem>
110+
)}
111+
/>
112+
113+
<div className="flex justify-end space-x-2">
114+
<ActionButton
115+
type="button"
116+
variant="outline"
117+
loading={false}
118+
onClick={() => setOpen(false)}
119+
>
120+
Cancel
121+
</ActionButton>
122+
<ActionButton
123+
type="submit"
124+
loading={createInviteMutation.isPending}
125+
>
126+
Send Invitation
127+
</ActionButton>
128+
</div>
129+
</form>
130+
</Form>
131+
</DialogContent>
132+
</Dialog>
133+
);
134+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"use client";
2+
3+
import { ActionButton } from "@/components/ui/action-button";
4+
import { ButtonWithTooltip } from "@/components/ui/button";
5+
import LoadingSpinner from "@/components/ui/spinner";
6+
import {
7+
Table,
8+
TableBody,
9+
TableCell,
10+
TableHead,
11+
TableHeader,
12+
TableRow,
13+
} from "@/components/ui/table";
14+
import { toast } from "@/components/ui/use-toast";
15+
import { api } from "@/lib/trpc";
16+
import { formatDistanceToNow } from "date-fns";
17+
import { Mail, MailX, UserPlus } from "lucide-react";
18+
19+
import ActionConfirmingDialog from "../ui/action-confirming-dialog";
20+
import CreateInviteDialog from "./CreateInviteDialog";
21+
22+
export default function InvitesList() {
23+
const invalidateInvitesList = api.useUtils().invites.list.invalidate;
24+
const { data: invites, isLoading } = api.invites.list.useQuery();
25+
26+
const { mutateAsync: revokeInvite, isPending: isRevokePending } =
27+
api.invites.revoke.useMutation({
28+
onSuccess: () => {
29+
toast({
30+
description: "Invite revoked successfully",
31+
});
32+
invalidateInvitesList();
33+
},
34+
onError: (e) => {
35+
toast({
36+
variant: "destructive",
37+
description: `Failed to revoke invite: ${e.message}`,
38+
});
39+
},
40+
});
41+
42+
const { mutateAsync: resendInvite, isPending: isResendPending } =
43+
api.invites.resend.useMutation({
44+
onSuccess: () => {
45+
toast({
46+
description: "Invite resent successfully",
47+
});
48+
invalidateInvitesList();
49+
},
50+
onError: (e) => {
51+
toast({
52+
variant: "destructive",
53+
description: `Failed to resend invite: ${e.message}`,
54+
});
55+
},
56+
});
57+
58+
if (isLoading) {
59+
return <LoadingSpinner />;
60+
}
61+
62+
const activeInvites =
63+
invites?.invites?.filter(
64+
(invite) => new Date(invite.expiresAt) > new Date(),
65+
) || [];
66+
67+
const expiredInvites =
68+
invites?.invites?.filter(
69+
(invite) => new Date(invite.expiresAt) <= new Date(),
70+
) || [];
71+
72+
const getStatusBadge = (
73+
invite: NonNullable<typeof invites>["invites"][0],
74+
) => {
75+
if (new Date(invite.expiresAt) <= new Date()) {
76+
return (
77+
<span className="rounded-full bg-red-100 px-2 py-1 text-xs text-red-800">
78+
Expired
79+
</span>
80+
);
81+
}
82+
return (
83+
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800">
84+
Active
85+
</span>
86+
);
87+
};
88+
89+
const InviteTable = ({
90+
invites: inviteList,
91+
title,
92+
}: {
93+
invites: NonNullable<typeof invites>["invites"];
94+
title: string;
95+
}) => (
96+
<div className="mb-6">
97+
<h3 className="mb-3 text-lg font-medium">
98+
{title} ({inviteList.length})
99+
</h3>
100+
{inviteList.length === 0 ? (
101+
<p className="text-sm text-gray-500">
102+
No {title.toLowerCase()} invites
103+
</p>
104+
) : (
105+
<Table>
106+
<TableHeader className="bg-gray-200">
107+
<TableHead>Email</TableHead>
108+
<TableHead>Invited By</TableHead>
109+
<TableHead>Created</TableHead>
110+
<TableHead>Expires</TableHead>
111+
<TableHead>Status</TableHead>
112+
<TableHead>Actions</TableHead>
113+
</TableHeader>
114+
<TableBody>
115+
{inviteList.map((invite) => (
116+
<TableRow key={invite.id}>
117+
<TableCell className="py-2">{invite.email}</TableCell>
118+
<TableCell className="py-2">{invite.invitedBy.name}</TableCell>
119+
<TableCell className="py-2">
120+
{formatDistanceToNow(new Date(invite.createdAt), {
121+
addSuffix: true,
122+
})}
123+
</TableCell>
124+
<TableCell className="py-2">
125+
{formatDistanceToNow(new Date(invite.expiresAt), {
126+
addSuffix: true,
127+
})}
128+
</TableCell>
129+
<TableCell className="py-2">{getStatusBadge(invite)}</TableCell>
130+
<TableCell className="flex gap-1 py-2">
131+
{new Date(invite.expiresAt) > new Date() && (
132+
<>
133+
<ButtonWithTooltip
134+
tooltip="Resend Invite"
135+
variant="outline"
136+
size="sm"
137+
onClick={() => resendInvite({ inviteId: invite.id })}
138+
disabled={isResendPending}
139+
>
140+
<Mail size={14} />
141+
</ButtonWithTooltip>
142+
<ActionConfirmingDialog
143+
title="Revoke Invite"
144+
description={`Are you sure you want to revoke the invite for ${invite.email}? This action cannot be undone.`}
145+
actionButton={(setDialogOpen) => (
146+
<ActionButton
147+
variant="destructive"
148+
loading={isRevokePending}
149+
onClick={async () => {
150+
await revokeInvite({ inviteId: invite.id });
151+
setDialogOpen(false);
152+
}}
153+
>
154+
Revoke
155+
</ActionButton>
156+
)}
157+
>
158+
<ButtonWithTooltip
159+
tooltip="Revoke Invite"
160+
variant="outline"
161+
size="sm"
162+
>
163+
<MailX size={14} color="red" />
164+
</ButtonWithTooltip>
165+
</ActionConfirmingDialog>
166+
</>
167+
)}
168+
</TableCell>
169+
</TableRow>
170+
))}
171+
</TableBody>
172+
</Table>
173+
)}
174+
</div>
175+
);
176+
177+
return (
178+
<div className="flex flex-col gap-4">
179+
<div className="mb-2 flex items-center justify-between text-xl font-medium">
180+
<span>User Invitations</span>
181+
<CreateInviteDialog>
182+
<ButtonWithTooltip tooltip="Send Invite" variant="outline">
183+
<UserPlus size={16} />
184+
</ButtonWithTooltip>
185+
</CreateInviteDialog>
186+
</div>
187+
188+
<InviteTable invites={activeInvites} title="Active Invites" />
189+
<InviteTable invites={expiredInvites} title="Expired Invites" />
190+
</div>
191+
);
192+
}

0 commit comments

Comments
 (0)