Improved login, signup and new post creating using js forms
continuous-integration/drone/push Build is failing Details

main
Guus van Meerveld 1 year ago
parent 6340d248fd
commit 66b617326d
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -14,6 +14,7 @@
},
"dependencies": {
"@prisma/client": "4.10.1",
"@tanstack/react-query": "^4.24.9",
"axios": "^1.1.2",
"bcrypt": "^5.1.0",
"configcat-node": "^8.0.0",

@ -0,0 +1,104 @@
import { useRouter } from "next/router";
import axios from "axios";
import { FC, FormEvent, useCallback, useState } from "react";
import { LoginCredentials } from "@models/login";
import { Response } from "@models/response";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import { parseUserInputError } from "@utils/errors";
const LoginForm: FC = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const login = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const parseUserInputResult = LoginCredentials.safeParse({
username,
password,
rememberMe
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/login", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[username, password, rememberMe, router]
);
return (
<form onSubmit={login}>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
onChange={(e) => setUsername(e.target.value)}
name="username"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-checkbox">
<input
checked={rememberMe}
onChange={() => setRememberMe((state) => !state)}
name="rememberMe"
type="checkbox"
/>
<i className="form-icon" /> Remember me
</label>
{error !== null && (
<div className="toast toast-error">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input className="btn btn-primary" type="submit" value="Login" />
</div>
</form>
);
};
export default LoginForm;

@ -0,0 +1,111 @@
import axios from "axios";
import { FC, FormEvent, useCallback, useState } from "react";
import { useRouter } from "next/router";
import { SignupCredentials } from "@models/signup";
import { Response } from "@models/response";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import { parseUserInputError } from "@utils/errors";
const SignupForm: FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const signup = useCallback(
async (e: FormEvent): Promise<void> => {
e.preventDefault();
const parseUserInputResult = SignupCredentials.safeParse({
email,
password,
name: username
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/signup", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[email, password, username, router]
);
return (
<form onSubmit={signup}>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
onChange={(e) => setEmail(e.target.value)}
name="email"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-label" htmlFor="password">
Name
</label>
<input
required
className="form-input"
placeholder="Full name"
onChange={(e) => setUsername(e.target.value)}
maxLength={32}
name="name"
type="text"
id="name"
/>
{error !== null && (
<div className="toast toast-error mt-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input className="btn btn-primary mt-2" type="submit" value="Signup" />
</div>
</form>
);
};
export default SignupForm;

@ -3,5 +3,5 @@ import z from "zod";
export const LoginCredentials = z.object({
username: z.string().email(),
password: z.string().min(8).max(128),
rememberMe: z.literal("on").optional()
rememberMe: z.boolean()
});

@ -2,7 +2,7 @@ import z from "zod";
export const Post = z.object({
title: z.string(),
content: z.string(),
content: z.string().min(100),
tags: z.string(),
published: z.literal("on").optional()
publish: z.boolean()
});

@ -0,0 +1,27 @@
import z from "zod";
interface Error {
ok: false;
error?: unknown;
}
export const ErrorModel: z.ZodType<Error> = z.object({
ok: z.literal(false),
error: z.unknown()
});
interface Ok {
ok: true;
data?: unknown;
}
export const OkModel: z.ZodType<Ok> = z.object({
ok: z.literal(true),
data: z.unknown()
});
export type Response = Ok | Error;
const ResponseModel: z.ZodType<Response> = ErrorModel.or(OkModel);
export default ResponseModel;

@ -1,3 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "@styles/globals.scss";
import SEO from "../next-seo.config";
@ -7,11 +9,15 @@ import { ThemeProvider } from "next-themes";
import type { AppProps } from "next/app";
const queryClient = new QueryClient();
const App = ({ Component, pageProps }: AppProps): JSX.Element => (
<>
<DefaultSeo {...SEO} />
<ThemeProvider>
<Component {...pageProps} />
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</ThemeProvider>
</>
);

@ -24,7 +24,7 @@ const AdminPage: NextPage<{
<div className="col col-8 col-mx-auto">
<h3>Welcome {user.name}</h3>
</div>
<div className="col col-8 col-mx-auto py-2">
<div className="col col-8 col-md-12 col-mx-auto py-2">
<h4>Users</h4>
<table className="table table-striped table-hover mb-2">
<thead>
@ -49,7 +49,7 @@ const AdminPage: NextPage<{
</tbody>
</table>
</div>
<div className="col col-8 col-mx-auto">
<div className="col col-8 col-md-12 col-mx-auto">
<h4>Posts</h4>
<table className="table table-striped table-hover">
<thead>

@ -2,11 +2,12 @@ import { NextApiHandler } from "next";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import { withIronSession } from "@utils/session";
import { Post } from "@models/post";
import prisma from "@utils/prisma";
const handle: NextApiHandler = async (req, res) => {
import { Response } from "@models/response";
import { Post } from "@models/post";
const handle: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() != "POST") {
res.status(405).json(methodNotAllowed);
return;
@ -26,23 +27,24 @@ const handle: NextApiHandler = async (req, res) => {
return;
}
const data = postData.data;
await prisma.user.update({
where: { id: user.id },
data: {
posts: {
create: {
...data,
published: data.published !== undefined,
tags: data.tags.trim().split(" "),
content: data.content.trim()
const { publish, ...data } = postData.data;
await prisma.user
.update({
where: { id: user.id },
data: {
posts: {
create: {
...data,
published: publish,
tags: data.tags.trim().split(" "),
content: data.content.trim()
}
}
}
}
});
res.redirect("/blog");
})
.then(() => res.json({ ok: true, data: "Successfully created new post" }))
.catch((error) => res.status(500).json({ ok: false, error }));
};
export default withIronSession(handle);

@ -2,13 +2,14 @@ import bcrypt from "bcrypt";
import { NextApiHandler } from "next";
import { LoginCredentials } from "@models/login";
import { Response } from "@models/response";
import { withIronSession } from "@utils/session";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import prisma from "@utils/prisma";
const handle: NextApiHandler = async (req, res) => {
if (req.method?.toUpperCase() != "POST") {
const handle: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() !== "POST") {
res.status(405).json(methodNotAllowed);
return;
}
@ -40,13 +41,14 @@ const handle: NextApiHandler = async (req, res) => {
if (!isCorrect) {
res.status(401).json(unauthorized);
return;
}
req.session.user = user;
await req.session.save();
res.redirect("/blog");
res.json({ ok: true, data: "Login successfull" });
};
export default withIronSession(handle);

@ -2,8 +2,9 @@ import { NextApiHandler } from "next";
import { withIronSession } from "@utils/session";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import { Response } from "@models/response";
const handle: NextApiHandler = (req, res) => {
const handle: NextApiHandler<Response> = (req, res) => {
if (req.method?.toUpperCase() != "GET") {
res.status(405).json(methodNotAllowed);
return;
@ -20,7 +21,7 @@ const handle: NextApiHandler = (req, res) => {
req.session.user = undefined;
res.redirect("/blog");
res.json({ ok: true, data: "Logout successfull" });
};
export default withIronSession(handle);

@ -0,0 +1,17 @@
import { Response } from "@models/response";
import { NextApiHandler } from "next";
import { registrationIsEnabled } from "@utils/config";
import { methodNotAllowed } from "@utils/errors";
const handler: NextApiHandler<Response> = (req, res) => {
if (req.method?.toUpperCase() !== "GET") {
res.status(405).json(methodNotAllowed);
return;
}
res.json({ ok: true, data: { registrationIsEnabled } });
};
export default handler;

@ -5,11 +5,13 @@ import { NextApiHandler } from "next";
import prisma from "@utils/prisma";
import { SignupCredentials } from "@models/signup";
import { Response } from "@models/response";
import { withIronSession } from "@utils/session";
import { methodNotAllowed } from "@utils/errors";
import { registrationIsEnabled, saltRoundsForPassword } from "@utils/config";
const handle: NextApiHandler = async (req, res) => {
const handle: NextApiHandler<Response> = async (req, res) => {
if (!registrationIsEnabled) {
res
.status(403)
@ -54,7 +56,7 @@ const handle: NextApiHandler = async (req, res) => {
await req.session.save();
res.redirect("/blog");
res.json({ ok: true, data: "Signup successfull" });
})
.catch((error) => res.status(500).json({ ok: false, error }));
};

@ -1,137 +0,0 @@
import { GetServerSideProps, NextPage } from "next";
import { NextSeo } from "next-seo";
import Layout from "@components/Layout";
import multipleClassNames from "@utils/multipleClassNames";
import styles from "./login.module.scss";
import { registrationIsEnabled } from "@utils/config";
const Login: NextPage<{ registrationEnabled: boolean }> = ({
registrationEnabled
}) => {
return (
<Layout>
<NextSeo title="Login" />
<div className={styles.body}>
<div className="columns">
<div
className={`col-md-4 ${
registrationEnabled ? "col-ml-auto" : "col-mx-auto"
}`}
>
<h2 className={styles.title}>Login to blog</h2>
<form action="/api/blog/login" method="post">
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
name="username"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-checkbox">
<input name="rememberMe" type="checkbox" />
<i className="form-icon" /> Remember me
</label>
<input
className={multipleClassNames(
"btn btn-primary",
styles.loginButton
)}
type="submit"
value="Login"
/>
</div>
</form>
</div>
{registrationEnabled && (
<>
<div
className={multipleClassNames("divider-vert", styles.divider)}
data-content="OR"
/>
<div className="col-md-4 col-mr-auto">
<h2 className={styles.title}>Create new account</h2>
<form action="/api/blog/signup" method="post">
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
name="email"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-label" htmlFor="password">
Name
</label>
<input
required
className="form-input"
placeholder="Full name"
maxLength={32}
name="name"
type="text"
id="name"
/>
<input
className={multipleClassNames(
"btn btn-primary",
styles.signupButton
)}
type="submit"
value="Signup"
/>
</div>
</form>
</div>
</>
)}
</div>
</div>
</Layout>
);
};
export const getServerSideProps: GetServerSideProps = async () => {
return { props: { registrationEnabled: registrationIsEnabled } };
};
export default Login;

@ -1,15 +1,64 @@
import axios from "axios";
import { FormEvent, useCallback, useState } from "react";
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
import { User } from "@prisma/client";
import Layout from "@components/Layout";
import { withSessionSsr } from "@utils/session";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import { parseUserInputError } from "@utils/errors";
import { Post } from "@models/post";
import { Response } from "@models/response";
import styles from "./new.module.scss";
const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const [publish, setPublish] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const createPost = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const parseUserInputResult = Post.safeParse({
title,
tags,
content,
publish
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/blog/new", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[title, tags, content, publish, router]
);
return (
<Layout>
<NextSeo title="New post" />
@ -19,12 +68,14 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
<div className="column col-8 col-md-12 col-mx-auto">
<h2>Create new post</h2>
<h5>Logged in as {user.name}</h5>
<form action="/api/blog/new" method="post">
<form onSubmit={createPost}>
<div className="form-group">
<label className="form-label" htmlFor="title">
Post title
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="form-input"
name="title"
@ -37,6 +88,8 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
Tags
</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
required
className="form-input"
name="tags"
@ -49,21 +102,39 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
Content
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ resize: "none" }}
placeholder="Some content"
required
className="form-input"
name="content"
id="content"
minLength={100}
cols={30}
rows={10}
/>
<label className="form-checkbox">
<input name="published" type="checkbox" />
<input
checked={publish}
onChange={() => setPublish((state) => !state)}
name="published"
type="checkbox"
/>
<i className="form-icon" /> Publish
</label>
{error !== null && (
<div className="toast toast-error my-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input
className={"btn btn-primary"}
type="submit"

@ -9,9 +9,13 @@ import Layout from "@components/Layout";
import FeaturedRepositories from "@components/FeaturedRepositories";
import BestRepository from "@components/BestRepository";
import { RepositoryResponse } from "@models/responses";
import { RepositoryResponse } from "@models/git/responses";
import { fetchAvailability, fetchRepositories, fetchUser } from "@utils/fetch";
import {
fetchAvailability,
fetchRepositories,
fetchUser
} from "@utils/git/fetch";
import { giteaUsername } from "@utils/config";
export const getStaticProps: GetStaticProps = async () => {

@ -0,0 +1,87 @@
import z from "zod";
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import Layout from "@components/Layout";
import multipleClassNames from "@utils/multipleClassNames";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import styles from "./login.module.scss";
import LoginForm from "@components/LoginForm";
import SignupForm from "@components/SignupForm";
const SettingsModel = z.object({ registrationIsEnabled: z.boolean() });
const Login: NextPage = () => {
const { data, isLoading, error } = useQuery<
z.infer<typeof SettingsModel>,
string
>(["settings"], async (): Promise<z.infer<typeof SettingsModel>> => {
const response = await axios
.get("/api/settings")
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (!response.ok) {
throw JSON.stringify(response.error);
}
const parseSettingsResult = SettingsModel.safeParse(response.data);
if (!parseSettingsResult.success) {
throw parseSettingsResult.error.message;
}
return parseSettingsResult.data;
});
const registrationEnabled =
data !== undefined && data.registrationIsEnabled && error === null;
return (
<Layout>
<NextSeo title="Login" />
<div className={styles.body}>
<div className="columns">
<div
className={`col-md-4 ${
registrationEnabled ? "col-ml-auto" : "col-mx-auto"
}`}
>
<h2 className={styles.title}>Login to blog</h2>
<LoginForm />
</div>
{registrationEnabled && (
<>
<div
className={multipleClassNames("divider-vert", styles.divider)}
data-content="OR"
/>
<div className="col-md-4 col-mr-auto">
<h2 className={styles.title}>Create new account</h2>
<SignupForm />
</div>
</>
)}
</div>
{isLoading && (
<div className="columns">
<div className="col col-mx-auto">
<div className="loading loading-lg" />
</div>
</div>
)}
</div>
</Layout>
);
};
export default Login;

@ -1,8 +1,17 @@
const baseError = <T>(error: T) => ({ error, ok: false });
import z from "zod";
export const methodNotAllowed = baseError("Method not allowed");
import { Response } from "@models/response";
export const unauthorized = {
ok: false,
error: "Could not login; incorrect email or password"
};
const baseError = (error: string): Response => ({
error,
ok: false
});
export const methodNotAllowed: Response = baseError("Method not allowed");
export const unauthorized: Response = baseError(
"Could not login; incorrect email or password"
);
export const parseUserInputError: (error: unknown) => string = (error) =>
"Failed to parse user input: ".concat(error);

@ -1,53 +1,23 @@
import axios from "axios";
import z from "zod";
import { AxiosError, AxiosResponse } from "axios";
import createConfigCatClient from "./createConfigCatClient";
import ResponseModel, { ErrorModel, Response } from "@models/response";
import {
RepositoryResponse,
SearchResultsResponse,
UserResponse
} from "@models/responses";
import { giteaServerUrl } from "./config";
export const parseAxiosResponse = (res: AxiosResponse<unknown>): Response => {
const parseResponseResult = ResponseModel.safeParse(res.data);
const apiUrl = `https://${giteaServerUrl}/api/v1`;
if (!parseResponseResult.success) {
return { ok: false, error: parseResponseResult.error };
}
export const fetchAvailability = async (): Promise<boolean> => {
const configCatClient = createConfigCatClient();
const isAvailable: boolean =
(await configCatClient?.getValueAsync("amiavailable", true)) ?? true;
return isAvailable;
};
export const fetchUser = async (
giteaUsername: string
): Promise<z.infer<typeof UserResponse>> => {
const { data: user } = await axios.get<unknown>(
`${apiUrl}/users/${giteaUsername}`
);
return UserResponse.parse(user);
return parseResponseResult.data;
};
export const fetchRepositories = async (
giteaUserUid: number
): Promise<z.infer<typeof RepositoryResponse>[]> => {
const { data: repositories } = await axios.get<unknown>(
`${apiUrl}/repos/search`,
{
params: {
topic: true,
q: "on-portfolio",
id: giteaUserUid,
limit: 6
}
}
);
export const parseAxiosError = (e: AxiosError<unknown>): Response => {
const parseErrorResult = ErrorModel.safeParse(e.response?.data);
const results = SearchResultsResponse.parse(repositories);
if (!parseErrorResult.success) {
return { ok: false, error: parseErrorResult.error };
}
if (results.ok) return results.data;
else throw results.data;
return parseErrorResult.data;
};

@ -0,0 +1,54 @@
import axios from "axios";
import z from "zod";
import createConfigCatClient from "@utils/createConfigCatClient";
import {
RepositoryResponse,
SearchResultsResponse,
UserResponse
} from "@models/git/responses";
import { giteaServerUrl } from "@utils/config";
const apiUrl = `https://${giteaServerUrl}/api/v1`;
export const fetchAvailability = async (): Promise<boolean> => {
const configCatClient = createConfigCatClient();
const isAvailable: boolean =
(await configCatClient?.getValueAsync("amiavailable", true)) ?? true;
return isAvailable;
};
export const fetchUser = async (
giteaUsername: string
): Promise<z.infer<typeof UserResponse>> => {
const { data: user } = await axios.get<unknown>(
`${apiUrl}/users/${giteaUsername}`
);
return UserResponse.parse(user);
};
export const fetchRepositories = async (
giteaUserUid: number
): Promise<z.infer<typeof RepositoryResponse>[]> => {
const { data: repositories } = await axios.get<unknown>(
`${apiUrl}/repos/search`,
{
params: {
topic: true,
q: "on-portfolio",
id: giteaUserUid,
limit: 6
}
}
);
const results = SearchResultsResponse.parse(repositories);
if (results.ok) return results.data;
else throw results.data;
};

@ -209,6 +209,19 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
"@tanstack/query-core@4.24.9":
version "4.24.9"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.24.9.tgz#52a5981d46f48e85630bcf5a645318e405493ce1"
integrity sha512-pZQ2NpdaHzx8gPPkAPh06d6zRkjfonUzILSYBXrdHDapP2eaBbGsx5L4/dMF+fyAglFzQZdDDzZgAykbM20QVw==
"@tanstack/react-query@^4.24.9":
version "4.24.9"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.24.9.tgz#8ec7e22dd3a858e174a90e81d20c85908f2ea653"
integrity sha512-6WLwUT9mrngIinRtcZjrWOUENOuLbWvQpKmU6DZCo2iPQVA+qvv3Ji90Amme4AkUyWQ8ZSSRTnAFq8V2tj2ACg==
dependencies:
"@tanstack/query-core" "4.24.9"
use-sync-external-store "^1.2.0"
"@types/accepts@*":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@ -2892,6 +2905,11 @@ use-subscription@1.5.1:
dependencies:
object-assign "^4.1.1"
use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"

Loading…
Cancel
Save