setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
- user.username as string,
+ user,
memberUsername || "",
collection,
setMemberState,
@@ -167,7 +167,7 @@ export default function EditCollectionSharingModal({
addMemberToCollection(
- user.username as string,
+ user,
memberUsername || "",
collection,
setMemberState,
diff --git a/components/ModalContent/InviteModal.tsx b/components/ModalContent/InviteModal.tsx
index 1cb92d2..6d83e83 100644
--- a/components/ModalContent/InviteModal.tsx
+++ b/components/ModalContent/InviteModal.tsx
@@ -49,13 +49,13 @@ export default function InviteModal({ onClose }: Props) {
await addUser.mutateAsync(form, {
onSettled: () => {
setSubmitLoader(false);
- signIn("invite", {
+ },
+ onSuccess: async () => {
+ await signIn("invite", {
email: form.email,
callbackUrl: "/member-onboarding",
redirect: false,
});
- },
- onSuccess: () => {
onClose();
},
});
diff --git a/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx
index acab61e..81e0505 100644
--- a/components/ProfileDropdown.tsx
+++ b/components/ProfileDropdown.tsx
@@ -12,7 +12,6 @@ export default function ProfileDropdown() {
const { data: user = {} } = useUser();
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
- const DISABLE_INVITES = process.env.DISABLE_INVITES === "true";
const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark";
@@ -74,16 +73,16 @@ export default function ProfileDropdown() {
)}
- {!DISABLE_INVITES && (
+ {!user.parentSubscriptionId && (
(document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
- {t("manage_team")}
+ {t("invite_users")}
)}
diff --git a/components/ui/Divider.tsx b/components/ui/Divider.tsx
new file mode 100644
index 0000000..80f50d8
--- /dev/null
+++ b/components/ui/Divider.tsx
@@ -0,0 +1,12 @@
+import clsx from "clsx";
+import React from "react";
+
+type Props = {
+ className?: string;
+};
+
+function Divider({ className }: Props) {
+ return
;
+}
+
+export default Divider;
diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx
index ad1d0be..969427e 100644
--- a/layouts/AuthRedirect.tsx
+++ b/layouts/AuthRedirect.tsx
@@ -44,7 +44,6 @@ export default function AuthRedirect({ children }: Props) {
{ path: "/tags", isProtected: true },
{ path: "/preserved", isProtected: true },
{ path: "/admin", isProtected: true },
- { path: "/team", isProtected: true },
{ path: "/search", isProtected: true },
];
diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts
index 0319a65..2f78d1c 100644
--- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts
+++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts
@@ -58,6 +58,12 @@ export default async function updateCollection(
}
}
+ const uniqueMembers = data.members.filter(
+ (e, i, a) =>
+ a.findIndex((el) => el.userId === e.userId) === i &&
+ e.userId !== collectionIsAccessible.ownerId
+ );
+
const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({
where: {
@@ -91,7 +97,7 @@ export default async function updateCollection(
}
: undefined,
members: {
- create: data.members.map((e) => ({
+ create: uniqueMembers.map((e) => ({
user: { connect: { id: e.userId } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
diff --git a/lib/api/controllers/public/users/getPublicUser.ts b/lib/api/controllers/public/users/getPublicUser.ts
index 68b6fab..66bf613 100644
--- a/lib/api/controllers/public/users/getPublicUser.ts
+++ b/lib/api/controllers/public/users/getPublicUser.ts
@@ -5,13 +5,20 @@ export default async function getPublicUser(
isId: boolean,
requestingId?: number
) {
- const user = await prisma.user.findUnique({
+ const user = await prisma.user.findFirst({
where: isId
? {
id: Number(targetId) as number,
}
: {
- username: targetId as string,
+ OR: [
+ {
+ username: targetId as string,
+ },
+ {
+ email: targetId as string,
+ },
+ ],
},
include: {
whitelistedUsers: {
@@ -22,7 +29,7 @@ export default async function getPublicUser(
},
});
- if (!user)
+ if (!user || !user.id)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
@@ -31,7 +38,7 @@ export default async function getPublicUser(
const isInAPublicCollection = await prisma.collection.findFirst({
where: {
- ["OR"]: [
+ OR: [
{ ownerId: user.id },
{
members: {
@@ -73,6 +80,7 @@ export default async function getPublicUser(
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
+ email: lessSensitiveInfo.email,
image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts
index dd589c5..2175fd9 100644
--- a/lib/api/controllers/users/getUsers.ts
+++ b/lib/api/controllers/users/getUsers.ts
@@ -1,21 +1,71 @@
import { prisma } from "@/lib/api/db";
+import { User } from "@prisma/client";
-export default async function getUsers() {
- // Get all users
- const users = await prisma.user.findMany({
- select: {
- id: true,
- username: true,
- email: true,
- emailVerified: true,
- subscriptions: {
- select: {
- active: true,
+export default async function getUsers(user: User) {
+ if (user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
+ const users = await prisma.user.findMany({
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ emailVerified: true,
+ subscriptions: {
+ select: {
+ active: true,
+ },
},
+ createdAt: true,
},
- createdAt: true,
- },
- });
+ });
- return { response: users.sort((a: any, b: any) => a.id - b.id), status: 200 };
+ return {
+ response: users.sort((a: any, b: any) => a.id - b.id),
+ status: 200,
+ };
+ } else {
+ let subscriptionId = (
+ await prisma.subscription.findFirst({
+ where: {
+ userId: user.id,
+ },
+ select: {
+ id: true,
+ },
+ })
+ )?.id;
+
+ if (!subscriptionId)
+ return {
+ response: "Subscription not found.",
+ status: 404,
+ };
+
+ const users = await prisma.user.findMany({
+ where: {
+ OR: [
+ {
+ parentSubscriptionId: subscriptionId,
+ },
+ {
+ subscriptions: {
+ id: subscriptionId,
+ },
+ },
+ ],
+ },
+ select: {
+ id: true,
+ name: true,
+ username: true,
+ email: true,
+ emailVerified: true,
+ createdAt: true,
+ },
+ });
+
+ return {
+ response: users.sort((a: any, b: any) => a.id - b.id),
+ status: 200,
+ };
+ }
}
diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts
index a481893..2c3d864 100644
--- a/lib/api/controllers/users/userId/deleteUserById.ts
+++ b/lib/api/controllers/users/userId/deleteUserById.ts
@@ -4,15 +4,19 @@ import removeFolder from "@/lib/api/storage/removeFolder";
import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
import removeFile from "@/lib/api/storage/removeFile";
+import updateSeats from "@/lib/api/stripe/updateSeats";
export default async function deleteUserById(
userId: number,
body: DeleteUserBody,
- isServerAdmin?: boolean
+ isServerAdmin: boolean,
+ queryId: number
) {
- // First, we retrieve the user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
+ include: {
+ subscriptions: true,
+ },
});
if (!user) {
@@ -23,21 +27,74 @@ export default async function deleteUserById(
}
if (!isServerAdmin) {
- if (user.password) {
- const isPasswordValid = bcrypt.compareSync(body.password, user.password);
+ if (queryId === userId) {
+ if (user.password) {
+ const isPasswordValid = bcrypt.compareSync(
+ body.password,
+ user.password
+ );
- if (!isPasswordValid && !isServerAdmin) {
+ if (!isPasswordValid && !isServerAdmin) {
+ return {
+ response: "Invalid credentials.",
+ status: 401,
+ };
+ }
+ } else {
return {
- response: "Invalid credentials.",
- status: 401, // Unauthorized
+ response:
+ "User has no password. Please reset your password from the forgot password page.",
+ status: 401,
};
}
} else {
- return {
- response:
- "User has no password. Please reset your password from the forgot password page.",
- status: 401, // Unauthorized
- };
+ if (user.parentSubscriptionId) {
+ console.log(userId, user.parentSubscriptionId);
+
+ return {
+ response: "Permission denied.",
+ status: 401,
+ };
+ } else {
+ if (!user.subscriptions) {
+ return {
+ response: "User has no subscription.",
+ status: 401,
+ };
+ }
+
+ const findChild = await prisma.user.findFirst({
+ where: { id: queryId, parentSubscriptionId: user.subscriptions?.id },
+ });
+
+ if (!findChild)
+ return {
+ response: "Permission denied.",
+ status: 401,
+ };
+
+ const removeUser = await prisma.user.update({
+ where: { id: findChild.id },
+ data: {
+ parentSubscription: {
+ disconnect: true,
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ await updateSeats(
+ user.subscriptions.stripeSubscriptionId,
+ user.subscriptions.quantity - 1
+ );
+
+ return {
+ response: removeUser,
+ status: 200,
+ };
+ }
}
}
@@ -47,27 +104,27 @@ export default async function deleteUserById(
async (prisma) => {
// Delete Access Tokens
await prisma.accessToken.deleteMany({
- where: { userId },
+ where: { userId: queryId },
});
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
- where: { userId },
+ where: { userId: queryId },
});
// Delete links
await prisma.link.deleteMany({
- where: { collection: { ownerId: userId } },
+ where: { collection: { ownerId: queryId } },
});
// Delete tags
await prisma.tag.deleteMany({
- where: { ownerId: userId },
+ where: { ownerId: queryId },
});
// Find collections that the user owns
const collections = await prisma.collection.findMany({
- where: { ownerId: userId },
+ where: { ownerId: queryId },
});
for (const collection of collections) {
@@ -86,29 +143,29 @@ export default async function deleteUserById(
// Delete collections after cleaning up related data
await prisma.collection.deleteMany({
- where: { ownerId: userId },
+ where: { ownerId: queryId },
});
// Delete subscription
if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription
.delete({
- where: { userId },
+ where: { userId: queryId },
})
.catch((err) => console.log(err));
await prisma.usersAndCollections.deleteMany({
where: {
- OR: [{ userId: userId }, { collection: { ownerId: userId } }],
+ OR: [{ userId: queryId }, { collection: { ownerId: queryId } }],
},
});
// Delete user's avatar
- await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
+ await removeFile({ filePath: `uploads/avatar/${queryId}.jpg` });
// Finally, delete the user
await prisma.user.delete({
- where: { id: userId },
+ where: { id: queryId },
});
},
{ timeout: 20000 }
diff --git a/lib/api/controllers/users/userId/getUserById.ts b/lib/api/controllers/users/userId/getUserById.ts
index f704155..062a70a 100644
--- a/lib/api/controllers/users/userId/getUserById.ts
+++ b/lib/api/controllers/users/userId/getUserById.ts
@@ -35,6 +35,7 @@ export default async function getUserById(userId: number) {
whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
+ quantity: subscriptions?.quantity,
},
parentSubscription: {
active: parentSubscription?.active,
diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts
index 49e5c4c..6dd09a8 100644
--- a/lib/api/controllers/users/userId/updateUserById.ts
+++ b/lib/api/controllers/users/userId/updateUserById.ts
@@ -277,6 +277,7 @@ export default async function updateUserById(
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: {
active: subscriptions?.active,
+ quantity: subscriptions?.quantity,
},
parentSubscription: {
active: parentSubscription?.active,
diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts
index 4e03720..7fa138f 100644
--- a/lib/client/addMemberToCollection.ts
+++ b/lib/client/addMemberToCollection.ts
@@ -2,9 +2,10 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "./getPublicUserData";
import { toast } from "react-hot-toast";
import { TFunction } from "i18next";
+import { User } from "@prisma/client";
const addMemberToCollection = async (
- ownerUsername: string,
+ owner: User,
memberUsername: string,
collection: CollectionIncludingMembersAndLinkCount,
setMember: (newMember: Member) => null | undefined,
@@ -12,7 +13,12 @@ const addMemberToCollection = async (
) => {
const checkIfMemberAlreadyExists = collection.members.find((e) => {
const username = (e.user.username || "").toLowerCase();
- return username === memberUsername.toLowerCase();
+ const email = (e.user.email || "").toLowerCase();
+
+ return (
+ username === memberUsername.toLowerCase() ||
+ email === memberUsername.toLowerCase()
+ );
});
if (
@@ -21,7 +27,8 @@ const addMemberToCollection = async (
// member can't be empty
memberUsername.trim() !== "" &&
// member can't be the owner
- memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
+ memberUsername.trim().toLowerCase() !== owner.username?.toLowerCase() &&
+ memberUsername.trim().toLowerCase() !== owner.email?.toLowerCase()
) {
// Lookup, get data/err, list ...
const user = await getPublicUserData(memberUsername.trim().toLowerCase());
@@ -37,12 +44,16 @@ const addMemberToCollection = async (
id: user.id,
name: user.name,
username: user.username,
+ email: user.email,
image: user.image,
},
});
}
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
- else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase())
+ else if (
+ memberUsername.trim().toLowerCase() === owner.username?.toLowerCase() ||
+ memberUsername.trim().toLowerCase() === owner.email?.toLowerCase()
+ )
toast.error(t("you_are_already_collection_owner"));
};
diff --git a/pages/admin.tsx b/pages/admin.tsx
index 31978b6..280e674 100644
--- a/pages/admin.tsx
+++ b/pages/admin.tsx
@@ -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() {
-
+
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts
index 161338d..9cf4fc9 100644
--- a/pages/api/v1/users/[id].ts
+++ b/pages/api/v1/users/[id].ts
@@ -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 });
}
@@ -59,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:
@@ -74,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 });
}
}
diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts
index 356a9f0..feebb5a 100644
--- a/pages/api/v1/users/index.ts
+++ b/pages/api/v1/users/index.ts
@@ -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 });
}
}
diff --git a/pages/member-onboarding.tsx b/pages/member-onboarding.tsx
index 7e76d10..57aad68 100644
--- a/pages/member-onboarding.tsx
+++ b/pages/member-onboarding.tsx
@@ -8,7 +8,6 @@ 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";
-import { useSession } from "next-auth/react";
interface FormData {
password: string;
@@ -28,8 +27,6 @@ export default function MemberOnboarding() {
const { data: user = {} } = useUser();
const updateUser = useUpdateUser();
- const { status } = useSession();
-
useEffect(() => {
toast.success(t("accepted_invitation_please_fill"));
}, []);
@@ -146,7 +143,7 @@ export default function MemberOnboarding() {
size="full"
loading={submitLoader}
>
- {t("sign_up")}
+ {t("continue_to_dashboard")}
diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx
index f75789d..963b512 100644
--- a/pages/settings/access-tokens.tsx
+++ b/pages/settings/access-tokens.tsx
@@ -67,10 +67,18 @@ export default function AccessTokens() {
)}
- {new Date(token.createdAt || "").toLocaleDateString()}
+ {new Date(token.createdAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
|
- {new Date(token.expires || "").toLocaleDateString()}
+ {new Date(token.expires).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })}
|
|