diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 4f5e39a33977..6c3aaf9fba6e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,7 +4,7 @@ We take the security and integrity of Monkeytype very seriously. If you have fou ### Reporting a Vulnerability -For vulnerabilities that impact the confidentiality, integrity, and availability of Monkeytype services, please send your disclosure via [email](contact@monkeytype.com). For non-security related platform bugs, follow the bug submission [guidelines](https://github.com/monkeytypegame/monkeytype#bug-report-or-feature-request). Include as much detail as possible to ensure reproducibility. At a minimum, vulnerability disclosures should include: +For vulnerabilities that impact the confidentiality, integrity, and availability of Monkeytype services, please send your disclosure via [email](mailto:contact@monkeytype.com). For non-security related platform bugs, follow the bug submission [guidelines](https://github.com/monkeytypegame/monkeytype#bug-report-or-feature-request). Include as much detail as possible to ensure reproducibility. At a minimum, vulnerability disclosures should include: - Vulnerability Description - Proof of Concept diff --git a/frontend/src/styles/fonts.scss b/frontend/src/styles/fonts.scss index cc015a6f201f..e668ae1c2ddb 100644 --- a/frontend/src/styles/fonts.scss +++ b/frontend/src/styles/fonts.scss @@ -1,5 +1,13 @@ @use "sass:map"; +@font-face { + font-family: "Vazirmatn"; + font-style: normal; + font-weight: 400; + font-display: block; + src: url("/webfonts/Vazirmatn-Regular.woff2") format("woff2"); +} + @each $font, $config in $fonts { $dir: "webfonts"; $previewDir: "webfonts-preview"; diff --git a/frontend/src/ts/elements/input-validation.ts b/frontend/src/ts/elements/input-validation.ts index 02ae9cfb84ad..21499aed68f7 100644 --- a/frontend/src/ts/elements/input-validation.ts +++ b/frontend/src/ts/elements/input-validation.ts @@ -143,6 +143,9 @@ export type ValidationOptions = (T extends string callback?: (result: ValidationResult) => void; }; +export type ValidatedHtmlInputElement = HTMLInputElement & { + isValid: () => boolean | undefined; +}; /** * adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation * @param inputElement @@ -151,7 +154,7 @@ export type ValidationOptions = (T extends string export function validateWithIndicator( inputElement: HTMLInputElement, options: ValidationOptions -): void { +): ValidatedHtmlInputElement { //use indicator const indicator = new InputIndicator(inputElement, { success: { @@ -172,7 +175,10 @@ export function validateWithIndicator( level: 0, }, }); + + let isValid: boolean | undefined = undefined; const callback = (result: ValidationResult): void => { + isValid = result.status === "success" || result.status === "warning"; if (result.status === "failed" || result.status === "warning") { indicator.show(result.status, result.errorMessage); } else { @@ -188,6 +194,11 @@ export function validateWithIndicator( ); inputElement.addEventListener("input", handler); + + const result = inputElement as ValidatedHtmlInputElement; + result.isValid = () => isValid; + + return result; } export type ConfigInputOptions = { diff --git a/frontend/src/ts/elements/test-activity-calendar.ts b/frontend/src/ts/elements/test-activity-calendar.ts index 4b839b393fdd..27476e8312d5 100644 --- a/frontend/src/ts/elements/test-activity-calendar.ts +++ b/frontend/src/ts/elements/test-activity-calendar.ts @@ -54,7 +54,7 @@ export class TestActivityCalendar implements TestActivityCalendar { } protected getInterval(lastDay: Date, fullYear = false): Interval { - const end = fullYear ? endOfYear(lastDay) : new Date(); + const end = fullYear ? endOfYear(lastDay) : new UTCDateMini(); let start = startOfYear(lastDay); if (!fullYear) { //show the last 52 weeks. Not using one year to avoid the graph to show 54 weeks diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index ee02b42fe2c4..f73c4fa775c6 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -23,7 +23,6 @@ import { import { createErrorMessage, isDevEnvironment, - isPasswordStrong, reloadAfter, } from "../utils/misc"; import * as CustomTextState from "../states/custom-text-name"; @@ -38,9 +37,14 @@ import { } from "../utils/simple-modal"; import { ShowOptions } from "../utils/animated-modal"; import { GenerateDataRequest } from "@monkeytype/contracts/dev"; -import { UserEmailSchema, UserNameSchema } from "@monkeytype/schemas/users"; +import { + PasswordSchema, + UserEmailSchema, + UserNameSchema, +} from "@monkeytype/schemas/users"; import { goToPage } from "../pages/leaderboards"; import FileStorage from "../utils/file-storage"; +import { z } from "zod"; type PopupKey = | "updateEmail" @@ -551,6 +555,9 @@ list.updatePassword = new SimpleModal({ placeholder: "new password", type: "password", initVal: "", + validation: { + schema: isDevEnvironment() ? z.string().min(6) : PasswordSchema, + }, }, { placeholder: "confirm new password", @@ -580,14 +587,6 @@ list.updatePassword = new SimpleModal({ }; } - if (!isDevEnvironment() && !isPasswordStrong(newPassword)) { - return { - status: 0, - message: - "New password must contain at least one capital letter, number, a special character and must be between 8 and 64 characters long", - }; - } - const reauth = await reauthenticate({ password: previousPass }); if (reauth.status !== 1) { return { diff --git a/frontend/src/ts/pages/login.ts b/frontend/src/ts/pages/login.ts index 5d1d0b84f810..632b6f5944fc 100644 --- a/frontend/src/ts/pages/login.ts +++ b/frontend/src/ts/pages/login.ts @@ -1,10 +1,14 @@ import Ape from "../ape"; import Page from "./page"; import * as Skeleton from "../utils/skeleton"; -import * as Misc from "../utils/misc"; import TypoList from "../utils/typo-list"; -import { UserEmailSchema, UserNameSchema } from "@monkeytype/schemas/users"; +import { + PasswordSchema, + UserEmailSchema, + UserNameSchema, +} from "@monkeytype/schemas/users"; import { validateWithIndicator } from "../elements/input-validation"; +import { isDevEnvironment } from "../utils/misc"; import { z } from "zod"; let registerForm: { @@ -39,11 +43,19 @@ export function hidePreloader(): void { $(".pageLogin .preloader").addClass("hidden"); } +function isFormComplete(): boolean { + return ( + registerForm.name !== undefined && + registerForm.email !== undefined && + registerForm.password !== undefined + ); +} + export const updateSignupButton = (): void => { - if (Object.values(registerForm).some((it) => it === undefined)) { - disableSignUpButton(); - } else { + if (isFormComplete()) { enableSignUpButton(); + } else { + disableSignUpButton(); } }; @@ -53,9 +65,7 @@ type SignupData = { password: string; }; export function getSignupData(): SignupData | false { - return Object.values(registerForm).some((it) => it === undefined) - ? false - : (registerForm as SignupData); + return isFormComplete() ? (registerForm as SignupData) : false; } const nameInputEl = document.querySelector( @@ -84,9 +94,58 @@ let disposableEmailModule: typeof import("disposable-email-domains-js") | null = null; let moduleLoadAttempted = false; -const emailInputEl = document.querySelector( - ".page.pageLogin .register.side input.emailInput" -) as HTMLInputElement; +const emailInputEl = validateWithIndicator( + document.querySelector( + ".page.pageLogin .register.side input.emailInput" + ) as HTMLInputElement, + { + schema: UserEmailSchema, + isValid: async (email: string) => { + const educationRegex = + /@.*(student|education|school|\.edu$|\.edu\.|\.ac\.|\.sch\.)/i; + if (educationRegex.test(email)) { + return { + warning: + "Some education emails will fail to receive our messages, or disable the account as soon as you graduate. Consider using a personal email address.", + }; + } + + const emailHasTypo = TypoList.some((typo) => { + return email.endsWith(typo); + }); + if (emailHasTypo) { + return { + warning: "Please check your email address, it may contain a typo.", + }; + } + + if ( + disposableEmailModule && + disposableEmailModule.isDisposableEmail !== undefined + ) { + try { + if (disposableEmailModule.isDisposableEmail(email)) { + return { + warning: + "Using a temporary email may cause issues with logging in, password resets and support. Consider using a permanent email address. Don't worry, we don't send spam.", + }; + } + } catch (e) { + // Silent failure + } + } + + return true; + }, + debounceDelay: 0, + callback: (result) => { + if (result.status === "success") { + //re-validate the verify email + emailVerifyInputEl.dispatchEvent(new Event("input")); + } + }, + } +); emailInputEl.addEventListener("focus", async () => { if (!moduleLoadAttempted) { @@ -99,54 +158,6 @@ emailInputEl.addEventListener("focus", async () => { } }); -validateWithIndicator(emailInputEl, { - schema: UserEmailSchema, - isValid: async (email: string) => { - const educationRegex = - /@.*(student|education|school|\.edu$|\.edu\.|\.ac\.|\.sch\.)/i; - if (educationRegex.test(email)) { - return { - warning: - "Some education emails will fail to receive our messages, or disable the account as soon as you graduate. Consider using a personal email address.", - }; - } - - const emailHasTypo = TypoList.some((typo) => { - return email.endsWith(typo); - }); - if (emailHasTypo) { - return { - warning: "Please check your email address, it may contain a typo.", - }; - } - - if ( - disposableEmailModule && - disposableEmailModule.isDisposableEmail !== undefined - ) { - try { - if (disposableEmailModule.isDisposableEmail(email)) { - return { - warning: - "Using a temporary email may cause issues with logging in, password resets and support. Consider using a permanent email address. Don't worry, we don't send spam.", - }; - } - } catch (e) { - // Silent failure - } - } - - return true; - }, - debounceDelay: 0, - callback: (result) => { - if (result.status === "success") { - //re-validate the verify email - emailVerifyInputEl.dispatchEvent(new Event("input")); - } - }, -}); - const emailVerifyInputEl = document.querySelector( ".page.pageLogin .register.side input.verifyEmailInput" ) as HTMLInputElement; @@ -159,36 +170,27 @@ validateWithIndicator(emailVerifyInputEl, { debounceDelay: 0, callback: (result) => { registerForm.email = - result.status === "success" ? emailInputEl.value : undefined; + emailInputEl.isValid() && result.status === "success" + ? emailInputEl.value + : undefined; updateSignupButton(); }, }); -const passwordInputEl = document.querySelector( - ".page.pageLogin .register.side .passwordInput" -) as HTMLInputElement; -validateWithIndicator(passwordInputEl, { - schema: z.string().min(6), //firebase requires min 6 chars, we apply stricter rules on prod - isValid: async (password: string) => { - if (!Misc.isDevEnvironment() && !Misc.isPasswordStrong(password)) { - if (password.length < 8) { - return "Password must be at least 8 characters"; - } else if (password.length > 64) { - return "Password must be at most 64 characters"; - } else { - return "Password must contain at least one capital letter, number, and special character"; +const passwordInputEl = validateWithIndicator( + document.querySelector( + ".page.pageLogin .register.side .passwordInput" + ) as HTMLInputElement, + { + schema: isDevEnvironment() ? z.string().min(6) : PasswordSchema, + callback: (result) => { + if (result.status === "success") { + //re-validate the verify password + passwordVerifyInputEl.dispatchEvent(new Event("input")); } - } - return true; - }, - debounceDelay: 0, - callback: (result) => { - if (result.status === "success") { - //re-validate the verify password - passwordVerifyInputEl.dispatchEvent(new Event("input")); - } - }, -}); + }, + } +); const passwordVerifyInputEl = document.querySelector( ".page.pageLogin .register.side .verifyPasswordInput" @@ -202,7 +204,9 @@ validateWithIndicator(passwordVerifyInputEl, { debounceDelay: 0, callback: (result) => { registerForm.password = - result.status === "success" ? passwordInputEl.value : undefined; + passwordInputEl.isValid() && result.status === "success" + ? passwordInputEl.value + : undefined; updateSignupButton(); }, }); diff --git a/frontend/src/ts/utils/simple-modal.ts b/frontend/src/ts/utils/simple-modal.ts index bd5200ba7a1b..2bc890d9d520 100644 --- a/frontend/src/ts/utils/simple-modal.ts +++ b/frontend/src/ts/utils/simple-modal.ts @@ -171,6 +171,7 @@ export class SimpleModal { el.find(".submitButton").remove(); } else { el.find(".submitButton").text(this.buttonText); + this.updateSubmitButtonState(); } if ((this.text ?? "") === "") { @@ -178,8 +179,6 @@ export class SimpleModal { } else { el.find(".text").removeClass("hidden"); } - - // } } initInputs(): void { @@ -315,9 +314,12 @@ export class SimpleModal { "#" + attributes["id"] ) as HTMLInputElement; - if (input.oninput !== undefined) { - element.oninput = input.oninput; - } + const originalOnInput = element.oninput; + element.oninput = (e) => { + if (originalOnInput) originalOnInput.call(element, e); + input.oninput?.(e); + this.updateSubmitButtonState(); + }; input.currentValue = () => { if (element.type === "checkbox") @@ -338,6 +340,8 @@ export class SimpleModal { callback: (result: ValidationResult) => { input.hasError = result.status !== "success"; + + this.updateSubmitButtonState(); }, debounceDelay: input.validation.debounceDelay, }; @@ -351,16 +355,12 @@ export class SimpleModal { exec(): void { if (!this.canClose) return; - if ( - this.inputs - .filter((i) => i.hidden !== true && i.optional !== true) - .some((v) => v.currentValue() === undefined || v.currentValue() === "") - ) { + if (this.hasMissingRequired()) { Notifications.add("Please fill in all fields", 0); return; } - if (this.inputs.some((i) => i.hasError === true)) { + if (this.hasValidationErrors()) { Notifications.add("Please solve all validation errors", 0); return; } @@ -429,6 +429,29 @@ export class SimpleModal { 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.querySelector( + ".submitButton" + ) as HTMLButtonElement; + if (button === null) return; + + if (this.hasMissingRequired() || this.hasValidationErrors()) { + button.disabled = true; + } else { + button.disabled = false; + } + } } function hide(): void { diff --git a/frontend/static/layouts/pine_v4.json b/frontend/static/layouts/pine_v4.json new file mode 100644 index 000000000000..adf150630ade --- /dev/null +++ b/frontend/static/layouts/pine_v4.json @@ -0,0 +1,62 @@ +{ + "keymapShowTopRow": false, + "type": "ansi", + "keys": { + "row1": [ + ["`", "~"], + ["1", "!"], + ["2", "@"], + ["3", "#"], + ["4", "$"], + ["5", "%"], + ["6", "^"], + ["7", "&"], + ["8", "*"], + ["9", "("], + ["0", ")"], + ["-", "_"], + ["=", "+"] + ], + "row2": [ + ["q", "Q"], + ["l", "L"], + ["c", "C"], + ["m", "M"], + ["K", "K"], + ["'", "\""], + ["f", "F"], + ["u", "U"], + ["o", "O"], + ["y", "Y"], + ["[", "{"], + ["]", "}"], + ["\\", "|"] + ], + "row3": [ + ["n", "N"], + ["r", "R"], + ["s", "S"], + ["t", "T"], + ["w", "W"], + ["p", "P"], + ["h", "H"], + ["e", "E"], + ["a", "A"], + ["i", "I"], + ["/", "?"] + ], + "row4": [ + ["j", "J"], + ["x", "X"], + ["z", "Z"], + ["g", "G"], + ["v", "V"], + ["b", "B"], + ["d", "D"], + [";", ":"], + [",", "<"], + [".", ">"] + ], + "row5": [[" "]] + } +} diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts index 204cd25ea5a9..a56dc7913223 100644 --- a/packages/contracts/src/users.ts +++ b/packages/contracts/src/users.ts @@ -439,7 +439,7 @@ export const usersContract = c.router( }, updatePassword: { summary: "update password", - description: "Updates a user's email", + description: "Updates a user's password", method: "PATCH", path: "/password", body: UpdatePasswordRequestSchema.strict(), diff --git a/packages/schemas/src/layouts.ts b/packages/schemas/src/layouts.ts index 735e455e40e8..89466b784049 100644 --- a/packages/schemas/src/layouts.ts +++ b/packages/schemas/src/layouts.ts @@ -111,6 +111,7 @@ export const LayoutNameSchema = z.enum( "klauser", "oneproduct", "pine", + "pine_v4", "real", "rolll", "stndc", diff --git a/packages/schemas/src/users.ts b/packages/schemas/src/users.ts index f129563cfacb..befb5488a9a1 100644 --- a/packages/schemas/src/users.ts +++ b/packages/schemas/src/users.ts @@ -371,3 +371,14 @@ export const ReportUserReasonSchema = z.enum([ "Suspected cheating", ]); export type ReportUserReason = z.infer; + +export const PasswordSchema = z + .string() + .min(8, { message: "must be at least 8 characters" }) + .max(64, { message: "must be at most 64 characters" }) + .regex(/[A-Z]/, { message: "must contain at least one capital letter" }) + .regex(/[\d]/, { message: "must contain at least one number" }) + .regex(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, { + message: "must contain at least one special character", + }); +export type Password = z.infer;