mirror of
https://github.com/outline/outline.git
synced 2026-05-12 13:21:17 -05:00
feat: Add OTP sign-in for PWA (#9556)
* wip * wip * wip * Only use code for desktop and PWA
This commit is contained in:
@@ -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")};
|
||||
}
|
||||
`;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 `Here’s 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 team’s
|
||||
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
|
||||
team’s 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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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 workspace’s subdomain.": "To continue, enter your workspace’s 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…",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user