Skip to content

Commit f22f1c7

Browse files
G3rootdahal
andauthored
feat: implement stakeholder update and access update features (#77)
* feat: add admin only procedure * feat: add deactivate user procedure * feat: add role in membership * feat: add update user * feat: add reinvite option * refactor: revoke existing tokens * fix: use delete many * fix: disable email editing * fix: some copies --------- Co-authored-by: Puru D <puru@dahal.me>
1 parent a75cf55 commit f22f1c7

File tree

7 files changed

+483
-148
lines changed

7 files changed

+483
-148
lines changed

src/components/stakeholder/member-modal.tsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,28 @@ import {
2828
type TypeZodInviteMemberMutationSchema,
2929
} from "@/trpc/routers/stakeholder-router/schema";
3030

31-
import { type z } from "zod";
3231
import { zodResolver } from "@hookform/resolvers/zod";
3332
import { useRouter } from "next/navigation";
3433
import { useState } from "react";
3534
import { useForm } from "react-hook-form";
36-
type InviteMemberMutationSchema = z.infer<typeof ZodInviteMemberMutationSchema>;
35+
3736
type MemberModalType = {
3837
title: string;
3938
subtitle: string;
40-
member: InviteMemberMutationSchema;
39+
member: TypeZodInviteMemberMutationSchema;
4140
children: React.ReactNode;
42-
};
41+
} & editModeType;
42+
43+
type editModeType =
44+
| { isEditMode: true; membershipId: string }
45+
| { isEditMode?: false; membershipId?: never };
4346

4447
const MemberModal = ({
4548
title,
4649
subtitle,
4750
member,
4851
children,
52+
...rest
4953
}: MemberModalType) => {
5054
const router = useRouter();
5155
const [open, setOpen] = useState(false);
@@ -55,9 +59,28 @@ const MemberModal = ({
5559
setOpen(false);
5660
toast({
5761
variant: "default",
58-
title: "🎉 Invitation successfully sent!",
62+
title: "🎉 Invited!",
63+
description: "You have successfully invited the stakeholder.",
64+
});
65+
router.refresh();
66+
},
67+
onError: (error) => {
68+
toast({
69+
variant: "destructive",
70+
title: error.message,
5971
description: "",
6072
});
73+
},
74+
});
75+
76+
const updateMember = api.stakeholder.updateMember.useMutation({
77+
onSuccess: () => {
78+
setOpen(false);
79+
toast({
80+
variant: "default",
81+
title: "🎉 Updated!",
82+
description: "You have successfully updated the stakeholder.",
83+
});
6184
router.refresh();
6285
},
6386
onError: (error) => {
@@ -68,6 +91,7 @@ const MemberModal = ({
6891
});
6992
},
7093
});
94+
7195
const form = useForm<TypeZodInviteMemberMutationSchema>({
7296
resolver: zodResolver(ZodInviteMemberMutationSchema),
7397
defaultValues: {
@@ -81,7 +105,11 @@ const MemberModal = ({
81105
const isSubmitting = form.formState.isSubmitting;
82106

83107
async function onSubmit(values: TypeZodInviteMemberMutationSchema) {
84-
inviteMember.mutate(values);
108+
if (rest.isEditMode) {
109+
updateMember.mutate({ ...values, membershipId: rest.membershipId });
110+
} else {
111+
inviteMember.mutate(values);
112+
}
85113
}
86114

87115
return (
@@ -120,7 +148,11 @@ const MemberModal = ({
120148
<FormItem>
121149
<FormLabel>Email</FormLabel>
122150
<FormControl>
123-
<Input disabled={isSubmitting} type="email" {...field} />
151+
<Input
152+
disabled={isSubmitting || rest.isEditMode === true}
153+
type="email"
154+
{...field}
155+
/>
124156
</FormControl>
125157
<FormMessage />
126158
</FormItem>
@@ -171,7 +203,7 @@ const MemberModal = ({
171203
</div>
172204

173205
<Button loading={isSubmitting} loadingText="Sending invite...">
174-
Send an invite
206+
{rest.isEditMode === true ? "Update stakeholder" : "Send an invite"}
175207
</Button>
176208

177209
<p className="text-center text-xs text-gray-500">

src/components/stakeholder/member-table.tsx

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import { api } from "@/trpc/react";
4444
import { type TypeGetMembers } from "@/server/stakeholder";
4545
import { Avatar, AvatarImage } from "@/components/ui/avatar";
4646
import MemberModal from "@/components/stakeholder/member-modal";
47+
import { useSession } from "next-auth/react";
48+
import { useRouter } from "next/navigation";
4749

4850
type MembersType = {
4951
members: TypeGetMembers;
@@ -186,9 +188,54 @@ export const columns: ColumnDef<TypeGetMembers[0]>[] = [
186188
id: "actions",
187189
enableHiding: false,
188190
cell: ({ row }) => {
191+
// eslint-disable-next-line react-hooks/rules-of-hooks
192+
const router = useRouter();
193+
// eslint-disable-next-line react-hooks/rules-of-hooks
194+
const { data } = useSession();
189195
const member = row.original;
190196
const removeMember = api.stakeholder.removeMember.useMutation();
191197
const revokeInvite = api.stakeholder.revokeInvite.useMutation();
198+
const revInvite = api.stakeholder.reInvite.useMutation();
199+
const deactivateUser = api.stakeholder.deactivateUser.useMutation({
200+
onSuccess: () => {
201+
router.refresh();
202+
},
203+
});
204+
205+
const isAdmin = data?.user?.access === "admin";
206+
const status = member.status;
207+
const membershipId = member.id;
208+
const email = member.user?.email ?? member.invitedEmail;
209+
const deleteAction =
210+
status === "pending" ? "Revoke invite" : "Remove member";
211+
212+
const isAdminActionable = isAdmin && member.userId !== data?.user.id;
213+
214+
const userStatus = member.active;
215+
216+
const handleDeactivateStakeholder = async () => {
217+
try {
218+
await removeMember.mutateAsync({ membershipId });
219+
if (status === "pending" && email) {
220+
await revokeInvite.mutateAsync({ email, membershipId });
221+
}
222+
223+
router.refresh();
224+
} catch (error) {}
225+
};
226+
227+
const handleDeactivate = async () => {
228+
try {
229+
await deactivateUser.mutateAsync({
230+
membershipId,
231+
status: !userStatus,
232+
});
233+
} catch (error) {}
234+
};
235+
236+
const handleReinvite = () => {
237+
revInvite.mutate({ membershipId: member.id });
238+
};
192239

193240
return (
194241
<DropdownMenu>
@@ -202,27 +249,45 @@ export const columns: ColumnDef<TypeGetMembers[0]>[] = [
202249
</div>
203250
<DropdownMenuContent align="end">
204251
<DropdownMenuLabel>Actions</DropdownMenuLabel>
252+
{isAdminActionable && status === "pending" && (
253+
<DropdownMenuItem onSelect={handleReinvite}>
254+
ReInvite
255+
</DropdownMenuItem>
256+
)}
205257

206-
<MemberModal
207-
title="Update stakeholder"
208-
subtitle="Update stakeholder's account information."
209-
member={{
210-
name: member.user?.name ?? "",
211-
email: member.user?.email ?? "",
212-
title: member.title ?? "",
213-
access: member.access ?? "stakesholder",
214-
}}
215-
>
216-
<span className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
217-
Update
218-
</span>
219-
</MemberModal>
220-
221-
<DropdownMenuItem>Reinvite</DropdownMenuItem>
258+
{isAdmin && status === "accepted" && (
259+
<MemberModal
260+
isEditMode
261+
membershipId={member.id}
262+
title="Update stakeholder"
263+
subtitle="Update stakeholder's account information."
264+
member={{
265+
name: member.user?.name ?? "",
266+
email: email ?? "",
267+
title: member.title ?? "",
268+
access: member.access ?? "stakeholder",
269+
}}
270+
>
271+
<span className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
272+
Update
273+
</span>
274+
</MemberModal>
275+
)}
222276

223277
<DropdownMenuSeparator />
224-
<DropdownMenuItem className="text-red-500">
225-
Deactivate
278+
<DropdownMenuItem
279+
onSelect={handleDeactivate}
280+
disabled={!isAdminActionable}
281+
className="text-red-500"
282+
>
283+
{userStatus ? "Deactivate" : "Activate User"}
284+
</DropdownMenuItem>
285+
<DropdownMenuItem
286+
onSelect={handleDeactivateStakeholder}
287+
disabled={!isAdminActionable}
288+
className="text-red-500"
289+
>
290+
{deleteAction}
226291
</DropdownMenuItem>
227292
</DropdownMenuContent>
228293
</DropdownMenu>

src/server/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { db } from "@/server/db";
1515
import { env } from "@/env";
1616
import { sendMail } from "./mailer";
1717
import MagicLinkEmail from "@/emails/MagicLinkEmail";
18+
import { type MEMBERSHIP_ACCESS } from "@prisma/client";
1819

1920
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID!;
2021
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!;
@@ -33,6 +34,7 @@ declare module "next-auth" {
3334
companyId: string;
3435
membershipId: string;
3536
companyPublicId: string;
37+
access: MEMBERSHIP_ACCESS;
3638
} & DefaultSession["user"];
3739
}
3840
}
@@ -44,6 +46,7 @@ declare module "next-auth/jwt" {
4446
companyId: string;
4547
membershipId: string;
4648
companyPublicId: string;
49+
access: MEMBERSHIP_ACCESS;
4750
}
4851
}
4952

@@ -59,6 +62,7 @@ export const authOptions: NextAuthOptions = {
5962
session.user.companyId = token.companyId;
6063
session.user.membershipId = token.membershipId;
6164
session.user.companyPublicId = token.companyPublicId;
65+
session.user.access = token.access;
6266

6367
if (token.sub) {
6468
session.user.id = token.sub;
@@ -82,6 +86,7 @@ export const authOptions: NextAuthOptions = {
8286
id: true,
8387
companyId: true,
8488
isOnboarded: true,
89+
access: true,
8590
user: {
8691
select: {
8792
name: true,
@@ -101,11 +106,13 @@ export const authOptions: NextAuthOptions = {
101106
token.membershipId = membership.id;
102107
token.name = membership.user?.name;
103108
token.companyPublicId = membership.company.publicId;
109+
token.access = membership.access;
104110
} else {
105111
token.isOnboarded = false;
106112
token.companyId = "";
107113
token.membershipId = "";
108114
token.companyPublicId = "";
115+
token.access = "stakeholder";
109116
}
110117
}
111118

src/server/stakeholder.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import { render } from "jsx-email";
12
import { db } from "./db";
3+
import { sendMail } from "./mailer";
4+
import MemberInviteEmail from "@/emails/MemberInviteEmail";
5+
import { constants } from "@/lib/constants";
6+
import { nanoid } from "nanoid";
7+
import { env } from "@/env";
8+
import { createHash } from "@/lib/crypto";
9+
import { type PrismaClient, type Prisma } from "@prisma/client";
210

311
export const getMembers = (companyId: string) => {
412
return db.membership.findMany({
@@ -69,3 +77,86 @@ export const generateMembershipIdentifier = ({
6977
}: generateMembershipIdentifierOptions) => {
7078
return `${email}:${membershipId}`;
7179
};
80+
81+
interface sendMembershipInviteEmailOptions {
82+
verificationToken: string;
83+
token: string;
84+
email: string;
85+
company: { name: string; id: string };
86+
user: { email: string | null | undefined; name: string | null | undefined };
87+
}
88+
89+
export async function sendMembershipInviteEmail({
90+
company,
91+
email,
92+
verificationToken,
93+
token,
94+
user,
95+
}: sendMembershipInviteEmailOptions) {
96+
const baseUrl = process.env.NEXTAUTH_URL;
97+
const callbackUrl = `${baseUrl}/verify-member/${verificationToken}`;
98+
99+
const params = new URLSearchParams({
100+
callbackUrl,
101+
token,
102+
email,
103+
});
104+
105+
const inviteLink = `${baseUrl}/api/auth/callback/email?${params.toString()}`;
106+
107+
await sendMail({
108+
to: email,
109+
subject: `Join ${company.name} on ${constants.title}`,
110+
html: await render(
111+
MemberInviteEmail({
112+
inviteLink,
113+
companyName: company.name,
114+
invitedBy: (user.name ?? user.email)!,
115+
}),
116+
),
117+
});
118+
}
119+
120+
export async function generateInviteToken() {
121+
const token = nanoid(32);
122+
123+
const secret = env.NEXTAUTH_SECRET;
124+
125+
const ONE_DAY_IN_SECONDS = 86400;
126+
const expires = new Date(Date.now() + ONE_DAY_IN_SECONDS * 1000);
127+
128+
const memberInviteTokenHash = await createHash(`member-${nanoid(16)}`);
129+
const authTokenHash = await createHash(`${token}${secret}`);
130+
131+
return { token, expires, memberInviteTokenHash, authTokenHash };
132+
}
133+
134+
interface revokeExistingInviteTokensOptions {
135+
membershipId: string;
136+
email: string;
137+
tx: Prisma.TransactionClient | PrismaClient;
138+
}
139+
140+
export async function revokeExistingInviteTokens({
141+
email,
142+
membershipId,
143+
tx,
144+
}: revokeExistingInviteTokensOptions) {
145+
const identifier = generateMembershipIdentifier({
146+
email,
147+
membershipId,
148+
});
149+
150+
const verificationToken = await tx.verificationToken.findMany({
151+
where: {
152+
identifier,
153+
},
154+
});
155+
await tx.verificationToken.deleteMany({
156+
where: {
157+
token: {
158+
in: verificationToken.map((item) => item.token),
159+
},
160+
},
161+
});
162+
}

src/trpc/api/trpc.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,11 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
9999
},
100100
});
101101
});
102+
103+
export const adminOnlyProcedure = protectedProcedure.use(({ ctx, next }) => {
104+
if (ctx.session.user.access !== "admin") {
105+
throw new TRPCError({ code: "UNAUTHORIZED" });
106+
}
107+
108+
return next();
109+
});

0 commit comments

Comments
 (0)