add team invitation functionality [WIP]
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
|
||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { User } from "@prisma/client";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Adapter } from "next-auth/adapters";
|
||||
import NextAuth from "next-auth/next";
|
||||
@@ -133,6 +135,7 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
|
||||
if (emailEnabled) {
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
id: "email",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
@@ -157,6 +160,56 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
EmailProvider({
|
||||
id: "invite",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
const parentSubscriptionEmail = (
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
email: identifier,
|
||||
emailVerified: null,
|
||||
},
|
||||
include: {
|
||||
parentSubscription: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)?.parentSubscription?.user.email;
|
||||
|
||||
if (!parentSubscriptionEmail) throw Error("Invalid email.");
|
||||
|
||||
const recentVerificationRequestsCount =
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
identifier,
|
||||
createdAt: {
|
||||
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (recentVerificationRequestsCount >= 4)
|
||||
throw Error("Too many requests. Please try again later.");
|
||||
|
||||
sendInvitationRequest({
|
||||
parentSubscriptionEmail,
|
||||
identifier,
|
||||
url,
|
||||
from: provider.from as string,
|
||||
token,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1179,6 +1232,52 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account, profile, email, credentials }) {
|
||||
if (
|
||||
!(user as User).emailVerified &&
|
||||
!email?.verificationRequest
|
||||
// && (account?.provider === "email" || account?.provider === "google")
|
||||
) {
|
||||
// Email is being verified for the first time...
|
||||
console.log("Email is being verified for the first time...");
|
||||
|
||||
const parentSubscriptionId = (user as User).parentSubscriptionId;
|
||||
|
||||
if (parentSubscriptionId) {
|
||||
// Add seat request to Stripe
|
||||
const parentSubscription = await prisma.subscription.findFirst({
|
||||
where: {
|
||||
id: parentSubscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
// Count child users with verified email under a specific subscription, excluding the current user
|
||||
const verifiedChildUsersCount = await prisma.user.count({
|
||||
where: {
|
||||
parentSubscriptionId: parentSubscriptionId,
|
||||
id: {
|
||||
not: user.id as number,
|
||||
},
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
STRIPE_SECRET_KEY &&
|
||||
parentSubscription?.quantity &&
|
||||
verifiedChildUsersCount + 2 > // add current user and the admin
|
||||
parentSubscription.quantity
|
||||
) {
|
||||
// Add seat if the user count exceeds the subscription limit
|
||||
await updateSeats(
|
||||
parentSubscription.stripeSubscriptionId,
|
||||
verifiedChildUsersCount + 2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account?.provider !== "credentials") {
|
||||
// registration via SSO can be separately disabled
|
||||
const existingUser = await prisma.account.findFirst({
|
||||
@@ -1287,8 +1386,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
|
||||
console.log("session", session);
|
||||
|
||||
if (STRIPE_SECRET_KEY) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -54,7 +54,7 @@ export default async function forgotPassword(
|
||||
});
|
||||
}
|
||||
|
||||
sendPasswordResetRequest(user.email, user.name);
|
||||
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Password reset email sent.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
|
||||
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import getUserById from "@/lib/api/controllers/users/userId/getUserById";
|
||||
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
||||
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import handleSubscription from "@/lib/api/handleSubscription";
|
||||
import handleSubscription from "@/lib/api/stripe/handleSubscription";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -17,7 +17,7 @@ const buffer = (req: NextApiRequest) => {
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
resolve(Buffer.concat(chunks as any));
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
@@ -78,7 +78,7 @@ export default async function webhook(
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active",
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
|
||||
+8
-5
@@ -23,13 +23,14 @@ export default function Subscribe() {
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const hasInactiveSubscription =
|
||||
user.id && !user.subscription?.active && stripeEnabled;
|
||||
|
||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
user.id &&
|
||||
user?.subscription?.active
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [session.status]);
|
||||
}, [session.status, user]);
|
||||
|
||||
async function submit() {
|
||||
setSubmitLoader(true);
|
||||
@@ -40,6 +41,8 @@ export default function Subscribe() {
|
||||
const data = await res.json();
|
||||
|
||||
router.push(data.response);
|
||||
|
||||
toast.dismiss(redirectionToast);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import UserListing from "@/components/UserListing";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type UserModal = {
|
||||
isOpen: boolean;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
export default function Admin() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||
isOpen: false,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-5">
|
||||
<div className="flex sm:flex-row flex-col justify-between gap-2">
|
||||
<div className="gap-2 inline-flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-neutral btn btn-square btn-sm btn-ghost"
|
||||
>
|
||||
<i className="bi-chevron-left text-xl"></i>
|
||||
</Link>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("team_management")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center relative justify-between gap-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="search-box"
|
||||
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
|
||||
>
|
||||
<i className="bi-search"></i>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="search-box"
|
||||
type="text"
|
||||
placeholder={t("search_users")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (users) {
|
||||
setFilteredUsers(
|
||||
users.filter((user: any) =>
|
||||
JSON.stringify(user)
|
||||
.toLowerCase()
|
||||
.includes(e.target.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setInviteModal(true)}
|
||||
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
|
||||
>
|
||||
<i className="bi-plus text-3xl absolute"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||
) : searchQuery !== "" ? (
|
||||
<p>{t("no_user_found_in_search")}</p>
|
||||
) : users && users.length > 0 ? (
|
||||
UserListing(users, deleteUserModal, setDeleteUserModal, t)
|
||||
) : (
|
||||
<p>{t("no_users_found")}</p>
|
||||
)}
|
||||
|
||||
{inviteModal && <InviteModal onClose={() => setInviteModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
||||
Reference in New Issue
Block a user