diff --git a/apps/demo_web/package.json b/apps/demo_web/package.json index 91382b34d..6a403c229 100644 --- a/apps/demo_web/package.json +++ b/apps/demo_web/package.json @@ -11,6 +11,7 @@ "lint": "next lint" }, "dependencies": { + "auth0-js": "^9.28.1", "next": "^15.4.5", "react": "*", "react-dom": "*" diff --git a/apps/demo_web/src/app/test/page.tsx b/apps/demo_web/src/app/test/page.tsx new file mode 100644 index 000000000..3ad718925 --- /dev/null +++ b/apps/demo_web/src/app/test/page.tsx @@ -0,0 +1,351 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import auth0 from "auth0-js"; + +const AUTH0_DOMAIN = "dev-0v00qjwpomau3ldk.us.auth0.com"; +const AUTH0_CLIENT_ID = "AMtmlNKxJiNY7abqewmq9mjERf2TOlfo"; +const AUTH0_EMAIL_CONNECTION = "email"; +const AUTH0_SCOPE = "openid profile email"; + +type Stage = "enter-email" | "enter-code" | "complete"; + +export default function Auth0TestPage(): React.ReactElement { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [stage, setStage] = useState("enter-email"); + const [statusMessage, setStatusMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [authResult, setAuthResult] = useState( + null, + ); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + const webAuth = useMemo( + () => + new auth0.WebAuth({ + domain: AUTH0_DOMAIN, + clientID: AUTH0_CLIENT_ID, + responseType: "token id_token", + scope: AUTH0_SCOPE, + redirectUri: + typeof window !== "undefined" + ? `${window.location.origin}/test` + : undefined, + audience: `https://${AUTH0_DOMAIN}/userinfo`, + }), + [], + ); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const hash = window.location.hash; + if (!hash || hash.length < 2) { + return; + } + + webAuth.parseHash({ hash }, (err, result) => { + window.history.replaceState( + {}, + document.title, + window.location.pathname + window.location.search, + ); + + if (err) { + setErrorMessage( + err.description ?? err.error_description ?? String(err), + ); + setIsVerifying(false); + return; + } + + if (result) { + setAuthResult(result); + setStatusMessage("Authentication completed successfully."); + setStage("complete"); + } + setIsVerifying(false); + }); + }, [webAuth]); + + const handleSendCode = async (event: React.FormEvent) => { + event.preventDefault(); + + setErrorMessage(null); + setStatusMessage(null); + + if (!email) { + setErrorMessage("Please enter your email address."); + return; + } + + setIsSending(true); + webAuth.passwordlessStart( + { + connection: AUTH0_EMAIL_CONNECTION, + email, + send: "code", + }, + (err) => { + setIsSending(false); + if (err) { + setErrorMessage( + err.description ?? err.error_description ?? String(err), + ); + return; + } + + setStatusMessage("Authentication code sent to your email."); + setStage("enter-code"); + }, + ); + }; + + const handleVerifyCode = async (event: React.FormEvent) => { + event.preventDefault(); + + setErrorMessage(null); + setStatusMessage(null); + + if (!code) { + setErrorMessage("Please enter the authentication code."); + return; + } + + setIsVerifying(true); + webAuth.passwordlessLogin( + { + connection: AUTH0_EMAIL_CONNECTION, + email, + verificationCode: code, + }, + (err) => { + if (err) { + setIsVerifying(false); + setErrorMessage( + err.description ?? err.error_description ?? String(err), + ); + } + }, + ); + }; + + return ( +
+
+

+ Auth0 Email OTP Login +

+

+ Enter your email address to receive an OTP for authentication. +

+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {statusMessage && ( +
+ {statusMessage} +
+ )} + + {stage === "enter-email" && ( +
+ + setEmail(e.target.value)} + placeholder="user@example.com" + style={{ + width: "100%", + padding: "14px", + borderRadius: "10px", + border: "1px solid #333", + background: "#101010", + color: "#fafafa", + marginBottom: "16px", + }} + /> + +
+ )} + + {stage === "enter-code" && ( +
+ + setCode(e.target.value)} + placeholder="123456" + style={{ + width: "100%", + padding: "14px", + borderRadius: "10px", + border: "1px solid #333", + background: "#101010", + color: "#fafafa", + letterSpacing: "0.15em", + marginBottom: "16px", + }} + /> + + +
+ )} + + {stage === "complete" && ( +
+

+ Login successful. Check the issued token information. +

+
+              {JSON.stringify(authResult, null, 2)}
+            
+ +
+ )} +
+
+ ); +} diff --git a/apps/demo_web/src/components/widgets/account_widget/account_widget.tsx b/apps/demo_web/src/components/widgets/account_widget/account_widget.tsx index b5f92b94f..858aad9d0 100644 --- a/apps/demo_web/src/components/widgets/account_widget/account_widget.tsx +++ b/apps/demo_web/src/components/widgets/account_widget/account_widget.tsx @@ -1,26 +1,46 @@ import React, { useState } from "react"; - import { useSDKState } from "@oko-wallet-demo-web/state/sdk"; import { useUserInfoState } from "@oko-wallet-demo-web/state/user_info"; import { AuthProgressWidget } from "./auth_progress_widget"; import { AccountInfoWidget } from "./account_info_widget"; -import { LoginWidget } from "../login_widget/login_widget"; +import { + type EmailLoginState, + LoginWidget, +} from "../login_widget/login_widget"; type SigningInState = | { status: "ready" } | { status: "signing-in" } | { status: "failed"; error: string }; +const initialEmailLoginState: EmailLoginState = { + stage: "enter-email", + email: "", +}; + export const AccountWidget: React.FC = () => { const okoWallet = useSDKState((state) => state.oko_cosmos)?.okoWallet; const [signingInState, setSigningInState] = useState({ status: "ready", }); - - const { email, publicKey, isSignedIn } = useUserInfoState(); + const [emailLoginState, setEmailLoginState] = useState( + initialEmailLoginState, + ); + const [emailStatusMessage, setEmailStatusMessage] = useState( + null, + ); + const [emailErrorMessage, setEmailErrorMessage] = useState( + null, + ); + const [isVerifyingCode, setIsVerifyingCode] = useState(false); + + const email = useUserInfoState((state) => state.email); + const publicKey = useUserInfoState((state) => state.publicKey); + const isSignedIn = useUserInfoState((state) => state.isSignedIn); + const clearUserInfo = useUserInfoState((state) => state.clearUserInfo); // TODO: add other login methods, and update the type accordingly - const [loginMethod] = useState< + const [loginMethod, setLoginMethod] = useState< "email" | "google" | "telegram" | "x" | "apple" >("google"); @@ -28,11 +48,54 @@ export const AccountWidget: React.FC = () => { method: "email" | "google" | "telegram" | "x" | "apple", email?: string, ) { + setLoginMethod(method); + if (!okoWallet) { console.error("eWallet is not initialized"); return; } + if (method === "email") { + const targetEmail = email?.trim() ?? ""; + + if (!targetEmail) { + setEmailErrorMessage("email is required"); + return; + } + + setEmailErrorMessage(null); + setEmailStatusMessage("Sending authentication code..."); + setEmailLoginState({ + stage: "sending-code", + email: targetEmail, + }); + + try { + await okoWallet.startEmailSignIn(targetEmail); + + setEmailLoginState({ + stage: "receive-code", + email: targetEmail, + }); + setEmailStatusMessage("Authentication code sent to your email."); + } catch (error: any) { + console.error("Failed to send Auth0 email code", error); + + setEmailLoginState({ + stage: "enter-email", + email: targetEmail, + }); + const message = + error instanceof Error + ? error.message + : "Failed to send authentication code"; + setEmailErrorMessage(message); + setEmailStatusMessage(null); + } + + return; + } + if (method !== "google") { console.error("Unsupported login method atm: %s", method); return; @@ -58,11 +121,19 @@ export const AccountWidget: React.FC = () => { } async function handleSignOut() { - if (okoWallet) { - await okoWallet.signOut(); - } else { + if (!okoWallet) { console.error("EWallet is not initialized"); + return; } + + await okoWallet.signOut(); + clearUserInfo(); + setLoginMethod("google"); + setSigningInState({ status: "ready" }); + setEmailLoginState(initialEmailLoginState); + setEmailStatusMessage(null); + setEmailErrorMessage(null); + setIsVerifyingCode(false); } if (!okoWallet) { @@ -94,7 +165,74 @@ export const AccountWidget: React.FC = () => { ); } - return ; + function handleEmailChange(inputEmail: string) { + setEmailLoginState({ + stage: "enter-email", + email: inputEmail, + }); + setEmailStatusMessage(null); + setEmailErrorMessage(null); + setIsVerifyingCode(false); + } + + async function handleVerifyEmailCode(code: string) { + if (!okoWallet) { + console.error("eWallet is not initialized"); + return; + } + + setLoginMethod("email"); + + const targetEmail = emailLoginState.email.trim(); + if (!targetEmail) { + setEmailErrorMessage("email is required"); + return; + } + + if (!code.trim()) { + setEmailErrorMessage("authentication code is required"); + return; + } + + try { + setSigningInState({ status: "signing-in" }); + setEmailErrorMessage(null); + setEmailStatusMessage("Verifying authentication code..."); + setIsVerifyingCode(true); + + await okoWallet.completeEmailSignIn(targetEmail, code.trim()); + + setEmailStatusMessage("Authentication code verified"); + setEmailErrorMessage(null); + setSigningInState({ status: "ready" }); + } catch (error: any) { + console.error("Failed to verify Auth0 email code", error); + const message = + error instanceof Error + ? error.message + : "Failed to verify authentication code"; + setEmailErrorMessage(message); + setEmailStatusMessage(null); + setSigningInState({ + status: "failed", + error: message, + }); + } finally { + setIsVerifyingCode(false); + } + } + + return ( + + ); }; export interface AccountWidgetProps {} diff --git a/apps/demo_web/src/components/widgets/login_widget/login_default_view.tsx b/apps/demo_web/src/components/widgets/login_widget/login_default_view.tsx index e0f98df7c..466fa0d3e 100644 --- a/apps/demo_web/src/components/widgets/login_widget/login_default_view.tsx +++ b/apps/demo_web/src/components/widgets/login_widget/login_default_view.tsx @@ -1,4 +1,4 @@ -import { type FC, Fragment, useState } from "react"; +import { type FC, Fragment, useEffect, useState } from "react"; import { Button } from "@oko-wallet/oko-common-ui/button"; import { GoogleIcon } from "@oko-wallet/oko-common-ui/icons/google_icon"; import { Logo } from "@oko-wallet/oko-common-ui/logo"; @@ -13,20 +13,45 @@ import { MailboxIcon } from "@oko-wallet/oko-common-ui/icons/mailbox"; import { OkoLogoIcon } from "@oko-wallet-common-ui/icons/oko_logo_icon"; import styles from "./login_widget.module.scss"; +import type { EmailLoginState } from "./login_widget"; export interface LoginDefaultViewProps { onSignIn: ( method: "email" | "google" | "telegram" | "x" | "apple", email?: string, ) => void; + emailLoginState: EmailLoginState; + onEmailChange: (email: string) => void; onShowSocials: () => void; + onVerifyEmailCode: (code: string) => void; + statusMessage?: string | null; + errorMessage?: string | null; + isVerifyingCode?: boolean; } export const LoginDefaultView: FC = ({ onSignIn, + emailLoginState, + onEmailChange, onShowSocials, + onVerifyEmailCode, + statusMessage, + errorMessage, + isVerifyingCode = false, }) => { - const [email, setEmail] = useState(""); + const { stage, email } = emailLoginState; + const [code, setCode] = useState(""); + const isSending = stage === "sending-code"; + const isAwaitingCode = stage === "receive-code"; + const isNextDisabled = + isSending || isVerifyingCode || email.trim().length === 0; + const isVerifyDisabled = isVerifyingCode || code.trim().length === 0; + + useEffect(() => { + if (stage === "enter-email") { + setCode(""); + } + }, [stage]); return ( @@ -57,22 +82,65 @@ export const LoginDefaultView: FC = ({ setEmail(e.target.value)} + value={email} + onChange={(e) => onEmailChange(e.target.value)} className={styles.emailInput} type="email" - disabled={true} + disabled={isSending || isVerifyingCode} /> + {isAwaitingCode ? ( + <> + +
+ + setCode(e.target.value)} + className={styles.emailInput} + type="text" + inputMode="numeric" + disabled={isVerifyingCode} + /> + +
+ + ) : null} + + {(statusMessage || errorMessage) && ( +
+ {statusMessage ? ( + + {statusMessage} + + ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ )} +