Accepted incoming changes

This commit is contained in:
daniel31x13
2024-11-12 10:09:02 -05:00
55 changed files with 6714 additions and 1281 deletions
+76 -35
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, ChangeEvent } from "react";
import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast";
import SettingsLayout from "@/layouts/SettingsLayout";
@@ -17,6 +17,7 @@ import { i18n } from "next-i18next.config";
import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import { z } from "zod";
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@@ -55,8 +56,10 @@ export default function Account() {
if (!objectIsEmpty(account)) setUser({ ...account });
}, [account]);
const handleImageUpload = async (e: any) => {
const file: File = e.target.files[0];
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return toast.error(t("image_upload_no_file_error"));
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["png", "jpeg", "jpg"];
if (allowedExtensions.includes(fileExtension as string)) {
@@ -78,6 +81,16 @@ export default function Account() {
};
const submit = async (password?: string) => {
if (!/^[a-z0-9_-]{3,50}$/.test(user.username || "")) {
return toast.error(t("username_invalid_guide"));
}
const emailSchema = z.string().trim().email().toLowerCase();
const emailValidation = emailSchema.safeParse(user.email || "");
if (!emailValidation.success) {
return toast.error(t("email_invalid"));
}
setSubmitLoader(true);
const load = toast.loading(t("applying_settings"));
@@ -88,13 +101,8 @@ export default function Account() {
password: password ? password : undefined,
},
{
onSuccess: (data) => {
if (data.response.email !== user.email) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
}
},
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -111,39 +119,72 @@ export default function Account() {
}
);
setSubmitLoader(false);
if (user.locale !== account.locale) {
setTimeout(() => {
location.reload();
}, 1000);
}
};
const importBookmarks = async (e: any, format: MigrationFormat) => {
setSubmitLoader(true);
const file: File = e.target.files[0];
const importBookmarks = async (
e: React.ChangeEvent<HTMLInputElement>,
format: MigrationFormat
) => {
const file: File | null = e.target.files && e.target.files[0];
if (file) {
var reader = new FileReader();
const reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = async function (e) {
const load = toast.loading(t("importing_bookmarks"));
const load = toast.loading("Importing...");
const request: string = e.target?.result as string;
const body: MigrationRequest = { format, data: request };
const response = await fetch("/api/v1/migration", {
method: "POST",
body: JSON.stringify(body),
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(t("import_success"));
const body: MigrationRequest = {
format,
data: request,
};
try {
const response = await fetch("/api/v1/migration", {
method: "POST",
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json();
toast.dismiss(load);
toast.error(
errorData.response ||
"Failed to import bookmarks. Please try again."
);
return;
}
await response.json();
toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page...");
setTimeout(() => {
location.reload();
}, 2000);
} else {
toast.error(data.response as string);
} catch (error) {
console.error("Request failed", error);
toast.dismiss(load);
toast.error(
"An error occurred while importing bookmarks. Please check the logs for more info."
);
}
};
reader.onerror = function (e) {
console.log("Error:", e);
console.log("Error reading file:", e);
toast.error(
"Failed to read the file. Please make sure the file is correct and try again."
);
};
}
setSubmitLoader(false);
};
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState("");
@@ -190,16 +231,17 @@ export default function Account() {
onChange={(e) => setUser({ ...user, username: e.target.value })}
/>
</div>
{emailEnabled ? (
{emailEnabled && (
<div>
<p className="mb-2">{t("email")}</p>
<TextInput
value={user.email || ""}
type="email"
className="bg-base-200"
onChange={(e) => setUser({ ...user, email: e.target.value })}
/>
</div>
) : undefined}
)}
<div>
<p className="mb-2">{t("language")}</p>
<select
@@ -437,9 +479,8 @@ export default function Account() {
<p>
{t("delete_account_warning")}
{process.env.NEXT_PUBLIC_STRIPE
? " " + t("cancel_subscription_notice")
: undefined}
{process.env.NEXT_PUBLIC_STRIPE &&
" " + t("cancel_subscription_notice")}
</p>
</div>
@@ -448,14 +489,14 @@ export default function Account() {
</Link>
</div>
{emailChangeVerificationModal ? (
{emailChangeVerificationModal && (
<EmailChangeVerificationModal
onClose={() => setEmailChangeVerificationModal(false)}
onSubmit={submit}
oldEmail={account.email || ""}
newEmail={user.email || ""}
/>
) : undefined}
)}
</SettingsLayout>
);
}
+231 -2
View File
@@ -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,195 @@ 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 className="relative">
<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(
account?.subscription?.quantity === 1
? "seat_purchased"
: "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>
);
}