Refactor login method (#599)

* fix login form

* use github oauth device flow

* refactor login form

* mixin otp

* fix ui

* clean up code

* upgrade deps

* fix intl tel input
This commit is contained in:
an-lee
2024-05-13 08:42:48 +08:00
committed by GitHub
parent fcd9217986
commit c01548d3a0
13 changed files with 822 additions and 536 deletions

View File

@@ -0,0 +1,176 @@
import {
Button,
toast,
Input,
Label,
Sheet,
SheetTrigger,
SheetContent,
} from "@renderer/components/ui";
import { useContext, useEffect, useRef, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import intlTelInput from "intl-tel-input";
import "intl-tel-input/build/css/intlTelInput.css";
export const BanduLoginButton = () => {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="学升"
className="w-10 h-10 rounded-full"
>
<img
src="assets/bandu-logo.svg"
className="w-full h-full"
alt="bandu-logo"
/>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-screen">
<div className="w-full h-full flex">
<div className="m-auto">{open && <BanduLoginForm />}</div>
</div>
</SheetContent>
</Sheet>
);
};
export const BanduLoginForm = () => {
const ref = useRef<HTMLInputElement>(null);
const [iti, setIti] = useState<any>(null);
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [code, setCode] = useState<string>("");
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi } = useContext(AppSettingsProviderContext);
const validatePhone = () => {
if (
iti?.isValidNumber() &&
iti?.getNumberType() === (intlTelInput.utils.numberType as any)?.MOBILE
) {
setPhoneNumber(iti.getNumber());
} else {
setPhoneNumber("");
}
};
useEffect(() => {
if (!ref.current) return;
intlTelInput(ref.current, {
initialCountry: "cn",
utilsScript:
"https://cdn.jsdelivr.net/npm/intl-tel-input@23.0.4/build/js/utils.js",
});
setIti(intlTelInput(ref.current));
return () => {
iti?.destroy();
};
}, [ref]);
useEffect(() => {
iti?.setCountry("cn");
}, [iti]);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (countdown > 0) {
timeout = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [countdown]);
return (
<div className="w-80">
<div className="flex items-center justify-center mb-4">
<img src="assets/bandu-logo.svg" className="w-20 h-20" alt="bandu" />
</div>
<div className="grid gap-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="phone">{t("phoneNumber")}</Label>
<input
id="phone"
value={phoneNumber}
onInput={validatePhone}
onBlur={validatePhone}
className="border text-lg py-2 px-4 rounded w-80"
ref={ref}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="verrificationCode">{t("verificationCode")}</Label>
<Input
id="verrificationCode"
className="border py-2 h-10 px-4 rounded"
type="text"
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
disabled={!phoneNumber || countdown > 0}
onClick={() => {
webApi
.loginCode({ phoneNumber })
.then(() => {
toast.success(t("codeSent"));
setCodeSent(true);
setCountdown(120);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{countdown > 0 && <span className="mr-2">{countdown}</span>}
<span>{codeSent ? t("resend") : t("sendCode")}</span>
</Button>
<Button
variant="default"
size="lg"
className="w-full"
disabled={!code || code.length < 5 || !phoneNumber}
onClick={() => {
webApi
.auth({ provider: "bandu", code, phoneNumber })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{t("login")}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,104 @@
import { Button, toast, Input, Label } from "@renderer/components/ui";
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
export const EmailLoginForm = () => {
const [email, setEmail] = useState<string>("");
const [code, setCode] = useState<string>("");
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi } = useContext(AppSettingsProviderContext);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (countdown > 0) {
timeout = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [countdown]);
return (
<div className="w-full">
<div className="w-full grid gap-4 mb-6">
<div className="grid gap-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
className="h-10"
type="email"
placeholder="m@example.com"
required
value={email}
disabled={countdown > 0}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="code">{t("verificationCode")}</Label>
<Input
id="code"
className="h-10"
type="text"
required
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
disabled={!email || countdown > 0}
onClick={() => {
webApi
.loginCode({ email })
.then(() => {
toast.success(t("codeSent"));
setCodeSent(true);
setCountdown(120);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{countdown > 0 && <span className="mr-2">{countdown}</span>}
<span>{codeSent ? t("resend") : t("sendCode")}</span>
</Button>
<Button
variant="default"
size="lg"
className="w-full"
disabled={!code || code.length < 5 || !email}
onClick={() => {
webApi
.auth({ provider: "email", code, email })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{t("login")}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,179 @@
import { useContext, useEffect, useState } from "react";
import { LoaderSpin } from "@renderer/components";
import { AppSettingsProviderContext } from "@/renderer/context";
import { toast } from "sonner";
import {
Button,
Sheet,
SheetContent,
SheetTrigger,
} from "@renderer/components/ui";
import { t } from "i18next";
import { useCopyToClipboard } from "@uidotdev/usehooks";
export const GithubLoginButton = () => {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="Github"
className="w-10 h-10 rounded-full"
>
<img
src="assets/github-mark.png"
className="w-full h-full"
alt="github-logo"
/>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-screen">
<div className="w-full h-full flex">
<div className="m-auto">{open && <GithubLoginForm />}</div>
</div>
</SheetContent>
</Sheet>
);
};
export const GithubLoginForm = () => {
const [oauthInfo, setOauthInfo] = useState<{
deviceCode: string;
expiresIn: number;
interval: number;
userCode: string;
verificationUri: string;
}>();
const { webApi, EnjoyApp, login } = useContext(AppSettingsProviderContext);
const [_, copyToClipboard] = useCopyToClipboard();
const [error, setError] = useState<string>();
let timeoutId: NodeJS.Timeout;
const fetchDeviceCode = async () => {
try {
const info = await webApi.deviceCode();
setOauthInfo(info);
const { deviceCode, interval, verificationUri } = info;
EnjoyApp.shell.openExternal(verificationUri);
timeoutId = setTimeout(() => {
auth(deviceCode);
}, (interval || 5) * 1000);
} catch (error) {
toast.error(error.message);
}
};
const auth = async (deviceCode: string) => {
if (!deviceCode) return;
if (timeoutId) {
clearTimeout(timeoutId);
}
let res: any = {};
try {
res = await webApi.auth({
provider: "github",
deviceCode,
});
} catch (error) {
toast.error(error.message);
}
if (res.id && res.accessToken) {
login(res);
} else if (
res.error === "authorization_pending" ||
res.error === "slow_down"
) {
const interval = res.interval || oauthInfo?.interval || 5;
setError(res.errorDescription);
timeoutId = setTimeout(() => {
auth(deviceCode);
}, interval * 1000);
} else {
toast.error(res.errorDescription || t("error"));
}
};
useEffect(() => {
fetchDeviceCode();
return () => {
setOauthInfo(null);
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, []);
return (
<div className="w-full h-full flex">
<div className="m-auto">
<div className="flex items-center justify-center mb-12">
<img
src="assets/github-mark.png"
className="w-20 h-20"
alt="github"
/>
</div>
{oauthInfo ? (
<div className="grid gap-8">
<div className="flex items-center justify-center gap-2 text-5xl">
{oauthInfo.userCode.split("").map((char, index) => {
if (char === "-") {
return (
<span key={index} className="text-muted-foreground">
{char}
</span>
);
} else {
return (
<span
key={index}
className="font-mono font-bold border px-3 py-2 rounded"
>
{char}
</span>
);
}
})}
</div>
<LoaderSpin />
<div className="text-center text-muted-foreground">{error}</div>
<div className="flex items-center justify-center space-x-4">
<Button
onClick={() => {
copyToClipboard(oauthInfo.userCode);
toast.success(t("copied"));
}}
variant="secondary"
>
{t("copy")}
</Button>
<Button
onClick={() =>
EnjoyApp.shell.openExternal(oauthInfo.verificationUri)
}
>
{t("continue")}
</Button>
</div>
</div>
) : (
<div className="grid gap-6">
<LoaderSpin />
</div>
)}
</div>
</div>
);
};

View File

@@ -1,7 +1,11 @@
export * from "./bandu-login-form";
export * from "./db-state";
export * from "./email-login-form";
export * from "./layout";
export * from "./loader-spin";
export * from "./login-form";
export * from "./github-login-form";
export * from "./mixin-login-form";
export * from "./no-records-found";
export * from "./page-placeholder";
export * from "./sidebar";

View File

@@ -1,127 +1,24 @@
import {
Button,
toast,
Input,
Separator,
Sheet,
SheetContent,
SheetTrigger,
Label,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@renderer/components/ui";
import { useContext, useEffect, useRef, useState } from "react";
import { useContext } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import {
UserSettings,
LanguageSettings,
LoaderSpin,
GithubLoginButton,
BanduLoginButton,
MixinLoginButton,
} from "@renderer/components";
import { ChevronLeftIcon } from "lucide-react";
import intlTelInput from "intl-tel-input";
import "intl-tel-input/build/css/intlTelInput.css";
import { WEB_API_URLS } from "@/constants";
import { useDebounce } from "@uidotdev/usehooks";
import { EmailLoginForm } from "./email-login-form";
export const LoginForm = () => {
const { EnjoyApp, login, webApi, user } = useContext(
AppSettingsProviderContext
);
const [webviewUrl, setWebviewUrl] = useState<string>();
const [webviewRect, setWebviewRect] = useState<DOMRect | null>(null);
const debouncedWebviewRect = useDebounce(webviewRect, 500);
const containerRef = useRef<HTMLDivElement>(null);
const handleLogin = (provider: "mixin" | "github") => {
const url = `${webApi.baseUrl}/sessions/new?provider=${provider}`;
setWebviewUrl(url);
};
const onViewState = (event: {
state: string;
error?: string;
url?: string;
html?: string;
}) => {
const { state, url, error } = event;
if (error) {
toast.error(error);
setWebviewUrl(null);
return;
}
const BASE_URL_REGEX = new RegExp(
`^(${[webApi.baseUrl, ...WEB_API_URLS].join("|")})`
);
if (state === "will-navigate" || state === "will-redirect") {
if (!url.match(BASE_URL_REGEX)) {
return;
}
const provider = new URL(url).pathname.split("/")[2] as
| "mixin"
| "github";
const code = new URL(url).searchParams.get("code");
if (provider && code) {
webApi
.auth({ provider, code })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setWebviewUrl(null);
});
}
}
};
useEffect(() => {
if (!webviewUrl) return;
if (!debouncedWebviewRect) return;
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
const { x, y, width, height } = debouncedWebviewRect;
EnjoyApp.view.load(
webviewUrl,
{
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
},
{
navigatable: true,
}
);
return () => {
EnjoyApp.view.removeViewStateListeners();
EnjoyApp.view.remove();
};
}, [webApi, webviewUrl, debouncedWebviewRect]);
useEffect(() => {
if (!containerRef?.current) return;
setWebviewRect(containerRef.current.getBoundingClientRect());
EnjoyApp.window.onResize(() => {
setWebviewRect(containerRef.current.getBoundingClientRect());
});
return () => {
EnjoyApp.window.removeListeners();
};
}, [containerRef?.current]);
const { user } = useContext(AppSettingsProviderContext);
if (user) {
return (
@@ -134,328 +31,26 @@ export const LoginForm = () => {
}
return (
<>
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t("login")}</CardTitle>
</CardHeader>
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{t("login")}</CardTitle>
</CardHeader>
<CardContent>
<EmailLoginForm />
<CardContent>
<EmailLoginForm />
<div className="">
<Separator className="my-4" />
<div className="flex items-center justify-center text-xs text-muted-foreground mb-4">
{t("youCanAlsoLoginWith")}
</div>
<div className="flex items-center space-x-2 justify-center">
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="GitHub"
className="w-10 h-10 rounded-full"
onClick={() => handleLogin("github")}
>
<img
src="assets/github-mark.png"
className="w-full h-full"
alt="github-logo"
/>
</Button>
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="Mixin"
className="w-10 h-10 rounded-full p-1"
onClick={() => handleLogin("mixin")}
>
<img
src="assets/mixin-logo.png"
className="w-full h-full"
alt="mixin-logo"
/>
</Button>
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="学升"
className="w-10 h-10 rounded-full"
>
<img
src="assets/bandu-logo.svg"
className="w-full h-full"
alt="bandu-logo"
/>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-screen">
<div className="w-full h-full flex">
<div className="m-auto">
<PandoLoginForm />
</div>
</div>
</SheetContent>
</Sheet>
</div>
<div className="">
<Separator className="my-4" />
<div className="flex items-center justify-center text-xs text-muted-foreground mb-4">
{t("youCanAlsoLoginWith")}
</div>
</CardContent>
</Card>
<div
className={`absolute top-0 left-0 w-screen h-screen z-10 flex flex-col overflow-hidden ${
webviewUrl ? "" : "hidden"
}`}
>
<div className="flex items-center py-2 px-6">
<Button variant="ghost" onClick={() => setWebviewUrl(null)}>
<ChevronLeftIcon className="w-5 h-5" />
<span className="ml-2">{t("goBack")}</span>
</Button>
</div>
<div ref={containerRef} className="w-full h-full flex-1 bg-muted">
<LoaderSpin />
</div>
</div>
</>
);
};
const EmailLoginForm = () => {
const [email, setEmail] = useState<string>("");
const [code, setCode] = useState<string>("");
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi } = useContext(AppSettingsProviderContext);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (countdown > 0) {
timeout = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [countdown]);
return (
<div className="w-full">
<div className="w-full grid gap-4 mb-6">
<div className="grid gap-2">
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
className="h-10"
type="email"
placeholder="m@example.com"
required
value={email}
disabled={countdown > 0}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="code">{t("verificationCode")}</Label>
<Input
id="code"
className="h-10"
type="text"
required
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
disabled={!email || countdown > 0}
onClick={() => {
webApi
.loginCode({ email })
.then(() => {
toast.success(t("codeSent"));
setCodeSent(true);
setCountdown(120);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{countdown > 0 && <span className="mr-2">{countdown}</span>}
<span>{codeSent ? t("resend") : t("sendCode")}</span>
</Button>
<Button
variant="default"
size="lg"
className="w-full"
disabled={!code || code.length < 5 || !email}
onClick={() => {
webApi
.auth({ provider: "email", code, email })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{t("login")}
</Button>
</div>
</div>
);
};
const PandoLoginForm = () => {
const ref = useRef<HTMLInputElement>(null);
const [iti, setIti] = useState<any>(null);
const [phoneNumber, setPhoneNumber] = useState<string>("");
const [code, setCode] = useState<string>("");
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi } = useContext(AppSettingsProviderContext);
const validatePhone = () => {
if (
iti?.isValidNumber() &&
iti?.getNumberType() === (intlTelInput.utils.numberType as any)?.MOBILE
) {
setPhoneNumber(iti.getNumber());
} else {
setPhoneNumber("");
}
};
useEffect(() => {
if (!ref.current) return;
intlTelInput(ref.current, {
initialCountry: "cn",
utilsScript:
"https://cdn.jsdelivr.net/npm/intl-tel-input@19.2.12/build/js/utils.js",
});
setIti(intlTelInput(ref.current));
return () => {
iti?.destroy();
};
}, [ref]);
useEffect(() => {
iti?.setCountry("cn");
}, [iti]);
useEffect(() => {
let timeout: NodeJS.Timeout;
if (countdown > 0) {
timeout = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [countdown]);
return (
<div>
<div className="flex items-center justify-center mb-4">
<img src="assets/bandu-logo.svg" className="w-20 h-20" alt="bandu" />
</div>
<div className="grid gap-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="phone">{t("phoneNumber")}</Label>
<input
id="phone"
value={phoneNumber}
onInput={validatePhone}
onBlur={validatePhone}
className="border text-lg py-2 px-4 rounded"
ref={ref}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="verrificationCode">{t("verificationCode")}</Label>
<Input
id="verrificationCode"
className="border py-2 h-10 px-4 rounded"
type="text"
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<div className="flex items-center space-x-2 justify-center">
<GithubLoginButton />
<MixinLoginButton />
<BanduLoginButton />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
disabled={!phoneNumber || countdown > 0}
onClick={() => {
webApi
.loginCode({ phoneNumber })
.then(() => {
toast.success(t("codeSent"));
setCodeSent(true);
setCountdown(120);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{countdown > 0 && <span className="mr-2">{countdown}</span>}
<span>{codeSent ? t("resend") : t("sendCode")}</span>
</Button>
<Button
variant="default"
size="lg"
className="w-full"
disabled={!code || code.length < 5 || !phoneNumber}
onClick={() => {
webApi
.auth({ provider: "bandu", code, phoneNumber })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{t("login")}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,173 @@
import {
Button,
toast,
Input,
Label,
Sheet,
SheetTrigger,
SheetContent,
} from "@renderer/components/ui";
import { useContext, useEffect, useRef, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { LoaderIcon } from "lucide-react";
export const MixinLoginButton = () => {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
data-tooltip-id="global-tooltip"
data-tooltip-content="Mixin Messenger"
className="w-10 h-10 rounded-full"
>
<img
src="assets/mixin-logo.png"
className="w-full h-full p-1"
alt="mixin-logo"
/>
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-screen">
<div className="w-full h-full flex">
<div className="m-auto">{open && <MixinLoginForm />}</div>
</div>
</SheetContent>
</Sheet>
);
};
export const MixinLoginForm = () => {
const [mixinId, setMixinId] = useState<string>("");
const [input, setInput] = useState<string>("");
const [code, setCode] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
const validateMixinId = (id: string) => {
setInput(id);
if (id?.match(/^[1-9]\d{5,10}$/)) {
setMixinId(id);
} else {
setMixinId("");
}
};
useEffect(() => {
let timeout: NodeJS.Timeout;
if (countdown > 0) {
timeout = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
}
return () => {
if (timeout) clearTimeout(timeout);
};
}, [countdown]);
return (
<div className="w-80">
<div className="flex items-center justify-center mb-4">
<img src="assets/mixin-logo.png" className="w-20 h-20" alt="bandu" />
</div>
<div className="grid gap-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="mixinId">{t("mixinId")}</Label>
<input
id="mixinId"
value={input}
placeholder={t("inputMixinId")}
onInput={(event) => validateMixinId(event.currentTarget.value)}
onBlur={(event) => validateMixinId(event.currentTarget.value)}
className="border py-2 px-4 rounded"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="verificationCode">{t("verificationCode")}</Label>
<Input
id="verificationCode"
className="border py-2 h-10 px-4 rounded"
type="text"
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<div
onClick={() =>
EnjoyApp.shell.openExternal("https://mixin.one/messenger")
}
className="text-xs text-muted-foreground cursor-pointer"
>
{t("dontHaveMixinAccount")}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full px-2"
disabled={!mixinId || countdown > 0 || loading}
onClick={() => {
if (!mixinId) return;
if (loading) return;
if (countdown > 0) return;
setLoading(true);
webApi
.loginCode({ mixinId })
.then(() => {
toast.success(t("codeSent"));
setCodeSent(true);
setCountdown(120);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
}}
>
{loading && <LoaderIcon className="w-5 h-5 mr-2 animate-spin" />}
{countdown > 0 && <span className="mr-2">{countdown}</span>}
<span>{codeSent ? t("resend") : t("sendCode")}</span>
</Button>
<Button
variant="default"
size="lg"
className="w-full"
disabled={!code || code.length < 5 || !mixinId}
onClick={() => {
webApi
.auth({ provider: "mixin", code, mixinId })
.then((user) => {
if (user?.id && user?.accessToken) login(user);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
{t("login")}
</Button>
</div>
</div>
</div>
);
};