Better 2FA Interface (#1707)
* - added TwoFactor component - added react-digit-input package - added SAMLLogin component - upgraded auth/logic to react-hook-form - fixed EmailField to match other ___Field components to include Label - cleaned up login logic * upgraded error component Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
ae5d5e1261
commit
d0a6d6a6e6
7 changed files with 258 additions and 199 deletions
64
components/auth/SAMLLogin.tsx
Normal file
64
components/auth/SAMLLogin.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { signIn } from "next-auth/react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
samlTenantID: string;
|
||||
samlProductID: string;
|
||||
hostedCal: boolean;
|
||||
setErrorMessage: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export default function SAMLLogin(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const mutation = trpc.useMutation("viewer.samlTenantProduct", {
|
||||
onSuccess: async (data) => {
|
||||
await signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||
},
|
||||
onError: (err) => {
|
||||
props.setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={"saml"}
|
||||
className="flex justify-center w-full"
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
|
||||
);
|
||||
|
||||
if (!props.hostedCal) {
|
||||
await signIn("saml", {}, { tenant: props.samlTenantID, product: props.samlProductID });
|
||||
} else {
|
||||
if (props.email.length === 0) {
|
||||
props.setErrorMessage(t("saml_email_required"));
|
||||
return;
|
||||
}
|
||||
// hosted solution, fetch tenant and product from the backend
|
||||
mutation.mutate({
|
||||
email: methods.getValues("email"),
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("signin_with_saml")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
42
components/auth/TwoFactor.tsx
Normal file
42
components/auth/TwoFactor.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useDigitInput from "react-digit-input";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { Input } from "@components/form/fields";
|
||||
|
||||
export default function TwoFactor() {
|
||||
const [value, onChange] = useState("");
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
|
||||
const digits = useDigitInput({
|
||||
acceptedCharacters: /^[0-9]$/,
|
||||
length: 6,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value) methods.setValue("totpCode", value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const className = "h-12 w-12 !text-xl text-center";
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto !mt-0">
|
||||
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||
<div className="flex flex-row space-x-1">
|
||||
<Input className={className} name="2fa1" inputMode="decimal" {...digits[0]} autoFocus />
|
||||
<Input className={className} name="2fa2" inputMode="decimal" {...digits[1]} />
|
||||
<Input className={className} name="2fa3" inputMode="decimal" {...digits[2]} />
|
||||
<Input className={className} name="2fa4" inputMode="decimal" {...digits[3]} />
|
||||
<Input className={className} name="2fa5" inputMode="decimal" {...digits[4]} />
|
||||
<Input className={className} name="2fa6" inputMode="decimal" {...digits[5]} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -115,7 +115,17 @@ export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function
|
|||
});
|
||||
|
||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||
return <EmailInput ref={ref} {...props} />;
|
||||
return (
|
||||
<InputField
|
||||
ref={ref}
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
inputMode="email"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string };
|
||||
|
|
|
@ -86,6 +86,7 @@
|
|||
"qrcode": "^1.5.0",
|
||||
"react": "^17.0.2",
|
||||
"react-date-picker": "^8.3.6",
|
||||
"react-digit-input": "^2.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-easy-crop": "^3.5.2",
|
||||
"react-hook-form": "^7.20.4",
|
||||
|
|
|
@ -5,7 +5,8 @@ import { useRouter } from "next/router";
|
|||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { HeadSeo } from "@components/seo/head-seo";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
|
@ -15,40 +16,26 @@ export default function Error() {
|
|||
const { error } = router.query;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<HeadSeo title={t("error")} description={t("error")} />
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">{t("error_during_login")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<Link href="/auth/login">
|
||||
<a className="inline-flex justify-center w-full rounded-sm border border-transparent shadow-sm px-4 py-2 bg-neutral-900 text-base font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500 sm:text-sm">
|
||||
{t("go_back_login")}
|
||||
</a>
|
||||
</Link>
|
||||
<AuthContainer title="" description="">
|
||||
<div>
|
||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<XIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
{error}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">{t("error_during_login")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<Link href="/auth/login">
|
||||
<Button className="w-full flex justify-center">{t("go_back_login")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,25 +1,37 @@
|
|||
import { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { getCsrfToken, signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode, getSession } from "@lib/auth";
|
||||
import { WEBSITE_URL } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID } from "@lib/saml";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { trpc } from "@lib/trpc";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import { EmailField, PasswordField, TextField } from "@components/form/fields";
|
||||
import SAMLLogin from "@components/auth/SAMLLogin";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import { EmailField, PasswordField, Form } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants";
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
export default function Login({
|
||||
csrfToken,
|
||||
isGoogleLoginEnabled,
|
||||
|
@ -30,14 +42,13 @@ export default function Login({
|
|||
}: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [secondFactorRequired, setSecondFactorRequired] = useState(false);
|
||||
const form = useForm<LoginValues>();
|
||||
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const errorMessages: { [key: string]: string } = {
|
||||
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
// [ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
|
||||
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
|
||||
[ErrorCode.UserNotFound]: t("no_account_exists"),
|
||||
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
|
||||
|
@ -49,186 +60,125 @@ export default function Login({
|
|||
|
||||
const callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "/";
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
const LoginFooter = (
|
||||
<span>
|
||||
{t("dont_have_an_account")}{" "}
|
||||
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
|
||||
{t("create_an_account")}
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await signIn<"credentials">("credentials", {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
totpCode: code,
|
||||
callbackUrl,
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error("Received empty response from next auth");
|
||||
}
|
||||
|
||||
if (!response.error) {
|
||||
// we're logged in! let's do a hard refresh to the desired url
|
||||
window.location.replace(callbackUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.error === ErrorCode.SecondFactorRequired) {
|
||||
setSecondFactorRequired(true);
|
||||
setErrorMessage(errorMessages[ErrorCode.SecondFactorRequired]);
|
||||
} else {
|
||||
setErrorMessage(errorMessages[response.error] || t("something_went_wrong"));
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("something_went_wrong"));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = trpc.useMutation("viewer.samlTenantProduct", {
|
||||
onSuccess: (data) => {
|
||||
signIn("saml", {}, { tenant: data.tenant, product: data.product });
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
const TwoFactorFooter = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTwoFactorRequired(false);
|
||||
form.setValue("totpCode", "");
|
||||
}}
|
||||
StartIcon={ArrowLeftIcon}
|
||||
color="minimal">
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthContainer
|
||||
title={t("login")}
|
||||
description={t("login")}
|
||||
loading={isSubmitting}
|
||||
loading={form.formState.isSubmitting}
|
||||
showLogo
|
||||
heading={t("sign_in_account")}
|
||||
footerText={
|
||||
<>
|
||||
{t("dont_have_an_account")} {/* replace this with your account creation flow */}
|
||||
<a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900">
|
||||
{t("create_an_account")}
|
||||
</a>
|
||||
</>
|
||||
}>
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken || undefined} hidden />
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-neutral-700">
|
||||
{t("email_address")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<EmailField
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="john.doe@example.com"
|
||||
heading={twoFactorRequired ? t("2fa_code") : t("sign_in_account")}
|
||||
footerText={twoFactorRequired ? TwoFactorFooter : LoginFooter}>
|
||||
<Form
|
||||
form={form}
|
||||
className="space-y-6"
|
||||
handleSubmit={(values) => {
|
||||
signIn<"credentials">("credentials", { ...values, callbackUrl, redirect: false })
|
||||
.then((res) => {
|
||||
if (!res) setErrorMessage(errorMessages[ErrorCode.InternalServerError]);
|
||||
// we're logged in! let's do a hard refresh to the desired url
|
||||
else if (!res.error) window.location.replace(callbackUrl);
|
||||
// reveal two factor input if required
|
||||
else if (res.error === ErrorCode.SecondFactorRequired) setTwoFactorRequired(true);
|
||||
// fallback if error not found
|
||||
else setErrorMessage(errorMessages[res.error] || t("something_went_wrong"));
|
||||
})
|
||||
.catch(() => setErrorMessage(errorMessages[ErrorCode.InternalServerError]));
|
||||
}}>
|
||||
<input defaultValue={csrfToken || undefined} type="hidden" hidden {...form.register("csrfToken")} />
|
||||
|
||||
<div className={classNames("space-y-6", { hidden: twoFactorRequired })}>
|
||||
<EmailField
|
||||
id="email"
|
||||
label={t("email_address")}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
{...form.register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[2px]">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
|
||||
{t("forgot")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={email}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
{...form.register("password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[2px]">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-primary-600">
|
||||
{t("forgot")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordField
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secondFactorRequired && (
|
||||
<TextField
|
||||
className="mt-1"
|
||||
id="totpCode"
|
||||
name={t("2fa_code")}
|
||||
type="text"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
{twoFactorRequired && <TwoFactor />}
|
||||
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="flex space-y-2">
|
||||
<Button className="flex justify-center w-full" type="submit" disabled={isSubmitting}>
|
||||
{t("sign_in")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
</form>
|
||||
{isGoogleLoginEnabled && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex justify-center w-full"
|
||||
data-testid={"google"}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
|
||||
);
|
||||
|
||||
await signIn("google");
|
||||
}}>
|
||||
{" "}
|
||||
{t("signin_with_google")}
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}>
|
||||
{twoFactorRequired ? t("submit") : t("sign_in")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={"saml"}
|
||||
className="flex justify-center w-full"
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
</Form>
|
||||
|
||||
// track SAML logins. Without personal data/payload
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.samlLogin, collectPageParameters())
|
||||
);
|
||||
|
||||
if (!hostedCal) {
|
||||
await signIn("saml", {}, { tenant: samlTenantID, product: samlProductID });
|
||||
} else {
|
||||
if (email.length === 0) {
|
||||
setErrorMessage(t("saml_email_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// hosted solution, fetch tenant and product from the backend
|
||||
mutation.mutate({
|
||||
email,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("signin_with_saml")}
|
||||
</Button>
|
||||
</div>
|
||||
{!twoFactorRequired && (
|
||||
<>
|
||||
{isGoogleLoginEnabled && (
|
||||
<div className="mt-5">
|
||||
<Button
|
||||
color="secondary"
|
||||
className="flex justify-center w-full"
|
||||
data-testid={"google"}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.googleLogin, collectPageParameters())
|
||||
);
|
||||
await signIn("google");
|
||||
}}>
|
||||
{t("signin_with_google")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isSAMLLoginEnabled && (
|
||||
<SAMLLogin
|
||||
email={form.getValues("email")}
|
||||
samlTenantID={samlTenantID}
|
||||
samlProductID={samlProductID}
|
||||
hostedCal={hostedCal}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AuthContainer>
|
||||
|
||||
<AddToHomescreen />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -9635,6 +9635,11 @@ react-date-picker@^8.3.6:
|
|||
react-fit "^1.0.3"
|
||||
update-input-width "^1.2.2"
|
||||
|
||||
react-digit-input@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-digit-input/-/react-digit-input-2.1.0.tgz#8b0be6d3ea247fd361855483f21d0aafba341196"
|
||||
integrity sha512-pGv0CtSmu3Mf4cD79LoYtJI7Wq4dpPiLiY1wvKsNaR+X2sJyk1ETiIxjq6G8i+XJqNXExM6vuytzDqblkkSaFw==
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
|
|
Loading…
Reference in a new issue