Merge pull request #66 from linkwarden/dev

Dev
This commit is contained in:
Daniel 2023-07-20 23:17:34 -04:00 committed by GitHub
commit dd7fb973a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 567 additions and 408 deletions

View File

@ -18,7 +18,10 @@ EMAIL_FROM=
EMAIL_SERVER= EMAIL_SERVER=
# Stripe settings (You don't need these, it's for the cloud instance payments) # Stripe settings (You don't need these, it's for the cloud instance payments)
NEXT_PUBLIC_STRIPE_IS_ACTIVE=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
PRICE_ID= PRICE_ID=
TRIAL_PERIOD_DAYS= NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL= NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000
NEXT_PUBLIC_PRICING=

4
.github/SECURITY.md vendored
View File

@ -12,6 +12,6 @@ First off, we really appreciate the time you spent!
If you found a vulnerability, these are the ways you can reach us: If you found a vulnerability, these are the ways you can reach us:
Email: [hello@linkwarden.app](mailto:hello@daniel31x13.io) Email: [security@linkwarden.app](mailto:security@linkwarden.app)
Or you can directly reach me via Twitter: [@daniel31x13](https://twitter.com/Daniel31X13). Or you can directly DM me via Twitter: [@daniel31x13](https://twitter.com/Daniel31X13).

View File

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

View File

@ -143,18 +143,18 @@ export default function LinkCard({ link, count, className }: Props) {
<div className="flex flex-col justify-between w-full"> <div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<p className="text-sm text-sky-400 font-bold">{count + 1}.</p> <p className="text-sm text-sky-400 font-bold">{count + 1}.</p>
<p className="text-lg text-sky-500 font-bold truncate max-w-[10rem] capitalize"> <p className="text-lg text-sky-500 font-bold truncate capitalize w-full pr-8">
{link.name} {link.name}
</p> </p>
</div> </div>
<div className="flex gap-3 items-center flex-wrap my-3"> <div className="flex gap-3 items-center my-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 w-full pr-20">
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow" className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }} style={{ color: collection?.color }}
/> />
<p className="text-sky-900 truncate max-w-[10rem] capitalize"> <p className="text-sky-900 truncate capitalize">
{collection?.name} {collection?.name}
</p> </p>
</div> </div>

View File

@ -163,7 +163,7 @@ export default function TeamManagement({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
value={member.user.username} value={member.user.username || ""}
onChange={(e) => { onChange={(e) => {
setMember({ setMember({
...member, ...member,
@ -174,7 +174,7 @@ export default function TeamManagement({
e.key === "Enter" && e.key === "Enter" &&
addMemberToCollection( addMemberToCollection(
session.data?.user.username as string, session.data?.user.username as string,
member.user.username, member.user.username || "",
collection, collection,
setMemberState setMemberState
) )
@ -188,7 +188,7 @@ export default function TeamManagement({
onClick={() => onClick={() =>
addMemberToCollection( addMemberToCollection(
session.data?.user.username as string, session.data?.user.username as string,
member.user.username, member.user.username || "",
collection, collection,
setMemberState setMemberState
) )

View File

@ -151,7 +151,7 @@ export default function LinkDetails({ link }: Props) {
/> />
)} )}
<div className="flex flex-col gap- justify-end drop-shadow"> <div className="flex flex-col gap- justify-end drop-shadow">
<p className="text-2xl text-sky-500 capitalize hyphens-auto"> <p className="text-2xl text-sky-500 capitalize break-words hyphens-auto">
{link.name} {link.name}
</p> </p>
<Link <Link

View File

@ -33,8 +33,8 @@ export default function PaymentPortal() {
<p className="text-md text-gray-500"> <p className="text-md text-gray-500">
If you still need help or encountered any issues, feel free to reach If you still need help or encountered any issues, feel free to reach
out to us at:{" "} out to us at:{" "}
<a className="font-semibold" href="mailto:hello@linkwarden.app"> <a className="font-semibold" href="mailto:support@linkwarden.app">
hello@linkwarden.app support@linkwarden.app
</a> </a>
</p> </p>
</div> </div>

View File

@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { AccountSettings } from "@/types/global"; import { AccountSettings } from "@/types/global";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
@ -23,7 +23,7 @@ export default function ChangePassword({
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore(); const { account, updateAccount } = useAccountStore();
const { update } = useSession(); const { update, data } = useSession();
useEffect(() => { useEffect(() => {
if ( if (
@ -37,38 +37,45 @@ export default function ChangePassword({
const submit = async () => { const submit = async () => {
if (newPassword == "" || newPassword2 == "") { if (newPassword == "" || newPassword2 == "") {
toast.error("Please fill all the fields."); toast.error("Please fill all the fields.");
} else if (newPassword === newPassword2) {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
togglePasswordFormModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
if (
(user.username !== account.username || user.name !== account.name) &&
user.username &&
user.email
)
update({ username: user.username, name: user.name });
if (response.ok) {
setUser({ ...user, newPassword: undefined });
togglePasswordFormModal();
}
} else {
toast.error("Passwords do not match.");
} }
if (newPassword !== newPassword2)
return toast.error("Passwords do not match.");
else if (newPassword.length < 8)
return toast.error("Passwords must be at least 8 characters.");
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
togglePasswordFormModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
}; };
return ( return (

View File

@ -2,7 +2,7 @@ import { Dispatch, SetStateAction, useEffect, useState } from "react";
import Checkbox from "../../Checkbox"; import Checkbox from "../../Checkbox";
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 { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton"; import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
@ -18,7 +18,7 @@ export default function PrivacySettings({
setUser, setUser,
user, user,
}: Props) { }: Props) {
const { update } = useSession(); const { update, data } = useSession();
const { account, updateAccount } = useAccountStore(); const { account, updateAccount } = useAccountStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -59,18 +59,25 @@ export default function PrivacySettings({
if (response.ok) { if (response.ok) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
toggleSettingsModal();
} else toast.error(response.data as string);
setSubmitLoader(false); if (user.email !== account.email) {
update({
id: data?.user.id,
});
if (user.username !== account.username || user.name !== account.name) signOut();
update({ username: user.username, name: user.name }); } else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
if (response.ok) {
setUser({ ...user, newPassword: undefined }); setUser({ ...user, newPassword: undefined });
toggleSettingsModal(); toggleSettingsModal();
} } else toast.error(response.data as string);
setSubmitLoader(false);
}; };
return ( return (

View File

@ -23,7 +23,7 @@ export default function ProfileSettings({
setUser, setUser,
user, user,
}: Props) { }: Props) {
const { update } = useSession(); const { update, data } = useSession();
const { account, updateAccount } = useAccountStore(); const { account, updateAccount } = useAccountStore();
const [profileStatus, setProfileStatus] = useState(true); const [profileStatus, setProfileStatus] = useState(true);
@ -77,21 +77,20 @@ export default function ProfileSettings({
if (response.ok) { if (response.ok) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
toggleSettingsModal();
if ( if (user.email !== account.email) {
user.username !== account.username ||
user.name !== account.name ||
user.email !== account.email
) {
update({ update({
username: user.username, id: data?.user.id,
email: user.username,
name: user.name,
}); });
signOut(); signOut();
} } else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined }); setUser({ ...user, newPassword: undefined });
toggleSettingsModal(); toggleSettingsModal();
@ -124,7 +123,7 @@ export default function ProfileSettings({
</div> </div>
)} )}
<div className="absolute -bottom-2 left-0 right-0 mx-auto w-fit text-center"> <div className="absolute -bottom-3 left-0 right-0 mx-auto w-fit text-center">
<label <label
htmlFor="upload-photo" htmlFor="upload-photo"
title="PNG or JPG (Max: 3MB)" title="PNG or JPG (Max: 3MB)"
@ -159,7 +158,7 @@ export default function ProfileSettings({
<p className="text-sm text-sky-500 mb-2">Username</p> <p className="text-sm text-sky-500 mb-2">Username</p>
<input <input
type="text" type="text"
value={user.username} value={user.username || ""}
onChange={(e) => setUser({ ...user, username: e.target.value })} onChange={(e) => setUser({ ...user, username: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100" className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
/> />
@ -176,6 +175,12 @@ export default function ProfileSettings({
/> />
</div> </div>
) : undefined} ) : undefined}
{user.email !== account.email ? (
<p className="text-gray-500">
You will need to log back in after you apply this Email.
</p>
) : undefined}
</div> </div>
</div> </div>

View File

@ -0,0 +1,38 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import useModalStore from "@/store/modals";
export default function NoLinksFound() {
const { setModal } = useModalStore();
return (
<div className="border border-solid border-sky-100 w-full p-10 rounded-2xl">
<p className="text-center text-3xl text-sky-500">
You haven&apos;t created any Links Here
</p>
<br />
<div className="text-center text-sky-900 text-sm flex items-baseline justify-center gap-1 w-full">
<p>Start by creating a</p>{" "}
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "CREATE",
});
}}
className="inline-flex gap-1 relative w-[7.2rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full text-white bg-sky-500 hover:bg-sky-400 duration-100 group"
>
<FontAwesomeIcon
icon={faPlus}
className="w-5 h-5 group-hover:ml-9 absolute duration-100"
/>
<span className="block group-hover:opacity-0 text-right w-full duration-100">
New Link
</span>
</div>
</div>
</div>
);
}

View File

@ -127,29 +127,41 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel className="flex flex-col gap-1"> <Disclosure.Panel className="flex flex-col gap-1">
{collections {collections[0] ? (
.sort((a, b) => a.name.localeCompare(b.name)) collections
.map((e, i) => { .sort((a, b) => a.name.localeCompare(b.name))
return ( .map((e, i) => {
<Link key={i} href={`/collections/${e.id}`}> return (
<div <Link key={i} href={`/collections/${e.id}`}>
className={`${ <div
active === `/collections/${e.id}` className={`${
? "bg-sky-200" active === `/collections/${e.id}`
: "hover:bg-slate-200 bg-gray-100" ? "bg-sky-200"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} : "hover:bg-slate-200 bg-gray-100"
> } duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
<FontAwesomeIcon >
icon={faFolder} <FontAwesomeIcon
className="w-6 h-6 drop-shadow" icon={faFolder}
style={{ color: e.color }} className="w-6 h-6 drop-shadow"
/> style={{ color: e.color }}
/>
<p className="text-sky-600 truncate w-4/6">{e.name}</p> <p className="text-sky-600 truncate w-full pr-7">
</div> {e.name}
</Link> </p>
); </div>
})} </Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-gray-500 text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</Disclosure> </Disclosure>
@ -175,28 +187,40 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel className="flex flex-col gap-1"> <Disclosure.Panel className="flex flex-col gap-1">
{tags {tags[0] ? (
.sort((a, b) => a.name.localeCompare(b.name)) tags
.map((e, i) => { .sort((a, b) => a.name.localeCompare(b.name))
return ( .map((e, i) => {
<Link key={i} href={`/tags/${e.id}`}> return (
<div <Link key={i} href={`/tags/${e.id}`}>
className={`${ <div
active === `/tags/${e.id}` className={`${
? "bg-sky-200" active === `/tags/${e.id}`
: "hover:bg-slate-200 bg-gray-100" ? "bg-sky-200"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} : "hover:bg-slate-200 bg-gray-100"
> } duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
<FontAwesomeIcon >
icon={faHashtag} <FontAwesomeIcon
className="w-4 h-4 text-sky-500 mt-1" icon={faHashtag}
/> className="w-4 h-4 text-sky-500 mt-1"
/>
<p className="text-sky-600 truncate w-4/6">{e.name}</p> <p className="text-sky-600 truncate w-full pr-7">
</div> {e.name}
</Link> </p>
); </div>
})} </Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-gray-500 text-xs font-semibold truncate w-full pr-7">
You Have No Tags...
</p>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</Disclosure> </Disclosure>

View File

@ -17,7 +17,7 @@ export default function useInitialData() {
setCollections(); setCollections();
setTags(); setTags();
// setLinks(); // setLinks();
setAccount(data.user.username as string); setAccount(data.user.id);
} }
}, [status]); }, [status]);
} }

View File

@ -14,11 +14,26 @@ export default function AuthRedirect({ children }: Props) {
const { status, data } = useSession(); const { status, data } = useSession();
const [redirect, setRedirect] = useState(true); const [redirect, setRedirect] = useState(true);
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
useInitialData(); useInitialData();
useEffect(() => { useEffect(() => {
if (!router.pathname.startsWith("/public")) { if (!router.pathname.startsWith("/public")) {
if (status === "authenticated" && data.user.isSubscriber === false) { if (
emailEnabled &&
status === "authenticated" &&
(data.user.isSubscriber === true ||
data.user.isSubscriber === undefined) &&
!data.user.username
) {
router.push("/choose-username").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
data.user.isSubscriber === false
) {
router.push("/subscribe").then(() => { router.push("/subscribe").then(() => {
setRedirect(false); setRedirect(false);
}); });
@ -28,6 +43,7 @@ export default function AuthRedirect({ children }: Props) {
router.pathname === "/register" || router.pathname === "/register" ||
router.pathname === "/confirmation" || router.pathname === "/confirmation" ||
router.pathname === "/subscribe" || router.pathname === "/subscribe" ||
router.pathname === "/choose-username" ||
router.pathname === "/forgot") router.pathname === "/forgot")
) { ) {
router.push("/").then(() => { router.push("/").then(() => {

View File

@ -19,9 +19,10 @@ export default async function checkSubscription(
const isSubscriber = listByEmail.data.some((customer, i) => { const isSubscriber = listByEmail.data.some((customer, i) => {
const hasValidSubscription = customer.subscriptions?.data.some( const hasValidSubscription = customer.subscriptions?.data.some(
(subscription) => { (subscription) => {
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
? Number(TRIAL_PERIOD_DAYS) * 86400 const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600; : 1209600;
subscriptionCanceledAt = subscription.canceled_at; subscriptionCanceledAt = subscription.canceled_at;

View File

@ -42,7 +42,7 @@ export default async function postCollection(
color: collection.color, color: collection.color,
members: { members: {
create: collection.members.map((e) => ({ create: collection.members.map((e) => ({
user: { connect: { username: e.user.username.toLowerCase() } }, user: { connect: { id: e.user.id } },
canCreate: e.canCreate, canCreate: e.canCreate,
canUpdate: e.canUpdate, canUpdate: e.canUpdate,
canDelete: e.canDelete, canDelete: e.canDelete,

View File

@ -43,7 +43,7 @@ export default async function updateCollection(
isPublic: collection.isPublic, isPublic: collection.isPublic,
members: { members: {
create: collection.members.map((e) => ({ create: collection.members.map((e) => ({
user: { connect: { username: e.user.username.toLowerCase() } }, user: { connect: { id: e.user.id } },
canCreate: e.canCreate, canCreate: e.canCreate,
canUpdate: e.canUpdate, canUpdate: e.canUpdate,
canDelete: e.canDelete, canDelete: e.canDelete,

View File

@ -29,16 +29,16 @@ export default async function getUser({
return { response: "This profile is private.", status: 401 }; return { response: "This profile is private.", status: 401 };
} }
const { password, ...unsensitiveInfo } = user; const { password, ...lessSensitiveInfo } = user;
const data = isSelf const data = isSelf
? // If user is requesting its own data ? // If user is requesting its own data
unsensitiveInfo lessSensitiveInfo
: { : {
// If user is requesting someone elses data // If user is requesting someone elses data
id: unsensitiveInfo.id, id: lessSensitiveInfo.id,
name: unsensitiveInfo.name, name: lessSensitiveInfo.name,
username: unsensitiveInfo.username, username: lessSensitiveInfo.username,
}; };
return { response: data || null, status: 200 }; return { response: data || null, status: 200 };

View File

@ -20,6 +20,15 @@ export default async function updateUser(
status: 400, status: 400,
}; };
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(user.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400,
};
const userIsTaken = await prisma.user.findFirst({ const userIsTaken = await prisma.user.findFirst({
where: { where: {
id: { not: sessionUser.id }, id: { not: sessionUser.id },

View File

@ -4,19 +4,12 @@ import checkSubscription from "./checkSubscription";
export default async function paymentCheckout( export default async function paymentCheckout(
stripeSecretKey: string, stripeSecretKey: string,
email: string, email: string,
action: "register" | "login",
priceId: string priceId: string
) { ) {
const stripe = new Stripe(stripeSecretKey, { const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2022-11-15", 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({ const listByEmail = await stripe.customers.list({
email: email.toLowerCase(), email: email.toLowerCase(),
expand: ["data.subscriptions"], expand: ["data.subscriptions"],
@ -24,32 +17,8 @@ export default async function paymentCheckout(
const isExistingCostomer = listByEmail?.data[0]?.id || undefined; const isExistingCostomer = listByEmail?.data[0]?.id || undefined;
// const hasPreviouslySubscribed = listByEmail.data.find((customer, i) => { const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
// const hasValidSubscription = customer.subscriptions?.data.some( process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
// (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({ const session = await stripe.checkout.sessions.create({
customer: isExistingCostomer ? isExistingCostomer : undefined, customer: isExistingCostomer ? isExistingCostomer : undefined,
line_items: [ line_items: [
@ -60,13 +29,15 @@ export default async function paymentCheckout(
], ],
mode: "subscription", mode: "subscription",
customer_email: isExistingCostomer ? undefined : email.toLowerCase(), customer_email: isExistingCostomer ? undefined : email.toLowerCase(),
success_url: "http://localhost:3000?session_id={CHECKOUT_SESSION_ID}", success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: "http://localhost:3000/login", cancel_url: `${process.env.BASE_URL}/login`,
automatic_tax: { automatic_tax: {
enabled: true, enabled: true,
}, },
subscription_data: { subscription_data: {
trial_period_days: TRIAL_PERIOD_DAYS ? Number(TRIAL_PERIOD_DAYS) : 14, trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
}, },
}); });

View File

@ -18,9 +18,10 @@ export default async function updateCustomerEmail(
const customer = listByEmail.data.find((customer, i) => { const customer = listByEmail.data.find((customer, i) => {
const hasValidSubscription = customer.subscriptions?.data.some( const hasValidSubscription = customer.subscriptions?.data.some(
(subscription) => { (subscription) => {
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
? Number(TRIAL_PERIOD_DAYS) * 86400 const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600; : 1209600;
const isNotCanceledOrHasTime = !( const isNotCanceledOrHasTime = !(

View File

@ -9,7 +9,7 @@ const addMemberToCollection = async (
setMember: (newMember: Member) => null | undefined setMember: (newMember: Member) => null | undefined
) => { ) => {
const checkIfMemberAlreadyExists = collection.members.find((e) => { const checkIfMemberAlreadyExists = collection.members.find((e) => {
const username = e.user.username.toLowerCase(); const username = (e.user.username || "").toLowerCase();
return username === memberUsername.toLowerCase(); return username === memberUsername.toLowerCase();
}); });

View File

@ -13,7 +13,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^1.0.0", "@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/client-s3": "^3.363.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
@ -52,8 +52,8 @@
"@playwright/test": "^1.35.1", "@playwright/test": "^1.35.1",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.24", "postcss": "^8.4.26",
"prisma": "^4.16.2", "prisma": "^4.16.2",
"tailwindcss": "^3.3.2" "tailwindcss": "^3.3.3"
} }
} }

View File

@ -18,7 +18,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
else if (session?.user?.isSubscriber === false) else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
const collectionIsAccessible = await getPermission( const collectionIsAccessible = await getPermission(

View File

@ -93,9 +93,10 @@ export const authOptions: AuthOptions = {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID; const PRICE_ID = process.env.PRICE_ID;
const TRIAL_PERIOD_DAYS = process.env.TRIAL_PERIOD_DAYS; const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
const secondsInTwoWeeks = TRIAL_PERIOD_DAYS process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
? Number(TRIAL_PERIOD_DAYS) * 86400 const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600; : 1209600;
const subscriptionIsTimesUp = const subscriptionIsTimesUp =
token.subscriptionCanceledAt && token.subscriptionCanceledAt &&
@ -110,15 +111,12 @@ export const authOptions: AuthOptions = {
PRICE_ID && PRICE_ID &&
(trigger || subscriptionIsTimesUp || !token.isSubscriber) (trigger || subscriptionIsTimesUp || !token.isSubscriber)
) { ) {
console.log("EXECUTED!!!");
const subscription = await checkSubscription( const subscription = await checkSubscription(
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
token.email as string, token.email as string,
PRICE_ID PRICE_ID
); );
subscription.isSubscriber;
if (subscription.subscriptionCanceledAt) { if (subscription.subscriptionCanceledAt) {
token.subscriptionCanceledAt = subscription.subscriptionCanceledAt; token.subscriptionCanceledAt = subscription.subscriptionCanceledAt;
} else token.subscriptionCanceledAt = undefined; } else token.subscriptionCanceledAt = undefined;
@ -129,11 +127,20 @@ export const authOptions: AuthOptions = {
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;
} else if (trigger === "update" && session?.name && session?.username) { } else if (trigger === "update" && token.id) {
// Note, that `session` can be any arbitrary object, remember to validate it! console.log(token);
token.name = session.name;
token.username = session.username.toLowerCase(); const user = await prisma.user.findUnique({
token.email = session.email.toLowerCase(); where: {
id: token.id as number,
},
});
if (user) {
token.name = user.name;
token.username = user.username?.toLowerCase();
token.email = user.email?.toLowerCase();
}
} }
return token; return token;
}, },

View File

@ -11,7 +11,7 @@ interface Data {
interface User { interface User {
name: string; name: string;
username: string; username?: string;
email?: string; email?: string;
password: string; password: string;
} }
@ -23,7 +23,7 @@ export default async function Index(
const body: User = req.body; const body: User = req.body;
const checkHasEmptyFields = emailEnabled const checkHasEmptyFields = emailEnabled
? !body.username || !body.password || !body.name || !body.email ? !body.password || !body.name || !body.email
: !body.username || !body.password || !body.name; : !body.username || !body.password || !body.name;
if (checkHasEmptyFields) if (checkHasEmptyFields)
@ -31,42 +31,22 @@ export default async function Index(
.status(400) .status(400)
.json({ response: "Please fill out all the fields." }); .json({ response: "Please fill out all the fields." });
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
// Remove user's who aren't verified for more than 10 minutes if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
if (emailEnabled) return res.status(400).json({
await prisma.user.deleteMany({ response:
where: { "Username has to be between 3-30 characters, no spaces and special characters are allowed.",
OR: [
{
email: body.email,
},
{
username: body.username,
},
],
createdAt: {
lt: tenMinutesAgo,
},
emailVerified: null,
},
}); });
const checkIfUserExists = await prisma.user.findFirst({ const checkIfUserExists = await prisma.user.findFirst({
where: emailEnabled where: emailEnabled
? { ? {
OR: [ email: body.email?.toLowerCase(),
{
username: body.username.toLowerCase(),
},
{
email: body.email?.toLowerCase(),
},
],
emailVerified: { not: null }, emailVerified: { not: null },
} }
: { : {
username: body.username.toLowerCase(), username: (body.username as string).toLowerCase(),
}, },
}); });
@ -78,14 +58,18 @@ export default async function Index(
await prisma.user.create({ await prisma.user.create({
data: { data: {
name: body.name, name: body.name,
username: body.username.toLowerCase(), username: emailEnabled
email: body.email?.toLowerCase(), ? undefined
: (body.username as string).toLowerCase(),
email: emailEnabled ? body.email?.toLowerCase() : undefined,
password: hashedPassword, password: hashedPassword,
}, },
}); });
res.status(201).json({ response: "User successfully created." }); return res.status(201).json({ response: "User successfully created." });
} else if (checkIfUserExists) { } else if (checkIfUserExists) {
res.status(400).json({ response: "Username and/or Email already exists." }); return res
.status(400)
.json({ response: "Username and/or Email already exists." });
} }
} }

View File

@ -8,10 +8,10 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
const userId = session?.user.id; const userId = session?.user.id;
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 (!userId || !userName) if (!userId || !username)
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/plain")
.status(401) .status(401)
@ -19,7 +19,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
else if (session?.user?.isSubscriber === false) else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
if (!queryId) if (!queryId)
@ -37,7 +37,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if ( if (
targetUser?.isPrivate && targetUser?.isPrivate &&
!targetUser.whitelistedUsers.includes(userName) !targetUser.whitelistedUsers.includes(username)
) { ) {
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/plain")

View File

@ -8,7 +8,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const PRICE_ID = process.env.PRICE_ID; const PRICE_ID = process.env.PRICE_ID;
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user?.username) if (!session?.user?.id)
return res.status(401).json({ response: "You must be logged in." }); return res.status(401).json({ response: "You must be logged in." });
else if (!STRIPE_SECRET_KEY || !PRICE_ID) { else if (!STRIPE_SECRET_KEY || !PRICE_ID) {
return res.status(400).json({ response: "Payment is disabled." }); return res.status(400).json({ response: "Payment is disabled." });
@ -18,7 +18,6 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const users = await paymentCheckout( const users = await paymentCheckout(
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
session?.user.email, session?.user.email,
"register",
PRICE_ID PRICE_ID
); );
return res.status(users.status).json({ response: users.response }); return res.status(users.status).json({ response: users.response });

View File

@ -12,12 +12,12 @@ export default async function collections(
) { ) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user?.username) { if (!session?.user?.id) {
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) } else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
if (req.method === "GET") { if (req.method === "GET") {

View File

@ -9,12 +9,12 @@ import updateLink from "@/lib/api/controllers/links/updateLink";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user?.username) { if (!session?.user?.id) {
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) } else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
if (req.method === "GET") { if (req.method === "GET") {

View File

@ -11,7 +11,7 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
} else if (session?.user?.isSubscriber === false) } else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
if (req.method === "GET") { if (req.method === "GET") {

View File

@ -7,17 +7,20 @@ import updateUser from "@/lib/api/controllers/users/updateUser";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);
if (!session?.user.username) { if (!session?.user.id) {
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) } else if (session?.user?.isSubscriber === false)
res.status(401).json({ res.status(401).json({
response: response:
"You are not a subscriber, feel free to reach out to us at hello@linkwarden.app in case of any issues.", "You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
}); });
const lookupUsername = (req.query.username as string) || undefined; const lookupUsername = (req.query.username as string) || undefined;
const lookupId = Number(req.query.id) || undefined; const lookupId = Number(req.query.id) || undefined;
const isSelf = session.user.username === lookupUsername ? true : false; const isSelf =
session.user.username === lookupUsername || session.user.id === lookupId
? true
: false;
if (req.method === "GET") { if (req.method === "GET") {
const users = await getUsers({ const users = await getUsers({
@ -29,15 +32,8 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
username: session.user.username, 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") {
const updated = await updateUser(req.body, session.user); 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

99
pages/choose-username.tsx Normal file
View File

@ -0,0 +1,99 @@
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";
import useAccountStore from "@/store/account";
export default function Subscribe() {
const [submitLoader, setSubmitLoader] = useState(false);
const [inputedUsername, setInputedUsername] = useState("");
const { data, status, update } = useSession();
const { updateAccount, account } = useAccountStore();
useEffect(() => {
console.log(data?.user);
}, [status]);
async function submitUsername() {
setSubmitLoader(true);
const redirectionToast = toast.loading("Applying...");
const response = await updateAccount({
...account,
username: inputedUsername,
});
if (response.ok) {
toast.success("Username Applied!");
update({
id: data?.user.id,
});
} else toast.error(response.data as string);
toast.dismiss(redirectionToast);
setSubmitLoader(false);
}
return (
<>
<Image
src="/linkwarden.png"
width={1694}
height={483}
alt="Linkwarden"
className="h-12 w-fit mx-auto mt-10"
/>
<div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-xl text-sky-500 w-fit font-bold">
Choose a Username (Last step)
</p>
<div>
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">
Username
</p>
<input
type="text"
placeholder="john"
value={inputedUsername}
onChange={(e) => setInputedUsername(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>
<p className="text-md text-gray-500 mt-1">
Feel free to reach out to us at{" "}
<a className="font-semibold" href="mailto:support@linkwarden.app">
support@linkwarden.app
</a>{" "}
in case of any issues.
</p>
</div>
<SubmitButton
onClick={submitUsername}
label="Complete Registration"
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>
<p className="text-center text-xs text-gray-500 my-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</>
);
}

View File

@ -18,6 +18,7 @@ import SortDropdown from "@/components/SortDropdown";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound";
export default function Index() { export default function Index() {
const { setModal } = useModalStore(); const { setModal } = useModalStore();
@ -59,7 +60,7 @@ export default function Index() {
style={{ color: activeCollection?.color }} style={{ color: activeCollection?.color }}
className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow" className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow"
/> />
<p className="sm:text-4xl text-3xl capitalize bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent font-bold py-1"> <p className="sm:text-4xl text-3xl capitalize bg-gradient-to-tr from-sky-500 to-slate-400 bg-clip-text text-transparent font-bold w-full py-1 break-words hyphens-auto">
{activeCollection?.name} {activeCollection?.name}
</p> </p>
</div> </div>
@ -234,13 +235,17 @@ export default function Index() {
</div> </div>
</div> </div>
</div> </div>
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> {links[0] ? (
{links <div className="grid 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
.filter((e) => e.collectionId === Number(router.query.id)) {links
.map((e, i) => { .filter((e) => e.collectionId === Number(router.query.id))
return <LinkCard key={i} link={e} count={i} />; .map((e, i) => {
})} return <LinkCard key={i} link={e} count={i} />;
</div> })}
</div>
) : (
<NoLinksFound />
)}
</div> </div>
</MainLayout> </MainLayout>
); );

View File

@ -167,7 +167,7 @@ export default function Dashboard() {
</div> </div>
</Disclosure> </Disclosure>
) : ( ) : (
<div className="border border-solid border-sky-100 w-full mx-auto md:w-2/3 p-10 rounded-md"> <div className="border border-solid border-sky-100 w-full mx-auto md:w-2/3 p-10 rounded-2xl">
<p className="text-center text-2xl text-sky-500"> <p className="text-center text-2xl text-sky-500">
No Pinned Links No Pinned Links
</p> </p>

View File

@ -39,20 +39,21 @@ export default function Forgot() {
return ( 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"> <Image
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5"> src="/linkwarden.png"
<Image width={1694}
src="/linkwarden.png" height={483}
width={1694} alt="Linkwarden"
height={483} className="h-12 w-fit mx-auto mt-10"
alt="Linkwarden" />
className="h-12 w-fit" <div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
/> <p className="text-xl text-sky-500 w-fit font-bold">Fogot Password?</p>
<div className="text-center sm:text-right"> <p className="text-md text-gray-500 mt-1">
<p className="text-3xl text-sky-500">Password Reset</p> Enter your Email so we can send you a link to recover your account.
</div> </p>
</div> <p className="text-md text-gray-500 mt-1">
Make sure to change your password in the profile settings afterwards.
</p>
<div> <div>
<p className="text-sm text-sky-500 w-fit font-semibold mb-1">Email</p> <p className="text-sm text-sky-500 w-fit font-semibold mb-1">Email</p>
@ -63,10 +64,6 @@ export default function Forgot() {
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: 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"
/> />
<p className="text-md text-gray-500 mt-1">
Make sure to change your password in the profile settings
afterwards.
</p>
</div> </div>
<SubmitButton <SubmitButton
@ -81,6 +78,9 @@ export default function Forgot() {
</Link> </Link>
</div> </div>
</div> </div>
<p className="text-center text-xs text-gray-500 my-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</> </>
); );
} }

View File

@ -1,4 +1,5 @@
import LinkCard from "@/components/LinkCard"; import LinkCard from "@/components/LinkCard";
import NoLinksFound from "@/components/NoLinksFound";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
@ -52,11 +53,15 @@ export default function Links() {
) : null} ) : null}
</div> </div>
</div> </div>
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> {links[0] ? (
{links.map((e, i) => { <div className="grid 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
return <LinkCard key={i} link={e} count={i} />; {links.map((e, i) => {
})} return <LinkCard key={i} link={e} count={i} />;
</div> })}
</div>
) : (
<NoLinksFound />
)}
</div> </div>
</MainLayout> </MainLayout>
); );

View File

@ -46,23 +46,20 @@ export default function Login() {
return ( return (
<> <>
<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"> <Image
<div className="text-right flex flex-col gap-2 sm:flex-row justify-between items-center mb-5"> src="/linkwarden.png"
<Image width={1694}
src="/linkwarden.png" height={483}
width={1694} alt="Linkwarden"
height={483} className="h-12 w-fit mx-auto mt-10"
alt="Linkwarden" />
className="h-12 w-fit" <p className="text-xl font-semibold text-sky-500 px-2 text-center">
/> Sign in to your account
<div className="text-center sm:text-right"> </p>
<p className="text-3xl text-sky-500">Welcome back</p> <div className="p-2 my-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-md font-semibold text-sky-400"> <p className="text-xl text-sky-500 w-fit font-bold">
Sign in to your account Enter your credentials
</p> </p>
</div>
</div>
<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
@ -112,6 +109,9 @@ export default function Login() {
</Link> </Link>
</div> </div>
</div> </div>
<p className="text-center text-xs text-gray-500 mb-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</> </>
); );
} }

View File

@ -9,7 +9,7 @@ const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
type FormData = { type FormData = {
name: string; name: string;
username: string; username?: string;
email?: string; email?: string;
password: string; password: string;
passwordConfirmation: string; passwordConfirmation: string;
@ -20,7 +20,7 @@ export default function Register() {
const [form, setForm] = useState<FormData>({ const [form, setForm] = useState<FormData>({
name: "", name: "",
username: "", username: emailEnabled ? undefined : "",
email: emailEnabled ? "" : undefined, email: emailEnabled ? "" : undefined,
password: "", password: "",
passwordConfirmation: "", passwordConfirmation: "",
@ -31,7 +31,6 @@ export default function Register() {
if (emailEnabled) { if (emailEnabled) {
return ( return (
form.name !== "" && form.name !== "" &&
form.username !== "" &&
form.email !== "" && form.email !== "" &&
form.password !== "" && form.password !== "" &&
form.passwordConfirmation !== "" form.passwordConfirmation !== ""
@ -54,35 +53,35 @@ export default function Register() {
}; };
if (checkHasEmptyFields()) { if (checkHasEmptyFields()) {
if (form.password === form.passwordConfirmation) { if (form.password !== form.passwordConfirmation)
const { passwordConfirmation, ...request } = form; return toast.error("Passwords do not match.");
else if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
const { passwordConfirmation, ...request } = form;
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading("Creating Account..."); const load = toast.loading("Creating Account...");
const response = await fetch("/api/auth/register", { const response = await fetch("/api/auth/register", {
body: JSON.stringify(request), body: JSON.stringify(request),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method: "POST", method: "POST",
}); });
const data = await response.json(); const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
setSubmitLoader(false); setSubmitLoader(false);
if (response.ok) { if (response.ok) {
if (form.email) await sendConfirmation(); if (form.email) await sendConfirmation();
toast.success("User Created!"); toast.success("User Created!");
} else {
toast.error(data.response);
}
} else { } else {
toast.error("Passwords do not match."); toast.error(data.response);
} }
} else { } else {
toast.error("Please fill out all the fields."); toast.error("Please fill out all the fields.");
@ -91,23 +90,24 @@ export default function Register() {
return ( return (
<> <>
<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"> <Image
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5"> src="/linkwarden.png"
<Image width={1694}
src="/linkwarden.png" height={483}
width={1694} alt="Linkwarden"
height={483} className="h-12 w-fit mx-auto mt-10"
alt="Linkwarden" />
className="h-12 w-fit" <p className="text-center px-2 text-xl font-semibold text-sky-500">
/> {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
<div className="text-center sm:text-right"> ? `Start using our premium services with a ${
<p className="text-3xl text-sky-500">Get started</p> process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
<p className="text-md font-semibold text-sky-400"> }-day free trial!`
Create a new account : "Create a new account"}
</p> </p>
</div> <div className="p-2 mx-auto my-10 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
</div> <p className="text-xl text-sky-500 w-fit font-bold">
Enter your details
</p>
<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">
Display Name Display Name
@ -122,19 +122,21 @@ export default function Register() {
/> />
</div> </div>
<div> {emailEnabled ? undefined : (
<p className="text-sm text-sky-500 w-fit font-semibold mb-1"> <div>
Username <p className="text-sm text-sky-500 w-fit font-semibold mb-1">
</p> Username
</p>
<input <input
type="text" type="text"
placeholder="john" placeholder="john"
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(e) => setForm({ ...form, username: 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>
)}
{emailEnabled ? ( {emailEnabled ? (
<div> <div>
@ -196,6 +198,9 @@ export default function Register() {
</Link> </Link>
</div> </div>
</div> </div>
<p className="text-center text-xs text-gray-500 mb-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</> </>
); );
} }

View File

@ -12,10 +12,6 @@ export default function Subscribe() {
const { data, status } = useSession(); const { data, status } = useSession();
const router = useRouter(); const router = useRouter();
useEffect(() => {
console.log(data?.user);
}, [status]);
async function loginUser() { async function loginUser() {
setSubmitLoader(true); setSubmitLoader(true);
@ -30,31 +26,26 @@ export default function Subscribe() {
return ( 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"> <Image
<div className="flex flex-col gap-2 sm:flex-row justify-between items-center mb-5"> src="/linkwarden.png"
<Image width={1694}
src="/linkwarden.png" height={483}
width={1694} alt="Linkwarden"
height={483} className="h-12 w-fit mx-auto mt-10"
alt="Linkwarden" />
className="h-12 w-fit" <p className="text-xl font-semibold text-sky-500 text-center px-2">
/> {process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14} days free trial, then
<div className="text-center sm:text-right"> ${process.env.NEXT_PUBLIC_PRICING}/month afterwards
<p className="text-3xl text-sky-500">14 days free trial</p> </p>
<p className="text-md font-semibold text-sky-400"> <div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
Then $5/month afterwards
</p>
</div>
</div>
<div> <div>
<p className="text-md text-gray-500 mt-1"> <p className="text-md text-gray-500 mt-1">
You will be redirected to Stripe. You will be redirected to Stripe.
</p> </p>
<p className="text-md text-gray-500 mt-1"> <p className="text-md text-gray-500 mt-1">
feel free to reach out to us at{" "} Feel free to reach out to us at{" "}
<a className="font-semibold" href="mailto:hello@linkwarden.app"> <a className="font-semibold" href="mailto:support@linkwarden.app">
hello@linkwarden.app support@linkwarden.app
</a>{" "} </a>{" "}
in case of any issues. in case of any issues.
</p> </p>
@ -74,6 +65,9 @@ export default function Subscribe() {
Sign Out Sign Out
</div> </div>
</div> </div>
<p className="text-center text-xs text-gray-500 my-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</> </>
); );
} }

View File

@ -30,7 +30,7 @@ CREATE TABLE "Session" (
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" SERIAL NOT NULL, "id" SERIAL NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"username" TEXT NOT NULL, "username" TEXT,
"email" TEXT, "email" TEXT,
"emailVerified" TIMESTAMP(3), "emailVerified" TIMESTAMP(3),
"image" TEXT, "image" TEXT,

View File

@ -38,7 +38,7 @@ 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?

View File

@ -3,19 +3,19 @@ import { AccountSettings } from "@/types/global";
type ResponseObject = { type ResponseObject = {
ok: boolean; ok: boolean;
data: object | string; data: Omit<AccountSettings, "password"> | object | string;
}; };
type AccountStore = { type AccountStore = {
account: AccountSettings; account: AccountSettings;
setAccount: (username: string) => void; setAccount: (id: number) => void;
updateAccount: (user: AccountSettings) => Promise<ResponseObject>; updateAccount: (user: AccountSettings) => Promise<ResponseObject>;
}; };
const useAccountStore = create<AccountStore>()((set) => ({ const useAccountStore = create<AccountStore>()((set) => ({
account: {} as AccountSettings, account: {} as AccountSettings,
setAccount: async (username) => { setAccount: async (id) => {
const response = await fetch(`/api/routes/users?username=${username}`); const response = await fetch(`/api/routes/users?id=${id}`);
const data = await response.json(); const data = await response.json();

View File

@ -17,10 +17,13 @@ declare global {
EMAIL_FROM?: string; EMAIL_FROM?: string;
EMAIL_SERVER?: string; EMAIL_SERVER?: string;
NEXT_PUBLIC_STRIPE_IS_ACTIVE?: string;
STRIPE_SECRET_KEY?: string; STRIPE_SECRET_KEY?: string;
PRICE_ID?: string; PRICE_ID?: string;
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string; NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string;
TRIAL_PERIOD_DAYS?: string; NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string;
BASE_URL?: string;
NEXT_PUBLIC_PRICING?: string;
} }
} }
} }

View File

@ -12,10 +12,10 @@
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
"@auth/core@0.8.1": "@auth/core@0.9.0":
version "0.8.1" version "0.9.0"
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.8.1.tgz#8fbfb7b11ed3e4b346857b033e454efb7c16df26" resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.9.0.tgz#7a5d66eea0bc059cef072734698547ae2a0c86a6"
integrity sha512-WudBmZudZ/cvykxHV5hIwrYsd7AlETQ535O7w3sSiiumT28+U9GvBb8oSRtfzxpW9rym3lAdfeTJqGA8U4FecQ== integrity sha512-W2WO0WCBg1T3P8+yjQPzurTQhPv6ecBYfJ2oE3uvXPAX5ZLWAMSjKFAIa9oLZy5pwrB+YehJZPnlIxVilhrVcg==
dependencies: dependencies:
"@panva/hkdf" "^1.0.4" "@panva/hkdf" "^1.0.4"
cookie "0.5.0" cookie "0.5.0"
@ -24,12 +24,12 @@
preact "10.11.3" preact "10.11.3"
preact-render-to-string "5.2.3" preact-render-to-string "5.2.3"
"@auth/prisma-adapter@^1.0.0": "@auth/prisma-adapter@^1.0.1":
version "1.0.0" version "1.0.1"
resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.0.tgz#9107498921997b6174e0189553f29d0eb49ba2a0" resolved "https://registry.yarnpkg.com/@auth/prisma-adapter/-/prisma-adapter-1.0.1.tgz#eba93843e77018fa7ca0d68726aa959d6b60512c"
integrity sha512-+x+s5dgpNmqrcQC2ZRAXZIM6yhkWP/EXjIUgqUyMepLiX1OHi2AXIUAAbXsW4oG9OpYr/rvPIzPBpuGt6sPFwQ== integrity sha512-sBp9l/jVr7l9y7rp2Pv6eoP7i8X2CgRNE3jDWJ0B/u+HnKRofXflD1cldPqRSAkJhqH3UxhVtMTEijT9FoofmQ==
dependencies: dependencies:
"@auth/core" "0.8.1" "@auth/core" "0.9.0"
"@aws-crypto/crc32@3.0.0": "@aws-crypto/crc32@3.0.0":
version "3.0.0" version "3.0.0"
@ -3391,12 +3391,7 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" type-check "~0.4.0"
lilconfig@^2.0.5: lilconfig@^2.0.5, lilconfig@^2.1.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
lilconfig@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
@ -3540,12 +3535,7 @@ mz@^2.7.0:
object-assign "^4.0.1" object-assign "^4.0.1"
thenify-all "^1.0.0" thenify-all "^1.0.0"
nanoid@^3.3.4: nanoid@^3.3.4, nanoid@^3.3.6:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^3.3.6:
version "3.3.6" version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
@ -3981,10 +3971,10 @@ postcss@8.4.14:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8.4.23, postcss@^8.4.24: postcss@^8.4.23, postcss@^8.4.26:
version "8.4.24" version "8.4.26"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.26.tgz#1bc62ab19f8e1e5463d98cf74af39702a00a9e94"
integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg== integrity sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==
dependencies: dependencies:
nanoid "^3.3.6" nanoid "^3.3.6"
picocolors "^1.0.0" picocolors "^1.0.0"
@ -4237,16 +4227,7 @@ resolve-from@^4.0.0:
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
resolve@^1.1.7, resolve@^1.19.0, resolve@^1.22.1: resolve@^1.1.7, resolve@^1.19.0, resolve@^1.22.1, resolve@^1.22.2:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
resolve@^1.22.2:
version "1.22.2" version "1.22.2"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f"
integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==
@ -4564,10 +4545,10 @@ synckit@^0.8.4:
"@pkgr/utils" "^2.3.1" "@pkgr/utils" "^2.3.1"
tslib "^2.5.0" tslib "^2.5.0"
tailwindcss@^3.3.2: tailwindcss@^3.3.3:
version "3.3.2" version "3.3.3"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.2.tgz#2f9e35d715fdf0bbf674d90147a0684d7054a2d3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
integrity sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w== integrity sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==
dependencies: dependencies:
"@alloc/quick-lru" "^5.2.0" "@alloc/quick-lru" "^5.2.0"
arg "^5.0.2" arg "^5.0.2"
@ -4589,7 +4570,6 @@ tailwindcss@^3.3.2:
postcss-load-config "^4.0.1" postcss-load-config "^4.0.1"
postcss-nested "^6.0.1" postcss-nested "^6.0.1"
postcss-selector-parser "^6.0.11" postcss-selector-parser "^6.0.11"
postcss-value-parser "^4.2.0"
resolve "^1.22.2" resolve "^1.22.2"
sucrase "^3.32.0" sucrase "^3.32.0"