diff --git a/backend/api/controllers/user.ts b/backend/api/controllers/user.ts index 4b8a43a15..123a7fce6 100644 --- a/backend/api/controllers/user.ts +++ b/backend/api/controllers/user.ts @@ -6,6 +6,7 @@ import { MonkeyResponse } from "../../utils/monkey-response"; import { linkAccount } from "../../utils/discord"; import { buildAgentLog } from "../../utils/misc"; import George from "../../tasks/george"; +import admin from "firebase-admin"; export async function createNewUser( req: MonkeyTypes.Request @@ -13,6 +14,11 @@ export async function createNewUser( const { name } = req.body; const { email, uid } = req.ctx.decodedToken; + const available = await UserDAL.isNameAvailable(name); + if (!available) { + throw new MonkeyError(409, "Username unavailable"); + } + await UserDAL.addUser(name, email, uid); Logger.logToDb("user_created", `${name} ${email}`, uid); @@ -92,22 +98,19 @@ export async function updateEmail( export async function getUser( req: MonkeyTypes.Request ): Promise { - const { email, uid } = req.ctx.decodedToken; + const { uid } = req.ctx.decodedToken; let userInfo; try { userInfo = await UserDAL.getUser(uid); } catch (e) { - if (email && uid) { - userInfo = await UserDAL.addUser(undefined, email, uid); - } else { - throw new MonkeyError( - 404, - "User not found. Could not recreate user document.", - "Tried to recreate user document but either email or uid is nullish", - uid - ); - } + await admin.auth().deleteUser(uid); + throw new MonkeyError( + 404, + "User not found. Please try to sign up again.", + "get user", + uid + ); } const agentLog = buildAgentLog(req); diff --git a/backend/api/routes/users.ts b/backend/api/routes/users.ts index 494c29d83..6f244972e 100644 --- a/backend/api/routes/users.ts +++ b/backend/api/routes/users.ts @@ -69,7 +69,7 @@ const usernameValidation = joi }) .messages({ "string.pattern.base": - "Username invalid. Name cannot contain special characters or contain more than 14 characters. Can include _ . and -", + "Username invalid. Name cannot use special characters or contain more than 16 characters. Can include _ . and -", }); const languageSchema = joi.string().min(1).required(); diff --git a/backend/dao/user.ts b/backend/dao/user.ts index 3497bd7d7..ae3efc8d2 100644 --- a/backend/dao/user.ts +++ b/backend/dao/user.ts @@ -7,7 +7,7 @@ import MonkeyError from "../utils/error"; import { DeleteResult, InsertOneResult, ObjectId, UpdateResult } from "mongodb"; export async function addUser( - name: string | undefined, + name: string, email: string, uid: string ): Promise> { diff --git a/backend/types/types.d.ts b/backend/types/types.d.ts index d05e3b290..2b63be5fb 100644 --- a/backend/types/types.d.ts +++ b/backend/types/types.d.ts @@ -62,7 +62,7 @@ declare namespace MonkeyTypes { lastNameChange?: number; lbMemory?: object; lbPersonalBests?: LbPersonalBests; - name?: string; + name: string; customThemes?: CustomTheme[]; personalBests?: PersonalBests; quoteRatings?: UserQuoteRatings; diff --git a/backend/utils/validation.ts b/backend/utils/validation.ts index e0970b274..1f62b6ab2 100644 --- a/backend/utils/validation.ts +++ b/backend/utils/validation.ts @@ -6,7 +6,7 @@ export function inRange(value: number, min: number, max: number): boolean { } export function isUsernameValid(name: string): boolean { - if (_.isNil(name) || !inRange(name.length, 1, 14)) { + if (_.isNil(name) || !inRange(name.length, 1, 16)) { return false; } diff --git a/frontend/src/scripts/controllers/account-controller.ts b/frontend/src/scripts/controllers/account-controller.ts index d9f4dc0c9..4dc3a1ae1 100644 --- a/frontend/src/scripts/controllers/account-controller.ts +++ b/frontend/src/scripts/controllers/account-controller.ts @@ -17,6 +17,7 @@ import * as PageTransition from "../states/page-transition"; import * as ActivePage from "../states/active-page"; import * as TestActive from "../states/test-active"; import * as LoadingPage from "../pages/loading"; +import * as LoginPage from "../pages/login"; import * as ResultFilters from "../account/result-filters"; import * as PaceCaret from "../test/pace-caret"; import * as CommandlineLists from "../elements/commandline-lists"; @@ -45,6 +46,7 @@ import { import { Auth } from "../firebase"; import differenceInDays from "date-fns/differenceInDays"; import { defaultSnap } from "../constants/default-snapshot"; +import { dispatch as dispatchSignUpEvent } from "../observables/google-sign-up-event"; export const gmailProvider = new GoogleAuthProvider(); @@ -230,7 +232,7 @@ export async function getDataAndInit(): Promise { return true; } -async function loadUser(user: UserType): Promise { +export async function loadUser(user: UserType): Promise { // User is signed in. $(".pageAccount .content p.accountVerificatinNotice").remove(); if (user.emailVerified === false) { @@ -251,7 +253,7 @@ async function loadUser(user: UserType): Promise { // var isAnonymous = user.isAnonymous; // var uid = user.uid; // var providerData = user.providerData; - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); // showFavouriteThemesAtTheTop(); @@ -319,9 +321,8 @@ const authListener = Auth.onAuthStateChanged(async function (user) { export function signIn(): void { UpdateConfig.setChangedBeforeDb(false); authListener(); - $(".pageLogin .preloader").removeClass("hidden"); - $(".pageLogin .button").addClass("disabled"); - $(".pageLogin input").prop("disabled", true); + LoginPage.showPreloader(); + LoginPage.disableInputs(); const email = ($(".pageLogin .login input")[0] as HTMLInputElement).value; const password = ($(".pageLogin .login input")[1] as HTMLInputElement).value; @@ -361,115 +362,29 @@ export function signIn(): void { message = "User not found"; } Notifications.add(message, -1); - $(".pageLogin .preloader").addClass("hidden"); - $(".pageLogin .button").removeClass("disabled"); - $(".pageLogin input").prop("disabled", false); + LoginPage.hidePreloader(); + LoginPage.enableInputs(); }); }); } export async function signInWithGoogle(): Promise { UpdateConfig.setChangedBeforeDb(false); - $(".pageLogin .preloader").removeClass("hidden"); - $(".pageLogin .button").addClass("disabled"); - $(".pageLogin input").prop("disabled", true); + LoginPage.showPreloader(); + LoginPage.disableInputs(); authListener(); - let signedInUser; - try { - const persistence = $(".pageLogin .login #rememberMe input").prop("checked") - ? browserLocalPersistence - : browserSessionPersistence; + const persistence = $(".pageLogin .login #rememberMe input").prop("checked") + ? browserLocalPersistence + : browserSessionPersistence; - await setPersistence(Auth, persistence); - signedInUser = await signInWithPopup(Auth, gmailProvider); + await setPersistence(Auth, persistence); + const signedInUser = await signInWithPopup(Auth, gmailProvider); - if (getAdditionalUserInfo(signedInUser)?.isNewUser) { - //ask for username - let nameGood = false; - let name = ""; - - while (!nameGood) { - name = - prompt( - "Please provide a new username (cannot be longer than 16 characters, can only contain letters, numbers, underscores, dots and dashes):" - ) || ""; - - if (!name) { - signOut(); - $(".pageLogin .preloader").addClass("hidden"); - return; - } - - const response = await Ape.users.getNameAvailability(name); - - if (response.status !== 200) { - return Notifications.add( - "Failed to check name: " + response.message, - -1 - ); - } - - nameGood = true; - } - //create database object for the new user - // try { - const response = await Ape.users.create(name); - if (response.status !== 200) { - throw response; - } - // } catch (e) { - // let msg = e?.response?.data?.message ?? e.message; - // Notifications.add("Failed to create account: " + msg, -1); - // return; - // } - if (response.status === 200) { - await updateProfile(signedInUser.user, { displayName: name }); - await sendEmailVerification(signedInUser.user); - AllTimeStats.clear(); - Notifications.add("Account created", 1, 3); - $("#menu .text-button.account .text").text(name); - $(".pageLogin .button").removeClass("disabled"); - $(".pageLogin input").prop("disabled", false); - $(".pageLogin .preloader").addClass("hidden"); - await loadUser(signedInUser.user); - PageController.change("account"); - if (TestLogic.notSignedInLastResult !== null) { - TestLogic.setNotSignedInUid(signedInUser.user.uid); - - const resultsSaveResponse = await Ape.results.save( - TestLogic.notSignedInLastResult - ); - - if (resultsSaveResponse.status === 200) { - const result = TestLogic.notSignedInLastResult; - DB.saveLocalResult(result); - DB.updateLocalStats({ - time: - result.testDuration + - result.incompleteTestSeconds - - result.afkDuration, - started: 1, - }); - } - } - } - } else { - await loadUser(signedInUser.user); - PageController.change("account"); - } - } catch (e) { - console.log(e); - const message = Misc.createErrorMessage(e, "Failed to sign in with Google"); - Notifications.add(message, -1); - $(".pageLogin .preloader").addClass("hidden"); - $(".pageLogin .button").removeClass("disabled"); - $(".pageLogin input").prop("disabled", false); - if (signedInUser && getAdditionalUserInfo(signedInUser)?.isNewUser) { - await Ape.users.delete(); - await signedInUser.user.delete(); - } - signOut(); - return; + if (getAdditionalUserInfo(signedInUser)?.isNewUser) { + dispatchSignUpEvent(signedInUser, true); + } else { + await loadUser(signedInUser.user); + PageController.change("account"); } } @@ -562,6 +477,7 @@ export async function addPasswordAuth( } export function signOut(): void { + if (!Auth.currentUser) return; Auth.signOut() .then(function () { Notifications.add("Signed out", 0, 2); @@ -579,9 +495,8 @@ export function signOut(): void { } async function signUp(): Promise { - $(".pageLogin .button").addClass("disabled"); - $(".pageLogin input").prop("disabled", true); - $(".pageLogin .preloader").removeClass("hidden"); + LoginPage.disableInputs(); + LoginPage.showPreloader(); const nname = ($(".pageLogin .register input")[0] as HTMLInputElement).value; const email = ($(".pageLogin .register input")[1] as HTMLInputElement).value; const emailVerify = ($(".pageLogin .register input")[2] as HTMLInputElement) @@ -594,7 +509,7 @@ async function signUp(): Promise { if (email !== emailVerify) { Notifications.add("Emails do not match", 0, 3); - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); $(".pageLogin .button").removeClass("disabled"); $(".pageLogin input").prop("disabled", false); return; @@ -602,7 +517,7 @@ async function signUp(): Promise { if (password !== passwordVerify) { Notifications.add("Passwords do not match", 0, 3); - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); $(".pageLogin .button").removeClass("disabled"); $(".pageLogin input").prop("disabled", false); return; @@ -612,7 +527,7 @@ async function signUp(): Promise { if (response.status !== 200) { Notifications.add(response.message, -1); - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); $(".pageLogin .button").removeClass("disabled"); $(".pageLogin input").prop("disabled", false); return; @@ -643,7 +558,7 @@ async function signUp(): Promise { $("#menu .text-button.account .text").text(nname); $(".pageLogin .button").removeClass("disabled"); $(".pageLogin input").prop("disabled", false); - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); await loadUser(createdAuthUser.user); if (TestLogic.notSignedInLastResult !== null) { TestLogic.setNotSignedInUid(createdAuthUser.user.uid); @@ -673,7 +588,7 @@ async function signUp(): Promise { console.log(e); const message = Misc.createErrorMessage(e, "Failed to create account"); Notifications.add(message, -1); - $(".pageLogin .preloader").addClass("hidden"); + LoginPage.hidePreloader(); $(".pageLogin .button").removeClass("disabled"); $(".pageLogin input").prop("disabled", false); signOut(); diff --git a/frontend/src/scripts/index.ts b/frontend/src/scripts/index.ts index bb49cab6c..a924c53a2 100644 --- a/frontend/src/scripts/index.ts +++ b/frontend/src/scripts/index.ts @@ -26,6 +26,7 @@ import "./popups/pb-tables-popup"; import "./elements/scroll-to-top"; import "./popups/mobile-test-config-popup"; import "./popups/edit-tags-popup"; +import "./popups/google-sign-up-popup"; import * as Account from "./pages/account"; type ExtendedGlobal = typeof globalThis & MonkeyTypes.Global; diff --git a/frontend/src/scripts/observables/google-sign-up-event.ts b/frontend/src/scripts/observables/google-sign-up-event.ts new file mode 100644 index 000000000..d995a251d --- /dev/null +++ b/frontend/src/scripts/observables/google-sign-up-event.ts @@ -0,0 +1,26 @@ +import { UserCredential } from "firebase/auth"; + +type SubscribeFunction = ( + signedInUser: UserCredential, + isNewUser: boolean +) => void; + +const subscribers: SubscribeFunction[] = []; + +export function subscribe(fn: SubscribeFunction): void { + subscribers.push(fn); +} + +export function dispatch( + signedInUser: UserCredential, + isNewUser: boolean +): void { + subscribers.forEach((fn) => { + try { + fn(signedInUser, isNewUser); + } catch (e) { + console.error("Google Sign Up event subscriber threw an error"); + console.error(e); + } + }); +} diff --git a/frontend/src/scripts/pages/login.ts b/frontend/src/scripts/pages/login.ts index a2de89839..f2fc79029 100644 --- a/frontend/src/scripts/pages/login.ts +++ b/frontend/src/scripts/pages/login.ts @@ -1,5 +1,23 @@ import Page from "./page"; +export function enableInputs(): void { + $(".pageLogin .button").removeClass("disabled"); + $(".pageLogin input").prop("disabled", false); +} + +export function disableInputs(): void { + $(".pageLogin .button").addClass("disabled"); + $(".pageLogin input").prop("disabled", true); +} + +export function showPreloader(): void { + $(".pageLogin .preloader").removeClass("hidden"); +} + +export function hidePreloader(): void { + $(".pageLogin .preloader").addClass("hidden"); +} + export const page = new Page( "login", $(".page.pageLogin"), diff --git a/frontend/src/scripts/popups/google-sign-up-popup.ts b/frontend/src/scripts/popups/google-sign-up-popup.ts new file mode 100644 index 000000000..94bc3020b --- /dev/null +++ b/frontend/src/scripts/popups/google-sign-up-popup.ts @@ -0,0 +1,245 @@ +import * as Notifications from "../elements/notifications"; +import { debounce } from "throttle-debounce"; +import { + sendEmailVerification, + updateProfile, + UserCredential, + getAdditionalUserInfo, +} from "firebase/auth"; +import Ape from "../ape"; +import { createErrorMessage } from "../utils/misc"; +import * as LoginPage from "../pages/login"; +import * as AllTimeStats from "../account/all-time-stats"; +import * as AccountController from "../controllers/account-controller"; +import * as PageController from "../controllers/page-controller"; +import * as TestLogic from "../test/test-logic"; +import * as DB from "../db"; +import * as Loader from "../elements/loader"; +import { subscribe as subscribeToSignUpEvent } from "../observables/google-sign-up-event"; + +let signedInUser: UserCredential | undefined = undefined; + +export function show(credential: UserCredential): void { + if ($("#googleSignUpPopupWrapper").hasClass("hidden")) { + enableInput(); + disableButton(); + signedInUser = credential; + $("#googleSignUpPopupWrapper") + .stop(true, true) + .css("opacity", 0) + .removeClass("hidden") + .animate({ opacity: 1 }, 100, () => { + $("#googleSignUpPopup input").trigger("focus").select(); + }); + } +} + +export async function hide(): Promise { + if (!$("#googleSignUpPopupWrapper").hasClass("hidden")) { + if (signedInUser !== undefined) { + Notifications.add("Sign up process canceled", 0, 5); + LoginPage.hidePreloader(); + LoginPage.enableInputs(); + if (signedInUser && getAdditionalUserInfo(signedInUser)?.isNewUser) { + Ape.users.delete(); + signedInUser.user.delete(); + } + AccountController.signOut(); + signedInUser = undefined; + } + $("#googleSignUpPopupWrapper") + .stop(true, true) + .css("opacity", 1) + .animate( + { + opacity: 0, + }, + 100, + () => { + $("#googleSignUpPopupWrapper").addClass("hidden"); + } + ); + } +} + +async function apply(): Promise { + if ($("#googleSignUpPopup .button").hasClass("disabled")) return; + disableInput(); + disableButton(); + if (!signedInUser) { + return Notifications.add( + "Missing user credential. Please close the popup and try again.", + -1 + ); + } + Loader.show(); + const name = $("#googleSignUpPopup input").val() as string; + try { + if (name.length === 0) throw new Error("Name cannot be empty"); + const response = await Ape.users.create(name); + if (response.status !== 200) { + throw response; + } + + if (response.status === 200) { + await updateProfile(signedInUser.user, { displayName: name }); + await sendEmailVerification(signedInUser.user); + AllTimeStats.clear(); + Notifications.add("Account created", 1, 3); + $("#menu .text-button.account .text").text(name); + LoginPage.enableInputs(); + LoginPage.hidePreloader(); + await AccountController.loadUser(signedInUser.user); + PageController.change("account"); + if (TestLogic.notSignedInLastResult !== null) { + TestLogic.setNotSignedInUid(signedInUser.user.uid); + + const resultsSaveResponse = await Ape.results.save( + TestLogic.notSignedInLastResult + ); + + if (resultsSaveResponse.status === 200) { + const result = TestLogic.notSignedInLastResult; + DB.saveLocalResult(result); + DB.updateLocalStats({ + time: + result.testDuration + + result.incompleteTestSeconds - + result.afkDuration, + started: 1, + }); + } + } + signedInUser = undefined; + Loader.hide(); + hide(); + } + } catch (e) { + console.log(e); + const message = createErrorMessage(e, "Failed to sign in with Google"); + Notifications.add(message, -1); + LoginPage.hidePreloader(); + LoginPage.enableInputs(); + if (signedInUser && getAdditionalUserInfo(signedInUser)?.isNewUser) { + await Ape.users.delete(); + await signedInUser.user.delete(); + } + AccountController.signOut(); + signedInUser = undefined; + hide(); + Loader.hide(); + return; + } +} + +function updateIndicator( + state: "checking" | "available" | "unavailable" | "taken" | "none", + balloon?: string +): void { + $("#googleSignUpPopup .checkStatus .checking").addClass("hidden"); + $("#googleSignUpPopup .checkStatus .available").addClass("hidden"); + $("#googleSignUpPopup .checkStatus .unavailable").addClass("hidden"); + $("#googleSignUpPopup .checkStatus .taken").addClass("hidden"); + if (state !== "none") { + $("#googleSignUpPopup .checkStatus ." + state).removeClass("hidden"); + if (balloon) { + $("#googleSignUpPopup .checkStatus ." + state).attr( + "aria-label", + balloon + ); + } else { + $("#googleSignUpPopup .checkStatus ." + state).removeAttr("aria-label"); + } + } +} + +function enableButton(): void { + $("#googleSignUpPopup .button").removeClass("disabled"); +} + +function disableButton(): void { + $("#googleSignUpPopup .button").addClass("disabled"); +} + +function enableInput(): void { + $("#googleSignUpPopup input").prop("disabled", false); +} + +function disableInput(): void { + $("#googleSignUpPopup input").prop("disabled", true); +} + +$("#googleSignUpPopupWrapper").on("mousedown", (e) => { + if ($(e.target).attr("id") === "googleSignUpPopupWrapper") { + hide(); + } +}); + +const checkNameDebounced = debounce(1000, async () => { + const val = $("#googleSignUpPopup input").val() as string; + if (!val) return; + const response = await Ape.users.getNameAvailability(val); + + if (response.status === 200) { + updateIndicator("available", response.message); + enableButton(); + return; + } + + if (response.status == 422) { + updateIndicator("unavailable", response.message); + return; + } + + if (response.status == 409) { + updateIndicator("taken", response.message); + return; + } + + if (response.status !== 200) { + updateIndicator("unavailable"); + return Notifications.add( + "Failed to check name availability: " + response.message, + -1 + ); + } +}); + +$("#googleSignUpPopup input").on("input", () => { + setTimeout(() => { + disableButton(); + const val = $("#googleSignUpPopup input").val() as string; + if (val === "") { + return updateIndicator("none"); + } else { + updateIndicator("checking"); + checkNameDebounced(); + } + }, 1); +}); + +$("#googleSignUpPopup input").on("keypress", (e) => { + if (e.key === "Enter") { + apply(); + } +}); + +$("#googleSignUpPopup .button").on("click", () => { + apply(); +}); + +$(document).on("keydown", (event) => { + if ( + event.key === "Escape" && + !$("#googleSignUpPopupWrapper").hasClass("hidden") + ) { + hide(); + event.preventDefault(); + } +}); + +subscribeToSignUpEvent((signedInUser, isNewUser) => { + if (signedInUser && isNewUser) { + show(signedInUser); + } +}); diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss index f47606826..bf8148232 100644 --- a/frontend/src/styles/popups.scss +++ b/frontend/src/styles/popups.scss @@ -349,6 +349,56 @@ } } +#googleSignUpPopup { + background: var(--bg-color); + border-radius: var(--roundness); + padding: 2rem; + display: grid; + gap: 1rem; + width: 400px; + .title { + font-size: 1.5rem; + color: var(--sub-color); + } + .inputAndIndicator { + input { + width: 100%; + } + position: relative; + .checkStatus { + width: 2.25rem; + height: 2.25rem; + position: absolute; + right: 0; + top: 0; + /* background: red; */ + display: grid; + grid-template-columns: 2.25rem; + grid-template-rows: 2.25rem; + place-items: center center; + cursor: pointer; + + .checking, + .available, + .unavailable, + .taken { + grid-column: 1/2; + grid-row: 1/2; + } + .checking { + color: var(--sub-color); + } + .available { + color: var(--main-color); + } + .unavailable, + .taken { + color: var(--error-color); + } + } + } +} + #pbTablesPopupWrapper #pbTablesPopup { .title { color: var(--text-color); diff --git a/frontend/static/html/popups.html b/frontend/static/html/popups.html index c20fe7756..34f00ab46 100644 --- a/frontend/static/html/popups.html +++ b/frontend/static/html/popups.html @@ -297,6 +297,38 @@
add
+