feat: Add OTP sign-in for PWA (#9556)

* wip

* wip

* wip

* Only use code for desktop and PWA
This commit is contained in:
Tom Moor
2025-07-07 18:36:43 -04:00
committed by GitHub
parent ad13e28ce9
commit a6b0fcff48
14 changed files with 418 additions and 60 deletions
+59
View File
@@ -0,0 +1,59 @@
import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
};
export const OneTimePasswordInput = React.forwardRef(
function _OneTimePasswordInput(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
return (
<OneTimePasswordRoot {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
<OneTimePasswordField.HiddenInput ref={ref} />
</OneTimePasswordRoot>
);
}
);
const OneTimePasswordRoot = styled(OneTimePasswordField.Root)`
display: flex;
gap: 0.5rem;
flex-wrap: nowrap;
justify-content: space-between;
`;
const OneTimePasswordInputField = styled(OneTimePasswordField.Input)`
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 4px;
font-size: 15px;
color: ${s("text")};
background: ${s("background")};
box-shadow: 0 0 0 1px ${s("inputBorder")};
padding: 0;
height: 38px;
width: 38px;
line-height: 1;
transition: box-shadow 0.1s ease-in-out;
&:focus {
box-shadow: 0 0 0 2px ${s("inputBorderFocused")};
}
&::selection {
background-color: ${s("background")};
color: ${s("text")};
}
`;
+46 -9
View File
@@ -7,7 +7,8 @@ import { useLocation, Link, Redirect } from "react-router-dom";
import styled from "styled-components";
import { getCookie, setCookie } from "tiny-cookie";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import { Client, UserPreference } from "@shared/types";
import { isPWA } from "@shared/utils/browser";
import { parseDomain } from "@shared/utils/domains";
import { Config } from "~/stores/AuthStore";
import { AvatarSize } from "~/components/Avatar";
@@ -18,6 +19,7 @@ import Heading from "~/components/Heading";
import OutlineIcon from "~/components/Icons/OutlineIcon";
import Input from "~/components/Input";
import LoadingIndicator from "~/components/LoadingIndicator";
import { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import PageTitle from "~/components/PageTitle";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
@@ -199,6 +201,8 @@ function Login({ children, onBack }: Props) {
config.providers,
(provider) => provider.id === auth.lastSignedIn && !isCreate
);
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = Desktop.isElectron() || isPWA;
if (firstRun) {
return <WorkspaceSetup onBack={onBack} />;
@@ -212,14 +216,43 @@ function Login({ children, onBack }: Props) {
<PageTitle title={t("Check your email")} />
<CheckEmailIcon size={38} />
<Heading centered>{t("Check your email")}</Heading>
<Note>
<Trans
defaults="A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists."
values={{ emailLinkSentTo }}
components={{ em: <em /> }}
/>
</Note>
<br />
{preferOTP ? (
<>
<Note>
<Trans
defaults="Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>"
values={{ emailLinkSentTo }}
components={{ em: <em /> }}
/>
.
</Note>
<Form
method="POST"
action="/auth/email.callback"
style={{ width: "100%" }}
>
<input type="hidden" name="email" value={emailLinkSentTo} />
<input type="hidden" name="client" value={clientType} />
<input type="hidden" name="follow" value="true" />
<OneTimePasswordInput name="code" />
<br />
<ButtonLarge type="submit" fullwidth>
{t("Continue")}
</ButtonLarge>
</Form>
</>
) : (
<>
<Note>
<Trans
defaults="A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists."
values={{ emailLinkSentTo }}
components={{ em: <em /> }}
/>
</Note>
<br />
</>
)}
<ButtonLarge onClick={handleReset} fullwidth neutral>
{t("Back to login")}
</ButtonLarge>
@@ -324,6 +357,10 @@ function Login({ children, onBack }: Props) {
);
}
const Form = styled.form`
margin: 1em 0;
`;
const StyledHeading = styled(Heading)`
margin: 0;
`;
@@ -2,6 +2,8 @@ import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Client } from "@shared/types";
import { isPWA } from "@shared/utils/browser";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import PluginIcon from "~/components/PluginIcon";
@@ -17,12 +19,16 @@ type Props = React.ComponentProps<typeof ButtonLarge> & {
onEmailSuccess: (email: string) => void;
};
type AuthState = "initial" | "email" | "code";
function AuthenticationProvider(props: Props) {
const { t } = useTranslation();
const [showEmailSignin, setShowEmailSignin] = React.useState(false);
const [authState, setAuthState] = React.useState<AuthState>("initial");
const [isSubmitting, setSubmitting] = React.useState(false);
const [email, setEmail] = React.useState("");
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
const preferOTP = Desktop.isElectron() || isPWA;
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
@@ -33,25 +39,27 @@ function AuthenticationProvider(props: Props) {
) => {
event.preventDefault();
if (showEmailSignin && email) {
if (authState === "email" && email) {
setSubmitting(true);
try {
const response = await client.post(event.currentTarget.action, {
email,
client: Desktop.isElectron() ? "desktop" : undefined,
client: clientType,
preferOTP,
});
if (response.redirect) {
window.location.href = response.redirect;
} else {
onEmailSuccess(email);
setSubmitting(false);
onEmailSuccess?.(email);
}
} finally {
} catch (_err) {
setSubmitting(false);
}
} else {
setShowEmailSignin(true);
setAuthState("email");
}
};
@@ -65,7 +73,7 @@ function AuthenticationProvider(props: Props) {
return (
<Wrapper>
<Form method="POST" action="/auth/email" onSubmit={handleSubmitEmail}>
{showEmailSignin ? (
{authState === "email" ? (
<>
<InputLarge
type="email"
+4
View File
@@ -6,6 +6,10 @@ import useQuery from "~/hooks/useQuery";
function Message({ notice }: { notice: string }) {
switch (notice) {
case "invalid-code":
return (
<Trans>Sorry, the code you entered is invalid or has expired.</Trans>
);
case "domain-not-allowed":
return (
<Trans>
+1
View File
@@ -93,6 +93,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-visually-hidden": "^1.2.2",
"@radix-ui/react-one-time-password-field": "^0.1.7",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
+40 -7
View File
@@ -6,11 +6,13 @@ import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
import { AuthorizationError } from "@server/errors";
import Logger from "@server/logging/Logger";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import validate from "@server/middlewares/validate";
import { User, Team } from "@server/models";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { VerificationCode } from "@server/utils/VerificationCode";
import { signIn } from "@server/utils/authentication";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
import * as T from "./schema";
@@ -22,7 +24,7 @@ router.post(
rateLimiter(RateLimiterStrategy.TenPerHour),
validate(T.EmailSchema),
async (ctx: APIContext<T.EmailReq>) => {
const { email, client } = ctx.input.body;
const { email, client, preferOTP } = ctx.input.body;
const domain = parseDomain(ctx.request.hostname);
@@ -68,12 +70,19 @@ router.post(
return;
}
// send email to users email address with a short-lived token
// Generate both a link token and a 6-digit verification code
const token = preferOTP ? undefined : user.getEmailSigninToken();
const verificationCode = preferOTP
? await user.getEmailVerificationCode()
: undefined;
// send email to users email address with a short-lived token and code
await new SigninEmail({
to: user.email,
token: user.getEmailSigninToken(),
token,
teamUrl: team.url,
client,
verificationCode,
}).schedule();
user.lastSigninEmailSentAt = new Date();
@@ -91,6 +100,8 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
const token = query?.token || body?.token;
const client = query?.client || body?.client || Client.Web;
const follow = query?.follow || body?.follow;
const code = query?.code || body?.code;
const email = query?.email || body?.email;
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
@@ -103,10 +114,32 @@ const emailCallback = async (ctx: APIContext<T.EmailCallbackReq>) => {
let user!: User;
try {
user = await getUserForEmailSigninToken(token as string);
} catch (_err) {
ctx.redirect(`/?notice=expired-token`);
return;
if (token) {
user = await getUserForEmailSigninToken(token as string);
} else if (code && email) {
user = await User.scope("withTeam").findOne({
rejectOnEmpty: true,
where: {
email: email.trim().toLowerCase(),
},
});
const isValid = await VerificationCode.verify(email, code);
if (!isValid) {
ctx.redirect(`/?notice=invalid-code`);
return;
}
// Delete the code after successful verification
await VerificationCode.delete(email);
} else {
ctx.redirect("/?notice=auth-error");
return;
}
} catch (err) {
Logger.debug("authentication", err);
return ctx.redirect("/?notice=auth-error");
}
if (!user.team.emailSigninEnabled) {
+19 -10
View File
@@ -6,22 +6,31 @@ export const EmailSchema = BaseSchema.extend({
body: z.object({
email: z.string().email(),
client: z.nativeEnum(Client).default(Client.Web),
preferOTP: z.boolean().default(false),
}),
});
export type EmailReq = z.infer<typeof EmailSchema>;
const callbackDataSchema = z
.object({
token: z.string().optional(),
code: z.string().optional(),
email: z.string().email().optional(),
client: z.nativeEnum(Client).optional(),
follow: z.string().default(""),
})
.refine(
(data: { code?: string; email?: string; token?: string }) =>
!(data.code && !data.email) && !(data.email && !data.code && !data.token),
{
message: "Both code and email must be provided together",
}
);
export const EmailCallbackSchema = BaseSchema.extend({
query: z.object({
token: z.string().optional(),
client: z.nativeEnum(Client).optional(),
follow: z.string().default(""),
}),
body: z.object({
token: z.string().optional(),
client: z.nativeEnum(Client).optional(),
follow: z.string().default(""),
}),
query: callbackDataSchema,
body: callbackDataSchema,
});
export type EmailCallbackReq = z.infer<typeof EmailCallbackSchema>;
+75 -25
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import { Client } from "@shared/types";
import env from "@server/env";
import logger from "@server/logging/Logger";
@@ -12,9 +11,10 @@ import Header from "./components/Header";
import Heading from "./components/Heading";
type Props = EmailProps & {
token: string;
token?: string;
teamUrl: string;
client: Client;
verificationCode?: string;
};
/**
@@ -25,51 +25,101 @@ export default class SigninEmail extends BaseEmail<Props, void> {
return EmailMessageCategory.Authentication;
}
protected subject() {
return "Magic signin link";
protected subject({ token }: Props) {
return token ? "Magic signin link" : "Sign in verification code";
}
protected preview(): string {
return `Heres your link to signin to ${env.APP_NAME}.`;
}
protected renderAsText({ token, teamUrl, client }: Props): string {
return `
Use the link below to signin to ${env.APP_NAME}:
protected renderAsText({
token,
teamUrl,
client,
verificationCode,
}: Props): string {
if (token) {
return `
Use the link below to sign in:
${this.signinLink(token, client)}
If your magic link expired you can request a new one from your teams
If the link expired you can request a new one from your team's
signin page at: ${teamUrl}
`;
}
return `
Enter this verification code: ${verificationCode}
If the code expired you can request a new one from your team's
signin page at: ${teamUrl}
`;
}
protected render({ token, client, teamUrl }: Props) {
protected render({ token, client, teamUrl, verificationCode }: Props) {
if (env.isDevelopment) {
logger.debug("email", `Sign-In link: ${this.signinLink(token, client)}`);
if (token) {
logger.debug(
"email",
`Sign-In link: ${this.signinLink(token, client)}`
);
}
if (verificationCode) {
logger.debug("email", `Verification code: ${verificationCode}`);
}
}
return (
<EmailTemplate
previewText={this.preview()}
goToAction={{ url: this.signinLink(token, client), name: "Sign In" }}
goToAction={
token
? { url: this.signinLink(token, client), name: "Sign In" }
: undefined
}
>
<Header />
<Body>
<Heading>Magic Sign-in Link</Heading>
<p>Click the button below to sign in to {env.APP_NAME}.</p>
<EmptySpace height={10} />
<p>
<Button href={this.signinLink(token, client)}>Sign In</Button>
</p>
<EmptySpace height={10} />
<p>
If your magic link expired you can request a new one from your
teams sign-in page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
{token ? (
<Body>
<Heading>Magic Sign-in Link</Heading>
<p>Click the button below to sign in to {env.APP_NAME}.</p>
<EmptySpace height={10} />
<p>
<Button href={this.signinLink(token, client)}>Sign In</Button>
</p>
<EmptySpace height={20} />
<p>
If the link expired you can request a new one from your team's
sign-in page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
) : (
<Body>
<Heading>Sign-in Code</Heading>
<p>Enter this code on your team's sign-in page to continue.</p>
<EmptySpace height={10} />
<p
style={{
fontSize: "24px",
letterSpacing: "0.25em",
fontWeight: "bold",
backgroundColor: "#F9FAFB",
padding: "12px",
borderRadius: "4px",
}}
>
{verificationCode}
</p>
<EmptySpace height={20} />
<p>
If the code expired you can request a new one from your team's
sign-in page at: <a href={teamUrl}>{teamUrl}</a>
</p>
</Body>
)}
<Footer />
</EmailTemplate>
);
+17
View File
@@ -50,6 +50,7 @@ import { UserValidation } from "@shared/validations";
import env from "@server/env";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import { APIContext } from "@server/types";
import { VerificationCode } from "@server/utils/VerificationCode";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { ValidationError } from "../errors";
import Attachment from "./Attachment";
@@ -591,6 +592,22 @@ class User extends ParanoidModel<
this.jwtSecret
);
/**
* Generate a 6-digit verification code for email authentication
* and store it in Redis with a 10-minute TTL.
*
* @returns The 6-digit verification code
*/
getEmailVerificationCode = async (): Promise<string> => {
if (!this.email) {
throw ValidationError("Email is required");
}
const code = VerificationCode.generate();
await VerificationCode.store(this.email, code);
return code;
};
/**
* Returns a temporary token that can be used to update the users
* email address.
+5 -1
View File
@@ -75,7 +75,11 @@ router.get("/redirect", authMiddleware(), async (ctx: APIContext) => {
);
});
app.use(bodyParser());
app.use(
bodyParser({
multipart: true,
})
);
app.use(coalesceBody());
app.use(router.routes());
+92
View File
@@ -0,0 +1,92 @@
import { randomInt } from "crypto";
import { Minute } from "@shared/utils/time";
import RedisAdapter from "@server/storage/redis";
/**
* This class manages verification codes for email authentication.
* It stores and retrieves 6-digit codes in Redis with a 10-minute TTL.
*/
export class VerificationCode {
/**
* Redis client instance
*/
private static redis = RedisAdapter.defaultClient;
/**
* TTL for verification codes in milliseconds (10 minutes)
*/
private static readonly TTL = Minute.ms * 10;
/**
* Prefix for Redis keys
*/
private static readonly KEY_PREFIX = "email_verification_code:";
/**
* Generate a random 6-digit code
*
* @returns A string representing a 6-digit code
*/
public static generate(): string {
// Generate a random integer between 100000 and 999999 (6 digits)
return randomInt(100000, 1000000).toString().padStart(6, "0");
}
/**
* Store a verification code in Redis with a 10-minute TTL
*
* @param email The email address associated with the code
* @param code The 6-digit verification code
* @returns Promise resolving to true if successful
*/
public static async store(email: string, code: string): Promise<boolean> {
const key = this.getKey(email);
await this.redis.set(key, code, "PX", this.TTL);
return true;
}
/**
* Retrieve a verification code from Redis
*
* @param email The email address associated with the code
* @returns Promise resolving to the code or null if not found
*/
public static async retrieve(email: string): Promise<string | null> {
const key = this.getKey(email);
return await this.redis.get(key);
}
/**
* Verify if a given code matches the stored code for an email
*
* @param email The email address associated with the code
* @param code The code to verify
* @returns Promise resolving to true if the code matches, false otherwise
*/
public static async verify(email: string, code: string): Promise<boolean> {
const storedCode = await this.retrieve(email);
return storedCode === code;
}
/**
* Delete a verification code from Redis
*
* @param email The email address associated with the code
* @returns Promise resolving to true if successful
*/
public static async delete(email: string): Promise<boolean> {
const key = this.getKey(email);
await this.redis.del(key);
return true;
}
/**
* Get the Redis key for an email address
*
* @param email The email address
* @returns The Redis key
*/
private static getKey(email: string): string {
return `${this.KEY_PREFIX}${email.toLowerCase()}`;
}
}
@@ -818,6 +818,7 @@
"To continue, enter your workspaces subdomain.": "To continue, enter your workspaces subdomain.",
"subdomain": "subdomain",
"Continue": "Continue",
"Sorry, the code you entered is invalid or has expired.": "Sorry, the code you entered is invalid or has expired.",
"The domain associated with your email address has not been allowed for this workspace.": "The domain associated with your email address has not been allowed for this workspace.",
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.",
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.",
@@ -851,6 +852,7 @@
"Choose workspace": "Choose workspace",
"This login method requires choosing your workspace to continue": "This login method requires choosing your workspace to continue",
"Check your email": "Check your email",
"Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>": "Enter the sign-in code sent to the email <em>{{ emailLinkSentTo }}</em>",
"A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.": "A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em> if an account exists.",
"Back to login": "Back to login",
"Get started by choosing a sign-in method for your new workspace below…": "Get started by choosing a sign-in method for your new workspace below…",
+8 -1
View File
@@ -1,8 +1,15 @@
/**
* Returns true if we're running in the browser.
* Is true if we're running in the browser.
*/
export const isBrowser = typeof window !== "undefined";
/**
* Is true if the browser is running as an installed PWA on mobile or desktop
*/
export const isPWA =
typeof window !== "undefined" &&
window.matchMedia?.("(display-mode: standalone)").matches;
/**
* Returns true if the client is a touch device.
*/
+35
View File
@@ -3318,6 +3318,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46"
integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==
"@radix-ui/popper@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063"
@@ -3518,6 +3523,24 @@
dependencies:
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-one-time-password-field@^0.1.7":
version "0.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.7.tgz#4778008364be4c83c9a00665c5f8ff93ceb47f87"
integrity sha512-w1vm7AGI8tNXVovOK7TYQHrAGpRF7qQL+ENpT1a743De5Zmay2RbWGKAiYDKIyIuqptns+znCKwNztE2xl1n0Q==
dependencies:
"@radix-ui/number" "1.1.1"
"@radix-ui/primitive" "1.1.2"
"@radix-ui/react-collection" "1.1.7"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-direction" "1.1.1"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-roving-focus" "1.1.10"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-effect-event" "0.0.2"
"@radix-ui/react-use-is-hydrated" "0.1.0"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-popover@^1.1.14":
version "1.1.14"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.14.tgz#5496d1986f0287cdfc77e73f70a887e4cb77ad08"
@@ -3786,6 +3809,13 @@
dependencies:
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-is-hydrated@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz#544da73369517036c77659d7cdd019dc0f5ff9a0"
integrity sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==
dependencies:
use-sync-external-store "^1.5.0"
"@radix-ui/react-use-layout-effect@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
@@ -16066,6 +16096,11 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
utf8-byte-length@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61"