commit
6d36a3e14c
|
@ -15,4 +15,10 @@ SPACES_REGION=
|
|||
# SMTP Settings (Optional)
|
||||
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||
EMAIL_FROM=
|
||||
EMAIL_SERVER=
|
||||
EMAIL_SERVER=
|
||||
|
||||
# Stripe settings (You don't need these, it's for the cloud instance payments)
|
||||
STRIPE_SECRET_KEY=
|
||||
PRICE_ID=
|
||||
TRIAL_PERIOD_DAYS=
|
||||
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
|
|
@ -45,6 +45,7 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
|||
return (
|
||||
<Select
|
||||
isClearable
|
||||
placeholder="Unnamed Collection"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
styles={styles}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClose,
|
||||
faCrown,
|
||||
faPenToSquare,
|
||||
faPlus,
|
||||
faUserPlus,
|
||||
|
@ -15,6 +16,7 @@ import SubmitButton from "@/components/SubmitButton";
|
|||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { toast } from "react-hot-toast";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
|
||||
type Props = {
|
||||
toggleCollectionModal: Function;
|
||||
|
@ -47,6 +49,21 @@ export default function TeamManagement({
|
|||
},
|
||||
});
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null,
|
||||
name: "",
|
||||
username: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
const owner = await getPublicUserData({ id: collection.ownerId });
|
||||
setCollectionOwner(owner);
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
}, []);
|
||||
|
||||
const { addCollection, updateCollection } = useCollectionStore();
|
||||
|
||||
const session = useSession();
|
||||
|
@ -163,7 +180,7 @@ export default function TeamManagement({
|
|||
)
|
||||
}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
placeholder="Username (without the '@')"
|
||||
className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
|
||||
/>
|
||||
|
||||
|
@ -225,7 +242,7 @@ export default function TeamManagement({
|
|||
<p className="text-sm font-bold text-sky-500">
|
||||
{e.user.name}
|
||||
</p>
|
||||
<p className="text-sky-900">{e.user.username}</p>
|
||||
<p className="text-sky-900">@{e.user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex sm:block items-center gap-5 min-w-[10rem]">
|
||||
|
@ -397,6 +414,30 @@ export default function TeamManagement({
|
|||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative border p-2 rounded-md border-sky-100 flex flex-col gap-2 justify-between"
|
||||
title={`'@${collectionOwner.username}' is the owner of this collection.`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfilePhoto
|
||||
src={`/api/avatar/${collection.ownerId}`}
|
||||
className="border-[3px]"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-sm font-bold text-sky-500">
|
||||
{collectionOwner.name}
|
||||
</p>
|
||||
<FontAwesomeIcon
|
||||
icon={faCrown}
|
||||
className="w-3 h-3 text-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sky-900">@{collectionOwner.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permissions === true && (
|
||||
<SubmitButton
|
||||
onClick={submit}
|
||||
|
|
|
@ -155,10 +155,7 @@ export default function AddOrEditLink({
|
|||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-sky-500 mb-2">
|
||||
Collection
|
||||
<RequiredBadge />
|
||||
</p>
|
||||
<p className="text-sm text-sky-500 mb-2">Collection</p>
|
||||
<CollectionSelection
|
||||
onChange={setCollection}
|
||||
// defaultValue={{
|
||||
|
|
|
@ -131,7 +131,7 @@ export default function LinkDetails({ link }: Props) {
|
|||
height={42}
|
||||
alt=""
|
||||
id={"favicon-" + link.id}
|
||||
className="select-none mt-2 rounded-full shadow border-[3px] border-white bg-white aspect-square"
|
||||
className="select-none mt-2 rounded-md shadow border-[3px] border-white bg-white aspect-square"
|
||||
draggable="false"
|
||||
onLoad={(e) => {
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { useState } from "react";
|
||||
import SubmitButton from "@/components/SubmitButton";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default function PaymentPortal() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const submit = () => {
|
||||
setSubmitLoader(true);
|
||||
const load = toast.loading("Redirecting to billing portal...");
|
||||
|
||||
router.push(process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL as string);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto sm:w-[35rem] w-80">
|
||||
<div className="max-w-[25rem] w-full mx-auto flex flex-col gap-3 justify-between">
|
||||
<p className="text-md text-gray-500">
|
||||
To manage/cancel your subsciption, visit the billing portal.
|
||||
</p>
|
||||
|
||||
<SubmitButton
|
||||
onClick={submit}
|
||||
loading={submitLoader}
|
||||
label="Go to Billing Portal"
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mx-auto mt-2"
|
||||
/>
|
||||
|
||||
<p className="text-md text-gray-500">
|
||||
If you still need help or encountered any issues, feel free to reach
|
||||
out to us at:{" "}
|
||||
<a className="font-semibold" href="mailto:hello@linkwarden.app">
|
||||
hello@linkwarden.app
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -80,7 +80,7 @@ export default function ChangePassword({
|
|||
value={newPassword}
|
||||
onChange={(e) => setNewPassword1(e.target.value)}
|
||||
type="password"
|
||||
placeholder="***********"
|
||||
placeholder="••••••••••••••"
|
||||
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
|
||||
/>
|
||||
<p className="text-sm text-sky-500">Confirm New Password</p>
|
||||
|
@ -89,7 +89,7 @@ export default function ChangePassword({
|
|||
value={newPassword2}
|
||||
onChange={(e) => setNewPassword2(e.target.value)}
|
||||
type="password"
|
||||
placeholder="***********"
|
||||
placeholder="••••••••••••••"
|
||||
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
|
||||
/>
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import { faClose } from "@fortawesome/free-solid-svg-icons";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { AccountSettings } from "@/types/global";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { resizeImage } from "@/lib/client/resizeImage";
|
||||
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
|
||||
import SubmitButton from "../../SubmitButton";
|
||||
|
@ -16,7 +16,7 @@ type Props = {
|
|||
user: AccountSettings;
|
||||
};
|
||||
|
||||
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
export default function ProfileSettings({
|
||||
toggleSettingsModal,
|
||||
|
@ -78,25 +78,25 @@ export default function ProfileSettings({
|
|||
if (response.ok) {
|
||||
toast.success("Settings Applied!");
|
||||
toggleSettingsModal();
|
||||
} else toast.error(response.data as string);
|
||||
|
||||
setSubmitLoader(false);
|
||||
if (
|
||||
user.username !== account.username ||
|
||||
user.name !== account.name ||
|
||||
user.email !== account.email
|
||||
) {
|
||||
update({
|
||||
username: user.username,
|
||||
email: user.username,
|
||||
name: user.name,
|
||||
});
|
||||
|
||||
if (
|
||||
user.username !== account.username ||
|
||||
user.name !== account.name ||
|
||||
user.email !== account.email
|
||||
)
|
||||
update({
|
||||
username: user.username,
|
||||
email: user.username,
|
||||
name: user.name,
|
||||
});
|
||||
signOut();
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
setUser({ ...user, newPassword: undefined });
|
||||
toggleSettingsModal();
|
||||
}
|
||||
} else toast.error(response.data as string);
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -165,7 +165,7 @@ export default function ProfileSettings({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{EmailProvider ? (
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<p className="text-sm text-sky-500 mb-2">Email</p>
|
||||
<input
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
|||
import ChangePassword from "./ChangePassword";
|
||||
import ProfileSettings from "./ProfileSettings";
|
||||
import PrivacySettings from "./PrivacySettings";
|
||||
import BillingPortal from "./BillingPortal";
|
||||
|
||||
type Props = {
|
||||
toggleSettingsModal: Function;
|
||||
|
@ -12,6 +13,9 @@ type Props = {
|
|||
defaultIndex?: number;
|
||||
};
|
||||
|
||||
const STRIPE_BILLING_PORTAL_URL =
|
||||
process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL;
|
||||
|
||||
export default function UserModal({
|
||||
className,
|
||||
defaultIndex,
|
||||
|
@ -53,6 +57,18 @@ export default function UserModal({
|
|||
>
|
||||
Password
|
||||
</Tab>
|
||||
|
||||
{STRIPE_BILLING_PORTAL_URL ? (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
selected
|
||||
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
|
||||
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
|
||||
}
|
||||
>
|
||||
Billing Portal
|
||||
</Tab>
|
||||
) : undefined}
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
|
@ -78,6 +94,12 @@ export default function UserModal({
|
|||
user={user}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
|
||||
{STRIPE_BILLING_PORTAL_URL ? (
|
||||
<Tab.Panel>
|
||||
<BillingPortal />
|
||||
</Tab.Panel>
|
||||
) : undefined}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
|
|
|
@ -60,7 +60,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
active === "/dashboard"
|
||||
? "bg-sky-200"
|
||||
: "hover:bg-slate-200 bg-gray-100"
|
||||
} outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1`}
|
||||
} outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1 w-full`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartSimple}
|
||||
|
@ -81,7 +81,9 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
icon={faLink}
|
||||
className={`w-8 h-8 drop-shadow text-sky-500`}
|
||||
/>
|
||||
<p className="text-sky-600 text-xs font-semibold">All Links</p>
|
||||
<p className="text-sky-600 text-xs font-semibold">
|
||||
<span className="hidden xl:inline-block">All</span> Links
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
|
|
@ -11,18 +11,23 @@ interface Props {
|
|||
|
||||
export default function AuthRedirect({ children }: Props) {
|
||||
const router = useRouter();
|
||||
const { status } = useSession();
|
||||
const { status, data } = useSession();
|
||||
const [redirect, setRedirect] = useState(true);
|
||||
|
||||
useInitialData();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.pathname.startsWith("/public")) {
|
||||
if (
|
||||
if (status === "authenticated" && data.user.isSubscriber === false) {
|
||||
router.push("/subscribe").then(() => {
|
||||
setRedirect(false);
|
||||
});
|
||||
} else if (
|
||||
status === "authenticated" &&
|
||||
(router.pathname === "/login" ||
|
||||
router.pathname === "/register" ||
|
||||
router.pathname === "/confirmation" ||
|
||||
router.pathname === "/subscribe" ||
|
||||
router.pathname === "/forgot")
|
||||
) {
|
||||
router.push("/").then(() => {
|
||||
|
|
|
@ -6,17 +6,26 @@ import Loader from "../components/Loader";
|
|||
import useRedirect from "@/hooks/useRedirect";
|
||||
import { useRouter } from "next/router";
|
||||
import ModalManagement from "@/components/ModalManagement";
|
||||
import useModalStore from "@/store/modals";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: Props) {
|
||||
const { status } = useSession();
|
||||
const { status, data } = useSession();
|
||||
const router = useRouter();
|
||||
const redirect = useRedirect();
|
||||
const routeExists = router.route === "/_error" ? false : true;
|
||||
|
||||
const { modal } = useModalStore();
|
||||
|
||||
useEffect(() => {
|
||||
modal
|
||||
? (document.body.style.overflow = "hidden")
|
||||
: (document.body.style.overflow = "auto");
|
||||
}, [modal]);
|
||||
|
||||
if (status === "authenticated" && !redirect && routeExists)
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import Stripe from "stripe";
|
||||
|
||||
export default async function checkSubscription(
|
||||
stripeSecretKey: string,
|
||||
email: string,
|
||||
priceId: string
|
||||
) {
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
let subscriptionCanceledAt: number | null | undefined;
|
||||
|
||||
const isSubscriber = listByEmail.data.some((customer, i) => {
|
||||
const hasValidSubscription = customer.subscriptions?.data.some(
|
||||
(subscription) => {
|
||||
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS;
|
||||
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS
|
||||
? Number(TRIAL_PERIOD_DAYS) * 86400
|
||||
: 1209600;
|
||||
|
||||
subscriptionCanceledAt = subscription.canceled_at;
|
||||
|
||||
const isNotCanceledOrHasTime = !(
|
||||
subscription.canceled_at &&
|
||||
new Date() >
|
||||
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
|
||||
);
|
||||
|
||||
return (
|
||||
subscription?.items?.data?.some(
|
||||
(subscriptionItem) => subscriptionItem?.plan?.id === priceId
|
||||
) && isNotCanceledOrHasTime
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
customer.email?.toLowerCase() === email.toLowerCase() &&
|
||||
hasValidSubscription
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
isSubscriber,
|
||||
subscriptionCanceledAt,
|
||||
};
|
||||
}
|
|
@ -4,6 +4,7 @@ import getTitle from "@/lib/api/getTitle";
|
|||
import archive from "@/lib/api/archive";
|
||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
export default async function postLink(
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
|
@ -14,7 +15,7 @@ export default async function postLink(
|
|||
if (!link.name) {
|
||||
return { response: "Please enter a valid name for the link.", status: 400 };
|
||||
} else if (!link.collection.name) {
|
||||
return { response: "Please enter a valid collection.", status: 400 };
|
||||
link.collection.name = "Unnamed Collection";
|
||||
}
|
||||
|
||||
if (link.collection.id) {
|
||||
|
@ -83,6 +84,8 @@ export default async function postLink(
|
|||
include: { tags: true, collection: true },
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${newLink.collectionId}` });
|
||||
|
||||
archive(newLink.url, newLink.collectionId, newLink.id);
|
||||
|
||||
return { response: newLink, status: 200 };
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getUser(
|
||||
lookupUsername: string,
|
||||
isSelf: boolean,
|
||||
username: string
|
||||
) {
|
||||
export default async function getUser({
|
||||
params,
|
||||
isSelf,
|
||||
username,
|
||||
}: {
|
||||
params: {
|
||||
lookupUsername?: string;
|
||||
lookupId?: number;
|
||||
};
|
||||
isSelf: boolean;
|
||||
username: string;
|
||||
}) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: lookupUsername.toLowerCase(),
|
||||
id: params.lookupId,
|
||||
username: params.lookupUsername?.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -3,10 +3,16 @@ import { AccountSettings } from "@/types/global";
|
|||
import bcrypt from "bcrypt";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
import updateCustomerEmail from "../../updateCustomerEmail";
|
||||
|
||||
export default async function updateUser(
|
||||
user: AccountSettings,
|
||||
userId: number
|
||||
sessionUser: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
isSubscriber: boolean;
|
||||
}
|
||||
) {
|
||||
if (!user.username || !user.email)
|
||||
return {
|
||||
|
@ -14,6 +20,26 @@ export default async function updateUser(
|
|||
status: 400,
|
||||
};
|
||||
|
||||
const userIsTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: { not: sessionUser.id },
|
||||
OR: [
|
||||
{
|
||||
username: user.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: user.email.toLowerCase(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (userIsTaken)
|
||||
return {
|
||||
response: "Username/Email is taken.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
// Avatar Settings
|
||||
|
||||
const profilePic = user.profilePic;
|
||||
|
@ -24,7 +50,7 @@ export default async function updateUser(
|
|||
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
|
||||
|
||||
await createFile({
|
||||
filePath: `uploads/avatar/${userId}.jpg`,
|
||||
filePath: `uploads/avatar/${sessionUser.id}.jpg`,
|
||||
data: base64Data,
|
||||
isBase64: true,
|
||||
});
|
||||
|
@ -39,7 +65,7 @@ export default async function updateUser(
|
|||
};
|
||||
}
|
||||
} else if (profilePic == "") {
|
||||
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` });
|
||||
}
|
||||
|
||||
// Other settings
|
||||
|
@ -49,7 +75,7 @@ export default async function updateUser(
|
|||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
id: sessionUser.id,
|
||||
},
|
||||
data: {
|
||||
name: user.name,
|
||||
|
@ -64,6 +90,17 @@ export default async function updateUser(
|
|||
},
|
||||
});
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const PRICE_ID = process.env.PRICE_ID;
|
||||
|
||||
if (STRIPE_SECRET_KEY && PRICE_ID)
|
||||
await updateCustomerEmail(
|
||||
STRIPE_SECRET_KEY,
|
||||
PRICE_ID,
|
||||
sessionUser.email,
|
||||
user.email
|
||||
);
|
||||
|
||||
const { password, ...userInfo } = updatedUser;
|
||||
|
||||
const response: Omit<AccountSettings, "password"> = {
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import Stripe from "stripe";
|
||||
import checkSubscription from "./checkSubscription";
|
||||
|
||||
export default async function paymentCheckout(
|
||||
stripeSecretKey: string,
|
||||
email: string,
|
||||
action: "register" | "login",
|
||||
priceId: string
|
||||
) {
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
// const a = await stripe.prices.retrieve("price_1NTn3PDaRUw6CJPLkw4dcwlJ");
|
||||
|
||||
// const listBySub = await stripe.subscriptions.list({
|
||||
// customer: "cus_OGUzJrRea8Qbxx",
|
||||
// });
|
||||
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
const isExistingCostomer = listByEmail?.data[0]?.id || undefined;
|
||||
|
||||
// const hasPreviouslySubscribed = listByEmail.data.find((customer, i) => {
|
||||
// const hasValidSubscription = customer.subscriptions?.data.some(
|
||||
// (subscription) => {
|
||||
// return subscription?.items?.data?.some(
|
||||
// (subscriptionItem) => subscriptionItem?.plan?.id === priceId
|
||||
// );
|
||||
// }
|
||||
// );
|
||||
|
||||
// return (
|
||||
// customer.email?.toLowerCase() === email.toLowerCase() &&
|
||||
// hasValidSubscription
|
||||
// );
|
||||
// });
|
||||
|
||||
// const previousSubscriptionId =
|
||||
// hasPreviouslySubscribed?.subscriptions?.data[0].id;
|
||||
|
||||
// if (previousSubscriptionId) {
|
||||
// console.log(previousSubscriptionId);
|
||||
// const subscription = await stripe.subscriptions.resume(
|
||||
// previousSubscriptionId
|
||||
// );
|
||||
// }
|
||||
|
||||
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS;
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: isExistingCostomer ? isExistingCostomer : undefined,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
customer_email: isExistingCostomer ? undefined : email.toLowerCase(),
|
||||
success_url: "http://localhost:3000?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: "http://localhost:3000/login",
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
subscription_data: {
|
||||
trial_period_days: TRIAL_PERIOD_DAYS ? Number(TRIAL_PERIOD_DAYS) : 14,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: session.url, status: 200 };
|
||||
}
|
|
@ -4,7 +4,7 @@ import s3Client from "./s3Client";
|
|||
|
||||
export default function createFolder({ filePath }: { filePath: string }) {
|
||||
if (s3Client) {
|
||||
// Do nothing, S3 builds files recursively
|
||||
// Do nothing, S3 creates directories recursively
|
||||
} else {
|
||||
const storagePath = process.env.STORAGE_FOLDER;
|
||||
const creationPath = path.join(process.cwd(), storagePath + "/" + filePath);
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import Stripe from "stripe";
|
||||
|
||||
export default async function updateCustomerEmail(
|
||||
stripeSecretKey: string,
|
||||
priceId: string,
|
||||
email: string,
|
||||
newEmail: string
|
||||
) {
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
const customer = listByEmail.data.find((customer, i) => {
|
||||
const hasValidSubscription = customer.subscriptions?.data.some(
|
||||
(subscription) => {
|
||||
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS;
|
||||
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS
|
||||
? Number(TRIAL_PERIOD_DAYS) * 86400
|
||||
: 1209600;
|
||||
|
||||
const isNotCanceledOrHasTime = !(
|
||||
subscription.canceled_at &&
|
||||
new Date() >
|
||||
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
|
||||
);
|
||||
|
||||
return (
|
||||
subscription?.items?.data?.some(
|
||||
(subscriptionItem) => subscriptionItem?.plan?.id === priceId
|
||||
) && isNotCanceledOrHasTime
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
customer.email?.toLowerCase() === email.toLowerCase() &&
|
||||
hasValidSubscription
|
||||
);
|
||||
});
|
||||
|
||||
if (customer)
|
||||
await stripe.customers.update(customer?.id, {
|
||||
email: newEmail.toLowerCase(),
|
||||
});
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
||||
import getPublicUserDataByUsername from "./getPublicUserDataByUsername";
|
||||
import getPublicUserData from "./getPublicUserData";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
const addMemberToCollection = async (
|
||||
|
@ -22,9 +22,9 @@ const addMemberToCollection = async (
|
|||
memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
|
||||
) {
|
||||
// Lookup, get data/err, list ...
|
||||
const user = await getPublicUserDataByUsername(
|
||||
memberUsername.trim().toLowerCase()
|
||||
);
|
||||
const user = await getPublicUserData({
|
||||
username: memberUsername.trim().toLowerCase(),
|
||||
});
|
||||
|
||||
if (user.username) {
|
||||
setMember({
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default async function getPublicUserData({
|
||||
username,
|
||||
id,
|
||||
}: {
|
||||
username?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`/api/routes/users?id=${id}&${
|
||||
username ? `username=${username?.toLowerCase()}` : undefined
|
||||
}`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) toast.error(data.response);
|
||||
|
||||
return data.response;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import { toast } from "react-hot-toast";
|
||||
|
||||
export default async function getPublicUserDataByEmail(username: string) {
|
||||
const response = await fetch(
|
||||
`/api/routes/users?username=${username.toLowerCase()}`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) toast.error(data.response);
|
||||
|
||||
return data.response;
|
||||
}
|
|
@ -22,6 +22,7 @@
|
|||
"@headlessui/react": "^1.7.15",
|
||||
"@next/font": "13.4.9",
|
||||
"@prisma/client": "^4.16.2",
|
||||
"@stripe/stripe-js": "^1.54.1",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/node": "20.3.3",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
|
@ -43,6 +44,7 @@
|
|||
"react-image-file-resizer": "^0.4.8",
|
||||
"react-select": "^5.7.3",
|
||||
"sharp": "^0.32.1",
|
||||
"stripe": "^12.13.0",
|
||||
"typescript": "4.9.4",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
|
|
|
@ -15,6 +15,11 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!session?.user?.username)
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
const collectionIsAccessible = await getPermission(
|
||||
session.user.id,
|
||||
|
|
|
@ -9,46 +9,45 @@ import { PrismaAdapter } from "@auth/prisma-adapter";
|
|||
import { Adapter } from "next-auth/adapters";
|
||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||
import { Provider } from "next-auth/providers";
|
||||
import checkSubscription from "@/lib/api/checkSubscription";
|
||||
|
||||
let email;
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
const providers: Provider[] = [
|
||||
CredentialsProvider({
|
||||
type: "credentials",
|
||||
credentials: {
|
||||
username: {
|
||||
label: "Username",
|
||||
type: "text",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
},
|
||||
credentials: {},
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials) return null;
|
||||
|
||||
const { username, password } = credentials as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const findUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
username: credentials.username.toLowerCase(),
|
||||
where: emailEnabled
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: username?.toLowerCase(),
|
||||
},
|
||||
],
|
||||
emailVerified: { not: null },
|
||||
}
|
||||
: {
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: credentials.username.toLowerCase(),
|
||||
},
|
||||
],
|
||||
emailVerified: { not: null },
|
||||
},
|
||||
});
|
||||
|
||||
let passwordMatches: boolean = false;
|
||||
|
||||
if (findUser?.password) {
|
||||
passwordMatches = bcrypt.compareSync(
|
||||
credentials.password,
|
||||
findUser.password
|
||||
);
|
||||
passwordMatches = bcrypt.compareSync(password, findUser.password);
|
||||
}
|
||||
|
||||
if (passwordMatches) {
|
||||
|
@ -58,14 +57,13 @@ const providers: Provider[] = [
|
|||
}),
|
||||
];
|
||||
|
||||
if (process.env.EMAIL_SERVER && process.env.EMAIL_FROM)
|
||||
if (emailEnabled)
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 600,
|
||||
maxAge: 1200,
|
||||
sendVerificationRequest(params) {
|
||||
email = params.identifier;
|
||||
sendVerificationRequest(params);
|
||||
},
|
||||
})
|
||||
|
@ -75,6 +73,7 @@ export const authOptions: AuthOptions = {
|
|||
adapter: PrismaAdapter(prisma) as Adapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
providers,
|
||||
pages: {
|
||||
|
@ -85,11 +84,48 @@ export const authOptions: AuthOptions = {
|
|||
session: async ({ session, token }: { session: Session; token: JWT }) => {
|
||||
session.user.id = parseInt(token.id as string);
|
||||
session.user.username = token.username as string;
|
||||
session.user.isSubscriber = token.isSubscriber as boolean;
|
||||
|
||||
return session;
|
||||
},
|
||||
// Using the `...rest` parameter to be able to narrow down the type based on `trigger`
|
||||
jwt({ token, trigger, session, user }) {
|
||||
async jwt({ token, trigger, session, user }) {
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const PRICE_ID = process.env.PRICE_ID;
|
||||
|
||||
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS;
|
||||
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS
|
||||
? Number(TRIAL_PERIOD_DAYS) * 86400
|
||||
: 1209600;
|
||||
const subscriptionIsTimesUp =
|
||||
token.subscriptionCanceledAt &&
|
||||
new Date() >
|
||||
new Date(
|
||||
((token.subscriptionCanceledAt as number) + secondsInTwoWeeks) *
|
||||
1000
|
||||
);
|
||||
|
||||
if (
|
||||
STRIPE_SECRET_KEY &&
|
||||
PRICE_ID &&
|
||||
(trigger || subscriptionIsTimesUp || !token.isSubscriber)
|
||||
) {
|
||||
console.log("EXECUTED!!!");
|
||||
const subscription = await checkSubscription(
|
||||
STRIPE_SECRET_KEY,
|
||||
token.email as string,
|
||||
PRICE_ID
|
||||
);
|
||||
|
||||
subscription.isSubscriber;
|
||||
|
||||
if (subscription.subscriptionCanceledAt) {
|
||||
token.subscriptionCanceledAt = subscription.subscriptionCanceledAt;
|
||||
} else token.subscriptionCanceledAt = undefined;
|
||||
|
||||
token.isSubscriber = subscription.isSubscriber;
|
||||
}
|
||||
|
||||
if (trigger === "signIn") {
|
||||
token.id = user.id;
|
||||
token.username = (user as any).username;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
const EmailProvider =
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
interface Data {
|
||||
|
@ -22,7 +22,7 @@ export default async function Index(
|
|||
) {
|
||||
const body: User = req.body;
|
||||
|
||||
const checkHasEmptyFields = EmailProvider
|
||||
const checkHasEmptyFields = emailEnabled
|
||||
? !body.username || !body.password || !body.name || !body.email
|
||||
: !body.username || !body.password || !body.name;
|
||||
|
||||
|
@ -34,7 +34,7 @@ export default async function Index(
|
|||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||||
|
||||
// Remove user's who aren't verified for more than 10 minutes
|
||||
if (EmailProvider)
|
||||
if (emailEnabled)
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
|
@ -53,10 +53,12 @@ export default async function Index(
|
|||
});
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: EmailProvider
|
||||
where: emailEnabled
|
||||
? {
|
||||
OR: [
|
||||
{ username: body.username.toLowerCase() },
|
||||
{
|
||||
username: body.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: body.email?.toLowerCase(),
|
||||
},
|
||||
|
|
|
@ -11,17 +11,22 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
const userName = session?.user.username?.toLowerCase();
|
||||
const queryId = Number(req.query.id);
|
||||
|
||||
if (!queryId)
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.status(401)
|
||||
.send("Invalid parameters.");
|
||||
|
||||
if (!userId || !userName)
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.status(401)
|
||||
.send("You must be logged in.");
|
||||
else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
if (!queryId)
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.status(401)
|
||||
.send("Invalid parameters.");
|
||||
|
||||
if (userId !== queryId) {
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import paymentCheckout from "@/lib/api/paymentCheckout";
|
||||
|
||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const PRICE_ID = process.env.PRICE_ID;
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session?.user?.username)
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
else if (!STRIPE_SECRET_KEY || !PRICE_ID) {
|
||||
return res.status(400).json({ response: "Payment is disabled." });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const users = await paymentCheckout(
|
||||
STRIPE_SECRET_KEY,
|
||||
session?.user.email,
|
||||
"register",
|
||||
PRICE_ID
|
||||
);
|
||||
return res.status(users.status).json({ response: users.response });
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "pages/api/auth/[...nextauth]";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import getCollections from "@/lib/api/controllers/collections/getCollections";
|
||||
import postCollection from "@/lib/api/controllers/collections/postCollection";
|
||||
import updateCollection from "@/lib/api/controllers/collections/updateCollection";
|
||||
|
@ -14,7 +14,11 @@ export default async function collections(
|
|||
|
||||
if (!session?.user?.username) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
}
|
||||
} else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
if (req.method === "GET") {
|
||||
const collections = await getCollections(session.user.id);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "pages/api/auth/[...nextauth]";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import getLinks from "@/lib/api/controllers/links/getLinks";
|
||||
import postLink from "@/lib/api/controllers/links/postLink";
|
||||
import deleteLink from "@/lib/api/controllers/links/deleteLink";
|
||||
|
@ -11,7 +11,11 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!session?.user?.username) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
}
|
||||
} else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
if (req.method === "GET") {
|
||||
const links = await getLinks(session.user.id, req?.query?.body as string);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "pages/api/auth/[...nextauth]";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import getTags from "@/lib/api/controllers/tags/getTags";
|
||||
|
||||
export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
@ -8,7 +8,11 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!session?.user?.username) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
}
|
||||
} else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
if (req.method === "GET") {
|
||||
const tags = await getTags(session.user.id);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "pages/api/auth/[...nextauth]";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import getUsers from "@/lib/api/controllers/users/getUsers";
|
||||
import updateUser from "@/lib/api/controllers/users/updateUser";
|
||||
|
||||
|
@ -9,16 +9,35 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!session?.user.username) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
}
|
||||
} else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
const lookupUsername = req.query.username as string;
|
||||
const lookupUsername = (req.query.username as string) || undefined;
|
||||
const lookupId = Number(req.query.id) || undefined;
|
||||
const isSelf = session.user.username === lookupUsername ? true : false;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const users = await getUsers(lookupUsername, isSelf, session.user.username);
|
||||
const users = await getUsers({
|
||||
params: {
|
||||
lookupUsername,
|
||||
lookupId,
|
||||
},
|
||||
isSelf,
|
||||
username: session.user.username,
|
||||
});
|
||||
return res.status(users.status).json({ response: users.response });
|
||||
} else if (req.method === "PUT" && !req.body.password) {
|
||||
const updated = await updateUser(req.body, session.user.id);
|
||||
const updated = await updateUser(req.body, session.user);
|
||||
return res.status(updated.status).json({ response: updated.response });
|
||||
}
|
||||
}
|
||||
|
||||
// {
|
||||
// lookupUsername,
|
||||
// lookupId,
|
||||
// },
|
||||
// isSelf,
|
||||
// session.user.username
|
||||
|
|
|
@ -105,7 +105,7 @@ export default function Collections() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{sortedCollections.map((e, i) => {
|
||||
return <CollectionCard key={i} collection={e} />;
|
||||
})}
|
||||
|
|
|
@ -107,14 +107,18 @@ export default function Dashboard() {
|
|||
<p className="font-bold text-6xl bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent">
|
||||
{collections.length}
|
||||
</p>
|
||||
<p className="text-sky-900 text-xl">Collections</p>
|
||||
<p className="text-sky-900 text-xl">
|
||||
{collections.length === 1 ? "Collection" : "Collections"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="font-bold text-6xl bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent">
|
||||
{tags.length}
|
||||
</p>
|
||||
<p className="text-sky-900 text-xl">Tags</p>
|
||||
<p className="text-sky-900 text-xl">
|
||||
{tags.length === 1 ? "Tag" : "Tags"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -122,45 +126,57 @@ export default function Dashboard() {
|
|||
<br />
|
||||
|
||||
<div className="flex flex-col 2xl:flex-row items-start justify-evenly 2xl:gap-2">
|
||||
<Disclosure defaultOpen={linkPinDisclosure}>
|
||||
<div className="flex flex-col gap-5 p-2 w-full mx-auto md:w-2/3">
|
||||
<Disclosure.Button
|
||||
onClick={() => {
|
||||
setLinkPinDisclosure(!linkPinDisclosure);
|
||||
}}
|
||||
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full"
|
||||
>
|
||||
<p className="text-sky-600 text-xl">Pinned Links</p>
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<Disclosure defaultOpen={linkPinDisclosure}>
|
||||
<div className="flex flex-col gap-5 p-2 w-full mx-auto md:w-2/3">
|
||||
<Disclosure.Button
|
||||
onClick={() => {
|
||||
setLinkPinDisclosure(!linkPinDisclosure);
|
||||
}}
|
||||
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full"
|
||||
>
|
||||
<p className="text-sky-600 text-xl">Pinned Links</p>
|
||||
|
||||
<div className="text-sky-600 flex items-center gap-2">
|
||||
{linkPinDisclosure ? "Hide" : "Show"}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={`w-4 h-4 text-sky-300 ${
|
||||
linkPinDisclosure ? "rotate-reverse" : "rotate"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<div className="text-sky-600 flex items-center gap-2">
|
||||
{linkPinDisclosure ? "Hide" : "Show"}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={`w-4 h-4 text-sky-300 ${
|
||||
linkPinDisclosure ? "rotate-reverse" : "rotate"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0 -translate-y-3"
|
||||
enterTo="transform opacity-100 translate-y-0"
|
||||
leave="transition duration-100 ease-out"
|
||||
leaveFrom="transform opacity-100 translate-y-0"
|
||||
leaveTo="transform opacity-0 -translate-y-3"
|
||||
>
|
||||
<Disclosure.Panel className="grid grid-cols-1 xl:grid-cols-2 gap-5 w-full">
|
||||
{links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.map((e, i) => (
|
||||
<LinkCard key={i} link={e} count={i} />
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0 -translate-y-3"
|
||||
enterTo="transform opacity-100 translate-y-0"
|
||||
leave="transition duration-100 ease-out"
|
||||
leaveFrom="transform opacity-100 translate-y-0"
|
||||
leaveTo="transform opacity-0 -translate-y-3"
|
||||
>
|
||||
<Disclosure.Panel className="grid grid-cols-1 xl:grid-cols-2 gap-5 w-full">
|
||||
{links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.map((e, i) => (
|
||||
<LinkCard key={i} link={e} count={i} />
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
</Disclosure>
|
||||
) : (
|
||||
<div className="border border-solid border-sky-100 w-full mx-auto md:w-2/3 p-10 rounded-md">
|
||||
<p className="text-center text-2xl text-sky-500">
|
||||
No Pinned Links
|
||||
</p>
|
||||
<p className="text-center text-sky-900 text-sm">
|
||||
You can Pin Links by clicking on the three dots on each Link and
|
||||
clicking "Pin to Dashboard."
|
||||
</p>
|
||||
</div>
|
||||
</Disclosure>
|
||||
)}
|
||||
|
||||
{/* <Disclosure defaultOpen={collectionPinDisclosure}>
|
||||
<div className="flex flex-col gap-5 p-2 w-full">
|
||||
|
|
|
@ -39,7 +39,7 @@ export default function Forgot() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
|
||||
<Image
|
||||
src="/linkwarden.png"
|
||||
|
@ -49,7 +49,7 @@ export default function Forgot() {
|
|||
className="h-12 w-fit"
|
||||
/>
|
||||
<div className="text-center sm:text-right">
|
||||
<p className="text-3xl font-bold text-sky-500">Password Reset</p>
|
||||
<p className="text-3xl text-sky-500">Password Reset</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ interface FormData {
|
|||
password: string;
|
||||
}
|
||||
|
||||
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
export default function Login() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
@ -46,7 +46,7 @@ export default function Login() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 my-10 mx-auto flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="p-2 my-10 mx-auto flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="text-right flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
|
||||
<Image
|
||||
src="/linkwarden.png"
|
||||
|
@ -56,7 +56,7 @@ export default function Login() {
|
|||
className="h-12 w-fit"
|
||||
/>
|
||||
<div className="text-center sm:text-right">
|
||||
<p className="text-3xl font-bold text-sky-500">Welcome back</p>
|
||||
<p className="text-3xl text-sky-500">Welcome back</p>
|
||||
<p className="text-md font-semibold text-sky-400">
|
||||
Sign in to your account
|
||||
</p>
|
||||
|
@ -66,7 +66,7 @@ export default function Login() {
|
|||
<div>
|
||||
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">
|
||||
Username
|
||||
{EmailProvider ? "/Email" : undefined}
|
||||
{emailEnabled ? "/Email" : undefined}
|
||||
</p>
|
||||
|
||||
<input
|
||||
|
@ -85,12 +85,12 @@ export default function Login() {
|
|||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="***********"
|
||||
placeholder="••••••••••••••"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
|
||||
/>
|
||||
{EmailProvider && (
|
||||
{emailEnabled && (
|
||||
<div className="w-fit ml-auto mt-1">
|
||||
<Link href={"/forgot"} className="text-gray-500 font-semibold">
|
||||
Forgot Password?
|
||||
|
|
|
@ -5,7 +5,7 @@ import SubmitButton from "@/components/SubmitButton";
|
|||
import { signIn } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
|
||||
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
|
@ -21,14 +21,14 @@ export default function Register() {
|
|||
const [form, setForm] = useState<FormData>({
|
||||
name: "",
|
||||
username: "",
|
||||
email: EmailProvider ? "" : undefined,
|
||||
email: emailEnabled ? "" : undefined,
|
||||
password: "",
|
||||
passwordConfirmation: "",
|
||||
});
|
||||
|
||||
async function registerUser() {
|
||||
const checkHasEmptyFields = () => {
|
||||
if (EmailProvider) {
|
||||
if (emailEnabled) {
|
||||
return (
|
||||
form.name !== "" &&
|
||||
form.username !== "" &&
|
||||
|
@ -77,11 +77,7 @@ export default function Register() {
|
|||
if (response.ok) {
|
||||
if (form.email) await sendConfirmation();
|
||||
|
||||
toast.success(
|
||||
EmailProvider
|
||||
? "User Created! Please check you email."
|
||||
: "User Created!"
|
||||
);
|
||||
toast.success("User Created!");
|
||||
} else {
|
||||
toast.error(data.response);
|
||||
}
|
||||
|
@ -95,7 +91,7 @@ export default function Register() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 mx-auto my-10 flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="p-2 mx-auto my-10 flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
|
||||
<Image
|
||||
src="/linkwarden.png"
|
||||
|
@ -105,7 +101,7 @@ export default function Register() {
|
|||
className="h-12 w-fit"
|
||||
/>
|
||||
<div className="text-center sm:text-right">
|
||||
<p className="text-3xl font-bold text-sky-500">Get started</p>
|
||||
<p className="text-3xl text-sky-500">Get started</p>
|
||||
<p className="text-md font-semibold text-sky-400">
|
||||
Create a new account
|
||||
</p>
|
||||
|
@ -140,7 +136,7 @@ export default function Register() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{EmailProvider ? (
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">
|
||||
Email
|
||||
|
@ -156,29 +152,29 @@ export default function Register() {
|
|||
</div>
|
||||
) : undefined}
|
||||
|
||||
<div className="flex item-center gap-5">
|
||||
<div>
|
||||
<div className="flex item-center gap-2">
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">
|
||||
Password
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="***********"
|
||||
placeholder="••••••••••••••"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">
|
||||
Confirm Password
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="***********"
|
||||
placeholder="••••••••••••••"
|
||||
value={form.passwordConfirmation}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, passwordConfirmation: e.target.value })
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import SubmitButton from "@/components/SubmitButton";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function Subscribe() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const { data, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(data?.user);
|
||||
}, [status]);
|
||||
|
||||
async function loginUser() {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const redirectionToast = toast.loading("Redirecting to Stripe...");
|
||||
|
||||
const res = await fetch("/api/payment");
|
||||
const data = await res.json();
|
||||
|
||||
console.log(data);
|
||||
router.push(data.response);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[28rem] w-80 bg-slate-50 rounded-md border border-sky-100">
|
||||
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
|
||||
<Image
|
||||
src="/linkwarden.png"
|
||||
width={1694}
|
||||
height={483}
|
||||
alt="Linkwarden"
|
||||
className="h-12 w-fit"
|
||||
/>
|
||||
<div className="text-center sm:text-right">
|
||||
<p className="text-3xl text-sky-500">14 days free trial</p>
|
||||
<p className="text-md font-semibold text-sky-400">
|
||||
Then $5/month afterwards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-md text-gray-500 mt-1">
|
||||
You will be redirected to Stripe.
|
||||
</p>
|
||||
<p className="text-md text-gray-500 mt-1">
|
||||
feel free to reach out to us at{" "}
|
||||
<a className="font-semibold" href="mailto:hello@linkwarden.app">
|
||||
hello@linkwarden.app
|
||||
</a>{" "}
|
||||
in case of any issues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
onClick={loginUser}
|
||||
label="Complete your Subscription"
|
||||
className="mt-2 w-full text-center"
|
||||
loading={submitLoader}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => signOut()}
|
||||
className="w-fit mx-auto cursor-pointer text-gray-500 font-semibold "
|
||||
>
|
||||
Sign Out
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -35,12 +35,12 @@ model Session {
|
|||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
||||
username String @unique
|
||||
|
||||
email String? @unique
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
|
@ -60,7 +60,6 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
|
@ -69,7 +68,6 @@ model VerificationToken {
|
|||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
|
||||
model Collection {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
|
|
@ -16,6 +16,11 @@ declare global {
|
|||
NEXT_PUBLIC_EMAIL_PROVIDER?: true;
|
||||
EMAIL_FROM?: string;
|
||||
EMAIL_SERVER?: string;
|
||||
|
||||
STRIPE_SECRET_KEY?: string;
|
||||
PRICE_ID?: string;
|
||||
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string;
|
||||
TRIAL_PERIOD_DAYS?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ declare module "next-auth" {
|
|||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
isSubscriber: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -1470,6 +1470,11 @@
|
|||
"@smithy/types" "^1.1.0"
|
||||
tslib "^2.5.0"
|
||||
|
||||
"@stripe/stripe-js@^1.54.1":
|
||||
version "1.54.1"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.54.1.tgz#e298b80c2963d9e622ea355db6c35df48e08cd89"
|
||||
integrity sha512-smEXPu1GKMcAj9g2luT16+oXfg2jAwyc68t2Dm5wdtYl3p8PqQaZEiI8tQmboaQAjgF8pIGma6byz1T1vgmpbA==
|
||||
|
||||
"@swc/helpers@0.4.14":
|
||||
version "0.4.14"
|
||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74"
|
||||
|
@ -1499,6 +1504,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.3.tgz#329842940042d2b280897150e023e604d11657d6"
|
||||
integrity sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==
|
||||
|
||||
"@types/node@>=8.1.0":
|
||||
version "20.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.2.tgz#129cc9ae69f93824f92fac653eebfb4812ab4af9"
|
||||
integrity sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==
|
||||
|
||||
"@types/nodemailer@^6.4.8":
|
||||
version "6.4.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.8.tgz#f06c661e9b201fc2acc3a00a0fded42ba7eaca9d"
|
||||
|
@ -4061,6 +4071,13 @@ punycode@^2.1.0, punycode@^2.1.1:
|
|||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
|
||||
integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
|
||||
|
||||
qs@^6.11.0:
|
||||
version "6.11.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
|
||||
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
qs@~6.5.2:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
|
||||
|
@ -4482,6 +4499,14 @@ strip-json-comments@~2.0.1:
|
|||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
|
||||
|
||||
stripe@^12.13.0:
|
||||
version "12.13.0"
|
||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-12.13.0.tgz#7a8b5705a6f633384e901f512fe1a834277f3123"
|
||||
integrity sha512-mn7CxL71FCRWkQp33jcJ7+xfRF7HGzPYZlq2c87U+6kxL1qd7f/N3S1g1E5uaSWe83V5v3jN/IiWqg9y8+kWRw==
|
||||
dependencies:
|
||||
"@types/node" ">=8.1.0"
|
||||
qs "^6.11.0"
|
||||
|
||||
strnum@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"
|
||||
|
|
Ŝarĝante…
Reference in New Issue