Merge branch 'dev' into tags-in-public-collection
This commit is contained in:
+2
-1
@@ -6,6 +6,7 @@ import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import UserListing from "@/components/UserListing";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
import Divider from "@/components/ui/Divider";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
@@ -88,7 +89,7 @@ export default function Admin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
<Divider className="my-3" />
|
||||
|
||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||
|
||||
@@ -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: {
|
||||
@@ -1296,6 +1393,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
parentSubscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -11,6 +11,12 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const token = await verifyToken({ req });
|
||||
|
||||
const queryId = Number(req.query.id);
|
||||
|
||||
if (!queryId) {
|
||||
return res.status(400).json({ response: "Invalid request." });
|
||||
}
|
||||
|
||||
if (typeof token === "string") {
|
||||
res.status(401).json({ response: token });
|
||||
return null;
|
||||
@@ -24,12 +30,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||
|
||||
const userId = isServerAdmin ? Number(req.query.id) : token.id;
|
||||
|
||||
if (userId !== Number(req.query.id) && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
const userId = token.id;
|
||||
|
||||
if (req.method === "GET") {
|
||||
if (userId !== queryId && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
const users = await getUserById(userId);
|
||||
return res.status(users.status).json({ response: users.response });
|
||||
}
|
||||
@@ -41,6 +47,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
parentSubscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +65,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
if (userId !== queryId && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
response:
|
||||
@@ -73,7 +83,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
||||
const updated = await deleteUserById(
|
||||
userId,
|
||||
req.body,
|
||||
isServerAdmin,
|
||||
queryId
|
||||
);
|
||||
return res.status(updated.status).json({ response: updated.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
} else if (req.method === "GET") {
|
||||
const user = await verifyUser({ req, res });
|
||||
|
||||
if (!user || user.id !== Number(process.env.NEXT_PUBLIC_ADMIN || 1))
|
||||
return res.status(401).json({ response: "Unauthorized..." });
|
||||
if (!user) return res.status(401).json({ response: "Unauthorized..." });
|
||||
|
||||
const response = await getUsers();
|
||||
const response = await getUsers(user);
|
||||
return res.status(response.status).json({ response: response.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import handleSubscription from "@/lib/api/stripe/handleSubscription";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
const buffer = (req: NextApiRequest) => {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks as any));
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default async function webhook(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
response:
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
// see if stripe is already initialized
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) {
|
||||
return res.status(400).json({
|
||||
response: "This action is disabled because Stripe is not initialized.",
|
||||
});
|
||||
}
|
||||
|
||||
let event = req.body;
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const signature = req.headers["stripe-signature"] as any;
|
||||
|
||||
try {
|
||||
const body = await buffer(req);
|
||||
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(400).send("Webhook signature verification failed.");
|
||||
}
|
||||
|
||||
// Handle the event based on its type
|
||||
const eventType = event.type;
|
||||
const data = event.data.object;
|
||||
|
||||
try {
|
||||
switch (eventType) {
|
||||
case "customer.subscription.created":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: false,
|
||||
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.cancelled":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: !(data.current_period_end * 1000 < Date.now()),
|
||||
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type ${eventType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling webhook event:", error);
|
||||
return res.status(500).send("Server Error");
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Done!",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
interface FormData {
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function MemberOnboarding() {
|
||||
const { t } = useTranslation();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
password: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (form.password !== "" && form.name !== "" && !submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("sending_password_recovery_link"));
|
||||
|
||||
await updateUser.mutateAsync(
|
||||
{
|
||||
...user,
|
||||
name: form.name,
|
||||
password: form.password,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
router.push("/dashboard");
|
||||
},
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("settings_applied"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("please_fill_all_fields"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredForm>
|
||||
<form onSubmit={submit}>
|
||||
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||
<p className="text-3xl text-center font-extralight">
|
||||
{t("invitation_accepted")}
|
||||
</p>
|
||||
|
||||
<div className="divider my-0"></div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
>
|
||||
{t("invitation_desc", {
|
||||
owner: user?.parentSubscription?.user?.email,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">
|
||||
{t("display_name")}
|
||||
</p>
|
||||
<TextInput
|
||||
autoFocus
|
||||
placeholder="John Doe"
|
||||
value={form.name}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">
|
||||
{t("new_password")}
|
||||
</p>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="••••••••••••••"
|
||||
value={form.password}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||
<div className="text-xs text-neutral mb-3">
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="sign_up_agreement"
|
||||
components={[
|
||||
<Link
|
||||
href="https://linkwarden.app/tos"
|
||||
className="font-semibold"
|
||||
data-testid="terms-of-service-link"
|
||||
key={0}
|
||||
/>,
|
||||
<Link
|
||||
href="https://linkwarden.app/privacy-policy"
|
||||
className="font-semibold"
|
||||
data-testid="privacy-policy-link"
|
||||
key={1}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
intent="accent"
|
||||
className="mt-2"
|
||||
size="full"
|
||||
loading={submitLoader}
|
||||
>
|
||||
{t("continue_to_dashboard")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CenteredForm>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -67,10 +67,18 @@ export default function AccessTokens() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(token.createdAt || "").toLocaleDateString()}
|
||||
{new Date(token.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(token.expires || "").toLocaleDateString()}
|
||||
{new Date(token.expires).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
|
||||
@@ -108,6 +108,7 @@ export default function Account() {
|
||||
}
|
||||
},
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -129,8 +130,6 @@ export default function Account() {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
const importBookmarks = async (
|
||||
|
||||
+226
-2
@@ -1,17 +1,57 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import clsx from "clsx";
|
||||
import { signIn } from "next-auth/react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type UserModal = {
|
||||
isOpen: boolean;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
export default function Billing() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: account } = useUser();
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE) router.push("/settings/profile");
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE || account.parentSubscriptionId)
|
||||
router.push("/settings/account");
|
||||
}, []);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0) {
|
||||
setFilteredUsers(users);
|
||||
}
|
||||
}, [users]);
|
||||
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||
isOpen: false,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
@@ -40,6 +80,190 @@ export default function Billing() {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full rounded-md h-8 mt-5">
|
||||
<p className="truncate w-full pr-7 text-3xl font-thin">
|
||||
{t("manage_seats")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-3 relative">
|
||||
<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 className="flex gap-3">
|
||||
<div
|
||||
onClick={() => setInviteModal(true)}
|
||||
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 h-[2.15rem] relative"
|
||||
>
|
||||
<p>{t("invite_user")}</p>
|
||||
<i className="bi-plus text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md shadow border-neutral-content">
|
||||
<table className="table bg-base-300 rounded-md">
|
||||
<thead>
|
||||
<tr className="sm:table-row hidden border-b-neutral-content">
|
||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||
<th>{t("email")}</th>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||
<th>{t("status")}</th>
|
||||
)}
|
||||
<th>{t("date_added")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers?.map((user, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={clsx(
|
||||
"group border-b-neutral-content duration-100 w-full relative flex flex-col sm:table-row",
|
||||
user.id !== account.id &&
|
||||
"hover:bg-neutral-content hover:bg-opacity-30"
|
||||
)}
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||
<td className="truncate max-w-full" title={user.email || ""}>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("email")}
|
||||
</p>
|
||||
<p>{user.email}</p>
|
||||
</td>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||
<td>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("status")}
|
||||
</p>
|
||||
{user.emailVerified ? (
|
||||
<p className="font-bold px-2 bg-green-600 text-white rounded-md w-fit">
|
||||
{t("active")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-bold px-2 bg-neutral-content rounded-md w-fit">
|
||||
{t("pending")}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("date_added")}
|
||||
</p>
|
||||
<p className="whitespace-nowrap">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</td>
|
||||
{user.id !== account.id && (
|
||||
<td>
|
||||
<div
|
||||
className={`dropdown dropdown-bottom font-normal dropdown-end absolute right-[0.35rem] top-[0.35rem]`}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square duration-100"
|
||||
>
|
||||
<i
|
||||
className={"bi bi-three-dots text-lg text-neutral"}
|
||||
></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
|
||||
{!user.emailVerified ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(
|
||||
document?.activeElement as HTMLElement
|
||||
)?.blur();
|
||||
signIn("invite", {
|
||||
email: user.email,
|
||||
callbackUrl: "/member-onboarding",
|
||||
redirect: false,
|
||||
}).then(() =>
|
||||
toast.success(t("resend_invite_success"))
|
||||
);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("resend_invite")}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteUserModal({
|
||||
isOpen: true,
|
||||
userId: user.id,
|
||||
});
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("remove_user")}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-center font-bold mt-3">
|
||||
{t("seats_purchased", { count: account?.subscription?.quantity })}
|
||||
</p>
|
||||
{inviteModal && <InviteModal onClose={() => setInviteModal(false)} />}
|
||||
{deleteUserModal.isOpen && deleteUserModal.userId && (
|
||||
<DeleteUserModal
|
||||
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
||||
userId={deleteUserModal.userId}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function Delete() {
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -15,6 +16,7 @@ export default function Delete() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { data } = useSession();
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const submit = async () => {
|
||||
const body = {
|
||||
@@ -83,7 +85,7 @@ export default function Delete() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
|
||||
<fieldset className="border rounded-md p-2 border-primary">
|
||||
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
|
||||
<b>{t("optional")}</b> <i>{t("feedback_help")}</i>
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function Password() {
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -47,8 +48,6 @@ export default function Password() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function Appearance() {
|
||||
{ ...user },
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -90,8 +91,6 @@ export default function Appearance() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+9
-8
@@ -9,8 +9,6 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||
|
||||
export default function Subscribe() {
|
||||
const { t } = useTranslation();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
@@ -23,13 +21,14 @@ export default function Subscribe() {
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const hasInactiveSubscription =
|
||||
user.id && !user.subscription?.active && stripeEnabled;
|
||||
|
||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
||||
console.log("user", user);
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
user.id &&
|
||||
(user?.subscription?.active || user.parentSubscription?.active)
|
||||
)
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [session.status]);
|
||||
}, [session.status, user]);
|
||||
|
||||
async function submit() {
|
||||
setSubmitLoader(true);
|
||||
@@ -40,6 +39,8 @@ export default function Subscribe() {
|
||||
const data = await res.json();
|
||||
|
||||
router.push(data.response);
|
||||
|
||||
toast.dismiss(redirectionToast);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -85,6 +85,7 @@ export default function Index() {
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -97,7 +98,6 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
setSubmitLoader(false);
|
||||
setRenameTag(false);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user