Made username non optional (#2859)

* reduced code repetition

* throwing error when no name provided

* stopping signout if no current user

* expanded to 16 chars

* moved code to popup file

* added google sign up popup

* checking if name is available in the backend

* deleting account if popup was closed
uncommented sign up process
addd functions to enable and disable input

* added google sign up event to avoid circular dependency

* hiding by default

* name is no longer optional
user is deleted if its database object is not found

* using loader to indicate background activity
This commit is contained in:
Jack 2022-04-23 19:46:41 +02:00 committed by GitHub
parent 6c4e19a65a
commit b60e6905ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 418 additions and 128 deletions

View file

@ -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<MonkeyResponse> {
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);

View file

@ -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();

View file

@ -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<InsertOneResult<MonkeyTypes.User>> {

View file

@ -62,7 +62,7 @@ declare namespace MonkeyTypes {
lastNameChange?: number;
lbMemory?: object;
lbPersonalBests?: LbPersonalBests;
name?: string;
name: string;
customThemes?: CustomTheme[];
personalBests?: PersonalBests;
quoteRatings?: UserQuoteRatings;

View file

@ -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;
}

View file

@ -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<boolean> {
return true;
}
async function loadUser(user: UserType): Promise<void> {
export async function loadUser(user: UserType): Promise<void> {
// User is signed in.
$(".pageAccount .content p.accountVerificatinNotice").remove();
if (user.emailVerified === false) {
@ -251,7 +253,7 @@ async function loadUser(user: UserType): Promise<void> {
// 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<void> {
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<void> {
$(".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<void> {
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<void> {
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<void> {
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<void> {
$("#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<void> {
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();

View file

@ -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;

View file

@ -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);
}
});
}

View file

@ -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"),

View file

@ -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<void> {
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<void> {
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);
}
});

View file

@ -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);

View file

@ -297,6 +297,38 @@
<div class="button">add</div>
</div>
</div>
<div id="googleSignUpPopupWrapper" class="popupWrapper hidden">
<div id="googleSignUpPopup">
<div class="title">Account name</div>
<div class="text">You need to choose a username before continuing</div>
<div class="inputAndIndicator">
<input type="text" placeholder="username" />
<div class="checkStatus">
<div
class="checking hidden"
data-balloon-length="large"
data-balloon-pos="up"
>
<i class="fas fa-fw fa-circle-notch fa-spin"></i>
</div>
<div class="available hidden" data-balloon-pos="up">
<i class="fas fa-fw fa-check"></i>
</div>
<div
class="unavailable hidden"
data-balloon-length="large"
data-balloon-pos="up"
>
<i class="fas fa-fw fa-times"></i>
</div>
<div class="taken hidden" data-balloon-pos="up">
<i class="fas fa-fw fa-user-friends"></i>
</div>
</div>
</div>
<div class="button disabled">Sign up</div>
</div>
</div>
<div id="customWordAmountPopupWrapper" class="popupWrapper hidden">
<div id="customWordAmountPopup">
<div class="title">Word amount</div>