Merge pull request #813 from linkwarden/feat/team-support
Feat/seats support
This commit is contained in:
commit
68cdde91ad
|
@ -35,6 +35,7 @@ READABILITY_MAX_BUFFER=
|
||||||
PREVIEW_MAX_BUFFER=
|
PREVIEW_MAX_BUFFER=
|
||||||
IMPORT_LIMIT=
|
IMPORT_LIMIT=
|
||||||
MAX_WORKERS=
|
MAX_WORKERS=
|
||||||
|
DISABLE_INVITES=
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -58,8 +59,6 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,7 @@ export default function DeleteCollectionModal({
|
||||||
|
|
||||||
deleteCollection.mutateAsync(collection.id as number, {
|
deleteCollection.mutateAsync(collection.id as number, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -55,8 +56,6 @@ export default function DeleteCollectionModal({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Button from "../ui/Button";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useDeleteUser } from "@/hooks/store/admin/users";
|
import { useDeleteUser } from "@/hooks/store/admin/users";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
|
@ -23,31 +24,40 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { data } = useSession();
|
||||||
|
const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal toggleModal={onClose}>
|
<Modal toggleModal={onClose}>
|
||||||
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
|
<p className="text-xl font-thin text-red-500">
|
||||||
|
{isAdmin ? t("delete_user") : t("remove_user")}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p>{t("confirm_user_deletion")}</p>
|
<p>{t("confirm_user_deletion")}</p>
|
||||||
|
<p>{t("confirm_user_removal_desc")}</p>
|
||||||
|
|
||||||
<div role="alert" className="alert alert-warning">
|
{isAdmin && (
|
||||||
<i className="bi-exclamation-triangle text-xl" />
|
<div role="alert" className="alert alert-warning">
|
||||||
<span>
|
<i className="bi-exclamation-triangle text-xl" />
|
||||||
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
|
<span>
|
||||||
</span>
|
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button className="ml-auto" intent="destructive" onClick={submit}>
|
<Button className="ml-auto" intent="destructive" onClick={submit}>
|
||||||
<i className="bi-trash text-xl" />
|
<i className="bi-trash text-xl" />
|
||||||
{t("delete_confirmation")}
|
{isAdmin ? t("delete_confirmation") : t("confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
@ -35,6 +35,7 @@ export default function EditCollectionModal({
|
||||||
|
|
||||||
await updateCollection.mutateAsync(collection, {
|
await updateCollection.mutateAsync(collection, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -45,8 +46,6 @@ export default function EditCollectionModal({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,7 @@ export default function EditCollectionSharingModal({
|
||||||
|
|
||||||
await updateCollection.mutateAsync(collection, {
|
await updateCollection.mutateAsync(collection, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -55,8 +56,6 @@ export default function EditCollectionSharingModal({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -67,7 +66,7 @@ export default function EditCollectionSharingModal({
|
||||||
|
|
||||||
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
|
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
|
||||||
|
|
||||||
const [memberUsername, setMemberUsername] = useState("");
|
const [memberIdentifier, setMemberIdentifier] = useState("");
|
||||||
|
|
||||||
const [collectionOwner, setCollectionOwner] = useState<
|
const [collectionOwner, setCollectionOwner] = useState<
|
||||||
Partial<AccountSettings>
|
Partial<AccountSettings>
|
||||||
|
@ -92,7 +91,7 @@ export default function EditCollectionSharingModal({
|
||||||
members: [...collection.members, newMember],
|
members: [...collection.members, newMember],
|
||||||
});
|
});
|
||||||
|
|
||||||
setMemberUsername("");
|
setMemberIdentifier("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -149,15 +148,15 @@ export default function EditCollectionSharingModal({
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TextInput
|
<TextInput
|
||||||
value={memberUsername || ""}
|
value={memberIdentifier || ""}
|
||||||
className="bg-base-200"
|
className="bg-base-200"
|
||||||
placeholder={t("members_username_placeholder")}
|
placeholder={t("add_member_placeholder")}
|
||||||
onChange={(e) => setMemberUsername(e.target.value)}
|
onChange={(e) => setMemberIdentifier(e.target.value)}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) =>
|
||||||
e.key === "Enter" &&
|
e.key === "Enter" &&
|
||||||
addMemberToCollection(
|
addMemberToCollection(
|
||||||
user.username as string,
|
user,
|
||||||
memberUsername || "",
|
memberIdentifier.replace(/^@/, "") || "",
|
||||||
collection,
|
collection,
|
||||||
setMemberState,
|
setMemberState,
|
||||||
t
|
t
|
||||||
|
@ -168,8 +167,8 @@ export default function EditCollectionSharingModal({
|
||||||
<div
|
<div
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addMemberToCollection(
|
addMemberToCollection(
|
||||||
user.username as string,
|
user,
|
||||||
memberUsername || "",
|
memberIdentifier.replace(/^@/, "") || "",
|
||||||
collection,
|
collection,
|
||||||
setMemberState,
|
setMemberState,
|
||||||
t
|
t
|
||||||
|
|
|
@ -0,0 +1,125 @@
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import TextInput from "../TextInput";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useTranslation, Trans } from "next-i18next";
|
||||||
|
import { useAddUser } from "@/hooks/store/admin/users";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
invite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
||||||
|
|
||||||
|
export default function InviteModal({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const addUser = useAddUser();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>({
|
||||||
|
username: emailEnabled ? undefined : "",
|
||||||
|
email: emailEnabled ? "" : undefined,
|
||||||
|
invite: true,
|
||||||
|
});
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!submitLoader) {
|
||||||
|
const checkFields = () => {
|
||||||
|
if (emailEnabled) {
|
||||||
|
return form.email !== "";
|
||||||
|
} else {
|
||||||
|
return form.username !== "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFields()) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
await addUser.mutateAsync(form, {
|
||||||
|
onSettled: () => {
|
||||||
|
setSubmitLoader(false);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await signIn("invite", {
|
||||||
|
email: form.email,
|
||||||
|
callbackUrl: "/member-onboarding",
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(t("fill_all_fields_error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">{t("invite_user")}</p>
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
<p className="mb-3">{t("invite_user_desc")}</p>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{emailEnabled ? (
|
||||||
|
<div>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("placeholder_email")}
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
value={form.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">
|
||||||
|
{t("username")}{" "}
|
||||||
|
{emailEnabled && (
|
||||||
|
<span className="text-xs text-neutral">{t("optional")}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("placeholder_john")}
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
value={form.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div role="note" className="alert alert-note mt-5">
|
||||||
|
<i className="bi-exclamation-triangle text-xl" />
|
||||||
|
<span>
|
||||||
|
<p className="mb-1">{t("invite_user_note")}</p>
|
||||||
|
<Link
|
||||||
|
href=""
|
||||||
|
className="font-semibold whitespace-nowrap hover:opacity-80 duration-100"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{t("learn_more")} <i className="bi-box-arrow-up-right"></i>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-5">
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("send_invitation")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -43,6 +43,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
||||||
|
|
||||||
await createCollection.mutateAsync(collection, {
|
await createCollection.mutateAsync(collection, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -53,8 +54,6 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -80,6 +80,7 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||||
|
|
||||||
await addLink.mutateAsync(link, {
|
await addLink.mutateAsync(link, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -90,8 +91,6 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,7 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
|
|
||||||
await addToken.mutateAsync(token, {
|
await addToken.mutateAsync(token, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -43,8 +44,6 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,9 @@ export default function NewUserModal({ onClose }: Props) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!submitLoader) {
|
if (!submitLoader) {
|
||||||
|
if (form.password.length < 8)
|
||||||
|
return toast.error(t("password_length_error"));
|
||||||
|
|
||||||
const checkFields = () => {
|
const checkFields = () => {
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
return form.name !== "" && form.email !== "" && form.password !== "";
|
return form.name !== "" && form.email !== "" && form.password !== "";
|
||||||
|
@ -52,9 +55,10 @@ export default function NewUserModal({ onClose }: Props) {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setSubmitLoader(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fill_all_fields_error"));
|
toast.error(t("fill_all_fields_error"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,6 +115,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
||||||
{ link, file },
|
{ link, file },
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -126,8 +127,6 @@ export default function UploadFileModal({ onClose }: Props) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,8 @@ import { signOut } from "next-auth/react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useUser } from "@/hooks/store/user";
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
|
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||||
|
|
||||||
export default function ProfileDropdown() {
|
export default function ProfileDropdown() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings, updateSettings } = useLocalSettingsStore();
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
@ -73,6 +75,19 @@ export default function ProfileDropdown() {
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{!user.parentSubscriptionId && stripeEnabled && (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/settings/billing"
|
||||||
|
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{t("invite_users")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -5,7 +5,7 @@ type Props = {
|
||||||
src?: string;
|
src?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
name?: string;
|
name?: string | null;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,14 @@ import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const LINKWARDEN_VERSION = process.env.version;
|
const LINKWARDEN_VERSION = process.env.version;
|
||||||
|
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState("");
|
||||||
|
|
||||||
|
@ -73,7 +76,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import clsx from "clsx";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Divider({ className }: Props) {
|
||||||
|
return <hr className={clsx("border-neutral-content border-t", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Divider;
|
|
@ -11,9 +11,6 @@ const useUsers = () => {
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch("/api/v1/users");
|
const response = await fetch("/api/v1/users");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) {
|
|
||||||
window.location.href = "/dashboard";
|
|
||||||
}
|
|
||||||
throw new Error("Failed to fetch users.");
|
throw new Error("Failed to fetch users.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,8 +27,6 @@ const useAddUser = () => {
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: any) => {
|
mutationFn: async (body: any) => {
|
||||||
if (body.password.length < 8) throw new Error(t("password_length_error"));
|
|
||||||
|
|
||||||
const load = toast.loading(t("creating_account"));
|
const load = toast.loading(t("creating_account"));
|
||||||
|
|
||||||
const response = await fetch("/api/v1/users", {
|
const response = await fetch("/api/v1/users", {
|
||||||
|
|
|
@ -23,7 +23,10 @@ export default function AuthRedirect({ children }: Props) {
|
||||||
const isUnauthenticated = status === "unauthenticated";
|
const isUnauthenticated = status === "unauthenticated";
|
||||||
const isPublicPage = router.pathname.startsWith("/public");
|
const isPublicPage = router.pathname.startsWith("/public");
|
||||||
const hasInactiveSubscription =
|
const hasInactiveSubscription =
|
||||||
user.id && !user.subscription?.active && stripeEnabled;
|
user.id &&
|
||||||
|
!user.subscription?.active &&
|
||||||
|
!user.parentSubscription?.active &&
|
||||||
|
stripeEnabled;
|
||||||
|
|
||||||
// There are better ways of doing this... but this one works for now
|
// There are better ways of doing this... but this one works for now
|
||||||
const routes = [
|
const routes = [
|
||||||
|
@ -49,6 +52,8 @@ export default function AuthRedirect({ children }: Props) {
|
||||||
} else {
|
} else {
|
||||||
if (isLoggedIn && hasInactiveSubscription) {
|
if (isLoggedIn && hasInactiveSubscription) {
|
||||||
redirectTo("/subscribe");
|
redirectTo("/subscribe");
|
||||||
|
} else if (isLoggedIn && !user.name && user.parentSubscriptionId) {
|
||||||
|
redirectTo("/member-onboarding");
|
||||||
} else if (
|
} else if (
|
||||||
isLoggedIn &&
|
isLoggedIn &&
|
||||||
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
|
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
|
|
||||||
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
|
|
||||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
|
||||||
|
|
||||||
export default async function checkSubscriptionByEmail(email: string) {
|
|
||||||
let active: boolean | undefined,
|
|
||||||
stripeSubscriptionId: string | undefined,
|
|
||||||
currentPeriodStart: number | undefined,
|
|
||||||
currentPeriodEnd: number | undefined;
|
|
||||||
|
|
||||||
if (!STRIPE_SECRET_KEY)
|
|
||||||
return {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart,
|
|
||||||
currentPeriodEnd,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
|
||||||
apiVersion: "2022-11-15",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Request made to Stripe by:", email);
|
|
||||||
const listByEmail = await stripe.customers.list({
|
|
||||||
email: email.toLowerCase(),
|
|
||||||
expand: ["data.subscriptions"],
|
|
||||||
});
|
|
||||||
|
|
||||||
listByEmail.data.some((customer) => {
|
|
||||||
customer.subscriptions?.data.some((subscription) => {
|
|
||||||
subscription.current_period_end;
|
|
||||||
|
|
||||||
active =
|
|
||||||
subscription.items.data.some(
|
|
||||||
(e) =>
|
|
||||||
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
|
|
||||||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
|
|
||||||
) || false;
|
|
||||||
stripeSubscriptionId = subscription.id;
|
|
||||||
currentPeriodStart = subscription.current_period_start * 1000;
|
|
||||||
currentPeriodEnd = subscription.current_period_end * 1000;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
active: active || false,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart,
|
|
||||||
currentPeriodEnd,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -35,7 +35,7 @@ export default async function updateCollection(
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
if (data.parentId) {
|
if (data.parentId) {
|
||||||
if (data.parentId !== ("root" as any)) {
|
if (data.parentId !== "root") {
|
||||||
const findParentCollection = await prisma.collection.findUnique({
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: data.parentId,
|
id: data.parentId,
|
||||||
|
@ -58,6 +58,12 @@ export default async function updateCollection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uniqueMembers = data.members.filter(
|
||||||
|
(e, i, a) =>
|
||||||
|
a.findIndex((el) => el.userId === e.userId) === i &&
|
||||||
|
e.userId !== collectionIsAccessible.ownerId
|
||||||
|
);
|
||||||
|
|
||||||
const updatedCollection = await prisma.$transaction(async () => {
|
const updatedCollection = await prisma.$transaction(async () => {
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -79,19 +85,19 @@ export default async function updateCollection(
|
||||||
iconWeight: data.iconWeight,
|
iconWeight: data.iconWeight,
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
parent:
|
parent:
|
||||||
data.parentId && data.parentId !== ("root" as any)
|
data.parentId && data.parentId !== "root"
|
||||||
? {
|
? {
|
||||||
connect: {
|
connect: {
|
||||||
id: data.parentId,
|
id: data.parentId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: data.parentId === ("root" as any)
|
: data.parentId === "root"
|
||||||
? {
|
? {
|
||||||
disconnect: true,
|
disconnect: true,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
members: {
|
members: {
|
||||||
create: data.members.map((e) => ({
|
create: uniqueMembers.map((e) => ({
|
||||||
user: { connect: { id: e.userId } },
|
user: { connect: { id: e.userId } },
|
||||||
canCreate: e.canCreate,
|
canCreate: e.canCreate,
|
||||||
canUpdate: e.canUpdate,
|
canUpdate: e.canUpdate,
|
||||||
|
|
|
@ -44,11 +44,6 @@ export default async function postCollection(
|
||||||
|
|
||||||
const newCollection = await prisma.collection.create({
|
const newCollection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
owner: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: collection.name.trim(),
|
name: collection.name.trim(),
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
color: collection.color,
|
color: collection.color,
|
||||||
|
@ -61,6 +56,16 @@ export default async function postCollection(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
owner: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
|
|
|
@ -6,8 +6,7 @@ import {
|
||||||
PostLinkSchema,
|
PostLinkSchema,
|
||||||
PostLinkSchemaType,
|
PostLinkSchemaType,
|
||||||
} from "@/lib/shared/schemaValidation";
|
} from "@/lib/shared/schemaValidation";
|
||||||
|
import { hasPassedLimit } from "../../verifyCapacity";
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
|
||||||
|
|
||||||
export default async function postLink(
|
export default async function postLink(
|
||||||
body: PostLinkSchemaType,
|
body: PostLinkSchemaType,
|
||||||
|
@ -59,19 +58,14 @@ export default async function postLink(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const hasTooManyLinks = await hasPassedLimit(userId, 1);
|
||||||
where: {
|
|
||||||
collection: {
|
|
||||||
ownerId: linkCollection.ownerId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
if (hasTooManyLinks) {
|
||||||
return {
|
return {
|
||||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
response: `Your subscription have reached the maximum number of links allowed.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
|
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
|
||||||
|
|
||||||
|
@ -98,6 +92,11 @@ export default async function postLink(
|
||||||
name,
|
name,
|
||||||
description: link.description,
|
description: link.description,
|
||||||
type: linkType,
|
type: linkType,
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: linkCollection.id,
|
id: linkCollection.id,
|
||||||
|
|
|
@ -2,8 +2,7 @@ import { prisma } from "@/lib/api/db";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { parse, Node, Element, TextNode } from "himalaya";
|
import { parse, Node, Element, TextNode } from "himalaya";
|
||||||
|
import { hasPassedLimit } from "../../verifyCapacity";
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
|
||||||
|
|
||||||
export default async function importFromHTMLFile(
|
export default async function importFromHTMLFile(
|
||||||
userId: number,
|
userId: number,
|
||||||
|
@ -20,19 +19,14 @@ export default async function importFromHTMLFile(
|
||||||
const bookmarks = document.querySelectorAll("A");
|
const bookmarks = document.querySelectorAll("A");
|
||||||
const totalImports = bookmarks.length;
|
const totalImports = bookmarks.length;
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
||||||
where: {
|
|
||||||
collection: {
|
|
||||||
ownerId: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
if (hasTooManyLinks) {
|
||||||
return {
|
return {
|
||||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
response: `Your subscription have reached the maximum number of links allowed.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const jsonData = parse(document.documentElement.outerHTML);
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
|
@ -183,6 +177,11 @@ const createCollection = async (
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -222,28 +221,27 @@ const createLink = async (
|
||||||
url,
|
url,
|
||||||
description,
|
description,
|
||||||
collectionId,
|
collectionId,
|
||||||
|
createdById: userId,
|
||||||
tags:
|
tags:
|
||||||
tags && tags[0]
|
tags && tags[0]
|
||||||
? {
|
? {
|
||||||
connectOrCreate: tags.map((tag: string) => {
|
connectOrCreate: tags.map((tag: string) => {
|
||||||
return (
|
return {
|
||||||
{
|
where: {
|
||||||
where: {
|
name_ownerId: {
|
||||||
name_ownerId: {
|
|
||||||
name: tag.trim(),
|
|
||||||
ownerId: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: tag.trim(),
|
name: tag.trim(),
|
||||||
owner: {
|
ownerId: userId,
|
||||||
connect: {
|
},
|
||||||
id: userId,
|
},
|
||||||
},
|
create: {
|
||||||
|
name: tag.trim(),
|
||||||
|
owner: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} || undefined
|
},
|
||||||
);
|
};
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { Backup } from "@/types/global";
|
import { Backup } from "@/types/global";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
|
import { hasPassedLimit } from "../../verifyCapacity";
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
|
||||||
|
|
||||||
export default async function importFromLinkwarden(
|
export default async function importFromLinkwarden(
|
||||||
userId: number,
|
userId: number,
|
||||||
|
@ -16,19 +15,14 @@ export default async function importFromLinkwarden(
|
||||||
totalImports += collection.links.length;
|
totalImports += collection.links.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
||||||
where: {
|
|
||||||
collection: {
|
|
||||||
ownerId: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
if (hasTooManyLinks) {
|
||||||
return {
|
return {
|
||||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
response: `Your subscription have reached the maximum number of links allowed.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
|
@ -47,6 +41,11 @@ export default async function importFromLinkwarden(
|
||||||
name: e.name?.trim().slice(0, 254),
|
name: e.name?.trim().slice(0, 254),
|
||||||
description: e.description?.trim().slice(0, 254),
|
description: e.description?.trim().slice(0, 254),
|
||||||
color: e.color?.trim().slice(0, 50),
|
color: e.color?.trim().slice(0, 50),
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -72,6 +71,11 @@ export default async function importFromLinkwarden(
|
||||||
id: newCollection.id,
|
id: newCollection.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
// Import Tags
|
// Import Tags
|
||||||
tags: {
|
tags: {
|
||||||
connectOrCreate: link.tags.map((tag) => ({
|
connectOrCreate: link.tags.map((tag) => ({
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
|
import { hasPassedLimit } from "../../verifyCapacity";
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
|
||||||
|
|
||||||
type WallabagBackup = {
|
type WallabagBackup = {
|
||||||
is_archived: number;
|
is_archived: number;
|
||||||
|
@ -36,19 +35,14 @@ export default async function importFromWallabag(
|
||||||
|
|
||||||
let totalImports = backup.length;
|
let totalImports = backup.length;
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
||||||
where: {
|
|
||||||
collection: {
|
|
||||||
ownerId: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
if (hasTooManyLinks) {
|
||||||
return {
|
return {
|
||||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
response: `Your subscription have reached the maximum number of links allowed.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
|
@ -61,6 +55,11 @@ export default async function importFromWallabag(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: "Imports",
|
name: "Imports",
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -89,6 +88,11 @@ export default async function importFromWallabag(
|
||||||
id: newCollection.id,
|
id: newCollection.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createdBy: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
tags:
|
tags:
|
||||||
link.tags && link.tags[0]
|
link.tags && link.tags[0]
|
||||||
? {
|
? {
|
||||||
|
|
|
@ -5,13 +5,20 @@ export default async function getPublicUser(
|
||||||
isId: boolean,
|
isId: boolean,
|
||||||
requestingId?: number
|
requestingId?: number
|
||||||
) {
|
) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findFirst({
|
||||||
where: isId
|
where: isId
|
||||||
? {
|
? {
|
||||||
id: Number(targetId) as number,
|
id: Number(targetId) as number,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
username: targetId as string,
|
OR: [
|
||||||
|
{
|
||||||
|
username: targetId as string,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: targetId as string,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
whitelistedUsers: {
|
whitelistedUsers: {
|
||||||
|
@ -22,7 +29,7 @@ export default async function getPublicUser(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user)
|
if (!user || !user.id)
|
||||||
return { response: "User not found or profile is private.", status: 404 };
|
return { response: "User not found or profile is private.", status: 404 };
|
||||||
|
|
||||||
const whitelistedUsernames = user.whitelistedUsers?.map(
|
const whitelistedUsernames = user.whitelistedUsers?.map(
|
||||||
|
@ -31,7 +38,7 @@ export default async function getPublicUser(
|
||||||
|
|
||||||
const isInAPublicCollection = await prisma.collection.findFirst({
|
const isInAPublicCollection = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
["OR"]: [
|
OR: [
|
||||||
{ ownerId: user.id },
|
{ ownerId: user.id },
|
||||||
{
|
{
|
||||||
members: {
|
members: {
|
||||||
|
@ -73,6 +80,7 @@ export default async function getPublicUser(
|
||||||
id: lessSensitiveInfo.id,
|
id: lessSensitiveInfo.id,
|
||||||
name: lessSensitiveInfo.name,
|
name: lessSensitiveInfo.name,
|
||||||
username: lessSensitiveInfo.username,
|
username: lessSensitiveInfo.username,
|
||||||
|
email: lessSensitiveInfo.email,
|
||||||
image: lessSensitiveInfo.image,
|
image: lessSensitiveInfo.image,
|
||||||
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
||||||
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
|
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
|
||||||
|
|
|
@ -1,21 +1,71 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
export default async function getUsers() {
|
export default async function getUsers(user: User) {
|
||||||
// Get all users
|
if (user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
select: {
|
select: {
|
||||||
active: true,
|
active: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
createdAt: true,
|
||||||
},
|
},
|
||||||
createdAt: true,
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { response: users, status: 200 };
|
return {
|
||||||
|
response: users.sort((a: any, b: any) => a.id - b.id),
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let subscriptionId = (
|
||||||
|
await prisma.subscription.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId)
|
||||||
|
return {
|
||||||
|
response: "Subscription not found.",
|
||||||
|
status: 404,
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
parentSubscriptionId: subscriptionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscriptions: {
|
||||||
|
id: subscriptionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: users.sort((a: any, b: any) => a.id - b.id),
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
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";
|
||||||
import isServerAdmin from "../../isServerAdmin";
|
|
||||||
import { PostUserSchema } from "@/lib/shared/schemaValidation";
|
import { PostUserSchema } from "@/lib/shared/schemaValidation";
|
||||||
|
import isAuthenticatedRequest from "../../isAuthenticatedRequest";
|
||||||
|
import { Subscription, User } from "@prisma/client";
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
|
@ -17,7 +18,11 @@ export default async function postUser(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse
|
res: NextApiResponse
|
||||||
): Promise<Data> {
|
): Promise<Data> {
|
||||||
let isAdmin = await isServerAdmin({ req });
|
const parentUser = await isAuthenticatedRequest({ req });
|
||||||
|
const isAdmin =
|
||||||
|
parentUser && parentUser.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||||
|
|
||||||
|
const DISABLE_INVITES = process.env.DISABLE_INVITES === "true";
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
|
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
|
||||||
return { response: "Registration is disabled.", status: 400 };
|
return { response: "Registration is disabled.", status: 400 };
|
||||||
|
@ -34,15 +39,28 @@ export default async function postUser(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, email, password } = dataValidation.data;
|
const { name, email, password, invite } = dataValidation.data;
|
||||||
let { username } = dataValidation.data;
|
let { username } = dataValidation.data;
|
||||||
|
|
||||||
|
if (invite && (DISABLE_INVITES || !emailEnabled)) {
|
||||||
|
return { response: "You are not authorized to invite users.", status: 401 };
|
||||||
|
} else if (invite && !parentUser) {
|
||||||
|
return { response: "You must be logged in to invite users.", status: 401 };
|
||||||
|
}
|
||||||
|
|
||||||
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
|
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
username = autoGeneratedUsername;
|
username = autoGeneratedUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!emailEnabled && !password) {
|
||||||
|
return {
|
||||||
|
response: "Password is required.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const checkIfUserExists = await prisma.user.findFirst({
|
const checkIfUserExists = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
|
@ -62,62 +80,57 @@ export default async function postUser(
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync(password, saltRounds);
|
const hashedPassword = bcrypt.hashSync(password || "", saltRounds);
|
||||||
|
|
||||||
// Subscription dates
|
const user = await prisma.user.create({
|
||||||
const currentPeriodStart = new Date();
|
data: {
|
||||||
const currentPeriodEnd = new Date();
|
name: name,
|
||||||
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
username: emailEnabled ? username || autoGeneratedUsername : username,
|
||||||
|
email: emailEnabled ? email : undefined,
|
||||||
if (isAdmin) {
|
emailVerified: isAdmin ? new Date() : undefined,
|
||||||
const user = await prisma.user.create({
|
password: password ? hashedPassword : undefined,
|
||||||
data: {
|
parentSubscription:
|
||||||
name: name,
|
parentUser && invite
|
||||||
username: emailEnabled
|
? {
|
||||||
? (username as string) || autoGeneratedUsername
|
connect: {
|
||||||
: (username as string),
|
id: (parentUser.subscriptions as Subscription).id,
|
||||||
email: emailEnabled ? email : undefined,
|
},
|
||||||
password: hashedPassword,
|
}
|
||||||
emailVerified: new Date(),
|
: undefined,
|
||||||
subscriptions: stripeEnabled
|
subscriptions:
|
||||||
|
stripeEnabled && isAdmin
|
||||||
? {
|
? {
|
||||||
create: {
|
create: {
|
||||||
stripeSubscriptionId:
|
stripeSubscriptionId:
|
||||||
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
||||||
active: true,
|
active: true,
|
||||||
currentPeriodStart,
|
currentPeriodStart: new Date(),
|
||||||
currentPeriodEnd,
|
currentPeriodEnd: new Date(
|
||||||
|
new Date().setFullYear(new Date().getFullYear() + 1000)
|
||||||
|
), // 1000 years from now
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: isAdmin
|
||||||
id: true,
|
? {
|
||||||
username: true,
|
id: true,
|
||||||
email: true,
|
username: true,
|
||||||
emailVerified: true,
|
email: true,
|
||||||
subscriptions: {
|
emailVerified: true,
|
||||||
select: {
|
password: true,
|
||||||
active: true,
|
subscriptions: {
|
||||||
|
select: {
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
createdAt: true,
|
||||||
createdAt: true,
|
}
|
||||||
},
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { response: user, status: 201 };
|
const { password: pass, ...userWithoutPassword } = user as User;
|
||||||
} else {
|
return { response: userWithoutPassword, status: 201 };
|
||||||
await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name: name,
|
|
||||||
username: emailEnabled ? autoGeneratedUsername : (username as string),
|
|
||||||
email: emailEnabled ? email : undefined,
|
|
||||||
password: hashedPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { response: "User successfully created.", status: 201 };
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return { response: "Email or Username already exists.", status: 400 };
|
return { response: "Email or Username already exists.", status: 400 };
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,15 +4,28 @@ import removeFolder from "@/lib/api/storage/removeFolder";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { DeleteUserBody } from "@/types/global";
|
import { DeleteUserBody } from "@/types/global";
|
||||||
import removeFile from "@/lib/api/storage/removeFile";
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
|
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||||
|
|
||||||
export default async function deleteUserById(
|
export default async function deleteUserById(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: DeleteUserBody,
|
body: DeleteUserBody,
|
||||||
isServerAdmin?: boolean
|
isServerAdmin: boolean,
|
||||||
|
queryId: number
|
||||||
) {
|
) {
|
||||||
// First, we retrieve the user from the database
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
subscriptions: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parentSubscription: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -23,21 +36,74 @@ export default async function deleteUserById(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isServerAdmin) {
|
if (!isServerAdmin) {
|
||||||
if (user.password) {
|
if (queryId === userId) {
|
||||||
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
|
if (user.password) {
|
||||||
|
const isPasswordValid = bcrypt.compareSync(
|
||||||
|
body.password,
|
||||||
|
user.password
|
||||||
|
);
|
||||||
|
|
||||||
if (!isPasswordValid && !isServerAdmin) {
|
if (!isPasswordValid && !isServerAdmin) {
|
||||||
|
return {
|
||||||
|
response: "Invalid credentials.",
|
||||||
|
status: 401,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
return {
|
return {
|
||||||
response: "Invalid credentials.",
|
response:
|
||||||
status: 401, // Unauthorized
|
"User has no password. Please reset your password from the forgot password page.",
|
||||||
|
status: 401,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
if (user.parentSubscriptionId) {
|
||||||
response:
|
console.log(userId, user.parentSubscriptionId);
|
||||||
"User has no password. Please reset your password from the forgot password page.",
|
|
||||||
status: 401, // Unauthorized
|
return {
|
||||||
};
|
response: "Permission denied.",
|
||||||
|
status: 401,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!user.subscriptions) {
|
||||||
|
return {
|
||||||
|
response: "User has no subscription.",
|
||||||
|
status: 401,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const findChild = await prisma.user.findFirst({
|
||||||
|
where: { id: queryId, parentSubscriptionId: user.subscriptions?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!findChild)
|
||||||
|
return {
|
||||||
|
response: "Permission denied.",
|
||||||
|
status: 401,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = await prisma.user.update({
|
||||||
|
where: { id: findChild.id },
|
||||||
|
data: {
|
||||||
|
parentSubscription: {
|
||||||
|
disconnect: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateSeats(
|
||||||
|
user.subscriptions.stripeSubscriptionId,
|
||||||
|
user.subscriptions.quantity - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: removeUser,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,27 +113,27 @@ export default async function deleteUserById(
|
||||||
async (prisma) => {
|
async (prisma) => {
|
||||||
// Delete Access Tokens
|
// Delete Access Tokens
|
||||||
await prisma.accessToken.deleteMany({
|
await prisma.accessToken.deleteMany({
|
||||||
where: { userId },
|
where: { userId: queryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete whitelisted users
|
// Delete whitelisted users
|
||||||
await prisma.whitelistedUser.deleteMany({
|
await prisma.whitelistedUser.deleteMany({
|
||||||
where: { userId },
|
where: { userId: queryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete links
|
// Delete links
|
||||||
await prisma.link.deleteMany({
|
await prisma.link.deleteMany({
|
||||||
where: { collection: { ownerId: userId } },
|
where: { collection: { ownerId: queryId } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete tags
|
// Delete tags
|
||||||
await prisma.tag.deleteMany({
|
await prisma.tag.deleteMany({
|
||||||
where: { ownerId: userId },
|
where: { ownerId: queryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find collections that the user owns
|
// Find collections that the user owns
|
||||||
const collections = await prisma.collection.findMany({
|
const collections = await prisma.collection.findMany({
|
||||||
where: { ownerId: userId },
|
where: { ownerId: queryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const collection of collections) {
|
for (const collection of collections) {
|
||||||
|
@ -86,29 +152,29 @@ export default async function deleteUserById(
|
||||||
|
|
||||||
// Delete collections after cleaning up related data
|
// Delete collections after cleaning up related data
|
||||||
await prisma.collection.deleteMany({
|
await prisma.collection.deleteMany({
|
||||||
where: { ownerId: userId },
|
where: { ownerId: queryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete subscription
|
// Delete subscription
|
||||||
if (process.env.STRIPE_SECRET_KEY)
|
if (process.env.STRIPE_SECRET_KEY)
|
||||||
await prisma.subscription
|
await prisma.subscription
|
||||||
.delete({
|
.delete({
|
||||||
where: { userId },
|
where: { userId: queryId },
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ userId: userId }, { collection: { ownerId: userId } }],
|
OR: [{ userId: queryId }, { collection: { ownerId: queryId } }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete user's avatar
|
// Delete user's avatar
|
||||||
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
await removeFile({ filePath: `uploads/avatar/${queryId}.jpg` });
|
||||||
|
|
||||||
// Finally, delete the user
|
// Finally, delete the user
|
||||||
await prisma.user.delete({
|
await prisma.user.delete({
|
||||||
where: { id: userId },
|
where: { id: queryId },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ timeout: 20000 }
|
{ timeout: 20000 }
|
||||||
|
@ -121,24 +187,36 @@ export default async function deleteUserById(
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const listByEmail = await stripe.customers.list({
|
if (user.subscriptions?.id) {
|
||||||
email: user.email?.toLowerCase(),
|
const listByEmail = await stripe.customers.list({
|
||||||
expand: ["data.subscriptions"],
|
email: user.email?.toLowerCase(),
|
||||||
});
|
expand: ["data.subscriptions"],
|
||||||
|
});
|
||||||
|
|
||||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||||
const deleted = await stripe.subscriptions.cancel(
|
const deleted = await stripe.subscriptions.cancel(
|
||||||
listByEmail.data[0].subscriptions?.data[0].id,
|
listByEmail.data[0].subscriptions?.data[0].id,
|
||||||
{
|
{
|
||||||
cancellation_details: {
|
cancellation_details: {
|
||||||
comment: body.cancellation_details?.comment,
|
comment: body.cancellation_details?.comment,
|
||||||
feedback: body.cancellation_details?.feedback,
|
feedback: body.cancellation_details?.feedback,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: deleted,
|
||||||
|
status: 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (user.parentSubscription?.id) {
|
||||||
|
await updateSeats(
|
||||||
|
user.parentSubscription.stripeSubscriptionId,
|
||||||
|
user.parentSubscription.quantity - 1
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: deleted,
|
response: "User account and all related data deleted successfully.",
|
||||||
status: 200,
|
status: 200,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,11 @@ export default async function getUserById(userId: number) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -22,13 +27,21 @@ export default async function getUserById(userId: number) {
|
||||||
(usernames) => usernames.username
|
(usernames) => usernames.username
|
||||||
);
|
);
|
||||||
|
|
||||||
const { password, subscriptions, ...lessSensitiveInfo } = user;
|
const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } =
|
||||||
|
user;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
...lessSensitiveInfo,
|
...lessSensitiveInfo,
|
||||||
whitelistedUsers: whitelistedUsernames,
|
whitelistedUsers: whitelistedUsernames,
|
||||||
subscription: {
|
subscription: {
|
||||||
active: subscriptions?.active,
|
active: subscriptions?.active,
|
||||||
|
quantity: subscriptions?.quantity,
|
||||||
|
},
|
||||||
|
parentSubscription: {
|
||||||
|
active: parentSubscription?.active,
|
||||||
|
user: {
|
||||||
|
email: parentSubscription?.user.email,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,6 @@ export default async function updateUserById(
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { email: true, password: true, name: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.email && data.email && data.email !== user.email) {
|
if (user && user.email && data.email && data.email !== user.email) {
|
||||||
|
@ -133,7 +132,7 @@ export default async function updateUserById(
|
||||||
sendChangeEmailVerificationRequest(
|
sendChangeEmailVerificationRequest(
|
||||||
user.email,
|
user.email,
|
||||||
data.email,
|
data.email,
|
||||||
data.name?.trim() || user.name
|
data.name?.trim() || user.name || "Linkwarden User"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,8 +169,20 @@ export default async function updateUserById(
|
||||||
|
|
||||||
// Other settings / Apply changes
|
// Other settings / Apply changes
|
||||||
|
|
||||||
|
const isInvited =
|
||||||
|
user?.name === null && user.parentSubscriptionId && !user.password;
|
||||||
|
|
||||||
|
if (isInvited && data.password === "")
|
||||||
|
return {
|
||||||
|
response: "Password is required.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
|
const newHashedPassword = bcrypt.hashSync(
|
||||||
|
data.newPassword || data.password || "",
|
||||||
|
saltRounds
|
||||||
|
);
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -198,18 +209,28 @@ export default async function updateUserById(
|
||||||
linksRouteTo: data.linksRouteTo,
|
linksRouteTo: data.linksRouteTo,
|
||||||
preventDuplicateLinks: data.preventDuplicateLinks,
|
preventDuplicateLinks: data.preventDuplicateLinks,
|
||||||
password:
|
password:
|
||||||
data.newPassword && data.newPassword !== ""
|
isInvited || (data.newPassword && data.newPassword !== "")
|
||||||
? newHashedPassword
|
? newHashedPassword
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
whitelistedUsers: true,
|
whitelistedUsers: true,
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { whitelistedUsers, password, subscriptions, ...userInfo } =
|
const {
|
||||||
updatedUser;
|
whitelistedUsers,
|
||||||
|
password,
|
||||||
|
subscriptions,
|
||||||
|
parentSubscription,
|
||||||
|
...userInfo
|
||||||
|
} = updatedUser;
|
||||||
|
|
||||||
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
||||||
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
|
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
|
||||||
|
@ -250,11 +271,20 @@ export default async function updateUserById(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: Omit<AccountSettings, "password"> = {
|
const response = {
|
||||||
...userInfo,
|
...userInfo,
|
||||||
whitelistedUsers: newWhitelistedUsernames,
|
whitelistedUsers: newWhitelistedUsernames,
|
||||||
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
|
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
|
||||||
subscription: { active: subscriptions?.active },
|
subscription: {
|
||||||
|
active: subscriptions?.active,
|
||||||
|
quantity: subscriptions?.quantity,
|
||||||
|
},
|
||||||
|
parentSubscription: {
|
||||||
|
active: parentSubscription?.active,
|
||||||
|
user: {
|
||||||
|
email: parentSubscription?.user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response, status: 200 };
|
return { response, status: 200 };
|
||||||
|
|
|
@ -6,16 +6,16 @@ type Props = {
|
||||||
req: NextApiRequest;
|
req: NextApiRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
export default async function isAuthenticatedRequest({ req }: Props) {
|
||||||
const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
const userId = token?.id;
|
const userId = token?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.exp < Date.now() / 1000) {
|
if (token.exp < Date.now() / 1000) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if token is revoked
|
// check if token is revoked
|
||||||
|
@ -27,18 +27,21 @@ export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (revoked) {
|
if (revoked) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUser = await prisma.user.findFirst({
|
const findUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
subscriptions: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (findUser?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
|
if (findUser && !findUser?.subscriptions) {
|
||||||
return true;
|
return null;
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return findUser;
|
||||||
}
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
import verifySubscription from "./stripe/verifySubscription";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
export default async function paymentCheckout(
|
export default async function paymentCheckout(
|
||||||
stripeSecretKey: string,
|
stripeSecretKey: string,
|
||||||
|
@ -9,6 +11,23 @@ export default async function paymentCheckout(
|
||||||
apiVersion: "2022-11-15",
|
apiVersion: "2022-11-15",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscriptions: true,
|
||||||
|
parentSubscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = await verifySubscription(user);
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// To prevent users from creating multiple subscriptions
|
||||||
|
return { response: "/dashboard", status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
const listByEmail = await stripe.customers.list({
|
const listByEmail = await stripe.customers.list({
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
expand: ["data.subscriptions"],
|
expand: ["data.subscriptions"],
|
||||||
|
@ -18,6 +37,7 @@ export default async function paymentCheckout(
|
||||||
|
|
||||||
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
||||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
||||||
line_items: [
|
line_items: [
|
||||||
|
@ -28,7 +48,7 @@ export default async function paymentCheckout(
|
||||||
],
|
],
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
|
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
|
||||||
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.BASE_URL}/dashboard`,
|
||||||
cancel_url: `${process.env.BASE_URL}/login`,
|
cancel_url: `${process.env.BASE_URL}/login`,
|
||||||
automatic_tax: {
|
automatic_tax: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
import transporter from "./transporter";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
parentSubscriptionEmail: string;
|
||||||
|
identifier: string;
|
||||||
|
url: string;
|
||||||
|
from: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function sendInvitationRequest({
|
||||||
|
parentSubscriptionEmail,
|
||||||
|
identifier,
|
||||||
|
url,
|
||||||
|
from,
|
||||||
|
token,
|
||||||
|
}: Params) {
|
||||||
|
const emailsDir = path.resolve(process.cwd(), "templates");
|
||||||
|
|
||||||
|
const templateFile = readFileSync(
|
||||||
|
path.join(emailsDir, "acceptInvitation.html"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailTemplate = Handlebars.compile(templateFile);
|
||||||
|
|
||||||
|
const { host } = new URL(url);
|
||||||
|
const result = await transporter.sendMail({
|
||||||
|
to: identifier,
|
||||||
|
from: {
|
||||||
|
name: "Linkwarden",
|
||||||
|
address: from as string,
|
||||||
|
},
|
||||||
|
subject: `You have been invited to join Linkwarden`,
|
||||||
|
text: text({ url, host }),
|
||||||
|
html: emailTemplate({
|
||||||
|
parentSubscriptionEmail,
|
||||||
|
identifier,
|
||||||
|
url: `${
|
||||||
|
process.env.NEXTAUTH_URL
|
||||||
|
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||||
|
if (failed.length) {
|
||||||
|
throw new Error(`Email (${failed.join(", ")}) could not be sent`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||||
|
function text({ url, host }: { url: string; host: string }) {
|
||||||
|
return `Sign in to ${host}\n${url}\n\n`;
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
||||||
data: {
|
data: {
|
||||||
name: link.collection.name.trim(),
|
name: link.collection.name.trim(),
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
|
createdById: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -78,6 +79,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
||||||
name: "Unorganized",
|
name: "Unorganized",
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
createdById: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
|
export default async function checkSubscriptionByEmail(email: string) {
|
||||||
|
if (!STRIPE_SECRET_KEY) return null;
|
||||||
|
|
||||||
|
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Request made to Stripe by:", email);
|
||||||
|
const listByEmail = await stripe.customers.list({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
expand: ["data.subscriptions"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listByEmail?.data[0]?.subscriptions?.data[0]) {
|
||||||
|
return {
|
||||||
|
active: (listByEmail.data[0].subscriptions?.data[0] as any).plan.active,
|
||||||
|
stripeSubscriptionId: listByEmail.data[0].subscriptions?.data[0].id,
|
||||||
|
currentPeriodStart:
|
||||||
|
listByEmail.data[0].subscriptions?.data[0].current_period_start * 1000,
|
||||||
|
currentPeriodEnd:
|
||||||
|
listByEmail.data[0].subscriptions?.data[0].current_period_end * 1000,
|
||||||
|
quantity: (listByEmail?.data[0]?.subscriptions?.data[0] as any).quantity,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { prisma } from "../db";
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
quantity: number;
|
||||||
|
periodStart: number;
|
||||||
|
periodEnd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handleSubscription({
|
||||||
|
id,
|
||||||
|
active,
|
||||||
|
quantity,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
}: Data) {
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: {
|
||||||
|
stripeSubscriptionId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: {
|
||||||
|
stripeSubscriptionId: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
active,
|
||||||
|
quantity,
|
||||||
|
currentPeriodStart: new Date(periodStart * 1000),
|
||||||
|
currentPeriodEnd: new Date(periodEnd * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (!process.env.STRIPE_SECRET_KEY)
|
||||||
|
throw new Error("Missing Stripe secret key");
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(id);
|
||||||
|
const customerId = subscription.customer;
|
||||||
|
|
||||||
|
const customer = await stripe.customers.retrieve(customerId.toString());
|
||||||
|
const email = (customer as Stripe.Customer).email;
|
||||||
|
|
||||||
|
if (!email) throw new Error("Email not found");
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
await prisma.subscription
|
||||||
|
.upsert({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId: id,
|
||||||
|
quantity,
|
||||||
|
currentPeriodStart: new Date(periodStart * 1000),
|
||||||
|
currentPeriodEnd: new Date(periodEnd * 1000),
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId: id,
|
||||||
|
quantity,
|
||||||
|
currentPeriodStart: new Date(periodStart * 1000),
|
||||||
|
currentPeriodEnd: new Date(periodEnd * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
|
const updateSeats = async (subscriptionId: string, seats: number) => {
|
||||||
|
if (!STRIPE_SECRET_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
|
const trialing = subscription.status === "trialing";
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await stripe.subscriptions.update(subscriptionId, {
|
||||||
|
billing_cycle_anchor: trialing ? undefined : "now",
|
||||||
|
proration_behavior: trialing ? undefined : "create_prorations",
|
||||||
|
quantity: seats,
|
||||||
|
} as Stripe.SubscriptionUpdateParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default updateSeats;
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { prisma } from "../db";
|
||||||
|
import { Subscription, User } from "@prisma/client";
|
||||||
|
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
|
||||||
|
|
||||||
|
interface UserIncludingSubscription extends User {
|
||||||
|
subscriptions: Subscription | null;
|
||||||
|
parentSubscription: Subscription | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function verifySubscription(
|
||||||
|
user?: UserIncludingSubscription | null
|
||||||
|
) {
|
||||||
|
if (!user || (!user.subscriptions && !user.parentSubscription)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.parentSubscription?.active) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!user.subscriptions?.active ||
|
||||||
|
new Date() > user.subscriptions.currentPeriodEnd
|
||||||
|
) {
|
||||||
|
const subscription = await checkSubscriptionByEmail(user.email as string);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!subscription ||
|
||||||
|
!subscription.stripeSubscriptionId ||
|
||||||
|
!subscription.currentPeriodEnd ||
|
||||||
|
!subscription.currentPeriodStart ||
|
||||||
|
!subscription.quantity
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
quantity,
|
||||||
|
} = subscription;
|
||||||
|
|
||||||
|
await prisma.subscription
|
||||||
|
.upsert({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart: new Date(currentPeriodStart),
|
||||||
|
currentPeriodEnd: new Date(currentPeriodEnd),
|
||||||
|
quantity,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart: new Date(currentPeriodStart),
|
||||||
|
currentPeriodEnd: new Date(currentPeriodEnd),
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import verifySubscription from "./verifySubscription";
|
import verifySubscription from "./stripe/verifySubscription";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -33,6 +33,7 @@ export default async function verifyByCredentials({
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||||
|
|
||||||
|
export const hasPassedLimit = async (
|
||||||
|
userId: number,
|
||||||
|
numberOfImports: number
|
||||||
|
) => {
|
||||||
|
if (!stripeEnabled) {
|
||||||
|
const totalLinks = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
createdBy: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
parentSubscription: true,
|
||||||
|
subscriptions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.parentSubscription ||
|
||||||
|
(user.subscriptions && user.subscriptions?.quantity > 1)
|
||||||
|
) {
|
||||||
|
const subscription = user.parentSubscription || user.subscriptions;
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the total allowed links for the organization
|
||||||
|
const totalCapacity = subscription.quantity * MAX_LINKS_PER_USER;
|
||||||
|
|
||||||
|
const totalLinks = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
createdBy: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
parentSubscriptionId: subscription.id || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subscriptions: {
|
||||||
|
id: subscription.id || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return totalCapacity - (numberOfImports + totalLinks) < 0;
|
||||||
|
} else {
|
||||||
|
const totalLinks = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
createdBy: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,73 +0,0 @@
|
||||||
import { prisma } from "./db";
|
|
||||||
import { Subscription, User } from "@prisma/client";
|
|
||||||
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
|
|
||||||
|
|
||||||
interface UserIncludingSubscription extends User {
|
|
||||||
subscriptions: Subscription | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function verifySubscription(
|
|
||||||
user?: UserIncludingSubscription
|
|
||||||
) {
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = user.subscriptions;
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
|
|
||||||
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
|
|
||||||
const {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart,
|
|
||||||
currentPeriodEnd,
|
|
||||||
} = await checkSubscriptionByEmail(user.email as string);
|
|
||||||
|
|
||||||
if (
|
|
||||||
active &&
|
|
||||||
stripeSubscriptionId &&
|
|
||||||
currentPeriodStart &&
|
|
||||||
currentPeriodEnd
|
|
||||||
) {
|
|
||||||
await prisma.subscription
|
|
||||||
.upsert({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStart),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEnd),
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStart),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEnd),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
} else if (!active) {
|
|
||||||
const subscription = await prisma.subscription.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (subscription)
|
|
||||||
await prisma.subscription.delete({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import verifySubscription from "./verifySubscription";
|
import verifySubscription from "./stripe/verifySubscription";
|
||||||
import verifyToken from "./verifyToken";
|
import verifyToken from "./verifyToken";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -30,6 +30,7 @@ export default async function verifyUser({
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -2,29 +2,36 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
||||||
import getPublicUserData from "./getPublicUserData";
|
import getPublicUserData from "./getPublicUserData";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
const addMemberToCollection = async (
|
const addMemberToCollection = async (
|
||||||
ownerUsername: string,
|
owner: User,
|
||||||
memberUsername: string,
|
memberIdentifier: string,
|
||||||
collection: CollectionIncludingMembersAndLinkCount,
|
collection: CollectionIncludingMembersAndLinkCount,
|
||||||
setMember: (newMember: Member) => null | undefined,
|
setMember: (newMember: Member) => null | undefined,
|
||||||
t: TFunction<"translation", undefined>
|
t: TFunction<"translation", 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();
|
const email = (e.user.email || "").toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
username === memberIdentifier.toLowerCase() ||
|
||||||
|
email === memberIdentifier.toLowerCase()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// no duplicate members
|
// no duplicate members
|
||||||
!checkIfMemberAlreadyExists &&
|
!checkIfMemberAlreadyExists &&
|
||||||
// member can't be empty
|
// member can't be empty
|
||||||
memberUsername.trim() !== "" &&
|
memberIdentifier.trim() !== "" &&
|
||||||
// member can't be the owner
|
// member can't be the owner
|
||||||
memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
|
memberIdentifier.trim().toLowerCase() !== owner.username?.toLowerCase() &&
|
||||||
|
memberIdentifier.trim().toLowerCase() !== owner.email?.toLowerCase()
|
||||||
) {
|
) {
|
||||||
// Lookup, get data/err, list ...
|
// Lookup, get data/err, list ...
|
||||||
const user = await getPublicUserData(memberUsername.trim().toLowerCase());
|
const user = await getPublicUserData(memberIdentifier.trim().toLowerCase());
|
||||||
|
|
||||||
if (user.username) {
|
if (user.username) {
|
||||||
setMember({
|
setMember({
|
||||||
|
@ -37,12 +44,16 @@ const addMemberToCollection = async (
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
image: user.image,
|
image: user.image,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
|
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
|
||||||
else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase())
|
else if (
|
||||||
|
memberIdentifier.trim().toLowerCase() === owner.username?.toLowerCase() ||
|
||||||
|
memberIdentifier.trim().toLowerCase() === owner.email?.toLowerCase()
|
||||||
|
)
|
||||||
toast.error(t("you_are_already_collection_owner"));
|
toast.error(t("you_are_already_collection_owner"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -33,8 +33,8 @@ export const PostUserSchema = () => {
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
|
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z.string().trim().min(1).max(50),
|
name: z.string().trim().min(1).max(50).optional(),
|
||||||
password: z.string().min(8).max(2048),
|
password: z.string().min(8).max(2048).optional(),
|
||||||
email: emailEnabled
|
email: emailEnabled
|
||||||
? z.string().trim().email().toLowerCase()
|
? z.string().trim().email().toLowerCase()
|
||||||
: z.string().optional(),
|
: z.string().optional(),
|
||||||
|
@ -47,6 +47,7 @@ export const PostUserSchema = () => {
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(50)
|
.max(50)
|
||||||
.regex(/^[a-z0-9_-]{3,50}$/),
|
.regex(/^[a-z0-9_-]{3,50}$/),
|
||||||
|
invite: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@ export const UpdateUserSchema = () => {
|
||||||
.min(3)
|
.min(3)
|
||||||
.max(30)
|
.max(30)
|
||||||
.regex(/^[a-z0-9_-]{3,30}$/),
|
.regex(/^[a-z0-9_-]{3,30}$/),
|
||||||
image: z.string().optional(),
|
image: z.string().nullish(),
|
||||||
password: z.string().min(8).max(2048).optional(),
|
password: z.string().min(8).max(2048).optional(),
|
||||||
newPassword: z.string().min(8).max(2048).optional(),
|
newPassword: z.string().min(8).max(2048).optional(),
|
||||||
oldPassword: z.string().min(8).max(2048).optional(),
|
oldPassword: z.string().min(8).max(2048).optional(),
|
||||||
|
@ -189,7 +190,7 @@ export const UpdateCollectionSchema = z.object({
|
||||||
isPublic: z.boolean().optional(),
|
isPublic: z.boolean().optional(),
|
||||||
icon: z.string().trim().max(50).nullish(),
|
icon: z.string().trim().max(50).nullish(),
|
||||||
iconWeight: z.string().trim().max(50).nullish(),
|
iconWeight: z.string().trim().max(50).nullish(),
|
||||||
parentId: z.number().nullish(),
|
parentId: z.union([z.number(), z.literal("root")]).nullish(),
|
||||||
members: z.array(
|
members: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
userId: z.number(),
|
userId: z.number(),
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useTranslation } from "next-i18next";
|
||||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
import UserListing from "@/components/UserListing";
|
import UserListing from "@/components/UserListing";
|
||||||
import { useUsers } from "@/hooks/store/admin/users";
|
import { useUsers } from "@/hooks/store/admin/users";
|
||||||
|
import Divider from "@/components/ui/Divider";
|
||||||
|
|
||||||
interface User extends U {
|
interface User extends U {
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
|
@ -88,7 +89,7 @@ export default function Admin() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider my-3"></div>
|
<Divider className="my-3" />
|
||||||
|
|
||||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
|
||||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||||
import verifySubscription from "@/lib/api/verifySubscription";
|
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||||
|
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { Adapter } from "next-auth/adapters";
|
import { Adapter } from "next-auth/adapters";
|
||||||
import NextAuth from "next-auth/next";
|
import NextAuth from "next-auth/next";
|
||||||
|
@ -133,6 +135,7 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
providers.push(
|
providers.push(
|
||||||
EmailProvider({
|
EmailProvider({
|
||||||
|
id: "email",
|
||||||
server: process.env.EMAIL_SERVER,
|
server: process.env.EMAIL_SERVER,
|
||||||
from: process.env.EMAIL_FROM,
|
from: process.env.EMAIL_FROM,
|
||||||
maxAge: 1200,
|
maxAge: 1200,
|
||||||
|
@ -157,6 +160,56 @@ if (emailEnabled) {
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
EmailProvider({
|
||||||
|
id: "invite",
|
||||||
|
server: process.env.EMAIL_SERVER,
|
||||||
|
from: process.env.EMAIL_FROM,
|
||||||
|
maxAge: 1200,
|
||||||
|
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||||
|
const parentSubscriptionEmail = (
|
||||||
|
await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: identifier,
|
||||||
|
emailVerified: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
parentSubscription: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.parentSubscription?.user.email;
|
||||||
|
|
||||||
|
if (!parentSubscriptionEmail) throw Error("Invalid email.");
|
||||||
|
|
||||||
|
const recentVerificationRequestsCount =
|
||||||
|
await prisma.verificationToken.count({
|
||||||
|
where: {
|
||||||
|
identifier,
|
||||||
|
createdAt: {
|
||||||
|
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentVerificationRequestsCount >= 4)
|
||||||
|
throw Error("Too many requests. Please try again later.");
|
||||||
|
|
||||||
|
sendInvitationRequest({
|
||||||
|
parentSubscriptionEmail,
|
||||||
|
identifier,
|
||||||
|
url,
|
||||||
|
from: provider.from as string,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1179,6 +1232,52 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user, account, profile, email, credentials }) {
|
async signIn({ user, account, profile, email, credentials }) {
|
||||||
|
if (
|
||||||
|
!(user as User).emailVerified &&
|
||||||
|
!email?.verificationRequest
|
||||||
|
// && (account?.provider === "email" || account?.provider === "google")
|
||||||
|
) {
|
||||||
|
// Email is being verified for the first time...
|
||||||
|
console.log("Email is being verified for the first time...");
|
||||||
|
|
||||||
|
const parentSubscriptionId = (user as User).parentSubscriptionId;
|
||||||
|
|
||||||
|
if (parentSubscriptionId) {
|
||||||
|
// Add seat request to Stripe
|
||||||
|
const parentSubscription = await prisma.subscription.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parentSubscriptionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count child users with verified email under a specific subscription, excluding the current user
|
||||||
|
const verifiedChildUsersCount = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
parentSubscriptionId: parentSubscriptionId,
|
||||||
|
id: {
|
||||||
|
not: user.id as number,
|
||||||
|
},
|
||||||
|
emailVerified: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
STRIPE_SECRET_KEY &&
|
||||||
|
parentSubscription?.quantity &&
|
||||||
|
verifiedChildUsersCount + 2 > // add current user and the admin
|
||||||
|
parentSubscription.quantity
|
||||||
|
) {
|
||||||
|
// Add seat if the user count exceeds the subscription limit
|
||||||
|
await updateSeats(
|
||||||
|
parentSubscription.stripeSubscriptionId,
|
||||||
|
verifiedChildUsersCount + 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (account?.provider !== "credentials") {
|
if (account?.provider !== "credentials") {
|
||||||
// registration via SSO can be separately disabled
|
// registration via SSO can be separately disabled
|
||||||
const existingUser = await prisma.account.findFirst({
|
const existingUser = await prisma.account.findFirst({
|
||||||
|
@ -1287,8 +1386,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
session.user.id = token.id;
|
session.user.id = token.id;
|
||||||
|
|
||||||
console.log("session", session);
|
|
||||||
|
|
||||||
if (STRIPE_SECRET_KEY) {
|
if (STRIPE_SECRET_KEY) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
@ -1296,6 +1393,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,7 @@ export default async function forgotPassword(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPasswordResetRequest(user.email, user.name);
|
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
response: "Password reset email sent.",
|
response: "Password reset email sent.",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
|
||||||
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import getUserById from "@/lib/api/controllers/users/userId/getUserById";
|
||||||
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
||||||
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import verifySubscription from "@/lib/api/verifySubscription";
|
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||||
import verifyToken from "@/lib/api/verifyToken";
|
import verifyToken from "@/lib/api/verifyToken";
|
||||||
|
|
||||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
@ -11,6 +11,12 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const token = await verifyToken({ req });
|
const token = await verifyToken({ req });
|
||||||
|
|
||||||
|
const queryId = Number(req.query.id);
|
||||||
|
|
||||||
|
if (!queryId) {
|
||||||
|
return res.status(400).json({ response: "Invalid request." });
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof token === "string") {
|
if (typeof token === "string") {
|
||||||
res.status(401).json({ response: token });
|
res.status(401).json({ response: token });
|
||||||
return null;
|
return null;
|
||||||
|
@ -24,12 +30,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|
||||||
const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||||
|
|
||||||
const userId = isServerAdmin ? Number(req.query.id) : token.id;
|
const userId = token.id;
|
||||||
|
|
||||||
if (userId !== Number(req.query.id) && !isServerAdmin)
|
|
||||||
return res.status(401).json({ response: "Permission denied." });
|
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
|
if (userId !== queryId && !isServerAdmin)
|
||||||
|
return res.status(401).json({ response: "Permission denied." });
|
||||||
|
|
||||||
const users = await getUserById(userId);
|
const users = await getUserById(userId);
|
||||||
return res.status(users.status).json({ response: users.response });
|
return res.status(users.status).json({ response: users.response });
|
||||||
}
|
}
|
||||||
|
@ -41,6 +47,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
|
parentSubscription: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,6 +65,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "PUT") {
|
if (req.method === "PUT") {
|
||||||
|
if (userId !== queryId && !isServerAdmin)
|
||||||
|
return res.status(401).json({ response: "Permission denied." });
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response:
|
response:
|
||||||
|
@ -73,7 +83,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
const updated = await deleteUserById(
|
||||||
|
userId,
|
||||||
|
req.body,
|
||||||
|
isServerAdmin,
|
||||||
|
queryId
|
||||||
|
);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,10 +16,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
} else if (req.method === "GET") {
|
} else if (req.method === "GET") {
|
||||||
const user = await verifyUser({ req, res });
|
const user = await verifyUser({ req, res });
|
||||||
|
|
||||||
if (!user || user.id !== Number(process.env.NEXT_PUBLIC_ADMIN || 1))
|
if (!user) return res.status(401).json({ response: "Unauthorized..." });
|
||||||
return res.status(401).json({ response: "Unauthorized..." });
|
|
||||||
|
|
||||||
const response = await getUsers();
|
const response = await getUsers(user);
|
||||||
return res.status(response.status).json({ response: response.response });
|
return res.status(response.status).json({ response: response.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import handleSubscription from "@/lib/api/stripe/handleSubscription";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const buffer = (req: NextApiRequest) => {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks as any));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function webhook(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||||
|
return res.status(400).json({
|
||||||
|
response:
|
||||||
|
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// see if stripe is already initialized
|
||||||
|
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "This action is disabled because Stripe is not initialized.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = req.body;
|
||||||
|
|
||||||
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = req.headers["stripe-signature"] as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await buffer(req);
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(400).send("Webhook signature verification failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the event based on its type
|
||||||
|
const eventType = event.type;
|
||||||
|
const data = event.data.object;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (eventType) {
|
||||||
|
case "customer.subscription.created":
|
||||||
|
await handleSubscription({
|
||||||
|
id: data.id,
|
||||||
|
active: data.status === "active" || data.status === "trialing",
|
||||||
|
quantity: data?.quantity ?? 1,
|
||||||
|
periodStart: data.current_period_start,
|
||||||
|
periodEnd: data.current_period_end,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
await handleSubscription({
|
||||||
|
id: data.id,
|
||||||
|
active: data.status === "active" || data.status === "trialing",
|
||||||
|
quantity: data?.quantity ?? 1,
|
||||||
|
periodStart: data.current_period_start,
|
||||||
|
periodEnd: data.current_period_end,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
await handleSubscription({
|
||||||
|
id: data.id,
|
||||||
|
active: false,
|
||||||
|
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||||
|
periodStart: data.current_period_start,
|
||||||
|
periodEnd: data.current_period_end,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "customer.subscription.cancelled":
|
||||||
|
await handleSubscription({
|
||||||
|
id: data.id,
|
||||||
|
active: !(data.current_period_end * 1000 < Date.now()),
|
||||||
|
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||||
|
periodStart: data.current_period_start,
|
||||||
|
periodEnd: data.current_period_end,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled event type ${eventType}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling webhook event:", error);
|
||||||
|
return res.status(500).send("Server Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
response: "Done!",
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,150 @@
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import CenteredForm from "@/layouts/CenteredForm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
|
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MemberOnboarding() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>({
|
||||||
|
password: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: user = {} } = useUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (form.password !== "" && form.name !== "" && !submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading(t("sending_password_recovery_link"));
|
||||||
|
|
||||||
|
await updateUser.mutateAsync(
|
||||||
|
{
|
||||||
|
...user,
|
||||||
|
name: form.name,
|
||||||
|
password: form.password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
},
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.success(t("settings_applied"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t("please_fill_all_fields"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredForm>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||||
|
<p className="text-3xl text-center font-extralight">
|
||||||
|
{t("invitation_accepted")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: "pre-line",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("invitation_desc", {
|
||||||
|
owner: user?.parentSubscription?.user?.email,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm w-fit font-semibold mb-1">
|
||||||
|
{t("display_name")}
|
||||||
|
</p>
|
||||||
|
<TextInput
|
||||||
|
autoFocus
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={form.name}
|
||||||
|
className="bg-base-100"
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm w-fit font-semibold mb-1">
|
||||||
|
{t("new_password")}
|
||||||
|
</p>
|
||||||
|
<TextInput
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••••••••"
|
||||||
|
value={form.password}
|
||||||
|
className="bg-base-100"
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||||
|
<div className="text-xs text-neutral mb-3">
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
i18nKey="sign_up_agreement"
|
||||||
|
components={[
|
||||||
|
<Link
|
||||||
|
href="https://linkwarden.app/tos"
|
||||||
|
className="font-semibold"
|
||||||
|
data-testid="terms-of-service-link"
|
||||||
|
key={0}
|
||||||
|
/>,
|
||||||
|
<Link
|
||||||
|
href="https://linkwarden.app/privacy-policy"
|
||||||
|
className="font-semibold"
|
||||||
|
data-testid="privacy-policy-link"
|
||||||
|
key={1}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
intent="accent"
|
||||||
|
className="mt-2"
|
||||||
|
size="full"
|
||||||
|
loading={submitLoader}
|
||||||
|
>
|
||||||
|
{t("continue_to_dashboard")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CenteredForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getServerSideProps };
|
|
@ -67,10 +67,18 @@ export default function AccessTokens() {
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{new Date(token.createdAt || "").toLocaleDateString()}
|
{new Date(token.createdAt).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{new Date(token.expires || "").toLocaleDateString()}
|
{new Date(token.expires).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -108,6 +108,7 @@ export default function Account() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -129,8 +130,6 @@ export default function Account() {
|
||||||
location.reload();
|
location.reload();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const importBookmarks = async (
|
const importBookmarks = async (
|
||||||
|
|
|
@ -1,17 +1,57 @@
|
||||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||||
|
import { User as U } from "@prisma/client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
|
import { useUsers } from "@/hooks/store/admin/users";
|
||||||
|
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
interface User extends U {
|
||||||
|
subscriptions: {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModal = {
|
||||||
|
isOpen: boolean;
|
||||||
|
userId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Billing() {
|
export default function Billing() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: account } = useUser();
|
||||||
|
const { data: users = [] } = useUsers();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!process.env.NEXT_PUBLIC_STRIPE) router.push("/settings/profile");
|
if (!process.env.NEXT_PUBLIC_STRIPE || account.parentSubscriptionId)
|
||||||
|
router.push("/settings/account");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (users.length > 0) {
|
||||||
|
setFilteredUsers(users);
|
||||||
|
}
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [inviteModal, setInviteModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<p className="capitalize text-3xl font-thin inline">
|
<p className="capitalize text-3xl font-thin inline">
|
||||||
|
@ -40,6 +80,190 @@ export default function Billing() {
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-full rounded-md h-8 mt-5">
|
||||||
|
<p className="truncate w-full pr-7 text-3xl font-thin">
|
||||||
|
{t("manage_seats")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3 relative">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="search-box"
|
||||||
|
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
|
||||||
|
>
|
||||||
|
<i className="bi-search"></i>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="search-box"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_users")}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
|
||||||
|
if (users) {
|
||||||
|
setFilteredUsers(
|
||||||
|
users.filter((user: any) =>
|
||||||
|
JSON.stringify(user)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(e.target.value.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div
|
||||||
|
onClick={() => setInviteModal(true)}
|
||||||
|
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 h-[2.15rem] relative"
|
||||||
|
>
|
||||||
|
<p>{t("invite_user")}</p>
|
||||||
|
<i className="bi-plus text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md shadow border-neutral-content">
|
||||||
|
<table className="table bg-base-300 rounded-md">
|
||||||
|
<thead>
|
||||||
|
<tr className="sm:table-row hidden border-b-neutral-content">
|
||||||
|
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||||
|
<th>{t("email")}</th>
|
||||||
|
)}
|
||||||
|
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||||
|
<th>{t("status")}</th>
|
||||||
|
)}
|
||||||
|
<th>{t("date_added")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsers?.map((user, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={clsx(
|
||||||
|
"group border-b-neutral-content duration-100 w-full relative flex flex-col sm:table-row",
|
||||||
|
user.id !== account.id &&
|
||||||
|
"hover:bg-neutral-content hover:bg-opacity-30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||||
|
<td className="truncate max-w-full" title={user.email || ""}>
|
||||||
|
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||||
|
{t("email")}
|
||||||
|
</p>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||||
|
<td>
|
||||||
|
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||||
|
{t("status")}
|
||||||
|
</p>
|
||||||
|
{user.emailVerified ? (
|
||||||
|
<p className="font-bold px-2 bg-green-600 text-white rounded-md w-fit">
|
||||||
|
{t("active")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="font-bold px-2 bg-neutral-content rounded-md w-fit">
|
||||||
|
{t("pending")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>
|
||||||
|
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||||
|
{t("date_added")}
|
||||||
|
</p>
|
||||||
|
<p className="whitespace-nowrap">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{user.id !== account.id && (
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
className={`dropdown dropdown-bottom font-normal dropdown-end absolute right-[0.35rem] top-[0.35rem]`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onMouseDown={dropdownTriggerer}
|
||||||
|
className="btn btn-ghost btn-sm btn-square duration-100"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={"bi bi-three-dots text-lg text-neutral"}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
|
||||||
|
{!user.emailVerified ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(
|
||||||
|
document?.activeElement as HTMLElement
|
||||||
|
)?.blur();
|
||||||
|
signIn("invite", {
|
||||||
|
email: user.email,
|
||||||
|
callbackUrl: "/member-onboarding",
|
||||||
|
redirect: false,
|
||||||
|
}).then(() =>
|
||||||
|
toast.success(t("resend_invite_success"))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{t("resend_invite")}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setDeleteUserModal({
|
||||||
|
isOpen: true,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{t("remove_user")}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-center font-bold mt-3">
|
||||||
|
{t("seats_purchased", { count: account?.subscription?.quantity })}
|
||||||
|
</p>
|
||||||
|
{inviteModal && <InviteModal onClose={() => setInviteModal(false)} />}
|
||||||
|
{deleteUserModal.isOpen && deleteUserModal.userId && (
|
||||||
|
<DeleteUserModal
|
||||||
|
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
||||||
|
userId={deleteUserModal.userId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import Link from "next/link";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
export default function Delete() {
|
export default function Delete() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
@ -15,6 +16,7 @@ export default function Delete() {
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
const { data } = useSession();
|
const { data } = useSession();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: user } = useUser();
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -83,7 +85,7 @@ export default function Delete() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
|
||||||
<fieldset className="border rounded-md p-2 border-primary">
|
<fieldset className="border rounded-md p-2 border-primary">
|
||||||
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
|
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
|
||||||
<b>{t("optional")}</b> <i>{t("feedback_help")}</i>
|
<b>{t("optional")}</b> <i>{t("feedback_help")}</i>
|
||||||
|
|
|
@ -34,6 +34,7 @@ export default function Password() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -47,8 +48,6 @@ export default function Password() {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -80,6 +80,7 @@ export default function Appearance() {
|
||||||
{ ...user },
|
{ ...user },
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -90,8 +91,6 @@ export default function Appearance() {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -9,8 +9,6 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
import { Trans, useTranslation } from "next-i18next";
|
import { Trans, useTranslation } from "next-i18next";
|
||||||
import { useUser } from "@/hooks/store/user";
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
@ -23,13 +21,14 @@ export default function Subscribe() {
|
||||||
const { data: user = {} } = useUser();
|
const { data: user = {} } = useUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasInactiveSubscription =
|
console.log("user", user);
|
||||||
user.id && !user.subscription?.active && stripeEnabled;
|
if (
|
||||||
|
session.status === "authenticated" &&
|
||||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
user.id &&
|
||||||
|
(user?.subscription?.active || user.parentSubscription?.active)
|
||||||
|
)
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}
|
}, [session.status, user]);
|
||||||
}, [session.status]);
|
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setSubmitLoader(true);
|
setSubmitLoader(true);
|
||||||
|
@ -40,6 +39,8 @@ export default function Subscribe() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
router.push(data.response);
|
router.push(data.response);
|
||||||
|
|
||||||
|
toast.dismiss(redirectionToast);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -85,6 +85,7 @@ export default function Index() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
|
setSubmitLoader(false);
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -97,7 +98,6 @@ export default function Index() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
setRenameTag(false);
|
setRenameTag(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Subscription" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1;
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `_LinkToUser` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TeamRole" AS ENUM ('MEMBER', 'ADMIN');
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "_LinkToUser" DROP CONSTRAINT "_LinkToUser_A_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "_LinkToUser" DROP CONSTRAINT "_LinkToUser_B_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Collection" ADD COLUMN "createdById" INTEGER,
|
||||||
|
ADD COLUMN "teamId" INTEGER;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ADD COLUMN "createdById" INTEGER;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "parentSubscriptionId" INTEGER,
|
||||||
|
ADD COLUMN "teamRole" "TeamRole" NOT NULL DEFAULT 'ADMIN',
|
||||||
|
ALTER COLUMN "name" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "_LinkToUser";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_PinnedLinks" (
|
||||||
|
"A" INTEGER NOT NULL,
|
||||||
|
"B" INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "_PinnedLinks_AB_unique" ON "_PinnedLinks"("A", "B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_PinnedLinks_B_index" ON "_PinnedLinks"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_parentSubscriptionId_fkey" FOREIGN KEY ("parentSubscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Link" ADD CONSTRAINT "Link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PinnedLinks" ADD CONSTRAINT "_PinnedLinks_A_fkey" FOREIGN KEY ("A") REFERENCES "Link"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PinnedLinks" ADD CONSTRAINT "_PinnedLinks_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `createdById` on table `Collection` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
- Made the column `createdById` on table `Link` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Update the Link table to set the createdBy based on the Collection's ownerId.
|
||||||
|
UPDATE "Link"
|
||||||
|
SET "createdById" = (
|
||||||
|
SELECT "ownerId"
|
||||||
|
FROM "Collection"
|
||||||
|
WHERE "Collection"."id" = "Link"."collectionId"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Set createdBy to ownerId for existing records
|
||||||
|
UPDATE "Collection"
|
||||||
|
SET "createdById" = "ownerId";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Collection" DROP CONSTRAINT "Collection_createdById_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Link" DROP CONSTRAINT "Link_createdById_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Collection" ALTER COLUMN "createdById" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ALTER COLUMN "createdById" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Link" ADD CONSTRAINT "Link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,11 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `teamRole` on the `User` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" DROP COLUMN "teamRole";
|
||||||
|
|
||||||
|
-- DropEnum
|
||||||
|
DROP TYPE "TeamRole";
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `teamId` on the `Collection` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Collection" DROP COLUMN "teamId";
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Collection" DROP CONSTRAINT "Collection_createdById_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Link" DROP CONSTRAINT "Link_createdById_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Collection" ALTER COLUMN "createdById" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ALTER COLUMN "createdById" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Link" ADD CONSTRAINT "Link_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -27,7 +27,7 @@ model Account {
|
||||||
|
|
||||||
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?
|
||||||
|
@ -35,10 +35,14 @@ model User {
|
||||||
image String?
|
image String?
|
||||||
password String?
|
password String?
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
|
parentSubscription Subscription? @relation("ChildUsers", fields: [parentSubscriptionId], references: [id])
|
||||||
|
parentSubscriptionId Int?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
tags Tag[]
|
tags Tag[]
|
||||||
pinnedLinks Link[]
|
pinnedLinks Link[] @relation("PinnedLinks")
|
||||||
|
createdLinks Link[] @relation("CreatedLinks")
|
||||||
|
createdCollections Collection[] @relation("CreatedCollections")
|
||||||
collectionsJoined UsersAndCollections[]
|
collectionsJoined UsersAndCollections[]
|
||||||
collectionOrder Int[] @default([])
|
collectionOrder Int[] @default([])
|
||||||
whitelistedUsers WhitelistedUser[]
|
whitelistedUsers WhitelistedUser[]
|
||||||
|
@ -104,6 +108,8 @@ model Collection {
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
ownerId Int
|
ownerId Int
|
||||||
members UsersAndCollections[]
|
members UsersAndCollections[]
|
||||||
|
createdBy User? @relation("CreatedCollections", fields: [createdById], references: [id])
|
||||||
|
createdById Int?
|
||||||
links Link[]
|
links Link[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
@ -131,7 +137,9 @@ model Link {
|
||||||
name String @default("")
|
name String @default("")
|
||||||
type String @default("url")
|
type String @default("url")
|
||||||
description String @default("")
|
description String @default("")
|
||||||
pinnedBy User[]
|
pinnedBy User[] @relation("PinnedLinks")
|
||||||
|
createdBy User? @relation("CreatedLinks", fields: [createdById], references: [id])
|
||||||
|
createdById Int?
|
||||||
collection Collection @relation(fields: [collectionId], references: [id])
|
collection Collection @relation(fields: [collectionId], references: [id])
|
||||||
collectionId Int
|
collectionId Int
|
||||||
tags Tag[]
|
tags Tag[]
|
||||||
|
@ -170,8 +178,10 @@ model Subscription {
|
||||||
stripeSubscriptionId String @unique
|
stripeSubscriptionId String @unique
|
||||||
currentPeriodStart DateTime
|
currentPeriodStart DateTime
|
||||||
currentPeriodEnd DateTime
|
currentPeriodEnd DateTime
|
||||||
|
quantity Int @default(1)
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
|
childUsers User[] @relation("ChildUsers")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
|
@ -318,7 +318,7 @@
|
||||||
"sharable_link": "Sharable Link",
|
"sharable_link": "Sharable Link",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
"members_username_placeholder": "Username (without the '@')",
|
"add_member_placeholder": "Add members by email or username",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"contributor": "Contributor",
|
"contributor": "Contributor",
|
||||||
|
@ -396,5 +396,25 @@
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
"invalid_url_guide":"Please enter a valid Address for the Link. (It should start with http/https)",
|
"invalid_url_guide":"Please enter a valid Address for the Link. (It should start with http/https)",
|
||||||
"email_invalid": "Please enter a valid email address.",
|
"email_invalid": "Please enter a valid email address.",
|
||||||
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed."
|
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed.",
|
||||||
|
"team_management": "Team Management",
|
||||||
|
"invite_user": "Invite User",
|
||||||
|
"invite_users": "Invite Users",
|
||||||
|
"invite_user_desc": "To invite someone to your team, please enter their email address below:",
|
||||||
|
"invite_user_note": "Please note that once the invitation is accepted, an additional seat will be purchased and your account will automatically be billed for this addition.",
|
||||||
|
"send_invitation": "Send Invitation",
|
||||||
|
"learn_more": "Learn more",
|
||||||
|
"invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.",
|
||||||
|
"invitation_accepted": "Invitation Accepted!",
|
||||||
|
"status": "Status",
|
||||||
|
"pending": "Pending",
|
||||||
|
"active": "Active",
|
||||||
|
"manage_seats": "Manage Seats",
|
||||||
|
"seats_purchased": "{{count}} seats purchased",
|
||||||
|
"date_added": "Date Added",
|
||||||
|
"resend_invite": "Resend Invitation",
|
||||||
|
"resend_invite_success": "Invitation Resent!",
|
||||||
|
"remove_user": "Remove User",
|
||||||
|
"continue_to_dashboard": "Continue to Dashboard",
|
||||||
|
"confirm_user_removal_desc": "They will need to have a subscription to access Linkwarden again."
|
||||||
}
|
}
|
|
@ -142,13 +142,13 @@ function delay(sec: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
console.log("\x1b[34m%s\x1b[0m", "Starting the link processing task");
|
console.log("\x1b[34m%s\x1b[0m", "Processing the links...");
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
await processBatch();
|
await processBatch();
|
||||||
await delay(intervalInSeconds);
|
await delay(intervalInSeconds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("\x1b[34m%s\x1b[0m", "Error processing links:", error);
|
console.error("\x1b[34m%s\x1b[0m", "Error processing link:", error);
|
||||||
await delay(intervalInSeconds);
|
await delay(intervalInSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,445 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<title>Email</title>
|
||||||
|
<style media="all" type="text/css">
|
||||||
|
@media only screen and (max-width: 640px) {
|
||||||
|
.main p,
|
||||||
|
.main td,
|
||||||
|
.main span {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
border-left-width: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border-right-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn table {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn a {
|
||||||
|
font-size: 16px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media all {
|
||||||
|
.ExternalClass {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ExternalClass,
|
||||||
|
.ExternalClass p,
|
||||||
|
.ExternalClass span,
|
||||||
|
.ExternalClass font,
|
||||||
|
.ExternalClass td,
|
||||||
|
.ExternalClass div {
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apple-link a {
|
||||||
|
color: inherit !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-size: inherit !important;
|
||||||
|
font-weight: inherit !important;
|
||||||
|
line-height: inherit !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#MessageViewBody a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="body"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
bgcolor="#f8f8f8"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="container"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0;
|
||||||
|
padding-top: 24px;
|
||||||
|
width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
"
|
||||||
|
width="600"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="content"
|
||||||
|
style="
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- START CENTERED WHITE CONTAINER -->
|
||||||
|
<span
|
||||||
|
class="preheader"
|
||||||
|
style="
|
||||||
|
color: transparent;
|
||||||
|
display: none;
|
||||||
|
height: 0;
|
||||||
|
max-height: 0;
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
mso-hide: all;
|
||||||
|
visibility: hidden;
|
||||||
|
width: 0;
|
||||||
|
"
|
||||||
|
>Please verify your email address by clicking the button
|
||||||
|
below.</span
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="main"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #eaebed;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<!-- START MAIN CONTENT AREA -->
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="wrapper"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 24px;
|
||||||
|
width: fit-content;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Dear
|
||||||
|
<a
|
||||||
|
href="mailto:{{parentSubscriptionEmail}}"
|
||||||
|
style="color: red"
|
||||||
|
>{{identifier}}</a
|
||||||
|
>,
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
You have been invited to join Linkwarden by
|
||||||
|
<a
|
||||||
|
href="mailto:{{parentSubscriptionEmail}}"
|
||||||
|
style="color: red"
|
||||||
|
>
|
||||||
|
{{parentSubscriptionEmail}}</a
|
||||||
|
>!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Linkwarden simplifies digital content management by allowing
|
||||||
|
teams and individuals to easily collect, organize, and
|
||||||
|
preserve webpages and articles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
class="btn btn-primary"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
align="left"
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
width: auto;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: top;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #00335a;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
align="center"
|
||||||
|
bgcolor="#0867ec"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{url}}"
|
||||||
|
target="_blank"
|
||||||
|
style="
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: capitalize;
|
||||||
|
background-color: #00335a;
|
||||||
|
color: #ffffff;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Please note that your Linkwarden account and billing will be
|
||||||
|
managed by
|
||||||
|
<a
|
||||||
|
href="mailto:{{parentSubscriptionEmail}}"
|
||||||
|
style="color: red"
|
||||||
|
>
|
||||||
|
{{parentSubscriptionEmail}}</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr
|
||||||
|
style="
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #eaebed;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #868686;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
If you’re having trouble clicking the button, click on the
|
||||||
|
following link:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
word-break: break-all;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{url}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- END MAIN CONTENT AREA -->
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- START FOOTER -->
|
||||||
|
<div
|
||||||
|
class="footer"
|
||||||
|
style="
|
||||||
|
clear: both;
|
||||||
|
padding-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
role="presentation"
|
||||||
|
border="0"
|
||||||
|
cellpadding="0"
|
||||||
|
cellspacing="0"
|
||||||
|
style="
|
||||||
|
border-collapse: separate;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="content-block"
|
||||||
|
style="vertical-align: top; text-align: center"
|
||||||
|
valign="top"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://raw.githubusercontent.com/linkwarden/linkwarden/main/public/linkwarden_light.png"
|
||||||
|
alt="logo"
|
||||||
|
style="width: 180px; height: auto"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- END FOOTER -->
|
||||||
|
|
||||||
|
<!-- END CENTERED WHITE CONTAINER -->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
"
|
||||||
|
valign="top"
|
||||||
|
>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -32,7 +32,7 @@ export interface Member {
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
user: OptionalExcluding<User, "username" | "name" | "id">;
|
user: OptionalExcluding<User, "email" | "username" | "name" | "id">;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectionIncludingMembersAndLinkCount
|
export interface CollectionIncludingMembersAndLinkCount
|
||||||
|
|
Ŝarĝante…
Reference in New Issue