Merge pull request #64 from linkwarden/dev

Dev
This commit is contained in:
Daniel 2023-07-18 13:00:32 -04:00 committed by GitHub
commit 6d36a3e14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 766 additions and 180 deletions

View File

@ -16,3 +16,9 @@ SPACES_REGION=
NEXT_PUBLIC_EMAIL_PROVIDER= NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM= 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=

View File

@ -45,6 +45,7 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
return ( return (
<Select <Select
isClearable isClearable
placeholder="Unnamed Collection"
onChange={onChange} onChange={onChange}
options={options} options={options}
styles={styles} styles={styles}

View File

@ -1,7 +1,8 @@
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faClose, faClose,
faCrown,
faPenToSquare, faPenToSquare,
faPlus, faPlus,
faUserPlus, faUserPlus,
@ -15,6 +16,7 @@ import SubmitButton from "@/components/SubmitButton";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData";
type Props = { type Props = {
toggleCollectionModal: Function; 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 { addCollection, updateCollection } = useCollectionStore();
const session = useSession(); const session = useSession();
@ -163,7 +180,7 @@ export default function TeamManagement({
) )
} }
type="text" 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" 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"> <p className="text-sm font-bold text-sky-500">
{e.user.name} {e.user.name}
</p> </p>
<p className="text-sky-900">{e.user.username}</p> <p className="text-sky-900">@{e.user.username}</p>
</div> </div>
</div> </div>
<div className="flex sm:block items-center gap-5 min-w-[10rem]"> <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 && ( {permissions === true && (
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}

View File

@ -155,10 +155,7 @@ export default function AddOrEditLink({
) : null} ) : null}
<div> <div>
<p className="text-sm text-sky-500 mb-2"> <p className="text-sm text-sky-500 mb-2">Collection</p>
Collection
<RequiredBadge />
</p>
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
// defaultValue={{ // defaultValue={{

View File

@ -131,7 +131,7 @@ export default function LinkDetails({ link }: Props) {
height={42} height={42}
alt="" alt=""
id={"favicon-" + link.id} 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" draggable="false"
onLoad={(e) => { onLoad={(e) => {
try { try {

View File

@ -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>
);
}

View File

@ -80,7 +80,7 @@ export default function ChangePassword({
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword1(e.target.value)} onChange={(e) => setNewPassword1(e.target.value)}
type="password" 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" 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> <p className="text-sm text-sky-500">Confirm New Password</p>
@ -89,7 +89,7 @@ export default function ChangePassword({
value={newPassword2} value={newPassword2}
onChange={(e) => setNewPassword2(e.target.value)} onChange={(e) => setNewPassword2(e.target.value)}
type="password" 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" className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
/> />

View File

@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons"; import { faClose } from "@fortawesome/free-solid-svg-icons";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global"; 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 { resizeImage } from "@/lib/client/resizeImage";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton"; import SubmitButton from "../../SubmitButton";
@ -16,7 +16,7 @@ type Props = {
user: AccountSettings; user: AccountSettings;
}; };
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
export default function ProfileSettings({ export default function ProfileSettings({
toggleSettingsModal, toggleSettingsModal,
@ -78,25 +78,25 @@ export default function ProfileSettings({
if (response.ok) { if (response.ok) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
toggleSettingsModal(); 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 ( signOut();
user.username !== account.username || }
user.name !== account.name ||
user.email !== account.email
)
update({
username: user.username,
email: user.username,
name: user.name,
});
if (response.ok) {
setUser({ ...user, newPassword: undefined }); setUser({ ...user, newPassword: undefined });
toggleSettingsModal(); toggleSettingsModal();
} } else toast.error(response.data as string);
setSubmitLoader(false);
}; };
return ( return (
@ -165,7 +165,7 @@ export default function ProfileSettings({
/> />
</div> </div>
{EmailProvider ? ( {emailEnabled ? (
<div> <div>
<p className="text-sm text-sky-500 mb-2">Email</p> <p className="text-sm text-sky-500 mb-2">Email</p>
<input <input

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import ChangePassword from "./ChangePassword"; import ChangePassword from "./ChangePassword";
import ProfileSettings from "./ProfileSettings"; import ProfileSettings from "./ProfileSettings";
import PrivacySettings from "./PrivacySettings"; import PrivacySettings from "./PrivacySettings";
import BillingPortal from "./BillingPortal";
type Props = { type Props = {
toggleSettingsModal: Function; toggleSettingsModal: Function;
@ -12,6 +13,9 @@ type Props = {
defaultIndex?: number; defaultIndex?: number;
}; };
const STRIPE_BILLING_PORTAL_URL =
process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL;
export default function UserModal({ export default function UserModal({
className, className,
defaultIndex, defaultIndex,
@ -53,6 +57,18 @@ export default function UserModal({
> >
Password Password
</Tab> </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.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel> <Tab.Panel>
@ -78,6 +94,12 @@ export default function UserModal({
user={user} user={user}
/> />
</Tab.Panel> </Tab.Panel>
{STRIPE_BILLING_PORTAL_URL ? (
<Tab.Panel>
<BillingPortal />
</Tab.Panel>
) : undefined}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -60,7 +60,7 @@ export default function Sidebar({ className }: { className?: string }) {
active === "/dashboard" active === "/dashboard"
? "bg-sky-200" ? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100" : "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 <FontAwesomeIcon
icon={faChartSimple} icon={faChartSimple}
@ -81,7 +81,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faLink} icon={faLink}
className={`w-8 h-8 drop-shadow text-sky-500`} 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>
<Link <Link

View File

@ -11,18 +11,23 @@ interface Props {
export default function AuthRedirect({ children }: Props) { export default function AuthRedirect({ children }: Props) {
const router = useRouter(); const router = useRouter();
const { status } = useSession(); const { status, data } = useSession();
const [redirect, setRedirect] = useState(true); const [redirect, setRedirect] = useState(true);
useInitialData(); useInitialData();
useEffect(() => { useEffect(() => {
if (!router.pathname.startsWith("/public")) { if (!router.pathname.startsWith("/public")) {
if ( if (status === "authenticated" && data.user.isSubscriber === false) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" && status === "authenticated" &&
(router.pathname === "/login" || (router.pathname === "/login" ||
router.pathname === "/register" || router.pathname === "/register" ||
router.pathname === "/confirmation" || router.pathname === "/confirmation" ||
router.pathname === "/subscribe" ||
router.pathname === "/forgot") router.pathname === "/forgot")
) { ) {
router.push("/").then(() => { router.push("/").then(() => {

View File

@ -6,17 +6,26 @@ import Loader from "../components/Loader";
import useRedirect from "@/hooks/useRedirect"; import useRedirect from "@/hooks/useRedirect";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import ModalManagement from "@/components/ModalManagement"; import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
} }
export default function MainLayout({ children }: Props) { export default function MainLayout({ children }: Props) {
const { status } = useSession(); const { status, data } = useSession();
const router = useRouter(); const router = useRouter();
const redirect = useRedirect(); const redirect = useRedirect();
const routeExists = router.route === "/_error" ? false : true; 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) if (status === "authenticated" && !redirect && routeExists)
return ( return (
<> <>

View File

@ -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,
};
}

View File

@ -4,6 +4,7 @@ import getTitle from "@/lib/api/getTitle";
import archive from "@/lib/api/archive"; import archive from "@/lib/api/archive";
import { Collection, Link, UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder";
export default async function postLink( export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
@ -14,7 +15,7 @@ export default async function postLink(
if (!link.name) { if (!link.name) {
return { response: "Please enter a valid name for the link.", status: 400 }; return { response: "Please enter a valid name for the link.", status: 400 };
} else if (!link.collection.name) { } else if (!link.collection.name) {
return { response: "Please enter a valid collection.", status: 400 }; link.collection.name = "Unnamed Collection";
} }
if (link.collection.id) { if (link.collection.id) {
@ -83,6 +84,8 @@ export default async function postLink(
include: { tags: true, collection: true }, include: { tags: true, collection: true },
}); });
createFolder({ filePath: `archives/${newLink.collectionId}` });
archive(newLink.url, newLink.collectionId, newLink.id); archive(newLink.url, newLink.collectionId, newLink.id);
return { response: newLink, status: 200 }; return { response: newLink, status: 200 };

View File

@ -1,13 +1,21 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
export default async function getUser( export default async function getUser({
lookupUsername: string, params,
isSelf: boolean, isSelf,
username: string username,
) { }: {
params: {
lookupUsername?: string;
lookupId?: number;
};
isSelf: boolean;
username: string;
}) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
username: lookupUsername.toLowerCase(), id: params.lookupId,
username: params.lookupUsername?.toLowerCase(),
}, },
}); });

View File

@ -3,10 +3,16 @@ import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "../../updateCustomerEmail";
export default async function updateUser( export default async function updateUser(
user: AccountSettings, user: AccountSettings,
userId: number sessionUser: {
id: number;
username: string;
email: string;
isSubscriber: boolean;
}
) { ) {
if (!user.username || !user.email) if (!user.username || !user.email)
return { return {
@ -14,6 +20,26 @@ export default async function updateUser(
status: 400, 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 // Avatar Settings
const profilePic = user.profilePic; const profilePic = user.profilePic;
@ -24,7 +50,7 @@ export default async function updateUser(
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
await createFile({ await createFile({
filePath: `uploads/avatar/${userId}.jpg`, filePath: `uploads/avatar/${sessionUser.id}.jpg`,
data: base64Data, data: base64Data,
isBase64: true, isBase64: true,
}); });
@ -39,7 +65,7 @@ export default async function updateUser(
}; };
} }
} else if (profilePic == "") { } else if (profilePic == "") {
removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` });
} }
// Other settings // Other settings
@ -49,7 +75,7 @@ export default async function updateUser(
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {
id: userId, id: sessionUser.id,
}, },
data: { data: {
name: user.name, 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 { password, ...userInfo } = updatedUser;
const response: Omit<AccountSettings, "password"> = { const response: Omit<AccountSettings, "password"> = {

View File

@ -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 };
}

View File

@ -4,7 +4,7 @@ import s3Client from "./s3Client";
export default function createFolder({ filePath }: { filePath: string }) { export default function createFolder({ filePath }: { filePath: string }) {
if (s3Client) { if (s3Client) {
// Do nothing, S3 builds files recursively // Do nothing, S3 creates directories recursively
} else { } else {
const storagePath = process.env.STORAGE_FOLDER; const storagePath = process.env.STORAGE_FOLDER;
const creationPath = path.join(process.cwd(), storagePath + "/" + filePath); const creationPath = path.join(process.cwd(), storagePath + "/" + filePath);

View File

@ -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(),
});
}

View File

@ -1,5 +1,5 @@
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserDataByUsername from "./getPublicUserDataByUsername"; import getPublicUserData from "./getPublicUserData";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
const addMemberToCollection = async ( const addMemberToCollection = async (
@ -22,9 +22,9 @@ const addMemberToCollection = async (
memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase() memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
) { ) {
// Lookup, get data/err, list ... // Lookup, get data/err, list ...
const user = await getPublicUserDataByUsername( const user = await getPublicUserData({
memberUsername.trim().toLowerCase() username: memberUsername.trim().toLowerCase(),
); });
if (user.username) { if (user.username) {
setMember({ setMember({

View File

@ -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;
}

View File

@ -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;
}

View File

@ -22,6 +22,7 @@
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@next/font": "13.4.9", "@next/font": "13.4.9",
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/node": "20.3.3", "@types/node": "20.3.3",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.8",
@ -43,6 +44,7 @@
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.3", "react-select": "^5.7.3",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"stripe": "^12.13.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },

View File

@ -15,6 +15,11 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!session?.user?.username) if (!session?.user?.username)
return res.status(401).json({ response: "You must be logged in." }); 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( const collectionIsAccessible = await getPermission(
session.user.id, session.user.id,

View File

@ -9,46 +9,45 @@ import { PrismaAdapter } from "@auth/prisma-adapter";
import { Adapter } from "next-auth/adapters"; import { Adapter } from "next-auth/adapters";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest"; import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
import { Provider } from "next-auth/providers"; 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[] = [ const providers: Provider[] = [
CredentialsProvider({ CredentialsProvider({
type: "credentials", type: "credentials",
credentials: { credentials: {},
username: {
label: "Username",
type: "text",
},
password: {
label: "Password",
type: "password",
},
},
async authorize(credentials, req) { async authorize(credentials, req) {
if (!credentials) return null; if (!credentials) return null;
const { username, password } = credentials as {
username: string;
password: string;
};
const findUser = await prisma.user.findFirst({ const findUser = await prisma.user.findFirst({
where: { where: emailEnabled
OR: [ ? {
{ OR: [
username: credentials.username.toLowerCase(), {
username: username.toLowerCase(),
},
{
email: username?.toLowerCase(),
},
],
emailVerified: { not: null },
}
: {
username: username.toLowerCase(),
}, },
{
email: credentials.username.toLowerCase(),
},
],
emailVerified: { not: null },
},
}); });
let passwordMatches: boolean = false; let passwordMatches: boolean = false;
if (findUser?.password) { if (findUser?.password) {
passwordMatches = bcrypt.compareSync( passwordMatches = bcrypt.compareSync(password, findUser.password);
credentials.password,
findUser.password
);
} }
if (passwordMatches) { if (passwordMatches) {
@ -58,14 +57,13 @@ const providers: Provider[] = [
}), }),
]; ];
if (process.env.EMAIL_SERVER && process.env.EMAIL_FROM) if (emailEnabled)
providers.push( providers.push(
EmailProvider({ EmailProvider({
server: process.env.EMAIL_SERVER, server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM, from: process.env.EMAIL_FROM,
maxAge: 600, maxAge: 1200,
sendVerificationRequest(params) { sendVerificationRequest(params) {
email = params.identifier;
sendVerificationRequest(params); sendVerificationRequest(params);
}, },
}) })
@ -75,6 +73,7 @@ export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, adapter: PrismaAdapter(prisma) as Adapter,
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
}, },
providers, providers,
pages: { pages: {
@ -85,11 +84,48 @@ export const authOptions: AuthOptions = {
session: async ({ session, token }: { session: Session; token: JWT }) => { session: async ({ session, token }: { session: Session; token: JWT }) => {
session.user.id = parseInt(token.id as string); session.user.id = parseInt(token.id as string);
session.user.username = token.username as string; session.user.username = token.username as string;
session.user.isSubscriber = token.isSubscriber as boolean;
return session; return session;
}, },
// Using the `...rest` parameter to be able to narrow down the type based on `trigger` // 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") { if (trigger === "signIn") {
token.id = user.id; token.id = user.id;
token.username = (user as any).username; token.username = (user as any).username;

View File

@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
const EmailProvider = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
interface Data { interface Data {
@ -22,7 +22,7 @@ export default async function Index(
) { ) {
const body: User = req.body; 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 || !body.email
: !body.username || !body.password || !body.name; : !body.username || !body.password || !body.name;
@ -34,7 +34,7 @@ export default async function Index(
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
// Remove user's who aren't verified for more than 10 minutes // Remove user's who aren't verified for more than 10 minutes
if (EmailProvider) if (emailEnabled)
await prisma.user.deleteMany({ await prisma.user.deleteMany({
where: { where: {
OR: [ OR: [
@ -53,10 +53,12 @@ export default async function Index(
}); });
const checkIfUserExists = await prisma.user.findFirst({ const checkIfUserExists = await prisma.user.findFirst({
where: EmailProvider where: emailEnabled
? { ? {
OR: [ OR: [
{ username: body.username.toLowerCase() }, {
username: body.username.toLowerCase(),
},
{ {
email: body.email?.toLowerCase(), email: body.email?.toLowerCase(),
}, },

View File

@ -11,17 +11,22 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const userName = session?.user.username?.toLowerCase(); const userName = session?.user.username?.toLowerCase();
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
if (!queryId)
return res
.setHeader("Content-Type", "text/plain")
.status(401)
.send("Invalid parameters.");
if (!userId || !userName) if (!userId || !userName)
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/plain")
.status(401) .status(401)
.send("You must be logged in."); .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) { if (userId !== queryId) {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({

View File

@ -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 });
}
}

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/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 getCollections from "@/lib/api/controllers/collections/getCollections";
import postCollection from "@/lib/api/controllers/collections/postCollection"; import postCollection from "@/lib/api/controllers/collections/postCollection";
import updateCollection from "@/lib/api/controllers/collections/updateCollection"; import updateCollection from "@/lib/api/controllers/collections/updateCollection";
@ -14,7 +14,11 @@ export default async function collections(
if (!session?.user?.username) { if (!session?.user?.username) {
return res.status(401).json({ response: "You must be logged in." }); 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") { if (req.method === "GET") {
const collections = await getCollections(session.user.id); const collections = await getCollections(session.user.id);

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/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 getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink"; import postLink from "@/lib/api/controllers/links/postLink";
import deleteLink from "@/lib/api/controllers/links/deleteLink"; 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) { if (!session?.user?.username) {
return res.status(401).json({ response: "You must be logged in." }); 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") { if (req.method === "GET") {
const links = await getLinks(session.user.id, req?.query?.body as string); const links = await getLinks(session.user.id, req?.query?.body as string);

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/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"; import getTags from "@/lib/api/controllers/tags/getTags";
export default async function tags(req: NextApiRequest, res: NextApiResponse) { 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) { if (!session?.user?.username) {
return res.status(401).json({ response: "You must be logged in." }); 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") { if (req.method === "GET") {
const tags = await getTags(session.user.id); const tags = await getTags(session.user.id);

View File

@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/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 getUsers from "@/lib/api/controllers/users/getUsers";
import updateUser from "@/lib/api/controllers/users/updateUser"; 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) { if (!session?.user.username) {
return res.status(401).json({ response: "You must be logged in." }); 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; const isSelf = session.user.username === lookupUsername ? true : false;
if (req.method === "GET") { 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 }); return res.status(users.status).json({ response: users.response });
} else if (req.method === "PUT" && !req.body.password) { } 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 }); return res.status(updated.status).json({ response: updated.response });
} }
} }
// {
// lookupUsername,
// lookupId,
// },
// isSelf,
// session.user.username

View File

@ -105,7 +105,7 @@ export default function Collections() {
</div> </div>
</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) => { {sortedCollections.map((e, i) => {
return <CollectionCard key={i} collection={e} />; return <CollectionCard key={i} collection={e} />;
})} })}

View File

@ -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"> <p className="font-bold text-6xl bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent">
{collections.length} {collections.length}
</p> </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>
<div className="flex items-baseline gap-2"> <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"> <p className="font-bold text-6xl bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent">
{tags.length} {tags.length}
</p> </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>
</div> </div>
@ -122,45 +126,57 @@ export default function Dashboard() {
<br /> <br />
<div className="flex flex-col 2xl:flex-row items-start justify-evenly 2xl:gap-2"> <div className="flex flex-col 2xl:flex-row items-start justify-evenly 2xl:gap-2">
<Disclosure defaultOpen={linkPinDisclosure}> {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="flex flex-col gap-5 p-2 w-full mx-auto md:w-2/3"> <Disclosure defaultOpen={linkPinDisclosure}>
<Disclosure.Button <div className="flex flex-col gap-5 p-2 w-full mx-auto md:w-2/3">
onClick={() => { <Disclosure.Button
setLinkPinDisclosure(!linkPinDisclosure); onClick={() => {
}} setLinkPinDisclosure(!linkPinDisclosure);
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full" }}
> 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> >
<p className="text-sky-600 text-xl">Pinned Links</p>
<div className="text-sky-600 flex items-center gap-2"> <div className="text-sky-600 flex items-center gap-2">
{linkPinDisclosure ? "Hide" : "Show"} {linkPinDisclosure ? "Hide" : "Show"}
<FontAwesomeIcon <FontAwesomeIcon
icon={faChevronDown} icon={faChevronDown}
className={`w-4 h-4 text-sky-300 ${ className={`w-4 h-4 text-sky-300 ${
linkPinDisclosure ? "rotate-reverse" : "rotate" linkPinDisclosure ? "rotate-reverse" : "rotate"
}`} }`}
/> />
</div> </div>
</Disclosure.Button> </Disclosure.Button>
<Transition <Transition
enter="transition duration-100 ease-out" enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3" enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0" enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out" leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0" leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel className="grid grid-cols-1 xl:grid-cols-2 gap-5 w-full"> <Disclosure.Panel className="grid grid-cols-1 xl:grid-cols-2 gap-5 w-full">
{links {links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])
.map((e, i) => ( .map((e, i) => (
<LinkCard key={i} link={e} count={i} /> <LinkCard key={i} link={e} count={i} />
))} ))}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </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> </div>
</Disclosure> )}
{/* <Disclosure defaultOpen={collectionPinDisclosure}> {/* <Disclosure defaultOpen={collectionPinDisclosure}>
<div className="flex flex-col gap-5 p-2 w-full"> <div className="flex flex-col gap-5 p-2 w-full">

View File

@ -39,7 +39,7 @@ export default function Forgot() {
return ( 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"> <div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
<Image <Image
src="/linkwarden.png" src="/linkwarden.png"
@ -49,7 +49,7 @@ export default function Forgot() {
className="h-12 w-fit" className="h-12 w-fit"
/> />
<div className="text-center sm:text-right"> <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>
</div> </div>

View File

@ -10,7 +10,7 @@ interface FormData {
password: string; password: string;
} }
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
export default function Login() { export default function Login() {
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -46,7 +46,7 @@ export default function Login() {
return ( 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"> <div className="text-right flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
<Image <Image
src="/linkwarden.png" src="/linkwarden.png"
@ -56,7 +56,7 @@ export default function Login() {
className="h-12 w-fit" className="h-12 w-fit"
/> />
<div className="text-center sm:text-right"> <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"> <p className="text-md font-semibold text-sky-400">
Sign in to your account Sign in to your account
</p> </p>
@ -66,7 +66,7 @@ export default function Login() {
<div> <div>
<p className="text-sm text-sky-500 w-fit font-semibold mb-1"> <p className="text-sm text-sky-500 w-fit font-semibold mb-1">
Username Username
{EmailProvider ? "/Email" : undefined} {emailEnabled ? "/Email" : undefined}
</p> </p>
<input <input
@ -85,12 +85,12 @@ export default function Login() {
<input <input
type="password" type="password"
placeholder="***********" placeholder="••••••••••••••"
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} 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" 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"> <div className="w-fit ml-auto mt-1">
<Link href={"/forgot"} className="text-gray-500 font-semibold"> <Link href={"/forgot"} className="text-gray-500 font-semibold">
Forgot Password? Forgot Password?

View File

@ -5,7 +5,7 @@ import SubmitButton from "@/components/SubmitButton";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
const EmailProvider = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
type FormData = { type FormData = {
name: string; name: string;
@ -21,14 +21,14 @@ export default function Register() {
const [form, setForm] = useState<FormData>({ const [form, setForm] = useState<FormData>({
name: "", name: "",
username: "", username: "",
email: EmailProvider ? "" : undefined, email: emailEnabled ? "" : undefined,
password: "", password: "",
passwordConfirmation: "", passwordConfirmation: "",
}); });
async function registerUser() { async function registerUser() {
const checkHasEmptyFields = () => { const checkHasEmptyFields = () => {
if (EmailProvider) { if (emailEnabled) {
return ( return (
form.name !== "" && form.name !== "" &&
form.username !== "" && form.username !== "" &&
@ -77,11 +77,7 @@ export default function Register() {
if (response.ok) { if (response.ok) {
if (form.email) await sendConfirmation(); if (form.email) await sendConfirmation();
toast.success( toast.success("User Created!");
EmailProvider
? "User Created! Please check you email."
: "User Created!"
);
} else { } else {
toast.error(data.response); toast.error(data.response);
} }
@ -95,7 +91,7 @@ export default function Register() {
return ( 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"> <div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5">
<Image <Image
src="/linkwarden.png" src="/linkwarden.png"
@ -105,7 +101,7 @@ export default function Register() {
className="h-12 w-fit" className="h-12 w-fit"
/> />
<div className="text-center sm:text-right"> <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"> <p className="text-md font-semibold text-sky-400">
Create a new account Create a new account
</p> </p>
@ -140,7 +136,7 @@ export default function Register() {
/> />
</div> </div>
{EmailProvider ? ( {emailEnabled ? (
<div> <div>
<p className="text-sm text-sky-500 w-fit font-semibold mb-1"> <p className="text-sm text-sky-500 w-fit font-semibold mb-1">
Email Email
@ -156,29 +152,29 @@ export default function Register() {
</div> </div>
) : undefined} ) : undefined}
<div className="flex item-center gap-5"> <div className="flex item-center gap-2">
<div> <div className="w-full">
<p className="text-sm text-sky-500 w-fit font-semibold mb-1"> <p className="text-sm text-sky-500 w-fit font-semibold mb-1">
Password Password
</p> </p>
<input <input
type="password" type="password"
placeholder="***********" placeholder="••••••••••••••"
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} 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" 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> <div className="w-full">
<p className="text-sm text-sky-500 w-fit font-semibold mb-1"> <p className="text-sm text-sky-500 w-fit font-semibold mb-1">
Confirm Password Confirm Password
</p> </p>
<input <input
type="password" type="password"
placeholder="***********" placeholder="••••••••••••••"
value={form.passwordConfirmation} value={form.passwordConfirmation}
onChange={(e) => onChange={(e) =>
setForm({ ...form, passwordConfirmation: e.target.value }) setForm({ ...form, passwordConfirmation: e.target.value })

79
pages/subscribe.tsx Normal file
View File

@ -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>
</>
);
}

View File

@ -35,12 +35,12 @@ model Session {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
username String @unique username String @unique
email String? @unique email String? @unique
emailVerified DateTime? emailVerified DateTime?
image String? image String?
@ -60,7 +60,6 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model VerificationToken { model VerificationToken {
identifier String identifier String
token String @unique token String @unique
@ -69,7 +68,6 @@ model VerificationToken {
@@unique([identifier, token]) @@unique([identifier, token])
} }
model Collection { model Collection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String

View File

@ -16,6 +16,11 @@ declare global {
NEXT_PUBLIC_EMAIL_PROVIDER?: true; NEXT_PUBLIC_EMAIL_PROVIDER?: true;
EMAIL_FROM?: string; EMAIL_FROM?: string;
EMAIL_SERVER?: string; EMAIL_SERVER?: string;
STRIPE_SECRET_KEY?: string;
PRICE_ID?: string;
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string;
TRIAL_PERIOD_DAYS?: string;
} }
} }
} }

View File

@ -6,6 +6,7 @@ declare module "next-auth" {
id: number; id: number;
username: string; username: string;
email: string; email: string;
isSubscriber: boolean;
}; };
} }
} }

View File

@ -1470,6 +1470,11 @@
"@smithy/types" "^1.1.0" "@smithy/types" "^1.1.0"
tslib "^2.5.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": "@swc/helpers@0.4.14":
version "0.4.14" version "0.4.14"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74" 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" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.3.tgz#329842940042d2b280897150e023e604d11657d6"
integrity sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw== 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": "@types/nodemailer@^6.4.8":
version "6.4.8" version "6.4.8"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.8.tgz#f06c661e9b201fc2acc3a00a0fded42ba7eaca9d" 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" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== 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: qs@~6.5.2:
version "6.5.3" version "6.5.3"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" 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" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== 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: strnum@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db"