rework cookies
This commit is contained in:
parent
9b9c3c1d87
commit
95d1ef31ef
17 changed files with 189 additions and 58 deletions
|
@ -9,6 +9,7 @@ import useSignedIn from "@lib/hooks/use-signed-in"
|
|||
import Input from "@components/input"
|
||||
import Button from "@components/button"
|
||||
import Note from "@components/note"
|
||||
import { USER_COOKIE_NAME } from "@lib/constants"
|
||||
|
||||
const NO_EMPTY_SPACE_REGEX = /^\S*$/
|
||||
const ERROR_MESSAGE =
|
||||
|
@ -32,7 +33,7 @@ const Auth = ({
|
|||
|
||||
const handleJson = (json: any) => {
|
||||
signin(json.token)
|
||||
Cookies.set("drift-userid", json.userId)
|
||||
Cookies.set(USER_COOKIE_NAME, json.userId)
|
||||
|
||||
router.push("/new")
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import DatePicker from "react-datepicker"
|
|||
import getTitleForPostCopy from "@lib/get-title-for-post-copy"
|
||||
import Description from "./description"
|
||||
import { PostWithFiles } from "app/prisma"
|
||||
import { USER_COOKIE_NAME } from "@lib/constants"
|
||||
|
||||
const emptyDoc = {
|
||||
title: "",
|
||||
|
@ -144,7 +145,7 @@ const Post = ({
|
|||
files: docs,
|
||||
visibility,
|
||||
password,
|
||||
userId: Cookies.get("drift-userid") || "",
|
||||
userId: Cookies.get(USER_COOKIE_NAME) || "",
|
||||
expiresAt: expiresAt || null,
|
||||
parentId: newPostParent
|
||||
})
|
||||
|
|
|
@ -18,6 +18,7 @@ import CreatedAgoBadge from "@components/badges/created-ago-badge"
|
|||
import Cookies from "js-cookie"
|
||||
import PasswordModalPage from "./password-modal-wrapper"
|
||||
import VisibilityControl from "@components/badges/visibility-control"
|
||||
import { USER_COOKIE_NAME } from "@lib/constants"
|
||||
|
||||
type Props = {
|
||||
post: Post
|
||||
|
@ -32,7 +33,7 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => {
|
|||
)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isOwner] = useState(
|
||||
post.users ? post.users[0].id === Cookies.get("drift-userid") : false
|
||||
post.users ? post.users[0].id === Cookies.get(USER_COOKIE_NAME) : false
|
||||
)
|
||||
const router = useRouter()
|
||||
const isMobile = useMediaQuery("mobile")
|
||||
|
|
|
@ -3,12 +3,12 @@ import { User } from "@prisma/client"
|
|||
import prisma from "app/prisma"
|
||||
import { sign } from "jsonwebtoken"
|
||||
|
||||
export async function generateAccessToken(user: User) {
|
||||
const token = sign({ id: user.id }, config.jwt_secret, { expiresIn: "2d" })
|
||||
export async function generateAndExpireAccessToken(userId: User["id"]) {
|
||||
const token = sign({ id: userId }, config.jwt_secret, { expiresIn: "2d" })
|
||||
|
||||
await prisma.authTokens.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: userId,
|
||||
token: token
|
||||
}
|
||||
})
|
||||
|
@ -16,7 +16,7 @@ export async function generateAccessToken(user: User) {
|
|||
// TODO: set expiredReason?
|
||||
prisma.authTokens.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId: userId,
|
||||
token: {
|
||||
not: token
|
||||
}
|
||||
|
|
25
client/lib/api/signin.ts
Normal file
25
client/lib/api/signin.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { USER_COOKIE_NAME, TOKEN_COOKIE_NAME } from "@lib/constants"
|
||||
import { User } from "app/prisma"
|
||||
import { setCookie } from "cookies-next"
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import { generateAndExpireAccessToken } from "./generate-access-token"
|
||||
|
||||
export const signin = async (
|
||||
userId: User["id"],
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) => {
|
||||
const token = await generateAndExpireAccessToken(userId)
|
||||
setCookie(USER_COOKIE_NAME, userId, {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days,
|
||||
req,
|
||||
res
|
||||
})
|
||||
setCookie(TOKEN_COOKIE_NAME, token, {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
req,
|
||||
res
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
|
@ -121,3 +121,6 @@ export const allowedFileExtensions = [
|
|||
"log",
|
||||
...codeFileExtensions
|
||||
]
|
||||
|
||||
export const TOKEN_COOKIE_NAME = "drift-token"
|
||||
export const USER_COOKIE_NAME = "drift-userid"
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import Cookies from "js-cookie"
|
||||
import { getCookie, setCookie } from "cookies-next"
|
||||
import { useEffect } from "react"
|
||||
import useSharedState from "./use-shared-state"
|
||||
|
||||
const useSignedIn = () => {
|
||||
const token = getCookie("drift-token")
|
||||
|
||||
const [signedIn, setSignedIn] = useSharedState(
|
||||
"signedIn",
|
||||
typeof window === "undefined" ? false : !!Cookies.get("drift-token")
|
||||
typeof window === "undefined" ? false : !!token
|
||||
)
|
||||
const token = Cookies.get("drift-token")
|
||||
const signin = (token: string) => {
|
||||
setSignedIn(true)
|
||||
// TODO: investigate SameSite / CORS cookie security
|
||||
Cookies.set("drift-token", token)
|
||||
setCookie("drift-token", token)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
60
client/lib/providers/auth/AuthClientContextProvider.tsx
Normal file
60
client/lib/providers/auth/AuthClientContextProvider.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import type {
|
||||
ChangeEventHandler,
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import React, { useContext, useState, createContext } from "react";
|
||||
import { DEFAULT_THEME, Theme, THEME_COOKIE_NAME } from "./theme";
|
||||
|
||||
const ThemeContext = createContext<Theme | null>(null);
|
||||
|
||||
export function useTheme(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
interface Props extends PropsWithChildren {
|
||||
defaultTheme: Theme;
|
||||
}
|
||||
|
||||
const ThemeClientContextProvider: FunctionComponent<Props> = ({
|
||||
defaultTheme,
|
||||
children,
|
||||
}) => {
|
||||
const [theme, setTheme] = useState<Theme>(defaultTheme);
|
||||
const onChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
|
||||
const value = e.target.value as Theme;
|
||||
setTheme(value);
|
||||
|
||||
if (value === DEFAULT_THEME) {
|
||||
Cookies.remove(THEME_COOKIE_NAME);
|
||||
} else {
|
||||
Cookies.set(THEME_COOKIE_NAME, value);
|
||||
}
|
||||
};
|
||||
const onReset = () => {
|
||||
setTheme(DEFAULT_THEME);
|
||||
Cookies.remove(THEME_COOKIE_NAME);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(theme === "dark" && "dark")}>
|
||||
<div className="mb-2">
|
||||
<h2 className="mb-2 font-bold text-xl">Theme Switcher</h2>
|
||||
<select value={theme} onChange={onChange} className="mr-2 inline-block">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<button className="bg-gray-300 p-2" onClick={onReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeClientContextProvider;
|
27
client/lib/providers/auth/AuthProvider.tsx
Normal file
27
client/lib/providers/auth/AuthProvider.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { FunctionComponent, PropsWithChildren } from "react";
|
||||
import ThemeClientContextProvider from "./ThemeClientContextProvider";
|
||||
import ThemeServerContextProvider, {
|
||||
useServerTheme,
|
||||
} from "./ThemeServerContextProvider";
|
||||
|
||||
const ThemeProviderWrapper: FunctionComponent<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const theme = useServerTheme();
|
||||
|
||||
return (
|
||||
<ThemeClientContextProvider defaultTheme={theme}>
|
||||
{children}
|
||||
</ThemeClientContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeProvider: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<ThemeServerContextProvider>
|
||||
<ThemeProviderWrapper>{children}</ThemeProviderWrapper>
|
||||
</ThemeServerContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
25
client/lib/providers/auth/AuthServerContextProvider.tsx
Normal file
25
client/lib/providers/auth/AuthServerContextProvider.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { FunctionComponent, PropsWithChildren } from "react";
|
||||
// @ts-ignore -- createServerContext is not in @types/react atm
|
||||
import { useContext, createServerContext } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { Theme, THEME_COOKIE_NAME } from "./theme";
|
||||
import { DEFAULT_THEME } from "./theme";
|
||||
|
||||
const ThemeContext = createServerContext<Theme | null>(null);
|
||||
|
||||
export function useServerTheme(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
const ThemeServerContextProvider: FunctionComponent<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const cookiesList = cookies();
|
||||
const theme = cookiesList.get(THEME_COOKIE_NAME) ?? DEFAULT_THEME;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeServerContextProvider;
|
|
@ -1,11 +1,12 @@
|
|||
import { NextFetchEvent, NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { TOKEN_COOKIE_NAME, USER_COOKIE_NAME } from "@lib/constants"
|
||||
|
||||
const PUBLIC_FILE = /\.(.*)$/
|
||||
|
||||
export function middleware(req: NextRequest, event: NextFetchEvent) {
|
||||
const pathname = req.nextUrl.pathname
|
||||
const signedIn = req.cookies.get("drift-token")
|
||||
const signedIn = req.cookies.get(TOKEN_COOKIE_NAME)
|
||||
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
|
||||
const isPageRequest =
|
||||
!PUBLIC_FILE.test(pathname) &&
|
||||
|
@ -17,8 +18,8 @@ export function middleware(req: NextRequest, event: NextFetchEvent) {
|
|||
// If you're not signed in we redirect to the home page
|
||||
if (signedIn) {
|
||||
const resp = NextResponse.redirect(getURL(""))
|
||||
resp.cookies.delete("drift-token")
|
||||
resp.cookies.delete("drift-userid")
|
||||
resp.cookies.delete(TOKEN_COOKIE_NAME)
|
||||
resp.cookies.delete(USER_COOKIE_NAME)
|
||||
const signoutPromise = new Promise((resolve) => {
|
||||
fetch(`${process.env.API_URL}/auth/signout`, {
|
||||
method: "POST",
|
||||
|
|
|
@ -15,14 +15,11 @@
|
|||
"@geist-ui/core": "^2.3.8",
|
||||
"@geist-ui/icons": "1.0.2",
|
||||
"@prisma/client": "^4.6.0",
|
||||
"@types/cookie": "0.5.1",
|
||||
"@types/js-cookie": "3.0.2",
|
||||
"bcrypt": "^5.1.0",
|
||||
"client-zip": "2.2.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cookie": "0.5.0",
|
||||
"cookies-next": "^2.1.1",
|
||||
"dotenv": "16.0.0",
|
||||
"js-cookie": "3.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"marked": "^4.2.2",
|
||||
"next": "13.0.3-canary.2",
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import config from "@lib/config"
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import prisma from "app/prisma"
|
||||
import bcrypt from "bcrypt"
|
||||
import { generateAccessToken } from "@lib/api/generate-access-token"
|
||||
import Cookies from "js-cookie"
|
||||
import { signin } from "@lib/api/signin"
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
|
@ -29,8 +27,7 @@ export default async function handler(
|
|||
return res.status(401).json({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const token = await generateAccessToken(user)
|
||||
Cookies.set("drift-user", user.id, { path: "/" })
|
||||
Cookies.set("drift-token", token, { path: "/" })
|
||||
const token = await signin(user.id, req, res);
|
||||
|
||||
return res.status(201).json({ token: token, userId: user.id })
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import config from "@lib/config"
|
|||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import prisma from "app/prisma"
|
||||
import bcrypt, { genSalt } from "bcrypt"
|
||||
import { generateAccessToken } from "@lib/api/generate-access-token"
|
||||
import { generateAndExpireAccessToken } from "@lib/api/generate-access-token"
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
|
@ -35,7 +35,7 @@ export default async function handler(
|
|||
},
|
||||
})
|
||||
|
||||
const token = await generateAccessToken(user)
|
||||
const token = await generateAndExpireAccessToken(user)
|
||||
|
||||
return res.status(201).json({ token: token, userId: user.id })
|
||||
}
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
import { withJwt } from "@lib/api/jwt"
|
||||
import config from "@lib/config"
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
const handleSelf = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) => {
|
||||
|
||||
}
|
||||
const handleSelf = async (req: NextApiRequest, res: NextApiResponse) => {}
|
||||
|
||||
const PATH_TO_HANDLER = {
|
||||
"self": handleRequiresPasscode
|
||||
self: handleRequiresPasscode
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export default withJwt((req: NextApiRequest, res: NextApiResponse) => {
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { slug } = req.query
|
||||
|
||||
if (!slug || Array.isArray(slug)) {
|
||||
|
@ -29,4 +23,4 @@ export default withJwt((req: NextApiRequest, res: NextApiResponse) => {
|
|||
default:
|
||||
return res.status(405).json({ error: "Method not allowed" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import type { GetServerSideProps } from "next"
|
|||
|
||||
import type { Post } from "@lib/types"
|
||||
import PostPage from "@components/post-page"
|
||||
import { USER_COOKIE_NAME } from "@lib/constants"
|
||||
|
||||
export type PostProps = {
|
||||
post: Post
|
||||
|
@ -47,7 +48,7 @@ export const getServerSideProps: GetServerSideProps = async ({
|
|||
|
||||
const json = (await post.json()) as Post
|
||||
const isAuthor = json.users?.find(
|
||||
(user) => user.id === req.cookies["drift-userid"]
|
||||
(user) => user.id === req.cookies[USER_COOKIE_NAME]
|
||||
)
|
||||
|
||||
if (json.visibility === "public" || json.visibility === "unlisted") {
|
||||
|
|
|
@ -6,8 +6,6 @@ specifiers:
|
|||
'@next/bundle-analyzer': 12.1.6
|
||||
'@prisma/client': ^4.6.0
|
||||
'@types/bcrypt': ^5.0.0
|
||||
'@types/cookie': 0.5.1
|
||||
'@types/js-cookie': 3.0.2
|
||||
'@types/jsonwebtoken': ^8.5.9
|
||||
'@types/marked': ^4.0.7
|
||||
'@types/node': 17.0.23
|
||||
|
@ -17,12 +15,11 @@ specifiers:
|
|||
bcrypt: ^5.1.0
|
||||
client-zip: 2.2.1
|
||||
clsx: ^1.2.1
|
||||
cookie: 0.5.0
|
||||
cookies-next: ^2.1.1
|
||||
cross-env: 7.0.3
|
||||
dotenv: 16.0.0
|
||||
eslint: 8.27.0
|
||||
eslint-config-next: 13.0.2
|
||||
js-cookie: 3.0.1
|
||||
jsonwebtoken: ^8.5.1
|
||||
marked: ^4.2.2
|
||||
next: 13.0.3-canary.2
|
||||
|
@ -49,14 +46,11 @@ dependencies:
|
|||
'@geist-ui/core': 2.3.8_biqbaboplfbrettd7655fr4n2y
|
||||
'@geist-ui/icons': 1.0.2_zhza2kbnl2wkkf7vqdl3ton2f4
|
||||
'@prisma/client': 4.6.0_prisma@4.6.0
|
||||
'@types/cookie': 0.5.1
|
||||
'@types/js-cookie': 3.0.2
|
||||
bcrypt: 5.1.0
|
||||
client-zip: 2.2.1
|
||||
clsx: 1.2.1
|
||||
cookie: 0.5.0
|
||||
cookies-next: 2.1.1
|
||||
dotenv: 16.0.0
|
||||
js-cookie: 3.0.1
|
||||
jsonwebtoken: 8.5.1
|
||||
marked: 4.2.2
|
||||
next: 13.0.3-canary.2_biqbaboplfbrettd7655fr4n2y
|
||||
|
@ -415,12 +409,8 @@ packages:
|
|||
'@types/node': 17.0.23
|
||||
dev: true
|
||||
|
||||
/@types/cookie/0.5.1:
|
||||
resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==}
|
||||
dev: false
|
||||
|
||||
/@types/js-cookie/3.0.2:
|
||||
resolution: {integrity: sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==}
|
||||
/@types/cookie/0.4.1:
|
||||
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
|
||||
dev: false
|
||||
|
||||
/@types/json5/0.0.29:
|
||||
|
@ -437,6 +427,10 @@ packages:
|
|||
resolution: {integrity: sha512-eEAhnz21CwvKVW+YvRvcTuFKNU9CV1qH+opcgVK3pIMI6YZzDm6gc8o2vHjldFk6MGKt5pueSB7IOpvpx5Qekw==}
|
||||
dev: true
|
||||
|
||||
/@types/node/16.18.3:
|
||||
resolution: {integrity: sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg==}
|
||||
dev: false
|
||||
|
||||
/@types/node/17.0.23:
|
||||
resolution: {integrity: sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==}
|
||||
dev: true
|
||||
|
@ -961,11 +955,19 @@ packages:
|
|||
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
|
||||
dev: false
|
||||
|
||||
/cookie/0.5.0:
|
||||
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
|
||||
/cookie/0.4.2:
|
||||
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/cookies-next/2.1.1:
|
||||
resolution: {integrity: sha512-AZGZPdL1hU3jCjN2UMJTGhLOYzNUN9Gm+v8BdptYIHUdwz397Et1p+sZRfvAl8pKnnmMdX2Pk9xDRKCGBum6GA==}
|
||||
dependencies:
|
||||
'@types/cookie': 0.4.1
|
||||
'@types/node': 16.18.3
|
||||
cookie: 0.4.2
|
||||
dev: false
|
||||
|
||||
/copy-anything/2.0.6:
|
||||
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
||||
dependencies:
|
||||
|
@ -2256,11 +2258,6 @@ packages:
|
|||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
dev: true
|
||||
|
||||
/js-cookie/3.0.1:
|
||||
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/js-sdsl/4.1.5:
|
||||
resolution: {integrity: sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==}
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue