mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
223 lines
6.1 KiB
TypeScript
223 lines
6.1 KiB
TypeScript
import { startAuthentication } from "@simplewebauthn/browser";
|
|
import { EmailIcon } from "outline-icons";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import styled from "styled-components";
|
|
import { Client } from "@shared/types";
|
|
import ButtonLarge from "~/components/ButtonLarge";
|
|
import InputLarge from "~/components/InputLarge";
|
|
import PluginIcon from "~/components/PluginIcon";
|
|
import Tooltip from "~/components/Tooltip";
|
|
import { client } from "~/utils/ApiClient";
|
|
import Desktop from "~/utils/Desktop";
|
|
import { getRedirectUrl } from "../urls";
|
|
import { CSRF } from "@shared/constants";
|
|
import { getCookie } from "tiny-cookie";
|
|
|
|
type Props = React.ComponentProps<typeof ButtonLarge> & {
|
|
id: string;
|
|
name: string;
|
|
authUrl: string;
|
|
isCreate: boolean;
|
|
onEmailSuccess: (email: string) => void;
|
|
preferOTP: boolean;
|
|
};
|
|
|
|
type AuthState = "initial" | "email" | "code";
|
|
|
|
function AuthenticationProvider(props: Props) {
|
|
const { t } = useTranslation();
|
|
const [authState, setAuthState] = React.useState<AuthState>("initial");
|
|
const [isSubmitting, setSubmitting] = React.useState(false);
|
|
const [email, setEmail] = React.useState("");
|
|
const formRef = React.useRef<HTMLFormElement>(null);
|
|
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
|
|
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
|
|
|
|
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setEmail(event.target.value);
|
|
};
|
|
|
|
const handleSubmitEmail = async (
|
|
event: React.SyntheticEvent<HTMLFormElement>
|
|
) => {
|
|
event.preventDefault();
|
|
|
|
if (authState === "email" && email) {
|
|
setSubmitting(true);
|
|
|
|
try {
|
|
const response = await client.post(event.currentTarget.action, {
|
|
email,
|
|
client: clientType,
|
|
preferOTP: props.preferOTP,
|
|
});
|
|
|
|
if (response.redirect) {
|
|
window.location.href = response.redirect;
|
|
} else {
|
|
setSubmitting(false);
|
|
onEmailSuccess?.(email);
|
|
}
|
|
} catch (_err) {
|
|
setSubmitting(false);
|
|
}
|
|
} else {
|
|
setAuthState("email");
|
|
}
|
|
};
|
|
|
|
const href = getRedirectUrl(authUrl);
|
|
|
|
if (id === "passkeys") {
|
|
const handleSubmitPasskey = async (
|
|
event: React.SyntheticEvent<HTMLFormElement>
|
|
) => {
|
|
event.preventDefault();
|
|
|
|
try {
|
|
const resp = await client.post(
|
|
"/passkeys.generateAuthenticationOptions",
|
|
undefined,
|
|
{
|
|
baseUrl: "/auth",
|
|
}
|
|
);
|
|
const { challengeId, ...optionsData } = resp.data;
|
|
const authResp = await startAuthentication(optionsData);
|
|
|
|
// Populate hidden form fields with authentication data
|
|
if (formRef.current) {
|
|
const createInputs = (obj: Record<string, unknown>, prefix = "") => {
|
|
Object.entries(obj).forEach(([key, value]) => {
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
|
|
const fieldName = prefix ? `${prefix}[${key}]` : key;
|
|
|
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
createInputs(value as Record<string, unknown>, fieldName);
|
|
} else {
|
|
// Create hidden input for primitive values
|
|
const input = document.createElement("input");
|
|
input.type = "hidden";
|
|
input.name = fieldName;
|
|
input.value = String(value as string | number | boolean);
|
|
formRef.current?.appendChild(input);
|
|
}
|
|
});
|
|
};
|
|
|
|
createInputs({
|
|
...authResp,
|
|
challengeId,
|
|
[CSRF.fieldName]: getCookie(CSRF.cookieName),
|
|
client: clientType,
|
|
});
|
|
}
|
|
|
|
// Submit form natively to let browser handle redirect and cookies
|
|
formRef.current?.submit();
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
}
|
|
};
|
|
|
|
const isDesktop = Desktop.isElectron();
|
|
const button = (
|
|
<ButtonLarge
|
|
type="submit"
|
|
icon={<PluginIcon id={id} color="currentColor" />}
|
|
fullwidth
|
|
disabled={isDesktop}
|
|
{...rest}
|
|
>
|
|
{t("Continue with Passkey")}
|
|
</ButtonLarge>
|
|
);
|
|
|
|
return (
|
|
<Wrapper>
|
|
<Form
|
|
ref={formRef}
|
|
method="POST"
|
|
action="/auth/passkeys.verifyAuthentication"
|
|
onSubmit={handleSubmitPasskey}
|
|
>
|
|
{isDesktop ? (
|
|
<Tooltip
|
|
content={t("Passkeys are not supported in the desktop app")}
|
|
>
|
|
{button}
|
|
</Tooltip>
|
|
) : (
|
|
button
|
|
)}
|
|
</Form>
|
|
</Wrapper>
|
|
);
|
|
}
|
|
|
|
if (id === "email") {
|
|
if (isCreate) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Wrapper>
|
|
<Form method="POST" action="/auth/email" onSubmit={handleSubmitEmail}>
|
|
{authState === "email" ? (
|
|
<>
|
|
<InputLarge
|
|
type="email"
|
|
name="email"
|
|
placeholder="me@domain.com"
|
|
value={email}
|
|
onChange={handleChangeEmail}
|
|
disabled={isSubmitting}
|
|
autoFocus
|
|
required
|
|
short
|
|
/>
|
|
<ButtonLarge type="submit" disabled={isSubmitting} {...rest}>
|
|
{t("Sign In")} →
|
|
</ButtonLarge>
|
|
</>
|
|
) : (
|
|
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth {...rest}>
|
|
{t("Continue with Email")}
|
|
</ButtonLarge>
|
|
)}
|
|
</Form>
|
|
</Wrapper>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ButtonLarge
|
|
onClick={() => (window.location.href = href)}
|
|
icon={<PluginIcon id={id} />}
|
|
fullwidth
|
|
{...rest}
|
|
>
|
|
{t("Continue with {{ authProviderName }}", {
|
|
authProviderName: name,
|
|
})}
|
|
</ButtonLarge>
|
|
);
|
|
}
|
|
|
|
const Wrapper = styled.div`
|
|
width: 100%;
|
|
`;
|
|
|
|
const Form = styled.form`
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
`;
|
|
|
|
export default AuthenticationProvider;
|