From c9e733e7d6388f668ac1a4c132572c58e95bd61e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 11:21:34 +0200 Subject: [PATCH 01/16] refactor: use solid simple modals (@fehmer) --- .../ts/components/pages/account/Filters.tsx | 125 +++++++++--------- 1 file changed, 59 insertions(+), 66 deletions(-) diff --git a/frontend/src/ts/components/pages/account/Filters.tsx b/frontend/src/ts/components/pages/account/Filters.tsx index 5f0ae2065b6e..a2b78e36e884 100644 --- a/frontend/src/ts/components/pages/account/Filters.tsx +++ b/frontend/src/ts/components/pages/account/Filters.tsx @@ -1,12 +1,13 @@ import { QuoteLength } from "@monkeytype/schemas/configs"; +import { PresetNameSchema } from "@monkeytype/schemas/presets"; import { - ResultFilterPresetNameSchema, ResultFilters, ResultFiltersGroupItem, ResultFiltersKeys, } from "@monkeytype/schemas/users"; import { createMemo, createSignal, For, JSXElement, Show } from "solid-js"; import { SetStoreFunction, unwrap } from "solid-js/store"; +import { z } from "zod"; import { deleteResultFilterPreset, @@ -16,9 +17,8 @@ import { import { type TagItem, useTagsLiveQuery } from "../../../collections/tags"; import { getConfig } from "../../../config/store"; import defaultResultFilters from "../../../constants/default-result-filters"; -import { SimpleModal } from "../../../elements/simple-modal"; +import { showSimpleModal } from "../../../states/simple-modal"; import { FaSolidIcon } from "../../../types/font-awesome"; -import { IsValidResponse } from "../../../types/validation"; import { cn } from "../../../utils/cn"; import { createErrorMessage } from "../../../utils/error"; import { @@ -34,63 +34,6 @@ import { Separator } from "../../common/Separator"; import SlimSelect from "../../ui/SlimSelect"; import { verifyResultFiltersStructure } from "./utils"; -const presetNameValidation = async ( - tagName: string, -): Promise => { - const validationResult = ResultFilterPresetNameSchema.safeParse( - normalizeName(tagName), - ); - if (validationResult.success) return true; - return validationResult.error.errors.map((err) => err.message).join(", "); -}; -const newFilterPresetModal = new SimpleModal({ - id: "newFilterPresetModal", - title: "New Filter Preset", - inputs: [ - { - placeholder: "Preset Name", - type: "text", - initVal: "", - validation: { - isValid: presetNameValidation, - debounceDelay: 0, - }, - }, - ], - buttonText: "add", - execFn: async (thisPopup, name) => { - const filters = thisPopup.context as ResultFilters; - - try { - await insertResultFilterPreset({ name: normalizeName(name), filters }); - return { status: "success", message: "Filter preset created" }; - } catch (e) { - const message = createErrorMessage(e, "Error creating filter preset"); - return { status: "error", message }; - } - }, -}); - -const deleteResultFilterPresetModal = new SimpleModal({ - id: "removeFilterPresetModal", - title: "Delete Filter Preset", - buttonText: "delete", - beforeInitFn: (thisPopup) => { - thisPopup.text = `Are you sure you want to delete preset ${thisPopup.parameters[1]}?`; - }, - execFn: async (thisPopup) => { - try { - await deleteResultFilterPreset({ - presetId: thisPopup.parameters[0] as string, - }); - return { status: "success", message: `Filter preset removed` }; - } catch (e) { - const message = createErrorMessage(e, "Error deleting filter preset"); - return { status: "error", message }; - } - }, -}); - export function Filters(props: { filters: ResultFilters; onChangeFilters: SetStoreFunction; @@ -119,10 +62,29 @@ export function Filters(props: { {" "} diff --git a/frontend/src/ts/components/modals/account-settings/UpdateName.tsx b/frontend/src/ts/components/modals/account-settings/UpdateName.tsx new file mode 100644 index 000000000000..b2ff002a08da --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/UpdateName.tsx @@ -0,0 +1,85 @@ +import { PasswordSchema, UserNameSchema } from "@monkeytype/schemas/users"; +import { z } from "zod"; + +import Ape from "../../../ape"; +import * as DB from "../../../db"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { isDevEnvironment } from "../../../utils/env"; +import { + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; +import { reloadAfter } from "../../../utils/misc"; +import { remoteValidation } from "../../../utils/remote-validation"; + +export function showUpdateNameModal(): void { + showSimpleModal({ + title: "Update name", + buttonText: isUsingPasswordAuthentication() + ? "reauthenticate to update" + : "update", + text: DB.getSnapshot()?.needsToChangeName + ? "You need to change your account name. This might be because you have a duplicate name, no account name or your name is not allowed (contains whitespace or invalid characters). Sorry for the inconvenience." + : undefined, + schema: z.object({ + password: isDevEnvironment() ? z.string().min(6) : PasswordSchema, + newName: UserNameSchema, + }), + inputs: { + password: { + placeholder: "password", + type: "password", + initVal: "", + hidden: !isUsingPasswordAuthentication(), + }, + newName: { + placeholder: "new name", + type: "text", + initVal: "", + validation: { + isValid: remoteValidation( + async (name: string) => + Ape.users.getNameAvailability({ params: { name } }), + { check: (data) => data.available || "Name not available" }, + ), + debounceDelay: 1000, + }, + }, + }, + + execFn: async ({ password, newName }) => { + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + const response = await Ape.users.updateName({ + body: { name: newName }, + }); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to update name", + notificationOptions: { response }, + }; + } + + const snapshot = DB.getSnapshot(); + if (snapshot) { + snapshot.name = newName; + DB.setSnapshot(snapshot); + if (snapshot.needsToChangeName) { + reloadAfter(2); + } + } + + return { + status: "success", + message: "Name updated", + }; + }, + }); +} diff --git a/frontend/src/ts/event-handlers/account-settings.ts b/frontend/src/ts/event-handlers/account-settings.ts new file mode 100644 index 000000000000..4191047edcaa --- /dev/null +++ b/frontend/src/ts/event-handlers/account-settings.ts @@ -0,0 +1,6 @@ +import { showUpdateNameModal } from "../components/modals/account-settings/UpdateName"; +import { qs } from "../utils/dom"; + +qs(".pageAccountSettings")?.onChild("click", "#updateAccountName", () => { + showUpdateNameModal(); +}); diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 10a5f7efaf93..2b5919cde8b6 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -7,6 +7,7 @@ import "solid-devtools"; import "./event-handlers/global"; import "./event-handlers/keymap"; import "./event-handlers/test"; +import "./event-handlers/account-settings"; import "./modals/google-sign-up"; import { init } from "./firebase"; diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index c3d9fccdad0d..87c61561b06d 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -4,7 +4,6 @@ import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = | "updateEmail" - | "updateName" | "updatePassword" | "removeGoogleAuth" | "removeGithubAuth" @@ -23,7 +22,6 @@ export type PopupKey = export const list: Record = { updateEmail: undefined, - updateName: undefined, updatePassword: undefined, removeGoogleAuth: undefined, removeGithubAuth: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 92150c9e408e..adfb71d3967b 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -4,17 +4,8 @@ import * as DB from "../db"; import { resetConfig } from "../config/lifecycle"; import { setConfig } from "../config/setters"; import { showNoticeNotification } from "../states/notifications"; -import { FirebaseError } from "firebase/app"; -import { getAuthenticatedUser, isAuthAvailable } from "../firebase"; import { isAuthenticated } from "../states/core"; -import { - EmailAuthProvider, - User, - linkWithCredential, - reauthenticateWithCredential, - reauthenticateWithPopup, - unlink, -} from "firebase/auth"; +import { EmailAuthProvider, linkWithCredential, unlink } from "firebase/auth"; import { reloadAfter } from "../utils/misc"; import { isDevEnvironment } from "../utils/env"; import { createErrorMessage } from "../utils/error"; @@ -31,7 +22,7 @@ import { } from "@monkeytype/schemas/users"; import FileStorage from "../utils/file-storage"; import { z } from "zod"; -import { remoteValidation } from "../utils/remote-validation"; + import { list, PopupKey, showPopup } from "./simple-modals-base"; import { getTheme } from "../states/theme"; import { normalizeName } from "../utils/strings"; @@ -42,137 +33,16 @@ import { PasswordInput, TextInput, } from "../elements/simple-modal"; +import { + isUsingGithubAuthentication, + isUsingGoogleAuthentication, + isUsingPasswordAuthentication, + reauthenticate, +} from "../utils/firebase-auth"; export { list, showPopup }; export type { PopupKey }; -type AuthMethod = "password" | "github.com" | "google.com"; - -type ReauthSuccess = { - status: "success"; - message: string; - user: User; -}; - -type ReauthFailed = { - status: "error" | "notice"; - message: string; -}; - -type ReauthenticateOptions = { - excludeMethod?: AuthMethod; - password?: string; -}; - -function getPreferredAuthenticationMethod( - exclude?: AuthMethod, -): AuthMethod | undefined { - const authMethods = ["password", "github.com", "google.com"] as AuthMethod[]; - const filteredMethods = authMethods.filter((it) => it !== exclude); - for (const method of filteredMethods) { - if (isUsingAuthentication(method)) return method; - } - return undefined; -} - -function isUsingPasswordAuthentication(): boolean { - return isUsingAuthentication("password"); -} - -function isUsingGithubAuthentication(): boolean { - return isUsingAuthentication("github.com"); -} - -function isUsingGoogleAuthentication(): boolean { - return isUsingAuthentication("google.com"); -} - -function isUsingAuthentication(authProvider: AuthMethod): boolean { - return ( - getAuthenticatedUser()?.providerData.some( - (p) => p.providerId === authProvider, - ) ?? false - ); -} - -async function reauthenticate( - options: ReauthenticateOptions, -): Promise { - if (!isAuthAvailable()) { - return { - status: "error", - message: "Authentication is not initialized", - }; - } - - const user = getAuthenticatedUser(); - if (user === null) { - return { - status: "error", - message: "User is not signed in", - }; - } - - const authMethod = getPreferredAuthenticationMethod(options.excludeMethod); - - try { - if (authMethod === undefined) { - return { - status: "error", - message: - "Failed to reauthenticate: there is no valid authentication present on the account.", - }; - } - - if (authMethod === "password") { - if (options.password === undefined) { - return { - status: "error", - message: "Failed to reauthenticate using password: password missing.", - }; - } - const credential = EmailAuthProvider.credential( - user.email as string, - options.password, - ); - await reauthenticateWithCredential(user, credential); - } else { - const authProvider = - authMethod === "github.com" - ? AccountController.githubProvider - : AccountController.gmailProvider; - await reauthenticateWithPopup(user, authProvider); - } - - return { - status: "success", - message: "Reauthenticated", - user, - }; - } catch (e) { - const typedError = e as FirebaseError; - if (typedError.code === "auth/wrong-password") { - return { - status: "notice", - message: "Incorrect password", - }; - } else if (typedError.code === "auth/invalid-credential") { - return { - status: "notice", - message: - "Password is incorrect or your account does not have password authentication enabled.", - }; - } else { - return { - status: "error", - message: `Failed to reauthenticate: ${ - typedError?.message ?? JSON.stringify(e) - }`, - }; - } - } -} - list.updateEmail = new SimpleModal({ id: "updateEmail", title: "Update email", @@ -406,79 +276,6 @@ list.removePasswordAuth = new SimpleModal({ }, }); -list.updateName = new SimpleModal({ - id: "updateName", - title: "Update name", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - { - placeholder: "new name", - type: "text", - initVal: "", - validation: { - schema: UserNameSchema, - isValid: remoteValidation( - async (name) => Ape.users.getNameAvailability({ params: { name } }), - { check: (data) => data.available || "Name not available" }, - ), - debounceDelay: 1000, - }, - }, - ], - buttonText: "update", - execFn: async (_thisPopup, password, newName): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.updateName({ - body: { name: newName }, - }); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to update name", - notificationOptions: { response }, - }; - } - - const snapshot = DB.getSnapshot(); - if (snapshot) { - snapshot.name = newName; - DB.setSnapshot(snapshot); - if (snapshot.needsToChangeName) { - reloadAfter(2); - } - } - - return { - status: "success", - message: "Name updated", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - if (!isUsingPasswordAuthentication()) { - (thisPopup.inputs[0] as PasswordInput).hidden = true; - thisPopup.buttonText = "reauthenticate to update"; - } - if (snapshot.needsToChangeName === true) { - thisPopup.text = - "You need to change your account name. This might be because you have a duplicate name, no account name or your name is not allowed (contains whitespace or invalid characters). Sorry for the inconvenience."; - } - }, -}); - list.updatePassword = new SimpleModal({ id: "updatePassword", title: "Update password", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 21618bcc7ee1..00c4e3d21de2 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -242,10 +242,6 @@ qs(".pageAccountSettings")?.onChild( }, ); -qs(".pageAccountSettings")?.onChild("click", "#updateAccountName", () => { - showPopup("updateName"); -}); - qs(".pageAccountSettings")?.onChild("click", "#addGoogleAuth", () => { void addGoogleAuth(); }); diff --git a/frontend/src/ts/utils/firebase-auth.ts b/frontend/src/ts/utils/firebase-auth.ts new file mode 100644 index 000000000000..806c5ce22ceb --- /dev/null +++ b/frontend/src/ts/utils/firebase-auth.ts @@ -0,0 +1,137 @@ +import * as AccountController from "../auth"; +import { + EmailAuthProvider, + reauthenticateWithCredential, + reauthenticateWithPopup, + User, +} from "firebase/auth"; +import { getAuthenticatedUser, isAuthAvailable } from "../firebase"; +import { FirebaseError } from "firebase/app"; + +type AuthMethod = "password" | "github.com" | "google.com"; + +type ReauthSuccess = { + status: "success"; + message: string; + user: User; +}; + +type ReauthFailed = { + status: "error" | "notice"; + message: string; +}; + +type ReauthenticateOptions = { + excludeMethod?: AuthMethod; + password?: string; +}; + +export function isUsingPasswordAuthentication(): boolean { + return isUsingAuthentication("password"); +} + +export function isUsingGithubAuthentication(): boolean { + return isUsingAuthentication("github.com"); +} + +export function isUsingGoogleAuthentication(): boolean { + return isUsingAuthentication("google.com"); +} + +function isUsingAuthentication(authProvider: AuthMethod): boolean { + return ( + getAuthenticatedUser()?.providerData.some( + (p) => p.providerId === authProvider, + ) ?? false + ); +} + +//todo maybe move somewhere else? utils/auth? +export async function reauthenticate( + options: ReauthenticateOptions, +): Promise { + if (!isAuthAvailable()) { + return { + status: "error", + message: "Authentication is not initialized", + }; + } + + const user = getAuthenticatedUser(); + if (user === null) { + return { + status: "error", + message: "User is not signed in", + }; + } + + const authMethod = getPreferredAuthenticationMethod(options.excludeMethod); + + try { + if (authMethod === undefined) { + return { + status: "error", + message: + "Failed to reauthenticate: there is no valid authentication present on the account.", + }; + } + + if (authMethod === "password") { + if (options.password === undefined) { + return { + status: "error", + message: "Failed to reauthenticate using password: password missing.", + }; + } + const credential = EmailAuthProvider.credential( + user.email as string, + options.password, + ); + await reauthenticateWithCredential(user, credential); + } else { + const authProvider = + authMethod === "github.com" + ? AccountController.githubProvider + : AccountController.gmailProvider; + await reauthenticateWithPopup(user, authProvider); + } + + return { + status: "success", + message: "Reauthenticated", + user, + }; + } catch (e) { + const typedError = e as FirebaseError; + if (typedError.code === "auth/wrong-password") { + return { + status: "notice", + message: "Incorrect password", + }; + } else if (typedError.code === "auth/invalid-credential") { + return { + status: "notice", + message: + "Password is incorrect or your account does not have password authentication enabled.", + }; + } else { + return { + status: "error", + message: `Failed to reauthenticate: ${ + typedError?.message ?? JSON.stringify(e) + }`, + }; + } + } +} + +function getPreferredAuthenticationMethod( + exclude?: AuthMethod, +): AuthMethod | undefined { + const authMethods = ["password", "github.com", "google.com"] as AuthMethod[]; + const filteredMethods = authMethods.filter((it) => it !== exclude); + for (const method of filteredMethods) { + if (isUsingAuthentication(method)) return method; + } + return undefined; +} From 74719e7715d99b687269f97bda060f84ad91b60c Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 14:23:51 +0200 Subject: [PATCH 05/16] forgot a file --- frontend/src/ts/utils/firebase-auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/ts/utils/firebase-auth.ts b/frontend/src/ts/utils/firebase-auth.ts index 806c5ce22ceb..56159253a748 100644 --- a/frontend/src/ts/utils/firebase-auth.ts +++ b/frontend/src/ts/utils/firebase-auth.ts @@ -46,7 +46,6 @@ function isUsingAuthentication(authProvider: AuthMethod): boolean { ); } -//todo maybe move somewhere else? utils/auth? export async function reauthenticate( options: ReauthenticateOptions, ): Promise { From bc313ed28534b9c1b542890b153592a90d1e84b2 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 15:08:12 +0200 Subject: [PATCH 06/16] update email, extract getPasswordSchema, revert handlers for account-settings --- .../modals/account-settings/UpdateEmail.tsx | 87 +++++++++++++++++++ .../modals/account-settings/UpdateName.tsx | 21 ++--- .../ts/components/pages/login/Register.tsx | 13 +-- .../src/ts/event-handlers/account-settings.ts | 6 -- frontend/src/ts/index.ts | 1 - frontend/src/ts/modals/simple-modals-base.ts | 2 - frontend/src/ts/modals/simple-modals.ts | 81 ----------------- frontend/src/ts/pages/account-settings.ts | 8 +- frontend/src/ts/utils/firebase-auth.ts | 7 ++ 9 files changed, 115 insertions(+), 111 deletions(-) create mode 100644 frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx delete mode 100644 frontend/src/ts/event-handlers/account-settings.ts diff --git a/frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx b/frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx new file mode 100644 index 000000000000..876e30b6cc23 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx @@ -0,0 +1,87 @@ +import { UserEmailSchema } from "@monkeytype/schemas/users"; +import { z } from "zod"; + +import Ape from "../../../ape"; +import { signOut } from "../../../auth"; +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { + getPasswordSchema, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; + +export function showUpdateEmailModal(): void { + if (!isAuthenticated()) return; + if (!isUsingPasswordAuthentication()) { + showNoticeNotification("Password authentication is not enabled"); + return; + } + + showSimpleModal({ + title: "Update email", + buttonText: "update", + schema: z.object({ + password: getPasswordSchema(), + email: UserEmailSchema, + emailConfirm: UserEmailSchema, + }), + inputs: { + password: { + placeholder: "Password", + type: "password", + initVal: "", + }, + email: { + type: "text", + placeholder: "New email", + initVal: "", + }, + emailConfirm: { + type: "text", + placeholder: "Confirm new email", + initVal: "", + }, + }, + + execFn: async ({ password, email, emailConfirm }) => { + if (email !== emailConfirm) { + return { + status: "notice", + message: "Emails don't match", + }; + } + + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + const response = await Ape.users.updateEmail({ + body: { + newEmail: email, + previousEmail: reauth.user.email as string, + }, + }); + + if (response.status !== 200) { + return { + status: "error", + message: "Failed to update email", + notificationOptions: { response }, + }; + } + + signOut(); + + return { + status: "success", + message: "Email updated", + }; + }, + }); +} diff --git a/frontend/src/ts/components/modals/account-settings/UpdateName.tsx b/frontend/src/ts/components/modals/account-settings/UpdateName.tsx index b2ff002a08da..85b1b426bc04 100644 --- a/frontend/src/ts/components/modals/account-settings/UpdateName.tsx +++ b/frontend/src/ts/components/modals/account-settings/UpdateName.tsx @@ -1,11 +1,12 @@ -import { PasswordSchema, UserNameSchema } from "@monkeytype/schemas/users"; +import { UserNameSchema } from "@monkeytype/schemas/users"; import { z } from "zod"; import Ape from "../../../ape"; import * as DB from "../../../db"; +import { isAuthenticated } from "../../../states/core"; import { showSimpleModal } from "../../../states/simple-modal"; -import { isDevEnvironment } from "../../../utils/env"; import { + getPasswordSchema, isUsingPasswordAuthentication, reauthenticate, } from "../../../utils/firebase-auth"; @@ -13,6 +14,9 @@ import { reloadAfter } from "../../../utils/misc"; import { remoteValidation } from "../../../utils/remote-validation"; export function showUpdateNameModal(): void { + const snapshot = DB.getSnapshot(); + if (!isAuthenticated() || !snapshot) return; + showSimpleModal({ title: "Update name", buttonText: isUsingPasswordAuthentication() @@ -22,7 +26,7 @@ export function showUpdateNameModal(): void { ? "You need to change your account name. This might be because you have a duplicate name, no account name or your name is not allowed (contains whitespace or invalid characters). Sorry for the inconvenience." : undefined, schema: z.object({ - password: isDevEnvironment() ? z.string().min(6) : PasswordSchema, + password: getPasswordSchema(), newName: UserNameSchema, }), inputs: { @@ -67,13 +71,10 @@ export function showUpdateNameModal(): void { }; } - const snapshot = DB.getSnapshot(); - if (snapshot) { - snapshot.name = newName; - DB.setSnapshot(snapshot); - if (snapshot.needsToChangeName) { - reloadAfter(2); - } + snapshot.name = newName; + DB.setSnapshot(snapshot); + if (snapshot.needsToChangeName) { + reloadAfter(2); } return { diff --git a/frontend/src/ts/components/pages/login/Register.tsx b/frontend/src/ts/components/pages/login/Register.tsx index 3eedcc384a8c..0284dd83996f 100644 --- a/frontend/src/ts/components/pages/login/Register.tsx +++ b/frontend/src/ts/components/pages/login/Register.tsx @@ -1,11 +1,6 @@ -import { - PasswordSchema, - UserEmailSchema, - UserNameSchema, -} from "@monkeytype/schemas/users"; +import { UserEmailSchema, UserNameSchema } from "@monkeytype/schemas/users"; import { createForm } from "@tanstack/solid-form"; import { JSXElement } from "solid-js"; -import { z } from "zod"; import Ape from "../../../ape"; import { signUp } from "../../../auth"; @@ -19,7 +14,7 @@ import { showErrorNotification, showNoticeNotification, } from "../../../states/notifications"; -import { isDevEnvironment } from "../../../utils/env"; +import { getPasswordSchema } from "../../../utils/firebase-auth"; import { remoteValidationForm } from "../../../utils/remote-validation"; import { H3 } from "../../common/Headers"; import { showRegisterCaptchaModal } from "../../modals/RegisterCaptchaModal"; @@ -219,9 +214,7 @@ export function Register(): JSXElement { ( { - showUpdateNameModal(); -}); diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 2b5919cde8b6..10a5f7efaf93 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -7,7 +7,6 @@ import "solid-devtools"; import "./event-handlers/global"; import "./event-handlers/keymap"; import "./event-handlers/test"; -import "./event-handlers/account-settings"; import "./modals/google-sign-up"; import { init } from "./firebase"; diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index 87c61561b06d..994d6c344b0d 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -3,7 +3,6 @@ import { ShowOptions } from "../utils/animated-modal"; import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = - | "updateEmail" | "updatePassword" | "removeGoogleAuth" | "removeGithubAuth" @@ -21,7 +20,6 @@ export type PopupKey = | "devGenerateData"; export const list: Record = { - updateEmail: undefined, updatePassword: undefined, removeGoogleAuth: undefined, removeGithubAuth: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index adfb71d3967b..e0ed8b0bdee5 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -17,7 +17,6 @@ import { GenerateDataRequest } from "@monkeytype/contracts/dev"; import { CustomThemeNameSchema, PasswordSchema, - UserEmailSchema, UserNameSchema, } from "@monkeytype/schemas/users"; import FileStorage from "../utils/file-storage"; @@ -43,86 +42,6 @@ import { export { list, showPopup }; export type { PopupKey }; -list.updateEmail = new SimpleModal({ - id: "updateEmail", - title: "Update email", - inputs: [ - { - placeholder: "Password", - type: "password", - initVal: "", - }, - { - type: "text", - placeholder: "New email", - initVal: "", - validation: { - schema: UserEmailSchema, - }, - }, - { - type: "text", - placeholder: "Confirm new email", - initVal: "", - validation: { - schema: UserEmailSchema, - }, - }, - ], - buttonText: "update", - execFn: async ( - _thisPopup, - password, - email, - emailConfirm, - ): Promise => { - if (email !== emailConfirm) { - return { - status: "notice", - message: "Emails don't match", - }; - } - - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.updateEmail({ - body: { - newEmail: email, - previousEmail: reauth.user.email as string, - }, - }); - - if (response.status !== 200) { - return { - status: "error", - message: "Failed to update email", - notificationOptions: { response }, - }; - } - - AccountController.signOut(); - - return { - status: "success", - message: "Email updated", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = ""; - thisPopup.text = "Password authentication is not enabled"; - } - }, -}); - list.removeGoogleAuth = new SimpleModal({ id: "removeGoogleAuth", title: "Remove Google authentication", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 00c4e3d21de2..b221cdf840cc 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -15,6 +15,8 @@ import { authEvent } from "../events/auth"; import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; import { showPopup } from "../modals/simple-modals-base"; import { addGithubAuth, addGoogleAuth } from "../auth"; +import { showUpdateEmailModal } from "../components/modals/account-settings/UpdateEmail"; +import { showUpdateNameModal } from "../components/modals/account-settings/UpdateName"; const pageElement = qsr(".page.pageAccountSettings"); @@ -206,8 +208,12 @@ qs(".pageAccountSettings")?.onChild("click", "#addPasswordAuth", () => { showPopup("addPasswordAuth"); }); +qs(".pageAccountSettings")?.onChild("click", "#updateAccountName", () => { + showUpdateNameModal(); +}); + qs(".pageAccountSettings")?.onChild("click", "#emailPasswordAuth", () => { - showPopup("updateEmail"); + showUpdateEmailModal(); }); qs(".pageAccountSettings")?.onChild("click", "#passPasswordAuth", () => { diff --git a/frontend/src/ts/utils/firebase-auth.ts b/frontend/src/ts/utils/firebase-auth.ts index 56159253a748..79805adb1eea 100644 --- a/frontend/src/ts/utils/firebase-auth.ts +++ b/frontend/src/ts/utils/firebase-auth.ts @@ -7,6 +7,9 @@ import { } from "firebase/auth"; import { getAuthenticatedUser, isAuthAvailable } from "../firebase"; import { FirebaseError } from "firebase/app"; +import { isDevEnvironment } from "./env"; +import { PasswordSchema } from "@monkeytype/schemas/users"; +import { z, ZodString } from "zod"; type AuthMethod = "password" | "github.com" | "google.com"; @@ -134,3 +137,7 @@ function getPreferredAuthenticationMethod( } return undefined; } + +export function getPasswordSchema(): ZodString { + return isDevEnvironment() ? z.string().min(6) : PasswordSchema; +} From 7c26ada429e9df4c70a081f8e3a0fb16e93003d8 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 15:36:01 +0200 Subject: [PATCH 07/16] updatePassword --- .../ts/components/layout/overlays/Banners.tsx | 2 +- .../account-settings/RemoveGoogleAuth.tsx | 0 .../{UpdateEmail.tsx => UpdateEmailModal.tsx} | 0 .../{UpdateName.tsx => UpdateNameModal.tsx} | 0 .../account-settings/UpdatePasswordModal.tsx | 84 ++++++++++++++++++ frontend/src/ts/modals/simple-modals-base.ts | 2 - frontend/src/ts/modals/simple-modals.ts | 87 +------------------ frontend/src/ts/pages/account-settings.ts | 7 +- 8 files changed, 91 insertions(+), 91 deletions(-) create mode 100644 frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx rename frontend/src/ts/components/modals/account-settings/{UpdateEmail.tsx => UpdateEmailModal.tsx} (100%) rename frontend/src/ts/components/modals/account-settings/{UpdateName.tsx => UpdateNameModal.tsx} (100%) create mode 100644 frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx diff --git a/frontend/src/ts/components/layout/overlays/Banners.tsx b/frontend/src/ts/components/layout/overlays/Banners.tsx index 11a9b947f6b4..60ed03a466a9 100644 --- a/frontend/src/ts/components/layout/overlays/Banners.tsx +++ b/frontend/src/ts/components/layout/overlays/Banners.tsx @@ -13,7 +13,7 @@ import { setGlobalOffsetTop } from "../../../states/core"; import { getSnapshot } from "../../../states/snapshot"; import { cn } from "../../../utils/cn"; import { Fa } from "../../common/Fa"; -import { showUpdateNameModal } from "../../modals/account-settings/UpdateName"; +import { showUpdateNameModal } from "../../modals/account-settings/UpdateNameModal"; function Banner(props: BannerType): JSXElement { const remove = (): void => { diff --git a/frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx b/frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx b/frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx similarity index 100% rename from frontend/src/ts/components/modals/account-settings/UpdateEmail.tsx rename to frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx diff --git a/frontend/src/ts/components/modals/account-settings/UpdateName.tsx b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx similarity index 100% rename from frontend/src/ts/components/modals/account-settings/UpdateName.tsx rename to frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx diff --git a/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx new file mode 100644 index 000000000000..26c5a59f9fd7 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/UpdatePasswordModal.tsx @@ -0,0 +1,84 @@ +import { z } from "zod"; + +import Ape from "../../../ape"; +import { signOut } from "../../../auth"; +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { + getPasswordSchema, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; + +export function showUpdatePasswordModal(): void { + if (!isAuthenticated()) return; + if (!isUsingPasswordAuthentication()) { + showNoticeNotification("Password authentication is not enabled"); + } + showSimpleModal({ + title: "Update password", + schema: z.object({ + previousPass: getPasswordSchema(), + newPassword: getPasswordSchema(), + newPassConfirm: getPasswordSchema(), + }), + inputs: { + previousPass: { + placeholder: "current password", + type: "password", + }, + newPassword: { + placeholder: "new password", + type: "password", + }, + newPassConfirm: { + placeholder: "confirm new password", + type: "password", + }, + }, + buttonText: "update", + execFn: async ({ previousPass, newPassword, newPassConfirm }) => { + if (newPassword !== newPassConfirm) { + return { + status: "notice", + message: "New passwords don't match", + }; + } + + if (newPassword === previousPass) { + return { + status: "notice", + message: "New password must be different from previous password", + }; + } + + const reauth = await reauthenticate({ password: previousPass }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + const response = await Ape.users.updatePassword({ + body: { newPassword }, + }); + + if (response.status !== 200) { + return { + status: "error", + message: "Failed to update password", + notificationOptions: { response }, + }; + } + + signOut(); + + return { + status: "success", + message: "Password updated", + }; + }, + }); +} diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index 994d6c344b0d..92fca93d4ebf 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -3,7 +3,6 @@ import { ShowOptions } from "../utils/animated-modal"; import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = - | "updatePassword" | "removeGoogleAuth" | "removeGithubAuth" | "removePasswordAuth" @@ -20,7 +19,6 @@ export type PopupKey = | "devGenerateData"; export const list: Record = { - updatePassword: undefined, removeGoogleAuth: undefined, removeGithubAuth: undefined, removePasswordAuth: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index e0ed8b0bdee5..9e77d4ef58ab 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -1,5 +1,5 @@ import Ape from "../ape"; -import * as AccountController from "../auth"; + import * as DB from "../db"; import { resetConfig } from "../config/lifecycle"; import { setConfig } from "../config/setters"; @@ -7,7 +7,7 @@ import { showNoticeNotification } from "../states/notifications"; import { isAuthenticated } from "../states/core"; import { EmailAuthProvider, linkWithCredential, unlink } from "firebase/auth"; import { reloadAfter } from "../utils/misc"; -import { isDevEnvironment } from "../utils/env"; + import { createErrorMessage } from "../utils/error"; import * as ThemeController from "../controllers/theme-controller"; import * as CustomThemes from "../collections/custom-themes"; @@ -16,11 +16,9 @@ import * as AccountSettings from "../pages/account-settings"; import { GenerateDataRequest } from "@monkeytype/contracts/dev"; import { CustomThemeNameSchema, - PasswordSchema, UserNameSchema, } from "@monkeytype/schemas/users"; import FileStorage from "../utils/file-storage"; -import { z } from "zod"; import { list, PopupKey, showPopup } from "./simple-modals-base"; import { getTheme } from "../states/theme"; @@ -195,87 +193,6 @@ list.removePasswordAuth = new SimpleModal({ }, }); -list.updatePassword = new SimpleModal({ - id: "updatePassword", - title: "Update password", - inputs: [ - { - placeholder: "current password", - type: "password", - initVal: "", - }, - { - placeholder: "new password", - type: "password", - initVal: "", - validation: { - schema: isDevEnvironment() ? z.string().min(6) : PasswordSchema, - }, - }, - { - placeholder: "confirm new password", - type: "password", - initVal: "", - }, - ], - buttonText: "update", - execFn: async ( - _thisPopup, - previousPass, - newPassword, - newPassConfirm, - ): Promise => { - if (newPassword !== newPassConfirm) { - return { - status: "notice", - message: "New passwords don't match", - }; - } - - if (newPassword === previousPass) { - return { - status: "notice", - message: "New password must be different from previous password", - }; - } - - const reauth = await reauthenticate({ password: previousPass }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.updatePassword({ - body: { newPassword }, - }); - - if (response.status !== 200) { - return { - status: "error", - message: "Failed to update password", - notificationOptions: { response }, - }; - } - - AccountController.signOut(); - - return { - status: "success", - message: "Password updated", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = ""; - thisPopup.text = "Password authentication is not enabled"; - } - }, -}); - list.addPasswordAuth = new SimpleModal({ id: "addPasswordAuth", title: "Add password authentication", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index b221cdf840cc..dbb85d4e089c 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -15,8 +15,9 @@ import { authEvent } from "../events/auth"; import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; import { showPopup } from "../modals/simple-modals-base"; import { addGithubAuth, addGoogleAuth } from "../auth"; -import { showUpdateEmailModal } from "../components/modals/account-settings/UpdateEmail"; -import { showUpdateNameModal } from "../components/modals/account-settings/UpdateName"; +import { showUpdateEmailModal } from "../components/modals/account-settings/UpdateEmailModal"; +import { showUpdateNameModal } from "../components/modals/account-settings/UpdateNameModal"; +import { showUpdatePasswordModal } from "../components/modals/account-settings/UpdatePasswordModal"; const pageElement = qsr(".page.pageAccountSettings"); @@ -217,7 +218,7 @@ qs(".pageAccountSettings")?.onChild("click", "#emailPasswordAuth", () => { }); qs(".pageAccountSettings")?.onChild("click", "#passPasswordAuth", () => { - showPopup("updatePassword"); + showUpdatePasswordModal(); }); qs(".pageAccountSettings")?.onChild("click", "#deleteAccount", () => { From 6292501b80d1a62f430ea5afbaa4e73a8b43154e Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 18:28:55 +0200 Subject: [PATCH 08/16] remove auth modal --- .../RemoveAuthMethodModal.tsx | 101 +++++++++++ .../account-settings/RemoveGoogleAuth.tsx | 0 frontend/src/ts/modals/simple-modals-base.ts | 6 - frontend/src/ts/modals/simple-modals.ts | 157 +----------------- frontend/src/ts/pages/account-settings.ts | 7 +- frontend/src/ts/utils/firebase-auth.ts | 2 +- 6 files changed, 107 insertions(+), 166 deletions(-) create mode 100644 frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx delete mode 100644 frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx diff --git a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx new file mode 100644 index 000000000000..285723e63edd --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx @@ -0,0 +1,101 @@ +import { unlink } from "firebase/auth"; +import { z } from "zod"; + +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { createErrorMessage } from "../../../utils/error"; +import { + AuthMethod, + getPasswordSchema, + isUsingGithubAuthentication, + isUsingGoogleAuthentication, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; +import { reloadAfter } from "../../../utils/misc"; + +export function showRemoveAuthMethodModal(options: { + authMethod: AuthMethod; + callback: () => void; +}): void { + if (!isAuthenticated()) return; + + //check there is at least one authentication remaining + const hasRemainingAuth = [ + isUsingPasswordAuthentication() && options.authMethod !== "password", + isUsingGithubAuthentication() && options.authMethod !== "github.com", + isUsingGoogleAuthentication() && options.authMethod !== "google.com", + ].find((it) => it); + + if (!hasRemainingAuth) { + showNoticeNotification("No remaining authentication enabled"); + return; + } + + const provider = + options.authMethod === "password" + ? "password" + : options.authMethod === "github.com" + ? "Github" + : "Google"; + + showSimpleModal({ + title: `Remove ${provider} authentication`, + buttonText: "remove", + buttonAlwaysEnabled: options.authMethod !== "password", + schema: z.object({ + password: getPasswordSchema(), + checked: z.literal(true), + }), + inputs: { + password: { + placeholder: "Password", + type: "password", + hidden: + !isUsingPasswordAuthentication() || options.authMethod === "password", + }, + checked: { + type: "checkbox", + label: `I understand I will lose access to my Monkeytype account if my Google/GitHub account is lost or disabled.`, + hidden: options.authMethod !== "password", + }, + }, + + execFn: async ({ password }) => { + const reauth = await reauthenticate({ + password, + excludeMethod: options.authMethod, + }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + try { + await unlink(reauth.user, options.authMethod); + } catch (e) { + const message = createErrorMessage( + e, + options.authMethod === "password" + ? "Failed to remove password authentication" + : `Failed to unlink ${provider} account`, + ); + return { + status: "error", + message, + }; + } + + options.callback(); + + reloadAfter(3); + return { + status: "success", + message: `${provider} authentication removed`, + }; + }, + }); +} diff --git a/frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx b/frontend/src/ts/components/modals/account-settings/RemoveGoogleAuth.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index 92fca93d4ebf..e162da70a5d9 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -3,9 +3,6 @@ import { ShowOptions } from "../utils/animated-modal"; import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = - | "removeGoogleAuth" - | "removeGithubAuth" - | "removePasswordAuth" | "addPasswordAuth" | "deleteAccount" | "resetAccount" @@ -19,9 +16,6 @@ export type PopupKey = | "devGenerateData"; export const list: Record = { - removeGoogleAuth: undefined, - removeGithubAuth: undefined, - removePasswordAuth: undefined, addPasswordAuth: undefined, deleteAccount: undefined, resetAccount: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 9e77d4ef58ab..4c51bbd3bbb5 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -5,7 +5,7 @@ import { resetConfig } from "../config/lifecycle"; import { setConfig } from "../config/setters"; import { showNoticeNotification } from "../states/notifications"; import { isAuthenticated } from "../states/core"; -import { EmailAuthProvider, linkWithCredential, unlink } from "firebase/auth"; +import { EmailAuthProvider, linkWithCredential } from "firebase/auth"; import { reloadAfter } from "../utils/misc"; import { createErrorMessage } from "../utils/error"; @@ -31,8 +31,6 @@ import { TextInput, } from "../elements/simple-modal"; import { - isUsingGithubAuthentication, - isUsingGoogleAuthentication, isUsingPasswordAuthentication, reauthenticate, } from "../utils/firebase-auth"; @@ -40,159 +38,6 @@ import { export { list, showPopup }; export type { PopupKey }; -list.removeGoogleAuth = new SimpleModal({ - id: "removeGoogleAuth", - title: "Remove Google authentication", - inputs: [ - { - placeholder: "Password", - type: "password", - initVal: "", - }, - ], - buttonText: "remove", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ - password, - excludeMethod: "google.com", - }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - try { - await unlink(reauth.user, "google.com"); - } catch (e) { - const message = createErrorMessage(e, "Failed to unlink Google account"); - return { - status: "error", - message, - }; - } - - AccountSettings.updateUI(); - - reloadAfter(3); - return { - status: "success", - message: "Google authentication removed", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - if (!isUsingGithubAuthentication()) { - thisPopup.buttonText = ""; - thisPopup.text = "Password or GitHub authentication is not enabled"; - } - } - }, -}); - -list.removeGithubAuth = new SimpleModal({ - id: "removeGithubAuth", - title: "Remove GitHub authentication", - inputs: [ - { - placeholder: "Password", - type: "password", - initVal: "", - }, - ], - buttonText: "remove", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ - password, - excludeMethod: "github.com", - }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - try { - await unlink(reauth.user, "github.com"); - } catch (e) { - const message = createErrorMessage(e, "Failed to unlink GitHub account"); - return { - status: "error", - message, - }; - } - - AccountSettings.updateUI(); - - reloadAfter(3); - return { - status: "success", - message: "GitHub authentication removed", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - if (!isUsingGoogleAuthentication()) { - thisPopup.buttonText = ""; - thisPopup.text = "Password or Google authentication is not enabled"; - } - } - }, -}); - -list.removePasswordAuth = new SimpleModal({ - id: "removePaswordAuth", - title: "Remove Password authentication", - inputs: [ - { - type: "checkbox", - label: `I understand I will lose access to my Monkeytype account if my Google/GitHub account is lost or disabled.`, - }, - ], - buttonText: "reauthenticate to remove", - execFn: async (_thisPopup): Promise => { - const reauth = await reauthenticate({ - excludeMethod: "password", - }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - try { - await unlink(reauth.user, "password"); - } catch (e) { - const message = createErrorMessage( - e, - "Failed to remove password authentication", - ); - return { - status: "error", - message, - }; - } - - AccountSettings.updateUI(); - - reloadAfter(3); - return { - status: "success", - message: "Password authentication removed", - }; - }, - beforeInitFn: (): void => { - if (!isAuthenticated()) return; - }, -}); - list.addPasswordAuth = new SimpleModal({ id: "addPasswordAuth", title: "Add password authentication", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index dbb85d4e089c..d627cc336ded 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -18,6 +18,7 @@ import { addGithubAuth, addGoogleAuth } from "../auth"; import { showUpdateEmailModal } from "../components/modals/account-settings/UpdateEmailModal"; import { showUpdateNameModal } from "../components/modals/account-settings/UpdateNameModal"; import { showUpdatePasswordModal } from "../components/modals/account-settings/UpdatePasswordModal"; +import { showRemoveAuthMethodModal } from "../components/modals/account-settings/RemoveAuthMethodModal"; const pageElement = qsr(".page.pageAccountSettings"); @@ -194,15 +195,15 @@ qs(".pageAccountSettings")?.onChild("click", "#unlinkDiscordButton", () => { }); qs(".pageAccountSettings")?.onChild("click", "#removeGoogleAuth", () => { - showPopup("removeGoogleAuth"); + showRemoveAuthMethodModal({ authMethod: "google.com", callback: updateUI }); }); qs(".pageAccountSettings")?.onChild("click", "#removeGithubAuth", () => { - showPopup("removeGithubAuth"); + showRemoveAuthMethodModal({ authMethod: "github.com", callback: updateUI }); }); qs(".pageAccountSettings")?.onChild("click", "#removePasswordAuth", () => { - showPopup("removePasswordAuth"); + showRemoveAuthMethodModal({ authMethod: "password", callback: updateUI }); }); qs(".pageAccountSettings")?.onChild("click", "#addPasswordAuth", () => { diff --git a/frontend/src/ts/utils/firebase-auth.ts b/frontend/src/ts/utils/firebase-auth.ts index 79805adb1eea..3d0f2794862b 100644 --- a/frontend/src/ts/utils/firebase-auth.ts +++ b/frontend/src/ts/utils/firebase-auth.ts @@ -11,7 +11,7 @@ import { isDevEnvironment } from "./env"; import { PasswordSchema } from "@monkeytype/schemas/users"; import { z, ZodString } from "zod"; -type AuthMethod = "password" | "github.com" | "google.com"; +export type AuthMethod = "password" | "github.com" | "google.com"; type ReauthSuccess = { status: "success"; From 489886fb0d11d9ff779e4bbbc2fb31ac75ef259b Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 19:01:26 +0200 Subject: [PATCH 09/16] review comments --- .../account-settings/RemoveAuthMethodModal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx index 285723e63edd..9742eadc4a87 100644 --- a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx @@ -33,15 +33,15 @@ export function showRemoveAuthMethodModal(options: { return; } - const provider = + const methodDisplay = options.authMethod === "password" - ? "password" + ? "Password" : options.authMethod === "github.com" - ? "Github" + ? "GitHub" : "Google"; showSimpleModal({ - title: `Remove ${provider} authentication`, + title: `Remove ${methodDisplay} authentication`, buttonText: "remove", buttonAlwaysEnabled: options.authMethod !== "password", schema: z.object({ @@ -81,7 +81,7 @@ export function showRemoveAuthMethodModal(options: { e, options.authMethod === "password" ? "Failed to remove password authentication" - : `Failed to unlink ${provider} account`, + : `Failed to unlink ${methodDisplay} account`, ); return { status: "error", @@ -94,7 +94,7 @@ export function showRemoveAuthMethodModal(options: { reloadAfter(3); return { status: "success", - message: `${provider} authentication removed`, + message: `${methodDisplay} authentication removed`, }; }, }); From dce531c26ca464e3eb78c3c6bd2d196b937dfc7a Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 19:10:59 +0200 Subject: [PATCH 10/16] add password auth modal --- .../account-settings/AddPasswordAuthModal.tsx | 103 ++++++++++++++++++ frontend/src/ts/modals/simple-modals-base.ts | 2 - frontend/src/ts/modals/simple-modals.ts | 95 ---------------- frontend/src/ts/pages/account-settings.ts | 3 +- 4 files changed, 105 insertions(+), 98 deletions(-) create mode 100644 frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx diff --git a/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx new file mode 100644 index 000000000000..c44079b53d9d --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx @@ -0,0 +1,103 @@ +import { UserEmailSchema } from "@monkeytype/schemas/users"; +import { EmailAuthProvider, linkWithCredential } from "firebase/auth"; +import { z } from "zod"; + +import Ape from "../../../ape"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { createErrorMessage } from "../../../utils/error"; +import { + getPasswordSchema, + reauthenticate, +} from "../../../utils/firebase-auth"; + +export function showAddPasswordAuthModal(options: { + callback: () => void; +}): void { + showSimpleModal({ + title: "Add password authentication", + buttonText: "reauthenticate to add", + schema: z.object({ + email: UserEmailSchema, + emailConfirm: UserEmailSchema, + password: getPasswordSchema(), + passConfirm: getPasswordSchema(), + }), + inputs: { + email: { + placeholder: "email", + type: "email", + }, + emailConfirm: { + placeholder: "confirm email", + type: "email", + }, + password: { + placeholder: "new password", + type: "password", + }, + passConfirm: { + placeholder: "confirm new password", + type: "password", + }, + }, + + execFn: async ({ email, emailConfirm, password, passConfirm }) => { + if (email !== emailConfirm) { + return { + status: "notice", + message: "Emails don't match", + }; + } + + if (password !== passConfirm) { + return { + status: "notice", + message: "Passwords don't match", + }; + } + + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + try { + const credential = EmailAuthProvider.credential(email, password); + await linkWithCredential(reauth.user, credential); + } catch (e) { + const message = createErrorMessage( + e, + "Failed to add password authentication", + ); + return { + status: "error", + message, + }; + } + + const response = await Ape.users.updateEmail({ + body: { + newEmail: email, + previousEmail: reauth.user.email as string, + }, + }); + if (response.status !== 200) { + return { + status: "error", + message: + "Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error", + notificationOptions: { response }, + }; + } + + options.callback(); + return { + status: "success", + message: "Password authentication added", + }; + }, + }); +} diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index e162da70a5d9..981eb9607768 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -3,7 +3,6 @@ import { ShowOptions } from "../utils/animated-modal"; import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = - | "addPasswordAuth" | "deleteAccount" | "resetAccount" | "optOutOfLeaderboards" @@ -16,7 +15,6 @@ export type PopupKey = | "devGenerateData"; export const list: Record = { - addPasswordAuth: undefined, deleteAccount: undefined, resetAccount: undefined, optOutOfLeaderboards: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 4c51bbd3bbb5..94c7ad1d6cdc 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -5,10 +5,7 @@ import { resetConfig } from "../config/lifecycle"; import { setConfig } from "../config/setters"; import { showNoticeNotification } from "../states/notifications"; import { isAuthenticated } from "../states/core"; -import { EmailAuthProvider, linkWithCredential } from "firebase/auth"; import { reloadAfter } from "../utils/misc"; - -import { createErrorMessage } from "../utils/error"; import * as ThemeController from "../controllers/theme-controller"; import * as CustomThemes from "../collections/custom-themes"; import * as AccountSettings from "../pages/account-settings"; @@ -38,98 +35,6 @@ import { export { list, showPopup }; export type { PopupKey }; -list.addPasswordAuth = new SimpleModal({ - id: "addPasswordAuth", - title: "Add password authentication", - inputs: [ - { - placeholder: "email", - type: "email", - initVal: "", - }, - { - placeholder: "confirm email", - type: "email", - initVal: "", - }, - { - placeholder: "new password", - type: "password", - initVal: "", - }, - { - placeholder: "confirm new password", - type: "password", - initVal: "", - }, - ], - buttonText: "reauthenticate to add", - execFn: async ( - _thisPopup, - email, - emailConfirm, - password, - passConfirm, - ): Promise => { - if (email !== emailConfirm) { - return { - status: "notice", - message: "Emails don't match", - }; - } - - if (password !== passConfirm) { - return { - status: "notice", - message: "Passwords don't match", - }; - } - - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - try { - const credential = EmailAuthProvider.credential(email, password); - await linkWithCredential(reauth.user, credential); - } catch (e) { - const message = createErrorMessage( - e, - "Failed to add password authentication", - ); - return { - status: "error", - message, - }; - } - - const response = await Ape.users.updateEmail({ - body: { - newEmail: email, - previousEmail: reauth.user.email as string, - }, - }); - if (response.status !== 200) { - return { - status: "error", - message: - "Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error", - notificationOptions: { response }, - }; - } - - AccountSettings.updateUI(); - return { - status: "success", - message: "Password authentication added", - }; - }, -}); - list.deleteAccount = new SimpleModal({ id: "deleteAccount", title: "Delete account", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index d627cc336ded..6edbbb0814f3 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -19,6 +19,7 @@ import { showUpdateEmailModal } from "../components/modals/account-settings/Upda import { showUpdateNameModal } from "../components/modals/account-settings/UpdateNameModal"; import { showUpdatePasswordModal } from "../components/modals/account-settings/UpdatePasswordModal"; import { showRemoveAuthMethodModal } from "../components/modals/account-settings/RemoveAuthMethodModal"; +import { showAddPasswordAuthModal } from "../components/modals/account-settings/AddPasswordAuthModal"; const pageElement = qsr(".page.pageAccountSettings"); @@ -207,7 +208,7 @@ qs(".pageAccountSettings")?.onChild("click", "#removePasswordAuth", () => { }); qs(".pageAccountSettings")?.onChild("click", "#addPasswordAuth", () => { - showPopup("addPasswordAuth"); + showAddPasswordAuthModal({ callback: updateUI }); }); qs(".pageAccountSettings")?.onChild("click", "#updateAccountName", () => { From 313e0069c99f9e4764511cd2fd73fdd1dc381861 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 19:36:54 +0200 Subject: [PATCH 11/16] delete/reset account --- .../account-settings/DeleteAccountModal.tsx | 66 +++++++++++ .../account-settings/ResetAccountModal.tsx | 72 ++++++++++++ frontend/src/ts/modals/simple-modals-base.ts | 4 - frontend/src/ts/modals/simple-modals.ts | 104 +----------------- frontend/src/ts/pages/account-settings.ts | 6 +- 5 files changed, 144 insertions(+), 108 deletions(-) create mode 100644 frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx create mode 100644 frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx diff --git a/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx b/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx new file mode 100644 index 000000000000..b8c90db246b9 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import Ape from "../../../ape"; +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { showSimpleModal } from "../../../states/simple-modal"; +import { + getPasswordSchema, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; +import { reloadAfter } from "../../../utils/misc"; + +export function showDeleteAccountModal(): void { + if (!isAuthenticated()) return; + + showSimpleModal({ + title: "Delete account", + buttonText: isUsingPasswordAuthentication() + ? "delete" + : "reauthenticate to delete", + schema: z.object({ + password: getPasswordSchema(), + checked: z.literal(true), + }), + inputs: { + password: { + placeholder: "password", + type: "password", + hidden: !isUsingPasswordAuthentication(), + }, + checked: { + type: "checkbox", + label: `I understand I will lose access to my Monkeytype account and all my data will be deleted and cannot be recovered.`, + }, + }, + + execFn: async ({ password }) => { + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + showNoticeNotification("Deleting all data..."); + const response = await Ape.users.delete(); + + if (response.status !== 200) { + return { + status: "error", + message: "Failed to delete user data", + notificationOptions: { response }, + }; + } + + reloadAfter(3); + + return { + status: "success", + message: "Account deleted, goodbye", + }; + }, + }); +} diff --git a/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx b/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx new file mode 100644 index 000000000000..b3d609aa96a0 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import Ape from "../../../ape"; +import { resetConfig } from "../../../config/lifecycle"; +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { showSimpleModal } from "../../../states/simple-modal"; +import FileStorage from "../../../utils/file-storage"; +import { + getPasswordSchema, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; +import { reloadAfter } from "../../../utils/misc"; + +export function showResetAccountModal(): void { + if (!isAuthenticated()) return; + + showSimpleModal({ + title: "Reset account", + buttonText: isUsingPasswordAuthentication() + ? "reset" + : "reauthenticate to reset", + schema: z.object({ + password: getPasswordSchema(), + checked: z.literal(true), + }), + inputs: { + password: { + placeholder: "password", + type: "password", + hidden: !isUsingPasswordAuthentication(), + }, + checked: { + type: "checkbox", + label: `I understand all my data will be deleted and cannot be recovered.`, + }, + }, + + execFn: async ({ password }) => { + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + showNoticeNotification("Resetting settings..."); + await resetConfig(); + await FileStorage.deleteFile("LocalBackgroundFile"); + await FileStorage.deleteFile("LocalFontFamilyFile"); + + showNoticeNotification("Resetting account..."); + const response = await Ape.users.reset(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to reset account", + notificationOptions: { response }, + }; + } + + reloadAfter(3); + + return { + status: "success", + message: "Account reset", + }; + }, + }); +} diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index 981eb9607768..459b0503a984 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -3,8 +3,6 @@ import { ShowOptions } from "../utils/animated-modal"; import { SimpleModal } from "../elements/simple-modal"; export type PopupKey = - | "deleteAccount" - | "resetAccount" | "optOutOfLeaderboards" | "resetPersonalBests" | "revokeAllTokens" @@ -15,8 +13,6 @@ export type PopupKey = | "devGenerateData"; export const list: Record = { - deleteAccount: undefined, - resetAccount: undefined, optOutOfLeaderboards: undefined, resetPersonalBests: undefined, revokeAllTokens: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index 94c7ad1d6cdc..f55ba2edf73e 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -1,9 +1,9 @@ import Ape from "../ape"; import * as DB from "../db"; -import { resetConfig } from "../config/lifecycle"; + import { setConfig } from "../config/setters"; -import { showNoticeNotification } from "../states/notifications"; + import { isAuthenticated } from "../states/core"; import { reloadAfter } from "../utils/misc"; import * as ThemeController from "../controllers/theme-controller"; @@ -15,7 +15,6 @@ import { CustomThemeNameSchema, UserNameSchema, } from "@monkeytype/schemas/users"; -import FileStorage from "../utils/file-storage"; import { list, PopupKey, showPopup } from "./simple-modals-base"; import { getTheme } from "../states/theme"; @@ -35,105 +34,6 @@ import { export { list, showPopup }; export type { PopupKey }; -list.deleteAccount = new SimpleModal({ - id: "deleteAccount", - title: "Delete account", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - ], - text: "This is the last time you can change your mind. After pressing the button everything is gone.", - buttonText: "delete", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - showNoticeNotification("Deleting all data..."); - const response = await Ape.users.delete(); - - if (response.status !== 200) { - return { - status: "error", - message: "Failed to delete user data", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Account deleted, goodbye", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = "reauthenticate to delete"; - } - }, -}); - -list.resetAccount = new SimpleModal({ - id: "resetAccount", - title: "Reset account", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - ], - text: "This is the last time you can change your mind. After pressing the button everything is gone.", - buttonText: "reset", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - showNoticeNotification("Resetting settings..."); - await resetConfig(); - await FileStorage.deleteFile("LocalBackgroundFile"); - - showNoticeNotification("Resetting account..."); - const response = await Ape.users.reset(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to reset account", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Account reset", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = "reauthenticate to reset"; - } - }, -}); - list.optOutOfLeaderboards = new SimpleModal({ id: "optOutOfLeaderboards", title: "Opt out of leaderboards", diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 6edbbb0814f3..0baed410260d 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -20,6 +20,8 @@ import { showUpdateNameModal } from "../components/modals/account-settings/Updat import { showUpdatePasswordModal } from "../components/modals/account-settings/UpdatePasswordModal"; import { showRemoveAuthMethodModal } from "../components/modals/account-settings/RemoveAuthMethodModal"; import { showAddPasswordAuthModal } from "../components/modals/account-settings/AddPasswordAuthModal"; +import { showDeleteAccountModal } from "../components/modals/account-settings/DeleteAccountModal"; +import { showResetAccountModal } from "../components/modals/account-settings/ResetAccountModal"; const pageElement = qsr(".page.pageAccountSettings"); @@ -224,11 +226,11 @@ qs(".pageAccountSettings")?.onChild("click", "#passPasswordAuth", () => { }); qs(".pageAccountSettings")?.onChild("click", "#deleteAccount", () => { - showPopup("deleteAccount"); + showDeleteAccountModal(); }); qs(".pageAccountSettings")?.onChild("click", "#resetAccount", () => { - showPopup("resetAccount"); + showResetAccountModal(); }); qs(".pageAccountSettings")?.onChild( From 543373123e22977835f6a79cfc61d3d7913e717a Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 22:48:35 +0200 Subject: [PATCH 12/16] move modals with reauth+confirm, last old modals --- .../ts/components/modals/DevOptionsModal.tsx | 89 +++- .../src/ts/components/modals/SimpleModal.tsx | 19 +- .../account-settings/AddPasswordAuthModal.tsx | 1 + .../account-settings/DeleteAccountModal.tsx | 66 --- .../account-settings/ReauthConfirmModals.tsx | 205 +++++++++ .../account-settings/ResetAccountModal.tsx | 72 ---- .../account-settings/UnlinkDiscordModal.tsx | 42 ++ .../account-settings/UpdateEmailModal.tsx | 3 - .../account-settings/UpdateNameModal.tsx | 2 - .../ts/components/pages/account/Filters.tsx | 1 - .../account-settings/ape-key-table.ts | 2 - frontend/src/ts/index.ts | 1 - frontend/src/ts/modals/simple-modals-base.ts | 37 -- frontend/src/ts/modals/simple-modals.ts | 399 ------------------ frontend/src/ts/pages/account-settings.ts | 19 +- frontend/src/ts/pages/friends.ts | 1 - 16 files changed, 362 insertions(+), 597 deletions(-) delete mode 100644 frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx create mode 100644 frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx delete mode 100644 frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx create mode 100644 frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx delete mode 100644 frontend/src/ts/modals/simple-modals-base.ts delete mode 100644 frontend/src/ts/modals/simple-modals.ts diff --git a/frontend/src/ts/components/modals/DevOptionsModal.tsx b/frontend/src/ts/components/modals/DevOptionsModal.tsx index e758460bf855..8ab2e439d203 100644 --- a/frontend/src/ts/components/modals/DevOptionsModal.tsx +++ b/frontend/src/ts/components/modals/DevOptionsModal.tsx @@ -1,5 +1,7 @@ +import { UserNameSchema } from "@monkeytype/schemas/users"; import { createSignal, For, JSXElement } from "solid-js"; import { envConfig } from "virtual:env-config"; +import { z } from "zod"; import Ape from "../../ape"; import { signIn } from "../../auth"; @@ -7,14 +9,14 @@ import { refetchInboxCollection } from "../../collections/inbox"; import { addXp } from "../../db"; import { toggleCaretDebug } from "../../elements/caret"; import { getInputElement } from "../../input/input-element"; -import { showPopup } from "../../modals/simple-modals"; -import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; +import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { hideModal, showModal } from "../../states/modals"; import { - showNoticeNotification, showErrorNotification, + showNoticeNotification, showSuccessNotification, } from "../../states/notifications"; +import { showSimpleModal } from "../../states/simple-modal"; import { toggleUserFakeChartData } from "../../test/result"; import { disableSlowTimerFail } from "../../test/test-timer"; import { FaSolidIcon } from "../../types/font-awesome"; @@ -35,7 +37,7 @@ export function DevOptionsModal(): JSXElement { { icon: "fa-database", label: () => "Generate Data", - onClick: () => showPopup("devGenerateData"), + onClick: () => showGenerateDataModal(), }, { icon: "fa-bell", @@ -222,3 +224,82 @@ export function DevOptionsModal(): JSXElement { ); } + +function showGenerateDataModal(): void { + showSimpleModal({ + title: "Generate data", + //showLabels: true, + text: `if create user is checked, user will be created with @example.com and password: password`, + schema: z.object({ + username: UserNameSchema, + createUser: z.boolean(), + firstTestTimestamp: z.date().optional(), + lastTestTimestamp: z.date().max(new Date()).optional(), + minTestsPerDay: z.number().safe().int().min(0).max(200), + maxTestsPerDay: z.number().safe().int().min(0).max(200), + }), + inputs: { + username: { + type: "text", + label: "username", + placeholder: "username", + }, + createUser: { + type: "checkbox", + label: "create user", + description: + "if checked, user will be created with {username}@example.com and password: password", + }, + firstTestTimestamp: { + type: "date", + label: "first test", + }, + lastTestTimestamp: { + type: "date", + label: "last test", + }, + minTestsPerDay: { + type: "range", + label: "min tests per day", + initVal: 0, + + step: 10, + }, + maxTestsPerDay: { + type: "range", + label: "max tests per day", + initVal: 50, + + step: 10, + }, + }, + buttonText: "generate (might take a while)", + execFn: async ({ + username, + createUser, + firstTestTimestamp, + lastTestTimestamp, + minTestsPerDay, + maxTestsPerDay, + }) => { + const result = await Ape.dev.generateData({ + body: { + username, + createUser, + firstTestTimestamp: firstTestTimestamp?.getTime(), + lastTestTimestamp: lastTestTimestamp?.getTime(), + minTestsPerDay, + maxTestsPerDay, + }, + }); + + return { + status: result.status === 200 ? "success" : "error", + message: result.body.message, + hideOptions: { + clearModalChain: true, + }, + }; + }, + }); +} diff --git a/frontend/src/ts/components/modals/SimpleModal.tsx b/frontend/src/ts/components/modals/SimpleModal.tsx index 6aac8bea6270..fa71f02d2216 100644 --- a/frontend/src/ts/components/modals/SimpleModal.tsx +++ b/frontend/src/ts/components/modals/SimpleModal.tsx @@ -9,7 +9,13 @@ import { Switch, untrack, } from "solid-js"; -import { z, ZodDefault, ZodFirstPartyTypeKind, ZodTypeAny } from "zod"; +import { + z, + ZodDefault, + ZodFirstPartyTypeKind, + ZodOptional, + ZodTypeAny, +} from "zod"; import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar"; import { @@ -57,7 +63,10 @@ function getDefaultValues( } return Object.fromEntries( - Object.entries(inputs).map(([key, input]) => [key, input.initVal ?? null]), + Object.entries(inputs).map(([key, input]) => [ + key, + input.initVal ?? undefined, + ]), ); } @@ -370,6 +379,7 @@ export function convertFn( return parsed.data as T; }; + console.log("###", { type }); switch (type) { case ZodFirstPartyTypeKind.ZodBoolean: return (val) => { @@ -395,6 +405,11 @@ export function convertFn( return convertFn(input, defaultSchema._def.innerType); } + case ZodFirstPartyTypeKind.ZodOptional: { + const defaultSchema = schema as ZodOptional; + return convertFn(input, defaultSchema._def.innerType); + } + default: return (val) => preprocess(val); } diff --git a/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx index c44079b53d9d..706a18e367a2 100644 --- a/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx @@ -94,6 +94,7 @@ export function showAddPasswordAuthModal(options: { } options.callback(); + return { status: "success", message: "Password authentication added", diff --git a/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx b/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx deleted file mode 100644 index b8c90db246b9..000000000000 --- a/frontend/src/ts/components/modals/account-settings/DeleteAccountModal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { z } from "zod"; - -import Ape from "../../../ape"; -import { isAuthenticated } from "../../../states/core"; -import { showNoticeNotification } from "../../../states/notifications"; -import { showSimpleModal } from "../../../states/simple-modal"; -import { - getPasswordSchema, - isUsingPasswordAuthentication, - reauthenticate, -} from "../../../utils/firebase-auth"; -import { reloadAfter } from "../../../utils/misc"; - -export function showDeleteAccountModal(): void { - if (!isAuthenticated()) return; - - showSimpleModal({ - title: "Delete account", - buttonText: isUsingPasswordAuthentication() - ? "delete" - : "reauthenticate to delete", - schema: z.object({ - password: getPasswordSchema(), - checked: z.literal(true), - }), - inputs: { - password: { - placeholder: "password", - type: "password", - hidden: !isUsingPasswordAuthentication(), - }, - checked: { - type: "checkbox", - label: `I understand I will lose access to my Monkeytype account and all my data will be deleted and cannot be recovered.`, - }, - }, - - execFn: async ({ password }) => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - showNoticeNotification("Deleting all data..."); - const response = await Ape.users.delete(); - - if (response.status !== 200) { - return { - status: "error", - message: "Failed to delete user data", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Account deleted, goodbye", - }; - }, - }); -} diff --git a/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx b/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx new file mode 100644 index 000000000000..387e0e1f35c2 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/ReauthConfirmModals.tsx @@ -0,0 +1,205 @@ +import { z } from "zod"; + +import Ape from "../../../ape"; +import { resetConfig } from "../../../config/lifecycle"; +import { getSnapshot } from "../../../db"; +import { isAuthenticated } from "../../../states/core"; +import { showNoticeNotification } from "../../../states/notifications"; +import { ExecReturn, showSimpleModal } from "../../../states/simple-modal"; +import FileStorage from "../../../utils/file-storage"; +import { + getPasswordSchema, + isUsingPasswordAuthentication, + reauthenticate, +} from "../../../utils/firebase-auth"; +import { reloadAfter } from "../../../utils/misc"; + +export function showDeleteAccountModal(): void { + showReauthConfirmModal({ + title: "Delete account", + buttonText: "delete", + confirmText: `I understand I will lose access to my Monkeytype account and all my data will be deleted and cannot be recovered.`, + action: async () => { + showNoticeNotification("Deleting all data..."); + const response = await Ape.users.delete(); + + if (response.status !== 200) { + return { + status: "error", + message: "Failed to delete user data", + notificationOptions: { response }, + }; + } + reloadAfter(3); + + return { + status: "success", + message: "Account deleted, goodbye", + }; + }, + }); +} + +export function showOptOutOfLeaderboardsModal(): void { + showReauthConfirmModal({ + title: "Opt out of leaderboards", + buttonText: "opt out", + confirmText: `I understand my account will be removed from all leaderboards and this cannot be undone.`, + + action: async () => { + const response = await Ape.users.optOutOfLeaderboards(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to opt out", + notificationOptions: { response }, + }; + } + + reloadAfter(3); + + return { + status: "success", + message: "Leaderboards opt out successful", + }; + }, + }); +} + +export function showResetAccountModal(): void { + showReauthConfirmModal({ + title: "Reset account", + buttonText: "reset", + confirmText: `I understand all my data will be deleted and cannot be recovered.`, + action: async () => { + showNoticeNotification("Resetting settings..."); + await resetConfig(); + await FileStorage.deleteFile("LocalBackgroundFile"); + await FileStorage.deleteFile("LocalFontFamilyFile"); + + showNoticeNotification("Resetting account..."); + const response = await Ape.users.reset(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to reset account", + notificationOptions: { response }, + }; + } + + reloadAfter(3); + + return { + status: "success", + message: "Account reset", + }; + }, + }); +} + +export function showResetPersonalBestsModal(): void { + showReauthConfirmModal({ + title: "Reset personal bests", + buttonText: "reset", + confirmText: `I understand all my personal bests will be deleted and this cannot be undone`, + action: async () => { + const response = await Ape.users.deletePersonalBests(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to reset personal bests", + notificationOptions: { response }, + }; + } + + const snapshot = getSnapshot(); + if (!snapshot) { + return { + status: "error", + message: "Failed to reset personal bests: no snapshot", + }; + } + + snapshot.personalBests = { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + + return { + status: "success", + message: "Personal bests reset", + }; + }, + }); +} + +export function showRevokeAllTokensModal() { + showReauthConfirmModal({ + title: "Revoke all tokens", + confirmText: `I understand that all my tokens will get revoked and I will be logged out of all devices.`, + buttonText: "revoke all", + action: async () => { + const response = await Ape.users.revokeAllTokens(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to revoke tokens", + notificationOptions: { response }, + }; + } + + reloadAfter(3); + + return { + status: "success", + message: "Tokens revoked", + }; + }, + }); +} + +function showReauthConfirmModal(options: { + title: string; + buttonText: string; + confirmText: string; + action: () => Promise; +}): void { + if (!isAuthenticated()) return; + + showSimpleModal({ + title: options.title, + buttonText: isUsingPasswordAuthentication() + ? options.buttonText + : `reauthenticate to ${options.buttonText}`, + schema: z.object({ + password: getPasswordSchema(), + confirm: z.literal(true), + }), + inputs: { + password: { + placeholder: "password", + type: "password", + hidden: !isUsingPasswordAuthentication(), + }, + confirm: { + type: "checkbox", + label: options.confirmText, + }, + }, + + execFn: async ({ password }) => { + const reauth = await reauthenticate({ password }); + if (reauth.status !== "success") { + return { + status: reauth.status, + message: reauth.message, + }; + } + + return options.action(); + }, + }); +} diff --git a/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx b/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx deleted file mode 100644 index b3d609aa96a0..000000000000 --- a/frontend/src/ts/components/modals/account-settings/ResetAccountModal.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from "zod"; - -import Ape from "../../../ape"; -import { resetConfig } from "../../../config/lifecycle"; -import { isAuthenticated } from "../../../states/core"; -import { showNoticeNotification } from "../../../states/notifications"; -import { showSimpleModal } from "../../../states/simple-modal"; -import FileStorage from "../../../utils/file-storage"; -import { - getPasswordSchema, - isUsingPasswordAuthentication, - reauthenticate, -} from "../../../utils/firebase-auth"; -import { reloadAfter } from "../../../utils/misc"; - -export function showResetAccountModal(): void { - if (!isAuthenticated()) return; - - showSimpleModal({ - title: "Reset account", - buttonText: isUsingPasswordAuthentication() - ? "reset" - : "reauthenticate to reset", - schema: z.object({ - password: getPasswordSchema(), - checked: z.literal(true), - }), - inputs: { - password: { - placeholder: "password", - type: "password", - hidden: !isUsingPasswordAuthentication(), - }, - checked: { - type: "checkbox", - label: `I understand all my data will be deleted and cannot be recovered.`, - }, - }, - - execFn: async ({ password }) => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - showNoticeNotification("Resetting settings..."); - await resetConfig(); - await FileStorage.deleteFile("LocalBackgroundFile"); - await FileStorage.deleteFile("LocalFontFamilyFile"); - - showNoticeNotification("Resetting account..."); - const response = await Ape.users.reset(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to reset account", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Account reset", - }; - }, - }); -} diff --git a/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx new file mode 100644 index 000000000000..6e8604cec8a5 --- /dev/null +++ b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx @@ -0,0 +1,42 @@ +import Ape from "../../../ape"; +import { getSnapshot, setSnapshot } from "../../../db"; +import { showSimpleModal } from "../../../states/simple-modal"; + +export function showUnlinkDiscordModal(options: { + callback: () => void; +}): void { + showSimpleModal({ + title: "Unlink Discord", + text: "Are you sure you want to unlink your Discord account?", + buttonText: "unlink", + execFn: async () => { + const snap = getSnapshot(); + if (!snap) { + return { + status: "error", + message: "Failed to unlink Discord: no snapshot", + }; + } + + const response = await Ape.users.unlinkDiscord(); + if (response.status !== 200) { + return { + status: "error", + message: "Failed to unlink Discord", + notificationOptions: { response }, + }; + } + + snap.discordAvatar = undefined; + snap.discordId = undefined; + setSnapshot(snap); + + options.callback(); + + return { + status: "success", + message: "Discord unlinked", + }; + }, + }); +} diff --git a/frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx index 876e30b6cc23..9075810dd9d4 100644 --- a/frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/UpdateEmailModal.tsx @@ -31,17 +31,14 @@ export function showUpdateEmailModal(): void { password: { placeholder: "Password", type: "password", - initVal: "", }, email: { type: "text", placeholder: "New email", - initVal: "", }, emailConfirm: { type: "text", placeholder: "Confirm new email", - initVal: "", }, }, diff --git a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx index 85b1b426bc04..a3e4d3a6cdc0 100644 --- a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx @@ -33,13 +33,11 @@ export function showUpdateNameModal(): void { password: { placeholder: "password", type: "password", - initVal: "", hidden: !isUsingPasswordAuthentication(), }, newName: { placeholder: "new name", type: "text", - initVal: "", validation: { isValid: remoteValidation( async (name: string) => diff --git a/frontend/src/ts/components/pages/account/Filters.tsx b/frontend/src/ts/components/pages/account/Filters.tsx index a2b78e36e884..4cb91f7121ca 100644 --- a/frontend/src/ts/components/pages/account/Filters.tsx +++ b/frontend/src/ts/components/pages/account/Filters.tsx @@ -266,7 +266,6 @@ export function Filters(props: { name: { placeholder: "Preset Name", type: "text", - initVal: "", preprocess: normalizeName, }, }, diff --git a/frontend/src/ts/elements/account-settings/ape-key-table.ts b/frontend/src/ts/elements/account-settings/ape-key-table.ts index 82172072520e..55810f091500 100644 --- a/frontend/src/ts/elements/account-settings/ape-key-table.ts +++ b/frontend/src/ts/elements/account-settings/ape-key-table.ts @@ -142,7 +142,6 @@ function refreshList(): void { name: { type: "text", placeholder: "name", - initVal: "", }, }, @@ -220,7 +219,6 @@ qs(".pageAccountSettings")?.onChild("click", "#generateNewApeKey", () => { name: { type: "text", placeholder: "Name", - initVal: "", }, }, diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 10a5f7efaf93..b9e848c87352 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -22,7 +22,6 @@ import * as Result from "./test/result"; import { onAuthStateChanged } from "./auth"; import { enable } from "./legacy-states/glarses-mode"; import "./test/caps-warning"; -import "./modals/simple-modals"; import "./input/listeners"; import "./controllers/route-controller"; import "./elements/no-css"; diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts deleted file mode 100644 index 459b0503a984..000000000000 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { showErrorNotification } from "../states/notifications"; -import { ShowOptions } from "../utils/animated-modal"; -import { SimpleModal } from "../elements/simple-modal"; - -export type PopupKey = - | "optOutOfLeaderboards" - | "resetPersonalBests" - | "revokeAllTokens" - | "unlinkDiscord" - | "editApeKey" - | "updateCustomTheme" - | "deleteCustomTheme" - | "devGenerateData"; - -export const list: Record = { - optOutOfLeaderboards: undefined, - resetPersonalBests: undefined, - revokeAllTokens: undefined, - unlinkDiscord: undefined, - editApeKey: undefined, - updateCustomTheme: undefined, - deleteCustomTheme: undefined, - devGenerateData: undefined, -}; - -export function showPopup( - key: PopupKey, - showParams = [] as string[], - showOptions: ShowOptions = {}, -): void { - const popup = list[key]; - if (popup === undefined) { - showErrorNotification("Failed to show popup - popup is not defined"); - return; - } - popup.show(showParams, showOptions); -} diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts deleted file mode 100644 index f55ba2edf73e..000000000000 --- a/frontend/src/ts/modals/simple-modals.ts +++ /dev/null @@ -1,399 +0,0 @@ -import Ape from "../ape"; - -import * as DB from "../db"; - -import { setConfig } from "../config/setters"; - -import { isAuthenticated } from "../states/core"; -import { reloadAfter } from "../utils/misc"; -import * as ThemeController from "../controllers/theme-controller"; -import * as CustomThemes from "../collections/custom-themes"; -import * as AccountSettings from "../pages/account-settings"; - -import { GenerateDataRequest } from "@monkeytype/contracts/dev"; -import { - CustomThemeNameSchema, - UserNameSchema, -} from "@monkeytype/schemas/users"; - -import { list, PopupKey, showPopup } from "./simple-modals-base"; -import { getTheme } from "../states/theme"; -import { normalizeName } from "../utils/strings"; -import { IsValidResponse } from "../types/validation"; -import { - ExecReturn, - SimpleModal, - PasswordInput, - TextInput, -} from "../elements/simple-modal"; -import { - isUsingPasswordAuthentication, - reauthenticate, -} from "../utils/firebase-auth"; - -export { list, showPopup }; -export type { PopupKey }; - -list.optOutOfLeaderboards = new SimpleModal({ - id: "optOutOfLeaderboards", - title: "Opt out of leaderboards", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - ], - text: "Are you sure you want to opt out of leaderboards?", - buttonText: "opt out", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.optOutOfLeaderboards(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to opt out", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Leaderboards opt out successful", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = "reauthenticate to opt out"; - } - }, -}); - -list.resetPersonalBests = new SimpleModal({ - id: "resetPersonalBests", - title: "Reset personal bests", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - ], - buttonText: "reset", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.deletePersonalBests(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to reset personal bests", - notificationOptions: { response }, - }; - } - - const snapshot = DB.getSnapshot(); - if (!snapshot) { - return { - status: "error", - message: "Failed to reset personal bests: no snapshot", - }; - } - - snapshot.personalBests = { - time: {}, - words: {}, - quote: {}, - zen: {}, - custom: {}, - }; - - return { - status: "success", - message: "Personal bests reset", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - if (!isUsingPasswordAuthentication()) { - thisPopup.inputs = []; - thisPopup.buttonText = "reauthenticate to reset"; - } - }, -}); - -list.revokeAllTokens = new SimpleModal({ - id: "revokeAllTokens", - title: "Revoke all tokens", - inputs: [ - { - placeholder: "password", - type: "password", - initVal: "", - }, - ], - text: "Are you sure you want to do this? This will log you out of all devices.", - buttonText: "revoke all", - execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate({ password }); - if (reauth.status !== "success") { - return { - status: reauth.status, - message: reauth.message, - }; - } - - const response = await Ape.users.revokeAllTokens(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to revoke tokens", - notificationOptions: { response }, - }; - } - - reloadAfter(3); - - return { - status: "success", - message: "Tokens revoked", - }; - }, - beforeInitFn: (thisPopup): void => { - if (!isAuthenticated()) return; - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - if (!isUsingPasswordAuthentication()) { - (thisPopup.inputs[0] as PasswordInput).hidden = true; - thisPopup.buttonText = "reauthenticate to revoke all tokens"; - } - }, -}); - -list.unlinkDiscord = new SimpleModal({ - id: "unlinkDiscord", - title: "Unlink Discord", - text: "Are you sure you want to unlink your Discord account?", - buttonText: "unlink", - execFn: async (): Promise => { - const snap = DB.getSnapshot(); - if (!snap) { - return { - status: "error", - message: "Failed to unlink Discord: no snapshot", - }; - } - - const response = await Ape.users.unlinkDiscord(); - if (response.status !== 200) { - return { - status: "error", - message: "Failed to unlink Discord", - notificationOptions: { response }, - }; - } - - snap.discordAvatar = undefined; - snap.discordId = undefined; - DB.setSnapshot(snap); - AccountSettings.updateUI(); - - return { - status: "success", - message: "Discord unlinked", - }; - }, -}); - -const customThemeValidation = async ( - name: string, -): Promise => { - const validationResult = CustomThemeNameSchema.safeParse(normalizeName(name)); - if (validationResult.success) return true; - return validationResult.error.errors.map((err) => err.message).join(", "); -}; - -list.updateCustomTheme = new SimpleModal({ - id: "updateCustomTheme", - title: "Update custom theme", - inputs: [ - { - type: "text", - placeholder: "name", - initVal: "", - validation: { isValid: customThemeValidation, debounceDelay: 0 }, - }, - { - type: "checkbox", - initVal: false, - label: "Update custom theme to current colors", - optional: true, - }, - ], - buttonText: "update", - execFn: async (_thisPopup, name, updateColors): Promise => { - const themeId = _thisPopup.parameters[0] as string; - const customTheme = CustomThemes.__nonReactive.getCustomTheme(themeId); - if (customTheme === undefined) { - return { - status: "error", - message: "Failed to update custom theme: theme not found", - }; - } - - let newColors = - updateColors === "true" - ? ThemeController.convertThemeToCustomColors(getTheme()) - : customTheme.colors; - - await CustomThemes.editCustomTheme({ - themeId: customTheme._id, - name: normalizeName(name), - colors: newColors, - }); - setConfig("customThemeColors", newColors); - - return { - status: "success", - message: "Custom theme updated", - }; - }, - beforeInitFn: (_thisPopup): void => { - const themeId = _thisPopup.parameters[0] as string; - const customTheme = CustomThemes.__nonReactive.getCustomTheme(themeId); - if (!customTheme) return; - (_thisPopup.inputs[0] as TextInput).initVal = customTheme.name.replace( - /_/g, - " ", - ); - }, -}); - -list.deleteCustomTheme = new SimpleModal({ - id: "deleteCustomTheme", - title: "Delete custom theme", - text: "Are you sure?", - buttonText: "delete", - execFn: async (_thisPopup): Promise => { - await CustomThemes.deleteCustomTheme({ - themeId: _thisPopup.parameters[0] as string, - }); - - return { - status: "success", - message: "Custom theme deleted", - }; - }, -}); - -list.devGenerateData = new SimpleModal({ - id: "devGenerateData", - title: "Generate data", - showLabels: true, - inputs: [ - { - type: "text", - label: "username", - placeholder: "username", - oninput: (event): void => { - const target = event.target as HTMLInputElement; - const span = document.querySelector( - "#devGenerateData_1 + span", - ) as HTMLInputElement; - span.innerText = `if checked, user will be created with ${target.value}@example.com and password: password`; - return; - }, - validation: { - schema: UserNameSchema, - }, - }, - { - type: "checkbox", - label: "create user", - description: - "if checked, user will be created with {username}@example.com and password: password", - optional: true, - }, - { - type: "date", - label: "first test", - optional: true, - }, - { - type: "date", - label: "last test", - max: new Date(), - optional: true, - }, - { - type: "range", - label: "min tests per day", - initVal: 0, - min: 0, - max: 200, - step: 10, - }, - { - type: "range", - label: "max tests per day", - initVal: 50, - min: 0, - max: 200, - step: 10, - }, - ], - buttonText: "generate (might take a while)", - execFn: async ( - _thisPopup, - username, - createUser, - firstTestTimestamp, - lastTestTimestamp, - minTestsPerDay, - maxTestsPerDay, - ): Promise => { - const request: GenerateDataRequest = { - username, - createUser: createUser === "true", - }; - if (firstTestTimestamp !== undefined && firstTestTimestamp.length > 0) { - request.firstTestTimestamp = Date.parse(firstTestTimestamp); - } - if (lastTestTimestamp !== undefined && lastTestTimestamp.length > 0) { - request.lastTestTimestamp = Date.parse(lastTestTimestamp); - } - if (minTestsPerDay !== undefined && minTestsPerDay.length > 0) { - request.minTestsPerDay = Number.parseInt(minTestsPerDay); - } - if (maxTestsPerDay !== undefined && maxTestsPerDay.length > 0) { - request.maxTestsPerDay = Number.parseInt(maxTestsPerDay); - } - - const result = await Ape.dev.generateData({ body: request }); - - return { - status: result.status === 200 ? "success" : "error", - message: result.body.message, - hideOptions: { - clearModalChain: true, - }, - }; - }, -}); diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index 0baed410260d..2df56a08e236 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -13,15 +13,20 @@ import { showErrorNotification } from "../states/notifications"; import { z } from "zod"; import { authEvent } from "../events/auth"; import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; -import { showPopup } from "../modals/simple-modals-base"; import { addGithubAuth, addGoogleAuth } from "../auth"; import { showUpdateEmailModal } from "../components/modals/account-settings/UpdateEmailModal"; import { showUpdateNameModal } from "../components/modals/account-settings/UpdateNameModal"; import { showUpdatePasswordModal } from "../components/modals/account-settings/UpdatePasswordModal"; import { showRemoveAuthMethodModal } from "../components/modals/account-settings/RemoveAuthMethodModal"; import { showAddPasswordAuthModal } from "../components/modals/account-settings/AddPasswordAuthModal"; -import { showDeleteAccountModal } from "../components/modals/account-settings/DeleteAccountModal"; -import { showResetAccountModal } from "../components/modals/account-settings/ResetAccountModal"; +import { + showDeleteAccountModal, + showOptOutOfLeaderboardsModal, + showResetAccountModal, + showResetPersonalBestsModal, + showRevokeAllTokensModal, +} from "../components/modals/account-settings/ReauthConfirmModals"; +import { showUnlinkDiscordModal } from "../components/modals/account-settings/UnlinkDiscordModal"; const pageElement = qsr(".page.pageAccountSettings"); @@ -194,7 +199,7 @@ qs(".page.pageAccountSettings #setStreakHourOffset")?.on("click", () => { }); qs(".pageAccountSettings")?.onChild("click", "#unlinkDiscordButton", () => { - showPopup("unlinkDiscord"); + showUnlinkDiscordModal({ callback: updateUI }); }); qs(".pageAccountSettings")?.onChild("click", "#removeGoogleAuth", () => { @@ -237,19 +242,19 @@ qs(".pageAccountSettings")?.onChild( "click", "#optOutOfLeaderboardsButton", () => { - showPopup("optOutOfLeaderboards"); + showOptOutOfLeaderboardsModal(); }, ); qs(".pageAccountSettings")?.onChild("click", "#revokeAllTokens", () => { - showPopup("revokeAllTokens"); + showRevokeAllTokensModal(); }); qs(".pageAccountSettings")?.onChild( "click", "#resetPersonalBestsButton", () => { - showPopup("resetPersonalBests"); + showResetPersonalBestsModal(); }, ); diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts index bc8a4e2b0910..3edf4962187e 100644 --- a/frontend/src/ts/pages/friends.ts +++ b/frontend/src/ts/pages/friends.ts @@ -306,7 +306,6 @@ qs(".pageFriends button.friendAdd")?.on("click", () => receiverName: { placeholder: "user name", type: "text", - initVal: "", validation: { isValid: remoteValidation( async (name: string) => From f8246bd53a5cdad0fbd293c0fea40ff3900f9279 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 23:06:11 +0200 Subject: [PATCH 13/16] review comments --- .../account-settings/RemoveAuthMethodModal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx index 9742eadc4a87..7b5d1537b070 100644 --- a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx +++ b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx @@ -15,6 +15,11 @@ import { } from "../../../utils/firebase-auth"; import { reloadAfter } from "../../../utils/misc"; +const displayByMethod: Record = { + password: "Password", + "github.com": "GitHub", + "google.com": "Google", +}; export function showRemoveAuthMethodModal(options: { authMethod: AuthMethod; callback: () => void; @@ -33,12 +38,7 @@ export function showRemoveAuthMethodModal(options: { return; } - const methodDisplay = - options.authMethod === "password" - ? "Password" - : options.authMethod === "github.com" - ? "GitHub" - : "Google"; + const methodDisplay = displayByMethod[options.authMethod]; showSimpleModal({ title: `Remove ${methodDisplay} authentication`, From 7c5b7f46aa90c675f786ec4fbc71718a0a0e5443 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 2 Jun 2026 23:13:24 +0200 Subject: [PATCH 14/16] cleanup --- .../src/ts/components/modals/SimpleModal.tsx | 1 - frontend/src/ts/elements/simple-modal.ts | 488 ------------------ 2 files changed, 489 deletions(-) delete mode 100644 frontend/src/ts/elements/simple-modal.ts diff --git a/frontend/src/ts/components/modals/SimpleModal.tsx b/frontend/src/ts/components/modals/SimpleModal.tsx index fa71f02d2216..4b95a6cda625 100644 --- a/frontend/src/ts/components/modals/SimpleModal.tsx +++ b/frontend/src/ts/components/modals/SimpleModal.tsx @@ -379,7 +379,6 @@ export function convertFn( return parsed.data as T; }; - console.log("###", { type }); switch (type) { case ZodFirstPartyTypeKind.ZodBoolean: return (val) => { diff --git a/frontend/src/ts/elements/simple-modal.ts b/frontend/src/ts/elements/simple-modal.ts deleted file mode 100644 index 983dcc5754ca..000000000000 --- a/frontend/src/ts/elements/simple-modal.ts +++ /dev/null @@ -1,488 +0,0 @@ -import AnimatedModal, { - HideOptions, - ShowOptions, -} from "../utils/animated-modal"; -import { Attributes, buildTag } from "../utils/tag-builder"; -import { format as dateFormat } from "date-fns/format"; - -import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import { - showNoticeNotification, - addNotificationWithLevel, -} from "../states/notifications"; -import { - ValidatedHtmlInputElement, - ValidationOptions, -} from "./input-validation"; -import { ElementWithUtils, qsr } from "../utils/dom"; -import { Validation, ValidationResult } from "../types/validation"; - -import { ExecReturn as BaseExecReturn } from "../states/simple-modal"; -const simpleModalEl = qsr("#simpleModal"); - -type CommonInput = { - type: TType; - name?: string; - initVal?: TValue; - placeholder?: string; - hidden?: boolean; - disabled?: boolean; - optional?: boolean; - label?: string; - class?: string; - oninput?: (event: Event) => void; - /** - * Validate the input value and indicate the validation result next to the input. - * If the schema is defined it is always checked first. - * Only if the schema validaton is passed or missing the `isValid` method is called. - */ - validation?: Validation; -}; - -export type TextInput = { - readOnly?: boolean; - clickToSelect?: boolean; -} & CommonInput<"text", string>; -export type TextArea = { - readOnly?: boolean; - clickToSelect?: boolean; -} & CommonInput<"textarea", string>; -export type PasswordInput = CommonInput<"password", string>; -type EmailInput = CommonInput<"email", string>; - -type RangeInput = { - min: number; - max: number; - step?: number; -} & CommonInput<"range", number>; - -type DateTimeInput = { - min?: Date; - max?: Date; -} & CommonInput<"datetime-local", Date>; - -type DateInput = { - min?: Date; - max?: Date; -} & CommonInput<"date", Date>; - -type CheckboxInput = { - label: string; - placeholder?: never; - description?: string; -} & CommonInput<"checkbox", boolean>; - -type NumberInput = { - min?: number; - max?: number; -} & CommonInput<"number", number>; - -type SimpleModalInput = - | TextInput - | TextArea - | PasswordInput - | EmailInput - | RangeInput - | DateTimeInput - | DateInput - | CheckboxInput - | NumberInput; - -type SimpleModalConfig = { - class?: string; - title: string; - inputs?: SimpleModalInput[]; - text?: string; - textClass?: string; - textAllowHtml?: boolean; - buttonText?: string; - buttonAlwaysEnabled?: boolean; - focusFirstInput?: true | "focusAndSelect"; - execFn: (...inputValues: string[]) => Promise; -}; -export type ExecReturn = BaseExecReturn & { - hideOptions?: HideOptions; -}; - -type FormInput = SimpleModalInput & { - hasError?: boolean; - currentValue: () => string; -}; -type SimpleModalOptions = Omit & { - id: string; - execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise; - beforeInitFn?: (thisPopup: SimpleModal) => void; - beforeShowFn?: (thisPopup: SimpleModal) => void; - canClose?: boolean; - hideCallsExec?: boolean; - showLabels?: boolean; - afterClickAway?: () => void; -}; - -export class SimpleModal { - parameters: string[]; - wrapper: ElementWithUtils; - element: ElementWithUtils; - modal: AnimatedModal; - id: string; - title: string; - inputs: FormInput[]; - text?: string; - textAllowHtml: boolean; - buttonText?: string; - execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise; - beforeInitFn: ((thisPopup: SimpleModal) => void) | undefined; - beforeShowFn: ((thisPopup: SimpleModal) => void) | undefined; - canClose: boolean; - hideCallsExec: boolean; - showLabels: boolean; - afterClickAway: (() => void) | undefined; - context?: unknown; - constructor(options: SimpleModalOptions) { - this.parameters = []; - this.id = options.id; - this.execFn = options.execFn; - this.title = options.title; - this.inputs = (options.inputs as FormInput[]) ?? []; - this.text = options.text; - this.textAllowHtml = options.textAllowHtml ?? false; - this.wrapper = modal.getWrapper(); - this.element = modal.getModal(); - this.modal = modal; - this.buttonText = options.buttonText; - this.beforeInitFn = options.beforeInitFn; - this.beforeShowFn = options.beforeShowFn; - this.canClose = options.canClose ?? true; - this.hideCallsExec = options.hideCallsExec ?? false; - this.showLabels = options.showLabels ?? false; - this.afterClickAway = options.afterClickAway; - } - reset(): void { - this.element.setHtml(` -
-
-
- `); - } - - init(): void { - this.reset(); - this.element.setAttribute("data-popup-id", this.id); - this.element.qs(".title")?.setText(this.title); - if (this.textAllowHtml) { - this.element.qs(".text")?.setHtml(this.text ?? ""); - } else { - this.element.qs(".text")?.setText(this.text ?? ""); - } - - this.initInputs(); - - if (this.buttonText === "" || this.buttonText === undefined) { - this.element.qs(".submitButton")?.remove(); - } else { - this.element.qs(".submitButton")?.setText(this.buttonText); - this.updateSubmitButtonState(); - } - - if ((this.text ?? "") === "") { - this.element.qs(".text")?.hide(); - } else { - this.element.qs(".text")?.show(); - } - } - - initInputs(): void { - const allInputsHidden = this.inputs.every((i) => i.hidden); - if (allInputsHidden || this.inputs.length === 0) { - this.element.qs(".inputs")?.hide(); - return; - } - - const inputsEl = this.element.qs(".inputs"); - if (this.showLabels) inputsEl?.addClass("withLabel"); - - this.inputs.forEach((input, index) => { - const id = `${this.id}_${index}`; - - if (this.showLabels && !input.hidden) { - inputsEl?.appendHtml(``); - } - - const tagname = input.type === "textarea" ? "textarea" : "input"; - const classes = input.hidden ? ["hidden"] : undefined; - const attributes: Attributes = { - id: id, - placeholder: input.placeholder ?? "", - autocomplete: "off", - }; - - if (input.type !== "textarea") { - attributes["value"] = input.initVal?.toString() ?? ""; - attributes["type"] = input.type; - } - if (!input.hidden && !input.optional) { - attributes["required"] = true; - } - if (input.disabled) { - attributes["disabled"] = true; - } - - if (input.type === "textarea") { - inputsEl?.appendHtml( - buildTag({ - tagname, - classes, - attributes, - innerHTML: input.initVal, - }), - ); - } else if (input.type === "checkbox") { - let html = buildTag({ tagname, classes, attributes }); - - if (input.description !== undefined) { - html += `${input.description}`; - } - if (!this.showLabels) { - html = ` - - `; - } else { - html = `
${html}
`; - } - inputsEl?.appendHtml(html); - } else if (input.type === "range") { - inputsEl?.appendHtml(` -
- ${buildTag({ - tagname, - classes, - attributes: { - ...attributes, - min: input.min.toString(), - max: input.max.toString(), - step: input.step?.toString(), - oninput: "this.nextElementSibling.innerHTML = this.value", - }, - })} - ${input.initVal ?? ""} -
- `); - } else { - switch (input.type) { - case "text": - case "password": - case "email": - break; - - case "datetime-local": { - if (input.min !== undefined) { - attributes["min"] = dateFormat( - input.min, - "yyyy-MM-dd'T'HH:mm:ss", - ); - } - if (input.max !== undefined) { - attributes["max"] = dateFormat( - input.max, - "yyyy-MM-dd'T'HH:mm:ss", - ); - } - if (input.initVal !== undefined) { - attributes["value"] = dateFormat( - input.initVal, - "yyyy-MM-dd'T'HH:mm:ss", - ); - } - break; - } - case "date": { - if (input.min !== undefined) { - attributes["min"] = dateFormat(input.min, "yyyy-MM-dd"); - } - if (input.max !== undefined) { - attributes["max"] = dateFormat(input.max, "yyyy-MM-dd"); - } - if (input.initVal !== undefined) { - attributes["value"] = dateFormat(input.initVal, "yyyy-MM-dd"); - } - break; - } - case "number": { - attributes["min"] = input.min?.toString(); - attributes["max"] = input.max?.toString(); - break; - } - } - inputsEl?.appendHtml(buildTag({ tagname, classes, attributes })); - } - const element = qsr(`#${attributes["id"]}`); - - const originalOnInput = element.native.oninput; - element.native.oninput = (e) => { - if (originalOnInput) originalOnInput.call(element.native, e); - input.oninput?.(e); - this.updateSubmitButtonState(); - }; - - input.currentValue = () => { - if (element.native.type === "checkbox") { - return element.native.checked ? "true" : "false"; - } - return element.native.value; - }; - - if (input.validation !== undefined) { - const options: ValidationOptions = { - schema: input.validation.schema ?? undefined, - isValid: - input.validation.isValid !== undefined - ? async (val: string) => { - //@ts-expect-error this is fine - return input.validation.isValid(val, this); - } - : undefined, - - callback: (result: ValidationResult) => { - input.hasError = result.status !== "success"; - - this.updateSubmitButtonState(); - }, - debounceDelay: input.validation.debounceDelay, - }; - - new ValidatedHtmlInputElement(element, options); - } - }); - - this.element.qs(".inputs")?.show(); - } - - exec(): void { - if (!this.canClose) return; - if (this.hasMissingRequired()) { - showNoticeNotification("Please fill in all fields"); - return; - } - - if (this.hasValidationErrors()) { - showNoticeNotification("Please solve all validation errors"); - return; - } - - this.disableInputs(); - showLoaderBar(); - const vals: string[] = this.inputs.map((it) => it.currentValue()); - void this.execFn(this, ...vals).then((res) => { - hideLoaderBar(); - if (!res.showNotification) { - addNotificationWithLevel( - res.message as string, - res.status, - res.notificationOptions, - ); - } - if (res.status === "success" || res.alwaysHide) { - void this.hide(true, res.hideOptions).then(() => { - if (res.afterHide) { - res.afterHide(); - } - }); - } else { - this.enableInputs(); - simpleModalEl.qsa("input")[0]?.focus(); - } - }); - } - - disableInputs(): void { - simpleModalEl.qsa("input").disable(); - simpleModalEl.qsa("button").disable(); - simpleModalEl.qsa("textarea").disable(); - simpleModalEl.qsa(".checkbox").addClass("disabled"); - } - - enableInputs(): void { - simpleModalEl.qsa("input").enable(); - simpleModalEl.qsa("button").enable(); - simpleModalEl.qsa("textarea").enable(); - simpleModalEl.qsa(".checkbox").removeClass("disabled"); - } - - show( - parameters: string[] = [], - showOptions: ShowOptions & { context?: unknown }, - ): void { - activePopup = this; - this.parameters = parameters; - this.context = showOptions.context; - void modal.show({ - focusFirstInput: true, - ...showOptions, - beforeAnimation: async () => { - this.beforeInitFn?.(this); - this.init(); - this.beforeShowFn?.(this); - }, - }); - } - - async hide(callerIsExec?: boolean, hideOptions?: HideOptions): Promise { - if (!this.canClose) return; - if (this.hideCallsExec && !callerIsExec) { - this.exec(); - } else { - activePopup = null; - await modal.hide(hideOptions); - } - } - - hasMissingRequired(): boolean { - return this.inputs - .filter((i) => i.hidden !== true && i.optional !== true) - .some((v) => v.currentValue() === undefined || v.currentValue() === ""); - } - - hasValidationErrors(): boolean { - return this.inputs.some((i) => i.hasError === true); - } - - updateSubmitButtonState(): void { - const button = this.element.qs(".submitButton"); - if (button === null) return; - - if (this.hasMissingRequired() || this.hasValidationErrors()) { - button.disable(); - } else { - button.enable(); - } - } -} - -function hide(): void { - if (activePopup) { - void activePopup.hide(); - return; - } -} - -let activePopup: SimpleModal | null = null; - -const modal = new AnimatedModal({ - dialogId: "simpleModal", - setup: async (modalEl): Promise => { - modalEl.on("submit", (e) => { - e.preventDefault(); - activePopup?.exec(); - }); - }, - customEscapeHandler: (e): void => { - hide(); - }, - customWrapperClickHandler: (e): void => { - activePopup?.afterClickAway?.(); - hide(); - }, -}); From cec53917bf6e2670ce717a2317cf62880e1e682f Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 3 Jun 2026 14:32:15 +0200 Subject: [PATCH 15/16] style fixes, remove obsolete modals and css --- frontend/src/html/popups.html | 49 +---- frontend/src/styles/popups.scss | 203 ------------------ .../ts/components/modals/DevOptionsModal.tsx | 12 +- .../src/ts/components/modals/SimpleModal.tsx | 2 +- .../account-settings/ViewApeKeyModal.tsx | 4 +- 5 files changed, 11 insertions(+), 259 deletions(-) diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 216026bc2132..736ca9f527b9 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -45,10 +45,6 @@ - -