From bda7b6d7a1c9c0c27201aa39ae202f3feb0c03c2 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 18:22:06 +0100 Subject: [PATCH 01/27] wip --- frontend/src/html/pages/login.html | 100 ----- frontend/src/index.html | 4 +- frontend/src/styles/index.scss | 6 +- frontend/src/styles/login.scss | 79 ---- frontend/src/ts/components/mount.tsx | 2 + .../src/ts/components/pages/LoginPage.tsx | 399 ++++++++++++++++++ .../src/ts/components/ui/ValidatedInput.tsx | 20 +- .../src/ts/controllers/page-controller.ts | 8 +- frontend/src/ts/event-handlers/login.ts | 8 - frontend/src/ts/index.ts | 2 - frontend/src/ts/modals/google-sign-up.ts | 2 +- frontend/src/ts/pages/login.ts | 350 --------------- frontend/src/ts/ready.ts | 5 +- frontend/src/ts/stores/login.ts | 45 ++ 14 files changed, 479 insertions(+), 551 deletions(-) delete mode 100644 frontend/src/html/pages/login.html delete mode 100644 frontend/src/styles/login.scss create mode 100644 frontend/src/ts/components/pages/LoginPage.tsx delete mode 100644 frontend/src/ts/event-handlers/login.ts delete mode 100644 frontend/src/ts/pages/login.ts create mode 100644 frontend/src/ts/stores/login.ts diff --git a/frontend/src/html/pages/login.html b/frontend/src/html/pages/login.html deleted file mode 100644 index f5321e360b8a..000000000000 --- a/frontend/src/html/pages/login.html +++ /dev/null @@ -1,100 +0,0 @@ - diff --git a/frontend/src/index.html b/frontend/src/index.html index c4ea32a36413..26f1c442e032 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -37,7 +37,9 @@ - + + } + else={ +
+ } + /> ); } diff --git a/frontend/src/ts/components/pages/LoginPage.tsx b/frontend/src/ts/components/pages/LoginPage.tsx index af75f38804c5..9adda199899d 100644 --- a/frontend/src/ts/components/pages/LoginPage.tsx +++ b/frontend/src/ts/components/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { UserEmailSchema, UserNameSchema, } from "@monkeytype/schemas/users"; +import { useQuery } from "@tanstack/solid-query"; import { createSignal, JSXElement, Show } from "solid-js"; import { z } from "zod"; @@ -13,19 +14,16 @@ import { ValidationResult, } from "../../elements/input-validation"; import * as ForgotPasswordModal from "../../modals/forgot-password"; +import { getServerConfigurationQueryOptions } from "../../queries/server-configuration"; import { getActivePage } from "../../signals/core"; import * as ConnectionState from "../../states/connection"; import { - getLoading, - getInputsDisabled, - getSignUpButtonEnabled, - getServerDisabled, - showPreloader, - hidePreloader, - enableInputs, - disableInputs, - enableSignUpButton, - disableSignUpButton, + showLoginPageLoader, + hideLoginPageLoader, + disableLoginPageInputs, + enableLoginPageInputs, + getLoginPageInputsEnabled, + getLoginPageLoader, } from "../../stores/login"; import { showNoticeNotification, @@ -34,6 +32,8 @@ import { import { isDevEnvironment } from "../../utils/misc"; import { remoteValidation } from "../../utils/remote-validation"; import TypoList from "../../utils/typo-list"; +import { Conditional } from "../common/Conditional"; +import { Separator } from "../common/Separator"; import { ValidatedInput } from "../ui/ValidatedInput"; let disposableEmailModule: typeof import("disposable-email-domains-js") | null = @@ -63,28 +63,18 @@ export function LoginPage(): JSXElement { passwordValid() && passwordVerifyValid(); - const updateSignUpButton = () => { - if (isRegisterFormComplete()) { - enableSignUpButton(); - } else { - disableSignUpButton(); - } - }; - const handleSignInWithGoogle = async () => { if (!ConnectionState.get()) { showNoticeNotification("You are offline"); return; } - showPreloader(); - disableInputs(); - disableSignUpButton(); + showLoginPageLoader(); + disableLoginPageInputs(); const data = await signInWithGoogle(rememberMe()); - hidePreloader(); + hideLoginPageLoader(); if (!data.success) { showErrorNotification(data.message); - enableInputs(); - updateSignUpButton(); + enableLoginPageInputs(); } }; @@ -93,15 +83,13 @@ export function LoginPage(): JSXElement { showNoticeNotification("You are offline"); return; } - showPreloader(); - disableInputs(); - disableSignUpButton(); + showLoginPageLoader(); + disableLoginPageInputs(); const data = await signInWithGitHub(rememberMe()); - hidePreloader(); + hideLoginPageLoader(); if (!data.success) { showErrorNotification(data.message); - enableInputs(); - updateSignUpButton(); + enableLoginPageInputs(); } }; @@ -115,15 +103,13 @@ export function LoginPage(): JSXElement { showNoticeNotification("Please fill in all fields"); return; } - showPreloader(); - disableInputs(); - disableSignUpButton(); + showLoginPageLoader(); + disableLoginPageInputs(); const data = await signIn(loginEmail(), loginPassword(), rememberMe()); - hidePreloader(); + hideLoginPageLoader(); if (!data.success) { showErrorNotification(data.message); - enableInputs(); - updateSignUpButton(); + enableLoginPageInputs(); } }; @@ -137,15 +123,13 @@ export function LoginPage(): JSXElement { showNoticeNotification("Please fill in all fields"); return; } - showPreloader(); - disableInputs(); - disableSignUpButton(); + showLoginPageLoader(); + disableLoginPageInputs(); const data = await signUp(nameValue(), emailValue(), passwordValue()); - hidePreloader(); + hideLoginPageLoader(); if (!data.success) { showErrorNotification(data.message); - enableInputs(); - updateSignUpButton(); + enableLoginPageInputs(); } }; @@ -187,213 +171,219 @@ export function LoginPage(): JSXElement { return true; }; + const Register = () => ( +
+
+ + register +
+
+ + Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => data.available || "Name not available" }, + )} + debounceDelay={1000} + callback={(result: ValidationResult) => { + setNameValid(result.success); + }} + onInput={(value: string) => setNameValue(value)} + /> + { + setEmailValid(result.success); + }} + onInput={(value: string) => setEmailValue(value)} + // oxlint-disable-next-line solid/reactivity + onFocus={async () => { + if (!moduleLoadAttempted) { + moduleLoadAttempted = true; + try { + disposableEmailModule = + await import("disposable-email-domains-js"); + } catch { + // Silent failure + } + } + }} + /> + + emailValue() === emailVerify + ? true + : "verify email not matching email" + } + debounceDelay={0} + callback={(result: ValidationResult) => { + setEmailVerifyValid(emailValid() && result.success); + }} + /> + { + setPasswordValid(result.success); + }} + onInput={(value: string) => setPasswordValue(value)} + /> + + passwordValue() === passwordVerify + ? true + : "verify password not matching password" + } + debounceDelay={0} + callback={(result: ValidationResult) => { + setPasswordVerifyValid(passwordValid() && result.success); + }} + /> + + +
+ ); + + const Login = () => ( +
+
+ + login +
+
+ + +
+
+ + setLoginEmail(e.target.value)} + /> + setLoginPassword(e.target.value)} + /> +
+ +
+ + + +
+ ); + + const serverConfig = useQuery(() => getServerConfigurationQueryOptions()); + const showError = (): boolean => !(serverConfig.data?.users.signUp ?? true); + return ( - +
- -

Login/Signup is disabled or the server is down/under maintenance.

-
- -
- {/* Register side */} -
-
- - register -
-
- - Ape.users.getNameAvailability({ params: { name } }), - { check: (data) => data.available || "Name not available" }, - )} - debounceDelay={1000} - callback={(result: ValidationResult) => { - setNameValid(result.success); - updateSignUpButton(); - }} - onInput={(value: string) => setNameValue(value)} - /> - { - setEmailValid(result.success); - updateSignUpButton(); - }} - onInput={(value: string) => setEmailValue(value)} - // oxlint-disable-next-line solid/reactivity - onFocus={async () => { - if (!moduleLoadAttempted) { - moduleLoadAttempted = true; - try { - disposableEmailModule = - await import("disposable-email-domains-js"); - } catch { - // Silent failure - } - } - }} - /> - - emailValue() === emailVerify - ? true - : "verify email not matching email" - } - debounceDelay={0} - callback={(result: ValidationResult) => { - setEmailVerifyValid(emailValid() && result.success); - updateSignUpButton(); - }} - /> - { - setPasswordValid(result.success); - updateSignUpButton(); - }} - onInput={(value: string) => setPasswordValue(value)} - /> - - passwordValue() === passwordVerify - ? true - : "verify password not matching password" - } - debounceDelay={0} - callback={(result: ValidationResult) => { - setPasswordVerifyValid(passwordValid() && result.success); - updateSignUpButton(); - }} - /> - - + +

+ Login/Signup is disabled or the server is down/under maintenance. +

- - {/* Login side */} -
-
- - login -
-
- - -
-
-
-
-
or
-
-
- setLoginEmail(e.target.value)} - /> - setLoginPassword(e.target.value)} - /> -
- -
- -
- + } + else={ +
+ +
-
- + } + /> ); } diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index 14563e12490e..6b04e0197d29 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -11,7 +11,6 @@ import { getAdditionalUserInfo, } from "firebase/auth"; import Ape from "../ape"; -import * as LoginPage from "../stores/login"; import * as AccountController from "../auth"; import * as CaptchaController from "../controllers/captcha-controller"; @@ -22,6 +21,7 @@ import { resetIgnoreAuthCallback } from "../firebase"; import { ValidatedHtmlInputElement } from "../elements/input-validation"; import { UserNameSchema } from "@monkeytype/schemas/users"; import { remoteValidation } from "../utils/remote-validation"; +import { enableLoginPageInputs, hideLoginPageLoader } from "../stores/login"; let signedInUser: UserCredential | undefined = undefined; @@ -62,8 +62,8 @@ async function hide(): Promise { showNoticeNotification("Sign up process cancelled", { durationMs: 5000, }); - LoginPage.hidePreloader(); - LoginPage.enableInputs(); + hideLoginPageLoader(); + enableLoginPageInputs(); if (getAdditionalUserInfo(signedInUser)?.isNewUser) { await Ape.users.delete(); await signedInUser?.user.delete().catch(() => { @@ -110,8 +110,8 @@ async function apply(): Promise { await updateProfile(signedInUser.user, { displayName: name }); await sendEmailVerification(signedInUser.user); showSuccessNotification("Account created"); - LoginPage.enableInputs(); - LoginPage.hidePreloader(); + enableLoginPageInputs(); + hideLoginPageLoader(); await AccountController.loadUser(signedInUser.user); signedInUser = undefined; @@ -121,9 +121,8 @@ async function apply(): Promise { } catch (e) { console.log(e); showErrorNotification("Failed to sign in with Google", { error: e }); - LoginPage.hidePreloader(); - LoginPage.enableInputs(); - LoginPage.enableSignUpButton(); + hideLoginPageLoader(); + enableLoginPageInputs(); if (signedInUser && getAdditionalUserInfo(signedInUser)?.isNewUser) { await Ape.users.delete(); await signedInUser?.user.delete().catch(() => { diff --git a/frontend/src/ts/ready.ts b/frontend/src/ts/ready.ts index f1c625ec3eaf..8384b77cfa23 100644 --- a/frontend/src/ts/ready.ts +++ b/frontend/src/ts/ready.ts @@ -8,7 +8,6 @@ import { configLoadPromise } from "./config"; import { authPromise } from "./firebase"; import { animate } from "animejs"; import { onDOMReady, qs } from "./utils/dom"; -import { setLoginDisabledByServer } from "./stores/login"; onDOMReady(async () => { await configLoadPromise; @@ -28,11 +27,7 @@ onDOMReady(async () => { duration: Misc.applyReducedMotion(250), }); - void ServerConfiguration.sync().then(() => { - if (!ServerConfiguration.get()?.users.signUp) { - setLoginDisabledByServer(); - } - }); + void ServerConfiguration.sync(); MonkeyPower.init(); diff --git a/frontend/src/ts/stores/login.ts b/frontend/src/ts/stores/login.ts index d1b080230a63..6732b7e8ba3d 100644 --- a/frontend/src/ts/stores/login.ts +++ b/frontend/src/ts/stores/login.ts @@ -1,45 +1,22 @@ -import { createSignal } from "solid-js"; - -const [loading, setLoading] = createSignal(false); -const [inputsDisabled, setInputsDisabled] = createSignal(false); -const [signUpButtonEnabled, setSignUpButtonEnabled] = createSignal(false); -const [serverDisabled, setServerDisabled] = createSignal(false); - -export const getLoading = loading; -export const getInputsDisabled = inputsDisabled; -export const getSignUpButtonEnabled = signUpButtonEnabled; -export const getServerDisabled = serverDisabled; - -export function showPreloader(): void { - setLoading(true); -} - -export function hidePreloader(): void { - setLoading(false); -} - -export function enableInputs(): void { - setInputsDisabled(false); -} - -export function disableInputs(): void { - setInputsDisabled(true); -} - -export function enableSignUpButton(): void { - setSignUpButtonEnabled(true); -} - -export function disableSignUpButton(): void { - setSignUpButtonEnabled(false); -} - -export function setLoginDisabledByServer(): void { - setServerDisabled(true); -} +import { createSignalWithSetters } from "../hooks/createSignalWithSetters"; + +export const [ + getLoginPageInputsEnabled, + { enableLoginPageInputs, disableLoginPageInputs }, +] = createSignalWithSetters(true)({ + enableLoginPageInputs: (set) => set(true), + disableLoginPageInputs: (set) => set(false), +}); + +export const [ + getLoginPageLoader, + { showLoginPageLoader, hideLoginPageLoader }, +] = createSignalWithSetters(false)({ + showLoginPageLoader: (set) => set(true), + hideLoginPageLoader: (set) => set(false), +}); export function resetForm(): void { - setLoading(false); - setInputsDisabled(false); - setSignUpButtonEnabled(false); + hideLoginPageLoader(); + enableLoginPageInputs(); } From a277cc10b4aa2d4176a345f3a468f22d8bfe1d03 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 20:06:38 +0100 Subject: [PATCH 03/27] fix notifs, disable --- frontend/src/ts/components/pages/LoginPage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/ts/components/pages/LoginPage.tsx b/frontend/src/ts/components/pages/LoginPage.tsx index 9adda199899d..2a824c886b74 100644 --- a/frontend/src/ts/components/pages/LoginPage.tsx +++ b/frontend/src/ts/components/pages/LoginPage.tsx @@ -73,7 +73,7 @@ export function LoginPage(): JSXElement { const data = await signInWithGoogle(rememberMe()); hideLoginPageLoader(); if (!data.success) { - showErrorNotification(data.message); + showErrorNotification("Failed to sign in with Google: " + data.message); enableLoginPageInputs(); } }; @@ -88,7 +88,7 @@ export function LoginPage(): JSXElement { const data = await signInWithGitHub(rememberMe()); hideLoginPageLoader(); if (!data.success) { - showErrorNotification(data.message); + showErrorNotification("Failed to sign in with GitHub: " + data.message); enableLoginPageInputs(); } }; @@ -108,7 +108,7 @@ export function LoginPage(): JSXElement { const data = await signIn(loginEmail(), loginPassword(), rememberMe()); hideLoginPageLoader(); if (!data.success) { - showErrorNotification(data.message); + showErrorNotification("Failed to sign in: " + data.message); enableLoginPageInputs(); } }; @@ -190,6 +190,7 @@ export function LoginPage(): JSXElement { placeholder="username" autocomplete="new-username" schema={UserNameSchema} + disabled={!getLoginPageInputsEnabled()} isValid={remoteValidation( async (name: string) => Ape.users.getNameAvailability({ params: { name } }), @@ -207,6 +208,7 @@ export function LoginPage(): JSXElement { placeholder="email" autocomplete="new-email" schema={UserEmailSchema} + disabled={!getLoginPageInputsEnabled()} isValid={emailIsValid} debounceDelay={0} callback={(result: ValidationResult) => { @@ -237,6 +239,7 @@ export function LoginPage(): JSXElement { ? true : "verify email not matching email" } + disabled={!getLoginPageInputsEnabled()} debounceDelay={0} callback={(result: ValidationResult) => { setEmailVerifyValid(emailValid() && result.success); @@ -248,6 +251,7 @@ export function LoginPage(): JSXElement { placeholder="password" autocomplete="new-password" name="new-password" + disabled={!getLoginPageInputsEnabled()} schema={isDevEnvironment() ? z.string().min(6) : PasswordSchema} callback={(result: ValidationResult) => { setPasswordValid(result.success); @@ -259,6 +263,7 @@ export function LoginPage(): JSXElement { class="w-68" placeholder="verify password" autocomplete="verify-password" + disabled={!getLoginPageInputsEnabled()} name="verify-password" // oxlint-disable-next-line solid/reactivity isValid={async (passwordVerify: string) => From 1f3e387e65ecd0e26f5c2711fda89198621e0b5c Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 20:06:50 +0100 Subject: [PATCH 04/27] disabled support --- frontend/src/ts/components/ui/ValidatedInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx index 684a24a3b14a..e7021a201e18 100644 --- a/frontend/src/ts/components/ui/ValidatedInput.tsx +++ b/frontend/src/ts/components/ui/ValidatedInput.tsx @@ -22,6 +22,7 @@ export function ValidatedInput( name?: string; onInput?: (value: T) => void; onFocus?: () => void; + disabled?: boolean; }, ): JSXElement { // Refs are assigned by SolidJS via the ref attribute @@ -61,6 +62,7 @@ export function ValidatedInput( class={props.class} placeholder={props.placeholder} value={props.value ?? ""} + disabled={props.disabled} // oxlint-disable-next-line react/no-unknown-property autocomplete={props.autocomplete} name={props.name} From 47af31d027b3faadab9c397fca6478653afda21b Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 20:06:59 +0100 Subject: [PATCH 05/27] fix error handling --- frontend/src/ts/auth.tsx | 3 --- frontend/src/ts/firebase.ts | 10 ++++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index ccc5faedc6bd..8723c68c1c2a 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -198,9 +198,6 @@ async function signInWithProvider( const { error } = await tryCatch(signInWithPopup(provider, rememberMe)); if (error !== null) { - if (error.message !== "") { - showErrorNotification(error.message); - } return { success: false, message: error.message }; } return { success: true }; diff --git a/frontend/src/ts/firebase.ts b/frontend/src/ts/firebase.ts index f2d5c74db7c8..ef1316c4022b 100644 --- a/frontend/src/ts/firebase.ts +++ b/frontend/src/ts/firebase.ts @@ -227,19 +227,17 @@ function translateFirebaseError( message = "Email/password is incorrect or your account does not have password authentication enabled."; } else if (error.code === "auth/popup-closed-by-user") { - message = ""; - // message = "Popup closed by user"; - // return; + message = "Popup closed by user"; } else if (error.code === "auth/popup-blocked") { message = "Sign in popup was blocked by the browser. Check the address bar for a blocked popup icon, or update your browser settings to allow popups."; } else if (error.code === "auth/user-cancelled") { - message = ""; - // message = "User refused to sign in"; - // return; + message = "Cancelled by user"; } else if (error.code === "auth/account-exists-with-different-credential") { message = "Account already exists, but its using a different authentication method. Try signing in with a different method"; + } else { + message = "Firebase error: " + error.code; } } From 49ded05bd1c8c02f1838a8e8c0fca7852d4d0d9b Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 20:24:17 +0100 Subject: [PATCH 06/27] split --- frontend/src/ts/components/mount.tsx | 2 +- .../src/ts/components/pages/login/Login.tsx | 149 +++++++++++++ .../ts/components/pages/login/LoginPage.tsx | 43 ++++ .../{LoginPage.tsx => login/Register.tsx} | 207 ++---------------- 4 files changed, 212 insertions(+), 189 deletions(-) create mode 100644 frontend/src/ts/components/pages/login/Login.tsx create mode 100644 frontend/src/ts/components/pages/login/LoginPage.tsx rename frontend/src/ts/components/pages/{LoginPage.tsx => login/Register.tsx} (53%) diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index dccc17a169c2..7e96184bfe23 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -13,7 +13,7 @@ import { Modals } from "./modals/Modals"; import { AboutPage } from "./pages/AboutPage"; import { MyProfile } from "./pages/account/MyProfile"; import { LeaderboardPage } from "./pages/leaderboard/LeaderboardPage"; -import { LoginPage } from "./pages/LoginPage"; +import { LoginPage } from "./pages/login/LoginPage"; import { ProfilePage } from "./pages/profile/ProfilePage"; import { ProfileSearchPage } from "./pages/profile/ProfileSearchPage"; import { Popups } from "./popups/Popups"; diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx new file mode 100644 index 000000000000..137b3ebd102d --- /dev/null +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -0,0 +1,149 @@ +import { createSignal, JSXElement } from "solid-js"; + +import { signIn, signInWithGitHub, signInWithGoogle } from "../../../auth"; +import * as ForgotPasswordModal from "../../../modals/forgot-password"; +import * as ConnectionState from "../../../states/connection"; +import { + showLoginPageLoader, + hideLoginPageLoader, + disableLoginPageInputs, + enableLoginPageInputs, + getLoginPageInputsEnabled, +} from "../../../stores/login"; +import { + showNoticeNotification, + showErrorNotification, +} from "../../../stores/notifications"; +import { Separator } from "../../common/Separator"; + +export function Login(): JSXElement { + const [loginEmail, setLoginEmail] = createSignal(""); + const [loginPassword, setLoginPassword] = createSignal(""); + const [rememberMe, setRememberMe] = createSignal(true); + + const handleSignInWithGoogle = async () => { + if (!ConnectionState.get()) { + showNoticeNotification("You are offline"); + return; + } + showLoginPageLoader(); + disableLoginPageInputs(); + const data = await signInWithGoogle(rememberMe()); + hideLoginPageLoader(); + if (!data.success) { + showErrorNotification("Failed to sign in with Google: " + data.message); + enableLoginPageInputs(); + } + }; + + const handleSignInWithGitHub = async () => { + if (!ConnectionState.get()) { + showNoticeNotification("You are offline"); + return; + } + showLoginPageLoader(); + disableLoginPageInputs(); + const data = await signInWithGitHub(rememberMe()); + hideLoginPageLoader(); + if (!data.success) { + showErrorNotification("Failed to sign in with GitHub: " + data.message); + enableLoginPageInputs(); + } + }; + + const handleSignIn = async (e: SubmitEvent) => { + e.preventDefault(); + if (!ConnectionState.get()) { + showNoticeNotification("You are offline"); + return; + } + if (loginEmail() === "" || loginPassword() === "") { + showNoticeNotification("Please fill in all fields"); + return; + } + showLoginPageLoader(); + disableLoginPageInputs(); + const data = await signIn(loginEmail(), loginPassword(), rememberMe()); + hideLoginPageLoader(); + if (!data.success) { + showErrorNotification("Failed to sign in: " + data.message); + enableLoginPageInputs(); + } + }; + + return ( +
+
+ + login +
+
+ + +
+
+ + setLoginEmail(e.target.value)} + /> + setLoginPassword(e.target.value)} + /> +
+ +
+ + + +
+ ); +} diff --git a/frontend/src/ts/components/pages/login/LoginPage.tsx b/frontend/src/ts/components/pages/login/LoginPage.tsx new file mode 100644 index 000000000000..ceda95e00b07 --- /dev/null +++ b/frontend/src/ts/components/pages/login/LoginPage.tsx @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/solid-query"; +import { JSXElement, Show } from "solid-js"; + +import { getServerConfigurationQueryOptions } from "../../../queries/server-configuration"; +import { getActivePage } from "../../../signals/core"; +import { getLoginPageLoader } from "../../../stores/login"; +import { Conditional } from "../../common/Conditional"; +import { Login } from "./Login"; +import { Register } from "./Register"; + +export function LoginPage(): JSXElement { + const isOpen = () => getActivePage() === "login"; + + const serverConfig = useQuery(() => getServerConfigurationQueryOptions()); + const isSignUpDisabled = (): boolean => + !(serverConfig.data?.users.signUp ?? true); + + return ( + + +
+ +
+
+ +

+ Login/Signup is disabled or the server is down/under maintenance. +

+
+ } + else={ +
+ + +
+ } + /> +
+ ); +} diff --git a/frontend/src/ts/components/pages/LoginPage.tsx b/frontend/src/ts/components/pages/login/Register.tsx similarity index 53% rename from frontend/src/ts/components/pages/LoginPage.tsx rename to frontend/src/ts/components/pages/login/Register.tsx index 2a824c886b74..c1b61e8f8fa9 100644 --- a/frontend/src/ts/components/pages/LoginPage.tsx +++ b/frontend/src/ts/components/pages/login/Register.tsx @@ -3,45 +3,40 @@ import { UserEmailSchema, UserNameSchema, } from "@monkeytype/schemas/users"; -import { useQuery } from "@tanstack/solid-query"; -import { createSignal, JSXElement, Show } from "solid-js"; +import { createSignal, JSXElement } from "solid-js"; import { z } from "zod"; -import Ape from "../../ape"; -import { signIn, signInWithGitHub, signInWithGoogle, signUp } from "../../auth"; +import Ape from "../../../ape"; +import { signUp } from "../../../auth"; import { IsValidResponse, ValidationResult, -} from "../../elements/input-validation"; -import * as ForgotPasswordModal from "../../modals/forgot-password"; -import { getServerConfigurationQueryOptions } from "../../queries/server-configuration"; -import { getActivePage } from "../../signals/core"; -import * as ConnectionState from "../../states/connection"; +} from "../../../elements/input-validation"; +import * as ConnectionState from "../../../states/connection"; import { - showLoginPageLoader, - hideLoginPageLoader, disableLoginPageInputs, enableLoginPageInputs, getLoginPageInputsEnabled, - getLoginPageLoader, -} from "../../stores/login"; + hideLoginPageLoader, + showLoginPageLoader, +} from "../../../stores/login"; import { - showNoticeNotification, showErrorNotification, -} from "../../stores/notifications"; -import { isDevEnvironment } from "../../utils/misc"; -import { remoteValidation } from "../../utils/remote-validation"; -import TypoList from "../../utils/typo-list"; -import { Conditional } from "../common/Conditional"; -import { Separator } from "../common/Separator"; -import { ValidatedInput } from "../ui/ValidatedInput"; + showNoticeNotification, +} from "../../../stores/notifications"; +import { isDevEnvironment } from "../../../utils/misc"; +import { remoteValidation } from "../../../utils/remote-validation"; +import TypoList from "../../../utils/typo-list"; +import { ValidatedInput } from "../../ui/ValidatedInput"; let disposableEmailModule: typeof import("disposable-email-domains-js") | null = null; let moduleLoadAttempted = false; -export function LoginPage(): JSXElement { - const isOpen = () => getActivePage() === "login"; +export function Register(): JSXElement { + const [nameValue, setNameValue] = createSignal(""); + const [emailValue, setEmailValue] = createSignal(""); + const [passwordValue, setPasswordValue] = createSignal(""); const [nameValid, setNameValid] = createSignal(false); const [emailValid, setEmailValid] = createSignal(false); @@ -49,13 +44,6 @@ export function LoginPage(): JSXElement { const [passwordValid, setPasswordValid] = createSignal(false); const [passwordVerifyValid, setPasswordVerifyValid] = createSignal(false); - const [emailValue, setEmailValue] = createSignal(""); - const [passwordValue, setPasswordValue] = createSignal(""); - - const [loginEmail, setLoginEmail] = createSignal(""); - const [loginPassword, setLoginPassword] = createSignal(""); - const [rememberMe, setRememberMe] = createSignal(true); - const isRegisterFormComplete = () => nameValid() && emailValid() && @@ -63,56 +51,6 @@ export function LoginPage(): JSXElement { passwordValid() && passwordVerifyValid(); - const handleSignInWithGoogle = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGoogle(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with Google: " + data.message); - enableLoginPageInputs(); - } - }; - - const handleSignInWithGitHub = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGitHub(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with GitHub: " + data.message); - enableLoginPageInputs(); - } - }; - - const handleSignIn = async (e: SubmitEvent) => { - e.preventDefault(); - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - if (loginEmail() === "" || loginPassword() === "") { - showNoticeNotification("Please fill in all fields"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signIn(loginEmail(), loginPassword(), rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in: " + data.message); - enableLoginPageInputs(); - } - }; - const handleSignUp = async (e: SubmitEvent) => { e.preventDefault(); if (!ConnectionState.get()) { @@ -133,8 +71,6 @@ export function LoginPage(): JSXElement { } }; - const [nameValue, setNameValue] = createSignal(""); - const emailIsValid = async (email: string): Promise => { const educationRegex = /@.*(student|education|school|\.edu$|\.edu\.|\.ac\.|\.sch\.)/i; @@ -171,7 +107,7 @@ export function LoginPage(): JSXElement { return true; }; - const Register = () => ( + return (
@@ -286,109 +222,4 @@ export function LoginPage(): JSXElement {
); - - const Login = () => ( -
-
- - login -
-
- - -
-
- - setLoginEmail(e.target.value)} - /> - setLoginPassword(e.target.value)} - /> -
- -
- - - -
- ); - - const serverConfig = useQuery(() => getServerConfigurationQueryOptions()); - const showError = (): boolean => !(serverConfig.data?.users.signUp ?? true); - - return ( - - -
- -
-
- -

- Login/Signup is disabled or the server is down/under maintenance. -

-
- } - else={ -
- - -
- } - /> -
- ); } From 20d3449d0288947bcaa339af2ac1b6a271926ab9 Mon Sep 17 00:00:00 2001 From: Miodec Date: Sun, 8 Mar 2026 23:05:31 +0100 Subject: [PATCH 07/27] brr --- .../src/ts/components/pages/login/Login.tsx | 4 +- .../ts/components/pages/login/LoginPage.tsx | 2 +- .../ts/components/pages/login/Register.tsx | 9 ++-- .../src/ts/components/ui/ValidatedInput.tsx | 48 +++++++++++++------ frontend/src/ts/elements/input-indicator.ts | 15 ++++-- frontend/src/ts/elements/input-validation.ts | 5 ++ 6 files changed, 55 insertions(+), 28 deletions(-) diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx index 137b3ebd102d..213fd96b27b8 100644 --- a/frontend/src/ts/components/pages/login/Login.tsx +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -72,7 +72,7 @@ export function Login(): JSXElement { }; return ( -
+
login @@ -98,7 +98,6 @@ export function Login(): JSXElement { } else={ -
+
diff --git a/frontend/src/ts/components/pages/login/Register.tsx b/frontend/src/ts/components/pages/login/Register.tsx index c1b61e8f8fa9..56e4f716fe90 100644 --- a/frontend/src/ts/components/pages/login/Register.tsx +++ b/frontend/src/ts/components/pages/login/Register.tsx @@ -108,7 +108,7 @@ export function Register(): JSXElement { }; return ( -
+
register @@ -122,7 +122,6 @@ export function Register(): JSXElement { > { setEmailVerifyValid(emailValid() && result.success); }} /> { setPasswordVerifyValid(passwordValid() && result.success); }} diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx index e7021a201e18..db8a720c7b47 100644 --- a/frontend/src/ts/components/ui/ValidatedInput.tsx +++ b/frontend/src/ts/components/ui/ValidatedInput.tsx @@ -2,6 +2,7 @@ import { splitProps, createEffect, JSXElement, + on, onCleanup, onMount, } from "solid-js"; @@ -23,6 +24,7 @@ export function ValidatedInput( onInput?: (value: T) => void; onFocus?: () => void; disabled?: boolean; + revalidateOn?: () => unknown; }, ): JSXElement { // Refs are assigned by SolidJS via the ref attribute @@ -46,6 +48,7 @@ export function ValidatedInput( "autocomplete", "name", "onFocus", + "revalidateOn", ]); validatedInput = new ValidatedHtmlInputElement( element, @@ -54,20 +57,37 @@ export function ValidatedInput( validatedInput.setValue(props.value ?? null); }); - onCleanup(() => validatedInput?.remove()); + createEffect( + on( + () => props.revalidateOn?.(), + () => { + if (validatedInput && inputEl()?.getValue() !== "") { + validatedInput.triggerValidation(); + } + }, + { defer: true }, + ), + ); + + onCleanup(() => { + validatedInput?.destroy(); + }); + return ( - props.onInput?.(e.target.value as T)} - onFocus={() => props.onFocus?.()} - /> +
+ props.onInput?.(e.target.value as T)} + onFocus={() => props.onFocus?.()} + /> +
); } diff --git a/frontend/src/ts/elements/input-indicator.ts b/frontend/src/ts/elements/input-indicator.ts index 56b65fc3e6a1..fb5d396bd91e 100644 --- a/frontend/src/ts/elements/input-indicator.ts +++ b/frontend/src/ts/elements/input-indicator.ts @@ -18,10 +18,17 @@ export class InputIndicator { options: Record, ) { this.inputElement = inputElement; - const wrapper = this.inputElement.wrapWith( - `
`, - ); - this.parentElement = wrapper; + const existingWrapper = inputElement.native.closest(".inputAndIndicator"); + if (existingWrapper) { + existingWrapper.querySelector(".statusIndicator")?.remove(); + this.parentElement = new ElementWithUtils( + existingWrapper as HTMLInputElement, + ); + } else { + this.parentElement = this.inputElement.wrapWith( + `
`, + ); + } this.options = options; this.currentStatus = null; diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index 6e008465cfac..04ba4fd7e972 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -234,6 +234,11 @@ export class ValidatedHtmlInputElement< triggerValidation(): void { this.dispatch("input"); } + + destroy(): void { + this.indicator.hide(); + this.remove(); + } } export type ConfigInputOptions = { From 360b0b70d60db4c4e213dee47b11002f3bfc7151 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 9 Mar 2026 10:01:11 +0100 Subject: [PATCH 08/27] use h3 --- frontend/src/ts/components/pages/login/Login.tsx | 12 ++++++++---- frontend/src/ts/components/pages/login/Register.tsx | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx index 213fd96b27b8..e5f7cde07790 100644 --- a/frontend/src/ts/components/pages/login/Login.tsx +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -14,6 +14,7 @@ import { showNoticeNotification, showErrorNotification, } from "../../../stores/notifications"; +import { H3 } from "../../common/Headers"; import { Separator } from "../../common/Separator"; export function Login(): JSXElement { @@ -73,10 +74,13 @@ export function Login(): JSXElement { return (
-
- - login -
+

- + />
@@ -128,24 +131,23 @@ export function Login(): JSXElement {
remember me

- + fa={{ + icon: "fa-sign-in-alt", + }} + text="sign in" + /> - + />
); } diff --git a/frontend/src/ts/components/pages/login/Register.tsx b/frontend/src/ts/components/pages/login/Register.tsx index 6e4fb9e0123a..41cc7dad0f8a 100644 --- a/frontend/src/ts/components/pages/login/Register.tsx +++ b/frontend/src/ts/components/pages/login/Register.tsx @@ -27,6 +27,7 @@ import { import { isDevEnvironment } from "../../../utils/misc"; import { remoteValidation } from "../../../utils/remote-validation"; import TypoList from "../../../utils/typo-list"; +import { Button } from "../../common/Button"; import { H3 } from "../../common/Headers"; import { ValidatedInput } from "../../ui/ValidatedInput"; @@ -213,13 +214,14 @@ export function Register(): JSXElement { setPasswordVerifyValid(passwordValid() && result.success); }} /> - + fa={{ + icon: "fa-user-plus", + }} + text="sign up" + />
); From e19677b11eb6401c818bdf06f950fa318a35832f Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 12 Mar 2026 15:00:19 +0100 Subject: [PATCH 10/27] use ts/forms Co-authored-by: Miodec --- frontend/.oxlintrc.json | 3 +- .../components/ui/ValidatedInput.spec.tsx | 82 ----- frontend/package.json | 3 +- frontend/src/styles/tailwind.css | 11 + frontend/src/ts/auth.tsx | 63 +--- frontend/src/ts/components/common/Button.tsx | 4 +- frontend/src/ts/components/core/DevTools.tsx | 1 - .../src/ts/components/pages/login/Login.tsx | 199 ++++++----- .../ts/components/pages/login/LoginPage.tsx | 4 +- .../ts/components/pages/login/Register.tsx | 323 ++++++++++-------- .../pages/profile/ProfileSearchPage.tsx | 89 +++-- .../src/ts/components/ui/ValidatedInput.tsx | 93 ----- .../src/ts/components/ui/form/Checkbox.tsx | 54 +++ .../ts/components/ui/form/FieldIndicator.tsx | 60 ++++ .../src/ts/components/ui/form/InputField.tsx | 45 +++ .../ts/components/ui/form/SubmitButton.tsx | 53 +++ frontend/src/ts/components/ui/form/utils.ts | 55 +++ .../src/ts/controllers/page-controller.ts | 7 +- frontend/src/ts/modals/google-sign-up.ts | 7 - frontend/src/ts/stores/login.ts | 13 - frontend/src/ts/utils/remote-validation.ts | 36 ++ frontend/src/ts/utils/typo-list.ts | 1 + .../storybook/stories/Checkbox.stories.tsx | 64 ++++ .../stories/FieldIndicator.stories.tsx | 90 +++++ frontend/storybook/stories/Form.stories.tsx | 108 ++++++ .../storybook/stories/InputField.stories.tsx | 109 ++++++ .../stories/ValidatedInput.stories.tsx | 91 ----- pnpm-lock.yaml | 78 ++++- 28 files changed, 1108 insertions(+), 638 deletions(-) delete mode 100644 frontend/__tests__/components/ui/ValidatedInput.spec.tsx delete mode 100644 frontend/src/ts/components/ui/ValidatedInput.tsx create mode 100644 frontend/src/ts/components/ui/form/Checkbox.tsx create mode 100644 frontend/src/ts/components/ui/form/FieldIndicator.tsx create mode 100644 frontend/src/ts/components/ui/form/InputField.tsx create mode 100644 frontend/src/ts/components/ui/form/SubmitButton.tsx create mode 100644 frontend/src/ts/components/ui/form/utils.ts create mode 100644 frontend/storybook/stories/Checkbox.stories.tsx create mode 100644 frontend/storybook/stories/FieldIndicator.stories.tsx create mode 100644 frontend/storybook/stories/Form.stories.tsx create mode 100644 frontend/storybook/stories/InputField.stories.tsx delete mode 100644 frontend/storybook/stories/ValidatedInput.stories.tsx diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index f4926fa1284a..ea42e45ed995 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -21,7 +21,8 @@ "rules": { "explicit-function-return-type": "off", "no-explicit-any": "off", - "no-unsafe-assignment": "off" + "no-unsafe-assignment": "off", + "no-empty-function": "off" } }, { diff --git a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx b/frontend/__tests__/components/ui/ValidatedInput.spec.tsx deleted file mode 100644 index 90f48a74ac67..000000000000 --- a/frontend/__tests__/components/ui/ValidatedInput.spec.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { render, waitFor } from "@solidjs/testing-library"; -import userEvent from "@testing-library/user-event"; -import { createSignal } from "solid-js"; -import { describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { ValidatedInput } from "../../../src/ts/components/ui/ValidatedInput"; - -vi.mock("../../../src/ts/config", () => ({})); - -describe("ValidatedInput", () => { - it("renders with valid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "success"); - expect(input).toHaveValue("Kevin"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "success"); - expect(indicator?.querySelector("i")).toHaveClass("fa-check"); - }); - - it("renders with invalid initial value", async () => { - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - - const wrapper = container.querySelector(".inputAndIndicator"); - const input = container.querySelector("input"); - console.log(container?.innerHTML); - expect(wrapper).toHaveClass("inputAndIndicator"); - expect(wrapper).toHaveAttribute("data-indicator-status", "failed"); - expect(input).toHaveValue("Bob"); - - const indicator = wrapper?.querySelector("div.indicator:not(.hidden)"); - expect(indicator).toBeInTheDocument(); - expect(indicator).toHaveAttribute("data-option-id", "failed"); - expect(indicator?.querySelector("i")).toHaveClass("fa-times"); - }); - - it("updates callback", async () => { - const [value, setValue] = createSignal("Bob"); - const schema = z.string().min(4); - const { container } = render(() => ( - - )); - - await waitFor(() => container.querySelector(".inputAndIndicator") !== null); - console.log(container.innerHTML); - const input = container.querySelector("input") as HTMLInputElement; - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "failed", - ); - - await userEvent.type(input, "ington"); - - expect(value()).toEqual("Bobington"); - expect(container.querySelector(".inputAndIndicator")).toHaveAttribute( - "data-indicator-status", - "success", - ); - }); -}); diff --git a/frontend/package.json b/frontend/package.json index e93a1ac5b9b4..8cb6cf5abed2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@date-fns/utc": "1.2.0", - "@leonabcd123/modern-caps-lock": "2.2.2", + "@leonabcd123/modern-caps-lock": "3.0.4", "@monkeytype/contracts": "workspace:*", "@monkeytype/funbox": "workspace:*", "@monkeytype/schemas": "workspace:*", @@ -37,6 +37,7 @@ "@tanstack/pacer-lite": "0.2.1", "@tanstack/query-db-collection": "1.0.27", "@tanstack/solid-db": "0.2.10", + "@tanstack/solid-form": "1.28.4", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", "@tanstack/solid-table": "8.21.3", diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 42a8040255cd..68ae21a5ea8d 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -91,3 +91,14 @@ padding: 0; } } + +@layer utilities { + .autofill-fix:-webkit-autofill, + .autofill-fix:-webkit-autofill:hover, + .autofill-fix:-webkit-autofill:focus { + @apply border-none font-(--font) caret-(--text-color) font-[inherit]; + outline: 0.15em solid var(--main-color); + -webkit-text-fill-color: var(--text-color); + -webkit-box-shadow: 0 0 0 1000000px var(--sub-alt-color) inset; + } +} diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx index 8723c68c1c2a..6656513afe67 100644 --- a/frontend/src/ts/auth.tsx +++ b/frontend/src/ts/auth.tsx @@ -34,6 +34,15 @@ import { } from "./stores/notifications"; import { createErrorMessage } from "./utils/misc"; +export type AuthResult = + | { + success: true; + } + | { + success: false; + message: string; + }; + export const gmailProvider = new GoogleAuthProvider(); export const githubProvider = new GithubAuthProvider(); @@ -156,15 +165,7 @@ export async function signIn( email: string, password: string, rememberMe: boolean, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } @@ -182,15 +183,7 @@ export async function signIn( async function signInWithProvider( provider: AuthProvider, rememberMe: boolean, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } @@ -203,27 +196,15 @@ async function signInWithProvider( return { success: true }; } -export async function signInWithGoogle(rememberMe: boolean): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +export async function signInWithGoogle( + rememberMe: boolean, +): Promise { return signInWithProvider(gmailProvider, rememberMe); } -export async function signInWithGitHub(rememberMe: boolean): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +export async function signInWithGitHub( + rememberMe: boolean, +): Promise { return signInWithProvider(githubProvider, rememberMe); } @@ -272,15 +253,7 @@ export async function signUp( name: string, email: string, password: string, -): Promise< - | { - success: true; - } - | { - success: false; - message: string; - } -> { +): Promise { if (!isAuthAvailable()) { return { success: false, message: "Authentication uninitialized" }; } diff --git a/frontend/src/ts/components/common/Button.tsx b/frontend/src/ts/components/common/Button.tsx index 04e307295f9d..545a4209a990 100644 --- a/frontend/src/ts/components/common/Button.tsx +++ b/frontend/src/ts/components/common/Button.tsx @@ -14,13 +14,14 @@ type BaseProps = { balloon?: BalloonProps; "router-link"?: true; onClick?: () => void; + type?: HTMLButtonElement["type"]; onMouseEnter?: () => void; onMouseLeave?: () => void; dataset?: Record; active?: boolean; }; -type ButtonProps = BaseProps & { +export type ButtonProps = BaseProps & { type?: "button" | "submit" | "reset"; href?: never; sameTarget?: true; @@ -31,6 +32,7 @@ type AnchorProps = BaseProps & { href: string; // onClick?: never; disabled?: never; + type?: never; }; export function Button(props: ButtonProps | AnchorProps): JSXElement { diff --git a/frontend/src/ts/components/core/DevTools.tsx b/frontend/src/ts/components/core/DevTools.tsx index ce32cc39bacf..5e64f9d98e9a 100644 --- a/frontend/src/ts/components/core/DevTools.tsx +++ b/frontend/src/ts/components/core/DevTools.tsx @@ -1,6 +1,5 @@ import { SolidQueryDevtools } from "@tanstack/solid-query-devtools"; import { JSXElement } from "solid-js"; - export function DevTools(): JSXElement { return ; } diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx index b0a219bc996c..898ea6206413 100644 --- a/frontend/src/ts/components/pages/login/Login.tsx +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -1,77 +1,70 @@ -import { createSignal, JSXElement } from "solid-js"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; -import { signIn, signInWithGitHub, signInWithGoogle } from "../../../auth"; +import { + AuthResult, + signIn, + signInWithGitHub, + signInWithGoogle, +} from "../../../auth"; import * as ForgotPasswordModal from "../../../modals/forgot-password"; -import * as ConnectionState from "../../../states/connection"; import { - showLoginPageLoader, - hideLoginPageLoader, disableLoginPageInputs, enableLoginPageInputs, getLoginPageInputsEnabled, } from "../../../stores/login"; import { - showNoticeNotification, showErrorNotification, + showNoticeNotification, } from "../../../stores/notifications"; import { Button } from "../../common/Button"; import { H3 } from "../../common/Headers"; import { Separator } from "../../common/Separator"; +import { Checkbox } from "../../ui/form/Checkbox"; +import { InputField } from "../../ui/form/InputField"; +import { SubmitButton } from "../../ui/form/SubmitButton"; +import { allFieldsMandatory } from "../../ui/form/utils"; export function Login(): JSXElement { - const [loginEmail, setLoginEmail] = createSignal(""); - const [loginPassword, setLoginPassword] = createSignal(""); - const [rememberMe, setRememberMe] = createSignal(true); - - const handleSignInWithGoogle = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGoogle(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with Google: " + data.message); - enableLoginPageInputs(); - } - }; - - const handleSignInWithGitHub = async () => { - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signInWithGitHub(rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in with GitHub: " + data.message); - enableLoginPageInputs(); - } - }; + const form = createForm(() => ({ + defaultValues: { + email: "", + password: "", + rememberMe: true, + }, + onSubmit: async ({ value, meta }) => { + disableLoginPageInputs(); + const action = (meta as { action: "Google" | "GitHub" })?.action; + try { + let data: AuthResult; - const handleSignIn = async (e: SubmitEvent) => { - e.preventDefault(); - if (!ConnectionState.get()) { - showNoticeNotification("You are offline"); - return; - } - if (loginEmail() === "" || loginPassword() === "") { + if (action === "Google") { + data = await signInWithGoogle(value.rememberMe); + } else if (action === "GitHub") { + data = await signInWithGitHub(value.rememberMe); + } else { + if (value.email === "" || value.password === "") { + showNoticeNotification("Please fill in all fields"); + return; + } + data = await signIn(value.email, value.password, value.rememberMe); + } + if (!data.success) { + showErrorNotification( + `Failed to sign in${action !== undefined ? " with " + action : ""} : ${data.message}`, + ); + } + } finally { + enableLoginPageInputs(); + } + }, + onSubmitInvalid: () => { showNoticeNotification("Please fill in all fields"); - return; - } - showLoginPageLoader(); - disableLoginPageInputs(); - const data = await signIn(loginEmail(), loginPassword(), rememberMe()); - hideLoginPageLoader(); - if (!data.success) { - showErrorNotification("Failed to sign in: " + data.message); - enableLoginPageInputs(); - } - }; + }, + validators: { + onChange: allFieldsMandatory(), + }, + })); return (
@@ -84,67 +77,69 @@ export function Login(): JSXElement { />
-
+ { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > - setLoginEmail(e.target.value)} + ( + + )} /> - setLoginPassword(e.target.value)} + ( + + )} /> -
- -
-
diff --git a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx index 0e444db2b50e..d5aa1e6a1903 100644 --- a/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx +++ b/frontend/src/ts/components/pages/profile/ProfileSearchPage.tsx @@ -1,39 +1,52 @@ import { UserNameSchema } from "@monkeytype/schemas/users"; +import { createForm } from "@tanstack/solid-form"; import { createEffect, createSignal, JSXElement, Show } from "solid-js"; import { useRefWithUtils } from "../../../hooks/useRefWithUtils"; import * as NavigationEvent from "../../../observables/navigation-event"; import { queryClient } from "../../../queries"; import { getUserProfile } from "../../../queries/profile"; -import { - getActivePage, - getSelectedProfileName, - setSelectedProfileName, -} from "../../../signals/core"; -import { Button } from "../../common/Button"; +import { getActivePage } from "../../../signals/core"; +import { showNoticeNotification } from "../../../stores/notifications"; import { H2 } from "../../common/Headers"; -import { ValidatedInput } from "../../ui/ValidatedInput"; +import { InputField } from "../../ui/form/InputField"; +import { SubmitButton } from "../../ui/form/SubmitButton"; +import { fromSchema } from "../../ui/form/utils"; export function ProfileSearchPage(): JSXElement { - const [isValid, setValid] = createSignal(false); + const [isEditable, setEditable] = createSignal(true); const isOpen = () => getActivePage() === "profileSearch"; // Refs are assigned by SolidJS via the ref attribute const [inputRef, inputEl] = useRefWithUtils(); - const goToPage = () => { - if (isValid()) { - NavigationEvent.dispatch(`/profile/${getSelectedProfileName()}`, {}); - } - }; + const form = createForm(() => ({ + defaultValues: { + username: "", + }, + onSubmit: async ({ value }) => { + setEditable(false); + try { + console.log("### onSubmit"); + NavigationEvent.dispatch(`/profile/${value.username}`, {}); + } finally { + setEditable(true); + } + }, + onSubmitInvalid: () => { + showNoticeNotification("Please fill in all fields"); + }, + })); createEffect(() => { if (isOpen()) { + form.reset(); requestAnimationFrame(() => { inputEl()?.qs("input")?.focus({ preventScroll: true }); }); } }); + return (
@@ -41,7 +54,8 @@ export function ProfileSearchPage(): JSXElement { class="inline-grid w-96 gap-2" onSubmit={(e) => { e.preventDefault(); - goToPage(); + e.stopPropagation(); + void form.handleSubmit(); }} >
@@ -50,31 +64,38 @@ export function ProfileSearchPage(): JSXElement {
- setValid(result.success)} - // fine unless we read a reactive state after the await - // eslint-disable-next-line solid/reactivity - isValid={async (name: string) => { - try { - const result = await queryClient.fetchQuery( - getUserProfile(name), - ); - setSelectedProfileName(name); - return result !== null || "Unknown user"; - } catch (e) { - return "Unknown user or error fetching."; - } + { + try { + const result = await queryClient.fetchQuery( + getUserProfile(field.value), + ); + return result !== null ? undefined : "Unknown user"; + } catch (e) { + return "Unknown user"; + } + }, }} + children={(field) => ( + + )} />
-
diff --git a/frontend/src/ts/components/ui/ValidatedInput.tsx b/frontend/src/ts/components/ui/ValidatedInput.tsx deleted file mode 100644 index db8a720c7b47..000000000000 --- a/frontend/src/ts/components/ui/ValidatedInput.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - splitProps, - createEffect, - JSXElement, - on, - onCleanup, - onMount, -} from "solid-js"; - -import { - ValidatedHtmlInputElement, - ValidationOptions, -} from "../../elements/input-validation"; -import { useRefWithUtils } from "../../hooks/useRefWithUtils"; - -export function ValidatedInput( - props: ValidationOptions & { - value?: string; - placeholder?: string; - class?: string; - type?: string; - autocomplete?: string; - name?: string; - onInput?: (value: T) => void; - onFocus?: () => void; - disabled?: boolean; - revalidateOn?: () => unknown; - }, -): JSXElement { - // Refs are assigned by SolidJS via the ref attribute - const [inputRef, inputEl] = useRefWithUtils(); - - let validatedInput: ValidatedHtmlInputElement | undefined; - - createEffect(() => { - validatedInput?.setValue(props.value ?? null); - }); - - onMount(() => { - const element = inputEl(); - if (element === undefined) return; - - const [_, others] = splitProps(props, [ - "value", - "class", - "placeholder", - "type", - "autocomplete", - "name", - "onFocus", - "revalidateOn", - ]); - validatedInput = new ValidatedHtmlInputElement( - element, - others as ValidationOptions, - ); - validatedInput.setValue(props.value ?? null); - }); - - createEffect( - on( - () => props.revalidateOn?.(), - () => { - if (validatedInput && inputEl()?.getValue() !== "") { - validatedInput.triggerValidation(); - } - }, - { defer: true }, - ), - ); - - onCleanup(() => { - validatedInput?.destroy(); - }); - - return ( -
- props.onInput?.(e.target.value as T)} - onFocus={() => props.onFocus?.()} - /> -
- ); -} diff --git a/frontend/src/ts/components/ui/form/Checkbox.tsx b/frontend/src/ts/components/ui/form/Checkbox.tsx new file mode 100644 index 000000000000..c7c89a1f329a --- /dev/null +++ b/frontend/src/ts/components/ui/form/Checkbox.tsx @@ -0,0 +1,54 @@ +import { AnyFieldApi } from "@tanstack/solid-form"; +import { Accessor, JSXElement } from "solid-js"; + +import { cn } from "../../../utils/cn"; +import { Fa } from "../../common/Fa"; + +export function Checkbox(props: { + field: Accessor; + label?: string; + class?: string; + disabled?: boolean; +}): JSXElement { + const checked = () => props.field().state.value as boolean; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/FieldIndicator.tsx b/frontend/src/ts/components/ui/form/FieldIndicator.tsx new file mode 100644 index 000000000000..0e34dac1677e --- /dev/null +++ b/frontend/src/ts/components/ui/form/FieldIndicator.tsx @@ -0,0 +1,60 @@ +import type { AnyFieldApi } from "@tanstack/solid-form"; + +import { Match, Switch } from "solid-js"; + +import { Balloon } from "../../common/Balloon"; +import { Fa } from "../../common/Fa"; +import { LoadingCircle } from "../../common/LoadingCircle"; + +export type FieldIndicatorProps = { + field: AnyFieldApi; +}; + +export function FieldIndicator(props: FieldIndicatorProps) { + //@ts-expect-error custom meta attributes + const hasWarning = () => props.field.getMeta().hasWarning as boolean; + //@ts-expect-error custom meta attributes + const getWarnings = () => props.field.getMeta().warnings as string[]; + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx new file mode 100644 index 000000000000..e2f5d03563c2 --- /dev/null +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -0,0 +1,45 @@ +import { AnyFieldApi } from "@tanstack/solid-form"; +import { Accessor, JSXElement, Show } from "solid-js"; + +import { cn } from "../../../utils/cn"; +import { FieldIndicator } from "./FieldIndicator"; + +export function InputField(props: { + field: Accessor; + placeholder?: string; + showIndicator?: true; + autocomplete?: string; + type?: string; + disabled?: boolean; + class?: string; + onFocus?: () => void; +}): JSXElement { + return ( +
+ props.field().handleBlur()} + onInput={(e) => props.field().handleChange(e.target.value)} + disabled={props.disabled} + onFocus={() => props.onFocus?.()} + /> + + + +
+ ); +} diff --git a/frontend/src/ts/components/ui/form/SubmitButton.tsx b/frontend/src/ts/components/ui/form/SubmitButton.tsx new file mode 100644 index 000000000000..bca2f98f2817 --- /dev/null +++ b/frontend/src/ts/components/ui/form/SubmitButton.tsx @@ -0,0 +1,53 @@ +import { JSXElement, splitProps } from "solid-js"; + +import { Button, ButtonProps } from "../../common/Button"; + +type FormStateSlice = { + canSubmit: boolean; + isSubmitting: boolean; + isValid: boolean; + isDirty: boolean; +}; + +type SubscribableForm = { + Subscribe: (props: { + selector: (state: { + canSubmit: boolean; + isSubmitting: boolean; + isValid: boolean; + isDirty: boolean; + }) => FormStateSlice; + children: (state: () => FormStateSlice) => JSXElement; + }) => JSXElement; +}; + +export function SubmitButton( + props: { + form: SubscribableForm; + } & Omit, +): JSXElement { + const [local, others] = splitProps(props, ["disabled"]); + return ( + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + isValid: state.isValid, + isDirty: state.isDirty, + })} + children={(state) => ( +
From b42938dc6fd49535e874a102debdae5bf0b73d7b Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Thu, 12 Mar 2026 15:45:54 +0100 Subject: [PATCH 15/27] name --- frontend/src/ts/components/pages/login/Login.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/components/pages/login/Login.tsx b/frontend/src/ts/components/pages/login/Login.tsx index 488548ed7801..be4d892dcd1a 100644 --- a/frontend/src/ts/components/pages/login/Login.tsx +++ b/frontend/src/ts/components/pages/login/Login.tsx @@ -26,7 +26,7 @@ import { SubmitButton } from "../../ui/form/SubmitButton"; import { allFieldsMandatory } from "../../ui/form/utils"; export function Login(): JSXElement { - const doLogin = async ( + const trySignIn = async ( auth: () => Promise, label?: string, ): Promise => { @@ -50,7 +50,7 @@ export function Login(): JSXElement { rememberMe: true, }, onSubmit: async ({ value }) => - doLogin(async () => + trySignIn(async () => signIn(value.email, value.password, value.rememberMe), ), onSubmitInvalid: () => { @@ -74,7 +74,7 @@ export function Login(): JSXElement {