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:
176
enjoy/src/renderer/components/misc/bandu-login-form.tsx
Normal file
176
enjoy/src/renderer/components/misc/bandu-login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
enjoy/src/renderer/components/misc/email-login-form.tsx
Normal file
104
enjoy/src/renderer/components/misc/email-login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
179
enjoy/src/renderer/components/misc/github-login-form.tsx
Normal file
179
enjoy/src/renderer/components/misc/github-login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
173
enjoy/src/renderer/components/misc/mixin-login-form.tsx
Normal file
173
enjoy/src/renderer/components/misc/mixin-login-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user