Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ad01a2d0 | |||
| 915d08a315 | |||
| 08c2ff278f | |||
| 154d0d5fb6 | |||
| 7856e76b15 | |||
| f37a4b9c9e | |||
| 389db59b28 | |||
| b702aa0401 | |||
| 9a92b4d229 | |||
| 8278878673 | |||
| 4640c1c966 | |||
| 49fbbe966c | |||
| 3610e73d3b | |||
| 76a5dcb90b | |||
| e51fba41e7 | |||
| e8edd1c9a0 | |||
| f30c652676 | |||
| 8cf621bc62 | |||
| a89274fc03 | |||
| baadd6c06b | |||
| 4a71af8a67 | |||
| ece09c6f3b | |||
| 189db27c5b | |||
| 68d8d403cf | |||
| 87eb2471ff | |||
| 58b6f7339c | |||
| 5503483502 | |||
| a6d018fb53 | |||
| 3929f32e63 | |||
| c08522386b | |||
| b51a876904 | |||
| 2e2d7baee1 | |||
| 495af0a752 | |||
| 388b9d9184 | |||
| a3d3b353a1 | |||
| cc2d7c863d | |||
| 53a65774f0 | |||
| ce2eb8eafb | |||
| 4e20d71a41 | |||
| cac90524ed | |||
| 9fce74971f | |||
| bde7b9aae0 | |||
| 7dd254af48 | |||
| 3969cc5abd |
@@ -21,6 +21,8 @@ ARCHIVE_TAKE_COUNT=
|
|||||||
BROWSER_TIMEOUT=
|
BROWSER_TIMEOUT=
|
||||||
IGNORE_UNAUTHORIZED_CA=
|
IGNORE_UNAUTHORIZED_CA=
|
||||||
IGNORE_HTTPS_ERRORS=
|
IGNORE_HTTPS_ERRORS=
|
||||||
|
IGNORE_URL_SIZE_LIMIT=
|
||||||
|
ADMINISTRATOR=
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
@@ -75,6 +77,13 @@ AUTH0_ISSUER=
|
|||||||
AUTH0_CLIENT_SECRET=
|
AUTH0_CLIENT_SECRET=
|
||||||
AUTH0_CLIENT_ID=
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
|
# Authelia
|
||||||
|
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||||
|
AUTHELIA_CLIENT_ID=""
|
||||||
|
AUTHELIA_CLIENT_SECRET=""
|
||||||
|
AUTHELIA_WELLKNOWN_URL=""
|
||||||
|
|
||||||
|
|
||||||
# Authentik
|
# Authentik
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
AUTHENTIK_CUSTOM_NAME=
|
AUTHENTIK_CUSTOM_NAME=
|
||||||
|
|||||||
+1
-1
@@ -20,4 +20,4 @@ COPY . .
|
|||||||
RUN yarn prisma generate && \
|
RUN yarn prisma generate && \
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
CMD yarn prisma migrate deploy && yarn start
|
CMD yarn prisma migrate deploy && yarn start
|
||||||
@@ -8,19 +8,38 @@ type Props = {
|
|||||||
onMount?: (rect: DOMRect) => void;
|
onMount?: (rect: DOMRect) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getZIndex(element: HTMLElement): number {
|
||||||
|
let zIndex = 0;
|
||||||
|
while (element) {
|
||||||
|
const zIndexStyle = window
|
||||||
|
.getComputedStyle(element)
|
||||||
|
.getPropertyValue("z-index");
|
||||||
|
const numericZIndex = Number(zIndexStyle);
|
||||||
|
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
|
||||||
|
zIndex = numericZIndex;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
element = element.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
return zIndex;
|
||||||
|
}
|
||||||
|
|
||||||
function useOutsideAlerter(
|
function useOutsideAlerter(
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
onClickOutside: Function
|
onClickOutside: Function
|
||||||
) {
|
) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: Event) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (
|
const clickedElement = event.target as HTMLElement;
|
||||||
ref.current &&
|
if (ref.current && !ref.current.contains(clickedElement)) {
|
||||||
!ref.current.contains(event.target as HTMLInputElement)
|
const refZIndex = getZIndex(ref.current);
|
||||||
) {
|
const clickedZIndex = getZIndex(clickedElement);
|
||||||
onClickOutside(event);
|
if (clickedZIndex <= refZIndex) {
|
||||||
|
onClickOutside(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ export default function LinkDate({
|
|||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
}) {
|
}) {
|
||||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
const formattedDate = new Date(
|
||||||
"en-US",
|
(link.importDate || link.createdAt) as string
|
||||||
{
|
).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-neutral">
|
<div className="flex items-center gap-1 text-neutral">
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import useUserStore from "@/store/admin/users";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteUserModal({ onClose, userId }: Props) {
|
||||||
|
const { removeUser } = useUserStore();
|
||||||
|
|
||||||
|
const deleteUser = async () => {
|
||||||
|
const load = toast.loading("Deleting...");
|
||||||
|
|
||||||
|
const response = await removeUser(userId);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
response.ok && toast.success(`User Deleted.`);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">Delete User</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>Are you sure you want to remove this user?</p>
|
||||||
|
|
||||||
|
<div role="alert" className="alert alert-warning">
|
||||||
|
<i className="bi-exclamation-triangle text-xl" />
|
||||||
|
<span>
|
||||||
|
<b>Warning:</b> This action is irreversible!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
||||||
|
onClick={deleteUser}
|
||||||
|
>
|
||||||
|
<i className="bi-trash text-xl" />
|
||||||
|
Delete, I know what I'm doing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import useUserStore from "@/store/admin/users";
|
||||||
|
import TextInput from "../TextInput";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
name: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
||||||
|
|
||||||
|
export default function NewUserModal({ onClose }: Props) {
|
||||||
|
const { addUser } = useUserStore();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>({
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
email: emailEnabled ? "" : undefined,
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!submitLoader) {
|
||||||
|
const checkFields = () => {
|
||||||
|
if (emailEnabled) {
|
||||||
|
return form.name !== "" && form.email !== "" && form.password !== "";
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
form.name !== "" && form.username !== "" && form.password !== ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkFields()) {
|
||||||
|
if (form.password.length < 8)
|
||||||
|
return toast.error("Passwords must be at least 8 characters.");
|
||||||
|
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Creating Account...");
|
||||||
|
|
||||||
|
const response = await addUser(form);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
setSubmitLoader(false);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success("User Created!");
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast.error(response.data as string);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("Please fill out all the fields.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Create New User</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Display Name</p>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Johnny"
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
value={form.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailEnabled ? (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Username</p>
|
||||||
|
<TextInput
|
||||||
|
placeholder="john"
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
value={form.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Email</p>
|
||||||
|
<TextInput
|
||||||
|
placeholder="johnny@example.com"
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
value={form.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Password</p>
|
||||||
|
<TextInput
|
||||||
|
placeholder="••••••••••••••"
|
||||||
|
className="bg-base-200"
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
value={form.password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-66
@@ -1,40 +1,24 @@
|
|||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import useAccountStore from "@/store/account";
|
|
||||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
|
||||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||||
import ToggleDarkMode from "./ToggleDarkMode";
|
import ToggleDarkMode from "./ToggleDarkMode";
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
|
||||||
import NewLinkModal from "./ModalContent/NewLinkModal";
|
import NewLinkModal from "./ModalContent/NewLinkModal";
|
||||||
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
||||||
import Link from "next/link";
|
|
||||||
import UploadFileModal from "./ModalContent/UploadFileModal";
|
import UploadFileModal from "./ModalContent/UploadFileModal";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
import MobileNavigation from "./MobileNavigation";
|
import MobileNavigation from "./MobileNavigation";
|
||||||
|
import ProfileDropdown from "./ProfileDropdown";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { settings, updateSettings } = useLocalSettingsStore();
|
|
||||||
|
|
||||||
const { account } = useAccountStore();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [sidebar, setSidebar] = useState(false);
|
const [sidebar, setSidebar] = useState(false);
|
||||||
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (settings.theme === "dark") {
|
|
||||||
updateSettings({ theme: "light" });
|
|
||||||
} else {
|
|
||||||
updateSettings({ theme: "dark" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebar(false);
|
setSidebar(false);
|
||||||
document.body.style.overflow = "auto";
|
document.body.style.overflow = "auto";
|
||||||
@@ -120,55 +104,7 @@ export default function Navbar() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dropdown dropdown-end">
|
<ProfileDropdown />
|
||||||
<div
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
onMouseDown={dropdownTriggerer}
|
|
||||||
className="btn btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<ProfilePhoto
|
|
||||||
src={account.image ? account.image : undefined}
|
|
||||||
priority={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/settings/account"
|
|
||||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="block sm:hidden">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
|
||||||
handleToggle();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
|
||||||
signOut();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileNavigation />
|
<MobileNavigation />
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
|
import ProfilePhoto from "./ProfilePhoto";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function ProfileDropdown() {
|
||||||
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (settings.theme === "dark") {
|
||||||
|
updateSettings({ theme: "light" });
|
||||||
|
} else {
|
||||||
|
updateSettings({ theme: "dark" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onMouseDown={dropdownTriggerer}
|
||||||
|
className="btn btn-circle btn-ghost"
|
||||||
|
>
|
||||||
|
<ProfilePhoto
|
||||||
|
src={account.image ? account.image : undefined}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/settings/account"
|
||||||
|
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="block sm:hidden">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
handleToggle();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
signOut();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
const [imageError, setImageError] = useState<boolean>(false);
|
const [imageError, setImageError] = useState<boolean>(false);
|
||||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||||
|
|
||||||
|
const [date, setDate] = useState<Date | string>();
|
||||||
|
|
||||||
const colorThief = new ColorThief();
|
const colorThief = new ColorThief();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLinkContent();
|
fetchLinkContent();
|
||||||
|
|
||||||
|
setDate(link.importDate || link.createdAt);
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="min-w-fit text-sm text-neutral">
|
<p className="min-w-fit text-sm text-neutral">
|
||||||
{link?.createdAt
|
{date
|
||||||
? new Date(link?.createdAt).toLocaleString("en-US", {
|
? new Date(date).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const LINKWARDEN_VERSION = "v2.5.1";
|
const LINKWARDEN_VERSION = process.env.version;
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
? await validateUrlSize(link.url)
|
? await validateUrlSize(link.url)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (validatedUrl === null)
|
if (
|
||||||
|
validatedUrl === null &&
|
||||||
|
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
|
||||||
|
)
|
||||||
throw "Something went wrong while retrieving the file size.";
|
throw "Something went wrong while retrieving the file size.";
|
||||||
|
|
||||||
const contentType = validatedUrl?.get("content-type");
|
const contentType = validatedUrl?.get("content-type");
|
||||||
|
|||||||
@@ -119,15 +119,24 @@ export default async function postLink(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user?.preventDuplicateLinks) {
|
if (user?.preventDuplicateLinks) {
|
||||||
|
const url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL
|
||||||
|
const hasWwwPrefix = url?.includes(`://www.`);
|
||||||
|
const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url;
|
||||||
|
const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`);
|
||||||
|
|
||||||
|
console.log(url, urlWithoutWww, urlWithWww);
|
||||||
|
|
||||||
const existingLink = await prisma.link.findFirst({
|
const existingLink = await prisma.link.findFirst({
|
||||||
where: {
|
where: {
|
||||||
url: link.url?.trim(),
|
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
|
||||||
collection: {
|
collection: {
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
|
||||||
|
|
||||||
if (existingLink)
|
if (existingLink)
|
||||||
return {
|
return {
|
||||||
response: "Link already exists",
|
response: "Link already exists",
|
||||||
@@ -174,7 +183,7 @@ export default async function postLink(
|
|||||||
|
|
||||||
const newLink = await prisma.link.create({
|
const newLink = await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
url: link.url?.trim() || null,
|
url: link.url?.trim().replace(/\/+$/, "") || null,
|
||||||
name: link.name,
|
name: link.name,
|
||||||
description,
|
description,
|
||||||
type: linkType,
|
type: linkType,
|
||||||
|
|||||||
@@ -2,6 +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 { writeFileSync } from "fs";
|
||||||
|
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
@@ -36,7 +37,9 @@ export default async function importFromHTMLFile(
|
|||||||
|
|
||||||
const jsonData = parse(document.documentElement.outerHTML);
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
for (const item of jsonData) {
|
const processedArray = processNodes(jsonData);
|
||||||
|
|
||||||
|
for (const item of processedArray) {
|
||||||
console.log(item);
|
console.log(item);
|
||||||
await processBookmarks(userId, item as Element);
|
await processBookmarks(userId, item as Element);
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,9 @@ async function processBookmarks(
|
|||||||
} else if (item.type === "element" && item.tagName === "a") {
|
} else if (item.type === "element" && item.tagName === "a") {
|
||||||
// process link
|
// process link
|
||||||
|
|
||||||
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
|
const linkUrl = item?.attributes.find(
|
||||||
|
(e) => e.key.toLowerCase() === "href"
|
||||||
|
)?.value;
|
||||||
const linkName = (
|
const linkName = (
|
||||||
item?.children.find((e) => e.type === "text") as TextNode
|
item?.children.find((e) => e.type === "text") as TextNode
|
||||||
)?.content;
|
)?.content;
|
||||||
@@ -82,14 +87,33 @@ async function processBookmarks(
|
|||||||
.find((e) => e.key === "tags")
|
.find((e) => e.key === "tags")
|
||||||
?.value.split(",");
|
?.value.split(",");
|
||||||
|
|
||||||
|
// set date if available
|
||||||
|
const linkDateValue = item?.attributes.find(
|
||||||
|
(e) => e.key.toLowerCase() === "add_date"
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
const linkDate = linkDateValue
|
||||||
|
? new Date(Number(linkDateValue) * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let linkDesc =
|
||||||
|
(
|
||||||
|
(
|
||||||
|
item?.children?.find(
|
||||||
|
(e) => e.type === "element" && e.tagName === "dd"
|
||||||
|
) as Element
|
||||||
|
)?.children[0] as TextNode
|
||||||
|
)?.content || "";
|
||||||
|
|
||||||
if (linkUrl && parentCollectionId) {
|
if (linkUrl && parentCollectionId) {
|
||||||
await createLink(
|
await createLink(
|
||||||
userId,
|
userId,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
parentCollectionId,
|
parentCollectionId,
|
||||||
linkName,
|
linkName,
|
||||||
"",
|
linkDesc,
|
||||||
linkTags
|
linkTags,
|
||||||
|
linkDate
|
||||||
);
|
);
|
||||||
} else if (linkUrl) {
|
} else if (linkUrl) {
|
||||||
// create a collection named "Imported Bookmarks" and add the link to it
|
// create a collection named "Imported Bookmarks" and add the link to it
|
||||||
@@ -100,8 +124,9 @@ async function processBookmarks(
|
|||||||
linkUrl,
|
linkUrl,
|
||||||
collectionId,
|
collectionId,
|
||||||
linkName,
|
linkName,
|
||||||
"",
|
linkDesc,
|
||||||
linkTags
|
linkTags,
|
||||||
|
linkDate
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +185,8 @@ const createLink = async (
|
|||||||
collectionId: number,
|
collectionId: number,
|
||||||
name?: string,
|
name?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
tags?: string[]
|
tags?: string[],
|
||||||
|
importDate?: Date
|
||||||
) => {
|
) => {
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -193,6 +219,48 @@ const createLink = async (
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
importDate: importDate || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function processNodes(nodes: Node[]) {
|
||||||
|
const findAndProcessDL = (node: Node) => {
|
||||||
|
if (node.type === "element" && node.tagName === "dl") {
|
||||||
|
processDLChildren(node);
|
||||||
|
} else if (
|
||||||
|
node.type === "element" &&
|
||||||
|
node.children &&
|
||||||
|
node.children.length
|
||||||
|
) {
|
||||||
|
node.children.forEach((child) => findAndProcessDL(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processDLChildren = (dlNode: Element) => {
|
||||||
|
dlNode.children.forEach((child, i) => {
|
||||||
|
if (child.type === "element" && child.tagName === "dt") {
|
||||||
|
const nextSibling = dlNode.children[i + 1];
|
||||||
|
if (
|
||||||
|
nextSibling &&
|
||||||
|
nextSibling.type === "element" &&
|
||||||
|
nextSibling.tagName === "dd"
|
||||||
|
) {
|
||||||
|
const aElement = child.children.find(
|
||||||
|
(el) => el.type === "element" && el.tagName === "a"
|
||||||
|
);
|
||||||
|
if (aElement && aElement.type === "element") {
|
||||||
|
// Add the 'dd' element as a child of the 'a' element
|
||||||
|
aElement.children.push(nextSibling);
|
||||||
|
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
||||||
|
dlNode.children.splice(i + 1, 1);
|
||||||
|
// Adjust the loop counter due to the removal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.forEach(findAndProcessDL);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
|
||||||
|
export default async function getUsers() {
|
||||||
|
// Get all users
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
subscriptions: {
|
||||||
|
select: {
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response: users, status: 200 };
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
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 verifyUser from "../../verifyUser";
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
|
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
response: string | object;
|
response: string | object;
|
||||||
@@ -20,7 +22,15 @@ export default async function postUser(
|
|||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<Data>
|
res: NextApiResponse<Data>
|
||||||
) {
|
) {
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
|
let isServerAdmin = false;
|
||||||
|
|
||||||
|
const user = await verifyUser({ req, res });
|
||||||
|
if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" &&
|
||||||
|
!isServerAdmin
|
||||||
|
) {
|
||||||
return res.status(400).json({ response: "Registration is disabled." });
|
return res.status(400).json({ response: "Registration is disabled." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +67,16 @@ export default async function postUser(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const checkIfUserExists = await prisma.user.findFirst({
|
const checkIfUserExists = await prisma.user.findFirst({
|
||||||
where: emailEnabled
|
where: {
|
||||||
? {
|
OR: [
|
||||||
|
{
|
||||||
email: body.email?.toLowerCase().trim(),
|
email: body.email?.toLowerCase().trim(),
|
||||||
}
|
},
|
||||||
: {
|
{
|
||||||
username: (body.username as string).toLowerCase().trim(),
|
username: (body.username as string).toLowerCase().trim(),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!checkIfUserExists) {
|
if (!checkIfUserExists) {
|
||||||
@@ -71,21 +84,63 @@ export default async function postUser(
|
|||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
||||||
|
|
||||||
await prisma.user.create({
|
// Subscription dates
|
||||||
data: {
|
const currentPeriodStart = new Date();
|
||||||
name: body.name,
|
const currentPeriodEnd = new Date();
|
||||||
username: emailEnabled
|
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
||||||
? undefined
|
|
||||||
: (body.username as string).toLowerCase().trim(),
|
|
||||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
|
||||||
password: hashedPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json({ response: "User successfully created." });
|
if (isServerAdmin) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
username: (body.username as string).toLowerCase().trim(),
|
||||||
|
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||||
|
password: hashedPassword,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
subscriptions: stripeEnabled
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
stripeSubscriptionId:
|
||||||
|
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
||||||
|
active: true,
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
subscriptions: {
|
||||||
|
select: {
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ response: user });
|
||||||
|
} else {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
username: emailEnabled
|
||||||
|
? undefined
|
||||||
|
: (body.username as string).toLowerCase().trim(),
|
||||||
|
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ response: "User successfully created." });
|
||||||
|
}
|
||||||
} else if (checkIfUserExists) {
|
} else if (checkIfUserExists) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
|
response: `Email or Username already exists.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
|
|||||||
|
|
||||||
export default async function deleteUserById(
|
export default async function deleteUserById(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: DeleteUserBody
|
body: DeleteUserBody,
|
||||||
|
isServerAdmin?: boolean
|
||||||
) {
|
) {
|
||||||
// First, we retrieve the user from the database
|
// First, we retrieve the user from the database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -25,13 +26,13 @@ export default async function deleteUserById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||||
if (!keycloakEnabled && !authentikEnabled) {
|
if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) {
|
||||||
const isPasswordValid = bcrypt.compareSync(
|
const isPasswordValid = bcrypt.compareSync(
|
||||||
body.password,
|
body.password,
|
||||||
user.password as string
|
user.password as string
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid && !isServerAdmin) {
|
||||||
return {
|
return {
|
||||||
response: "Invalid credentials.",
|
response: "Invalid credentials.",
|
||||||
status: 401, // Unauthorized
|
status: 401, // Unauthorized
|
||||||
@@ -43,6 +44,11 @@ export default async function deleteUserById(
|
|||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
async (prisma) => {
|
async (prisma) => {
|
||||||
|
// Delete Access Tokens
|
||||||
|
await prisma.accessToken.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
// Delete whitelisted users
|
// Delete whitelisted users
|
||||||
await prisma.whitelistedUser.deleteMany({
|
await prisma.whitelistedUser.deleteMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -87,6 +93,7 @@ export default async function deleteUserById(
|
|||||||
await prisma.subscription.delete({
|
await prisma.subscription.delete({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
// .catch((err) => console.log(err));
|
||||||
|
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||||
|
|
||||||
export default async function validateUrlSize(url: string) {
|
export default async function validateUrlSize(url: string) {
|
||||||
|
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized:
|
rejectUnauthorized:
|
||||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let fetchOpts = {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
agent: httpsAgent,
|
agent: httpsAgent,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
let proxy = new URL(process.env.PROXY);
|
||||||
|
if (process.env.PROXY_USERNAME) {
|
||||||
|
proxy.username = process.env.PROXY_USERNAME;
|
||||||
|
proxy.password = process.env.PROXY_PASSWORD || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOpts = {
|
||||||
|
method: "HEAD",
|
||||||
|
agent: new SocksProxyAgent(proxy.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOpts);
|
||||||
|
|
||||||
const totalSizeMB =
|
const totalSizeMB =
|
||||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||||
|
|||||||
+17
-6
@@ -27,14 +27,25 @@ export default async function getTitle(url: string) {
|
|||||||
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOpts);
|
const responsePromise = fetch(url, fetchOpts);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error("Fetch title timeout"));
|
||||||
|
}, 10 * 1000); // Stop after 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||||
|
|
||||||
// regular expression to find the <title> tag
|
if ((response as any)?.status) {
|
||||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
const text = await (response as any).text();
|
||||||
if (match) return match[1];
|
|
||||||
else return "";
|
// regular expression to find the <title> tag
|
||||||
|
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||||
|
if (match) return match[1];
|
||||||
|
else return "";
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
const { version } = require("./package.json");
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
images: {
|
||||||
domains: ["t2.gstatic.com"],
|
domains: ["t2.gstatic.com"],
|
||||||
minimumCacheTTL: 10,
|
minimumCacheTTL: 10,
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
version,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkwarden",
|
"name": "linkwarden",
|
||||||
"version": "2.5.1",
|
"version": "v2.5.4",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||||
|
|||||||
+27
-2
@@ -5,7 +5,8 @@ import { SessionProvider } from "next-auth/react";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import AuthRedirect from "@/layouts/AuthRedirect";
|
import AuthRedirect from "@/layouts/AuthRedirect";
|
||||||
import { Toaster } from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { Toaster, ToastBar } from "react-hot-toast";
|
||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { isPWA } from "@/lib/client/utils";
|
import { isPWA } from "@/lib/client/utils";
|
||||||
|
|
||||||
@@ -61,7 +62,31 @@ export default function App({
|
|||||||
className:
|
className:
|
||||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{(t) => (
|
||||||
|
<ToastBar toast={t}>
|
||||||
|
{({ icon, message }) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-row"
|
||||||
|
data-testid="toast-message-container"
|
||||||
|
data-type={t.type}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span data-testid="toast-message">{message}</span>
|
||||||
|
{t.type !== "loading" && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs outline-none btn-circle btn-ghost"
|
||||||
|
data-testid="close-toast-button"
|
||||||
|
onClick={() => toast.dismiss(t.id)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ToastBar>
|
||||||
|
)}
|
||||||
|
</Toaster>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</AuthRedirect>
|
</AuthRedirect>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
|
||||||
|
import NewUserModal from "@/components/ModalContent/NewUserModal";
|
||||||
|
import useUserStore from "@/store/admin/users";
|
||||||
|
import { User as U } from "@prisma/client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Fragment, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface User extends U {
|
||||||
|
subscriptions: {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModal = {
|
||||||
|
isOpen: boolean;
|
||||||
|
userId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const { users, setUsers } = useUserStore();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||||
|
|
||||||
|
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [newUserModal, setNewUserModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-5">
|
||||||
|
<div className="flex sm:flex-row flex-col justify-between gap-2">
|
||||||
|
<div className="gap-2 inline-flex items-center">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-neutral btn btn-square btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
<i className="bi-chevron-left text-xl"></i>
|
||||||
|
</Link>
|
||||||
|
<p className="capitalize text-3xl font-thin inline">
|
||||||
|
User Administration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center relative justify-between gap-2">
|
||||||
|
<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={"Search for Users"}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
|
||||||
|
if (users) {
|
||||||
|
setFilteredUsers(
|
||||||
|
users.filter((user) =>
|
||||||
|
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
|
||||||
|
onClick={() => setNewUserModal(true)}
|
||||||
|
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
|
||||||
|
>
|
||||||
|
<i className="bi-plus text-3xl absolute"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
|
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||||
|
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal)
|
||||||
|
) : searchQuery !== "" ? (
|
||||||
|
<p>No users found with the given search query.</p>
|
||||||
|
) : users && users.length > 0 ? (
|
||||||
|
UserListing(users, deleteUserModal, setDeleteUserModal)
|
||||||
|
) : (
|
||||||
|
<p>No users found.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newUserModal ? (
|
||||||
|
<NewUserModal onClose={() => setNewUserModal(false)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserListing = (
|
||||||
|
users: User[],
|
||||||
|
deleteUserModal: UserModal,
|
||||||
|
setDeleteUserModal: Function
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto whitespace-nowrap w-full">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Username</th>
|
||||||
|
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||||
|
<th>Email</th>
|
||||||
|
)}
|
||||||
|
{process.env.NEXT_PUBLIC_STRIPE === "true" && <th>Subscribed</th>}
|
||||||
|
<th>Created At</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
|
||||||
|
>
|
||||||
|
<td className="text-primary">{index + 1}</td>
|
||||||
|
<td>{user.username ? user.username : <b>N/A</b>}</td>
|
||||||
|
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||||
|
<td>{user.email}</td>
|
||||||
|
)}
|
||||||
|
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||||
|
<td>
|
||||||
|
{user.subscriptions?.active ? (
|
||||||
|
JSON.stringify(user.subscriptions?.active)
|
||||||
|
) : (
|
||||||
|
<b>N/A</b>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td>{new Date(user.createdAt).toLocaleString()}</td>
|
||||||
|
<td className="relative">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteUserModal({ isOpen: true, userId: user.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{deleteUserModal.isOpen && deleteUserModal.userId ? (
|
||||||
|
<DeleteUserModal
|
||||||
|
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
||||||
|
userId={deleteUserModal.userId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,6 @@ import formidable from "formidable";
|
|||||||
import createFile from "@/lib/api/storage/createFile";
|
import createFile from "@/lib/api/storage/createFile";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import verifyToken from "@/lib/api/verifyToken";
|
import verifyToken from "@/lib/api/verifyToken";
|
||||||
import Jimp from "jimp";
|
|
||||||
import generatePreview from "@/lib/api/generatePreview";
|
import generatePreview from "@/lib/api/generatePreview";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
|
|
||||||
|
|||||||
@@ -98,19 +98,19 @@ if (
|
|||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: emailEnabled
|
where: emailEnabled
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
username: username.toLowerCase(),
|
username: username.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: username?.toLowerCase(),
|
email: username?.toLowerCase(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
emailVerified: { not: null },
|
emailVerified: { not: null },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
username: username.toLowerCase(),
|
username: username.toLowerCase(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let passwordMatches: boolean = false;
|
let passwordMatches: boolean = false;
|
||||||
@@ -240,6 +240,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authelia
|
||||||
|
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
||||||
|
providers.push(
|
||||||
|
{
|
||||||
|
id: "authelia",
|
||||||
|
name: "Authelia",
|
||||||
|
type: "oauth",
|
||||||
|
clientId: process.env.AUTHELIA_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
|
||||||
|
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
|
||||||
|
authorization: { params: { scope: "openid email profile" } },
|
||||||
|
idToken: true,
|
||||||
|
checks: ["pkce", "state"],
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
username: profile.preferred_username,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const _linkAccount = adapter.linkAccount;
|
||||||
|
adapter.linkAccount = (account) => {
|
||||||
|
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
|
||||||
|
return _linkAccount ? _linkAccount(data) : undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Authentik
|
// Authentik
|
||||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||||
providers.push(
|
providers.push(
|
||||||
|
|||||||
@@ -391,10 +391,17 @@ export function getLogins() {
|
|||||||
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Authelia
|
||||||
|
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
||||||
|
buttonAuths.push({
|
||||||
|
method: "authelia",
|
||||||
|
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
credentialsEnabled:
|
credentialsEnabled:
|
||||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
||||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
||||||
? "true"
|
? "true"
|
||||||
: "false",
|
: "false",
|
||||||
emailEnabled:
|
emailEnabled:
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = token?.id;
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: token?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (userId !== Number(req.query.id))
|
const isServerAdmin = process.env.ADMINISTRATOR === user?.username;
|
||||||
|
|
||||||
|
const userId = isServerAdmin ? Number(req.query.id) : token.id;
|
||||||
|
|
||||||
|
if (userId !== Number(req.query.id) && !isServerAdmin)
|
||||||
return res.status(401).json({ response: "Permission denied." });
|
return res.status(401).json({ response: "Permission denied." });
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
@@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const updated = await updateUserById(userId, req.body);
|
const updated = await updateUserById(userId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
} else if (req.method === "DELETE") {
|
} else if (req.method === "DELETE") {
|
||||||
const updated = await deleteUserById(userId, req.body);
|
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import postUser from "@/lib/api/controllers/users/postUser";
|
import postUser from "@/lib/api/controllers/users/postUser";
|
||||||
|
import getUsers from "@/lib/api/controllers/users/getUsers";
|
||||||
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
|
|
||||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const response = await postUser(req, res);
|
const response = await postUser(req, res);
|
||||||
return response;
|
return response;
|
||||||
|
} else if (req.method === "GET") {
|
||||||
|
const user = await verifyUser({ req, res });
|
||||||
|
if (!user || process.env.ADMINISTRATOR !== user.username)
|
||||||
|
return res.status(401).json({ response: "Unauthorized..." });
|
||||||
|
|
||||||
|
const response = await getUsers();
|
||||||
|
return res.status(response.status).json({ response: response.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
||||||
@@ -128,6 +128,7 @@ model Link {
|
|||||||
pdf String?
|
pdf String?
|
||||||
readable String?
|
readable String?
|
||||||
lastPreserved DateTime?
|
lastPreserved DateTime?
|
||||||
|
importDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
import { User as U } from "@prisma/client";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface User extends U {
|
||||||
|
subscriptions: {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseObject = {
|
||||||
|
ok: boolean;
|
||||||
|
data: object | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserStore = {
|
||||||
|
users: User[];
|
||||||
|
setUsers: () => void;
|
||||||
|
addUser: (body: Partial<U>) => Promise<ResponseObject>;
|
||||||
|
removeUser: (userId: number) => Promise<ResponseObject>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useUserStore = create<UserStore>((set) => ({
|
||||||
|
users: [],
|
||||||
|
setUsers: async () => {
|
||||||
|
const response = await fetch("/api/v1/users");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) set({ users: data.response });
|
||||||
|
else if (response.status === 401) window.location.href = "/dashboard";
|
||||||
|
},
|
||||||
|
addUser: async (body) => {
|
||||||
|
const response = await fetch("/api/v1/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok)
|
||||||
|
set((state) => ({
|
||||||
|
users: [...state.users, data.response],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ok: response.ok, data: data.response };
|
||||||
|
},
|
||||||
|
removeUser: async (userId) => {
|
||||||
|
const response = await fetch(`/api/v1/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok)
|
||||||
|
set((state) => ({
|
||||||
|
users: state.users.filter((user) => user.id !== userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ok: response.ok, data: data.response };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
Vendored
+9
@@ -13,6 +13,8 @@ declare global {
|
|||||||
MAX_LINKS_PER_USER?: string;
|
MAX_LINKS_PER_USER?: string;
|
||||||
ARCHIVE_TAKE_COUNT?: string;
|
ARCHIVE_TAKE_COUNT?: string;
|
||||||
IGNORE_UNAUTHORIZED_CA?: string;
|
IGNORE_UNAUTHORIZED_CA?: string;
|
||||||
|
IGNORE_URL_SIZE_LIMIT?: string;
|
||||||
|
ADMINISTRATOR?: string;
|
||||||
|
|
||||||
SPACES_KEY?: string;
|
SPACES_KEY?: string;
|
||||||
SPACES_SECRET?: string;
|
SPACES_SECRET?: string;
|
||||||
@@ -76,6 +78,13 @@ declare global {
|
|||||||
AUTH0_CLIENT_SECRET?: string;
|
AUTH0_CLIENT_SECRET?: string;
|
||||||
AUTH0_CLIENT_ID?: string;
|
AUTH0_CLIENT_ID?: string;
|
||||||
|
|
||||||
|
// Authelia
|
||||||
|
NEXT_PUBLIC_AUTHELIA_ENABLED?: string;
|
||||||
|
AUTHELIA_CUSTOM_NAME?: string;
|
||||||
|
AUTHELIA_CLIENT_ID?: string;
|
||||||
|
AUTHELIA_CLIENT_SECRET?: string;
|
||||||
|
AUTHELIA_WELLKNOWN_URL?: string;
|
||||||
|
|
||||||
// Authentik
|
// Authentik
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
||||||
AUTHENTIK_CUSTOM_NAME?: string;
|
AUTHENTIK_CUSTOM_NAME?: string;
|
||||||
|
|||||||
+7
-1
@@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
|||||||
export interface LinkIncludingShortenedCollectionAndTags
|
export interface LinkIncludingShortenedCollectionAndTags
|
||||||
extends Omit<
|
extends Omit<
|
||||||
Link,
|
Link,
|
||||||
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
| "id"
|
||||||
|
| "createdAt"
|
||||||
|
| "collectionId"
|
||||||
|
| "updatedAt"
|
||||||
|
| "lastPreserved"
|
||||||
|
| "importDate"
|
||||||
> {
|
> {
|
||||||
id?: number;
|
id?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
importDate?: string;
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
pinnedBy?: {
|
pinnedBy?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user