diff --git a/frontend/src/html/pages/login.html b/frontend/src/html/pages/login.html index ab7585225efb..8941db609989 100644 --- a/frontend/src/html/pages/login.html +++ b/frontend/src/html/pages/login.html @@ -77,10 +77,10 @@ Google Sign In - + diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html index 81ba19a318d5..52cc4475d02e 100644 --- a/frontend/src/html/pages/settings.html +++ b/frontend/src/html/pages/settings.html @@ -1635,6 +1635,21 @@ +
diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index b9971a7c7169..b81e3d751ee5 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -20,6 +20,7 @@ import * as Account from "../pages/account"; import * as Alerts from "../elements/alerts"; import { GoogleAuthProvider, + GithubAuthProvider, browserSessionPersistence, browserLocalPersistence, createUserWithEmailAndPassword, @@ -31,6 +32,7 @@ import { getAdditionalUserInfo, User as UserType, Unsubscribe, + AuthProvider, } from "firebase/auth"; import { Auth, getAuthenticatedUser, isAuthenticated } from "../firebase"; import { dispatch as dispatchSignUpEvent } from "../observables/google-sign-up-event"; @@ -45,6 +47,7 @@ import { getHtmlByUserFlags } from "./user-flag-controller"; let signedOutThisSession = false; export const gmailProvider = new GoogleAuthProvider(); +export const githubProvider = new GithubAuthProvider(); async function sendVerificationEmail(): Promise { if (Auth === undefined) { @@ -266,6 +269,8 @@ if (Auth && ConnectionState.get()) { // ChallengeController.setup(challengeName); // }, 1000); } + + Settings.updateAuthSections(); }); } else { $("nav .signInOut").addClass("hidden"); @@ -357,7 +362,7 @@ async function signIn(): Promise { }); } -async function signInWithGoogle(): Promise { +async function signInWithProvider(provider: AuthProvider): Promise { if (Auth === undefined) { Notifications.add("Authentication uninitialized", -1, { duration: 3, @@ -382,7 +387,7 @@ async function signInWithGoogle(): Promise { : browserSessionPersistence; await setPersistence(Auth, persistence); - signInWithPopup(Auth, gmailProvider) + signInWithPopup(Auth, provider) .then(async (signedInUser) => { if (getAdditionalUserInfo(signedInUser)?.isNewUser) { dispatchSignUpEvent(signedInUser, true); @@ -405,6 +410,11 @@ async function signInWithGoogle(): Promise { } else if (error.code === "auth/user-cancelled") { // message = "User refused to sign in"; return; + } else if ( + error.code === "auth/account-exists-with-different-credential" + ) { + message = + "Account already exists, but its using a different authentication method. Try signing in with a different method"; } Notifications.add(message, -1); LoginPage.hidePreloader(); @@ -413,7 +423,32 @@ async function signInWithGoogle(): Promise { }); } +async function signInWithGoogle(): Promise { + return signInWithProvider(gmailProvider); +} + +async function signInWithGitHub(): Promise { + return signInWithProvider(githubProvider); +} + async function addGoogleAuth(): Promise { + return addAuthProvider("Google", gmailProvider); +} + +async function addGithubAuth(): Promise { + return addAuthProvider("GitHub", githubProvider); +} + +async function addAuthProvider( + providerName: string, + provider: AuthProvider +): Promise { + if (!ConnectionState.get()) { + Notifications.add("You are offline", 0, { + duration: 2, + }); + return; + } if (Auth === undefined) { Notifications.add("Authentication uninitialized", -1, { duration: 3, @@ -422,16 +457,16 @@ async function addGoogleAuth(): Promise { } Loader.show(); if (!isAuthenticated()) return; - linkWithPopup(getAuthenticatedUser(), gmailProvider) + linkWithPopup(getAuthenticatedUser(), provider) .then(function () { Loader.hide(); - Notifications.add("Google authentication added", 1); + Notifications.add(`${providerName} authentication added`, 1); Settings.updateAuthSections(); }) .catch(function (error) { Loader.hide(); Notifications.add( - "Failed to add Google authentication: " + error.message, + `Failed to add ${providerName} authentication: ` + error.message, -1 ); }); @@ -620,9 +655,9 @@ $(".pageLogin .login button.signInWithGoogle").on("click", () => { void signInWithGoogle(); }); -// $(".pageLogin .login .button.signInWithGitHub").on("click",(e) => { -// signInWithGitHub(); -// }); +$(".pageLogin .login button.signInWithGitHub").on("click", () => { + void signInWithGitHub(); +}); $("header .signInOut").on("click", () => { if (Auth === undefined) { @@ -645,15 +680,13 @@ $(".pageLogin .register form").on("submit", (e) => { }); $(".pageSettings #addGoogleAuth").on("click", async () => { - if (!ConnectionState.get()) { - Notifications.add("You are offline", 0, { - duration: 2, - }); - return; - } void addGoogleAuth(); }); +$(".pageSettings #addGithubAuth").on("click", async () => { + void addGithubAuth(); +}); + $(".pageAccount").on("click", ".sendVerificationEmail", () => { if (!ConnectionState.get()) { Notifications.add("You are offline", 0, { diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts index 944c5dc7f9e7..c295d7f00d57 100644 --- a/frontend/src/ts/pages/settings.ts +++ b/frontend/src/ts/pages/settings.ts @@ -734,16 +734,20 @@ export function updateDiscordSection(): void { export function updateAuthSections(): void { $(".pageSettings .section.passwordAuthSettings button").addClass("hidden"); $(".pageSettings .section.googleAuthSettings button").addClass("hidden"); + $(".pageSettings .section.githubAuthSettings button").addClass("hidden"); if (!isAuthenticated()) return; const user = getAuthenticatedUser(); - const passwordProvider = user.providerData.find( + const passwordProvider = user.providerData.some( (provider) => provider.providerId === "password" ); - const googleProvider = user.providerData.find( + const googleProvider = user.providerData.some( (provider) => provider.providerId === "google.com" ); + const githubProvider = user.providerData.some( + (provider) => provider.providerId === "github.com" + ); if (passwordProvider) { $( @@ -762,7 +766,7 @@ export function updateAuthSections(): void { $( ".pageSettings .section.googleAuthSettings #removeGoogleAuth" ).removeClass("hidden"); - if (passwordProvider) { + if (passwordProvider || githubProvider) { $( ".pageSettings .section.googleAuthSettings #removeGoogleAuth" ).removeClass("disabled"); @@ -776,6 +780,24 @@ export function updateAuthSections(): void { "hidden" ); } + if (githubProvider) { + $( + ".pageSettings .section.githubAuthSettings #removeGithubAuth" + ).removeClass("hidden"); + if (passwordProvider || googleProvider) { + $( + ".pageSettings .section.githubAuthSettings #removeGithubAuth" + ).removeClass("disabled"); + } else { + $(".pageSettings .section.githubAuthSettings #removeGithubAuth").addClass( + "disabled" + ); + } + } else { + $(".pageSettings .section.githubAuthSettings #addGithubAuth").removeClass( + "hidden" + ); + } } function setActiveFunboxButton(): void { diff --git a/frontend/src/ts/popups/simple-popups.ts b/frontend/src/ts/popups/simple-popups.ts index 976a38a983c1..3e841453d27b 100644 --- a/frontend/src/ts/popups/simple-popups.ts +++ b/frontend/src/ts/popups/simple-popups.ts @@ -59,6 +59,7 @@ type PopupKey = | "updateName" | "updatePassword" | "removeGoogleAuth" + | "removeGithubAuth" | "addPasswordAuth" | "deleteAccount" | "resetAccount" @@ -86,6 +87,7 @@ const list: Record = { updateName: undefined, updatePassword: undefined, removeGoogleAuth: undefined, + removeGithubAuth: undefined, addPasswordAuth: undefined, deleteAccount: undefined, resetAccount: undefined, @@ -372,7 +374,7 @@ function hide(): void { } } -type ReauthMethod = "passwordOnly" | "passwordFirst"; +type AuthMethod = "password" | "github.com" | "google.com"; type ReauthSuccess = { status: 1; @@ -385,9 +387,44 @@ type ReauthFailed = { 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 ( + Auth?.currentUser?.providerData.some( + (p) => p.providerId === authProvider + ) || false + ); +} + async function reauthenticate( - method: ReauthMethod, - password: string + options: ReauthenticateOptions ): Promise { if (Auth === undefined) { return { @@ -403,28 +440,35 @@ async function reauthenticate( }; } const user = getAuthenticatedUser(); + const authMethod = getPreferredAuthenticationMethod(options.excludeMethod); try { - const passwordAuthEnabled = user.providerData.some( - (p) => p?.providerId === "password" - ); - - if (!passwordAuthEnabled && method === "passwordOnly") { + if (authMethod === undefined) { return { status: -1, message: - "Failed to reauthenticate in password only mode: password authentication is not enabled on this account", + "Failed to reauthenticate: there is no valid authentication present on the account.", }; } - if (passwordAuthEnabled) { + if (authMethod === "password") { + if (options.password === undefined) { + return { + status: -1, + message: "Failed to reauthenticate using password: password missing.", + }; + } const credential = EmailAuthProvider.credential( user.email as string, - password + options.password ); await reauthenticateWithCredential(user, credential); - } else if (method === "passwordFirst") { - await reauthenticateWithPopup(user, AccountController.gmailProvider); + } else { + const authProvider = + authMethod === "github.com" + ? AccountController.githubProvider + : AccountController.gmailProvider; + await reauthenticateWithPopup(user, authProvider); } return { @@ -484,7 +528,7 @@ list.updateEmail = new SimplePopup({ }; } - const reauth = await reauthenticate("passwordOnly", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -535,7 +579,10 @@ list.removeGoogleAuth = new SimplePopup({ onlineOnly: true, buttonText: "remove", execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordOnly", password); + const reauth = await reauthenticate({ + password, + excludeMethod: "google.com", + }); if (reauth.status !== 1) { return { status: reauth.status, @@ -565,8 +612,65 @@ list.removeGoogleAuth = new SimplePopup({ if (!isAuthenticated()) return; if (!isUsingPasswordAuthentication()) { thisPopup.inputs = []; - thisPopup.buttonText = ""; - thisPopup.text = "Password authentication is not enabled"; + if (!isUsingGithubAuthentication()) { + thisPopup.buttonText = ""; + thisPopup.text = "Password or GitHub authentication is not enabled"; + } + } + }, +}); + +list.removeGithubAuth = new SimplePopup({ + id: "removeGithubAuth", + type: "text", + title: "Remove GitHub authentication", + inputs: [ + { + placeholder: "Password", + type: "password", + initVal: "", + }, + ], + onlineOnly: true, + buttonText: "remove", + execFn: async (_thisPopup, password): Promise => { + const reauth = await reauthenticate({ + password, + excludeMethod: "github.com", + }); + if (reauth.status !== 1) { + 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: -1, + message, + }; + } + + Settings.updateAuthSections(); + + reloadAfter(3); + return { + status: 1, + 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"; + } } }, }); @@ -589,8 +693,8 @@ list.updateName = new SimplePopup({ ], buttonText: "update", onlineOnly: true, - execFn: async (_thisPopup, pass, newName): Promise => { - const reauth = await reauthenticate("passwordFirst", pass); + execFn: async (_thisPopup, password, newName): Promise => { + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -700,7 +804,7 @@ list.updatePassword = new SimplePopup({ }; } - const reauth = await reauthenticate("passwordOnly", previousPass); + const reauth = await reauthenticate({ password: previousPass }); if (reauth.status !== 1) { return { status: reauth.status, @@ -767,7 +871,7 @@ list.addPasswordAuth = new SimplePopup({ _thisPopup, email, emailConfirm, - pass, + password, passConfirm ): Promise => { if (email !== emailConfirm) { @@ -777,14 +881,14 @@ list.addPasswordAuth = new SimplePopup({ }; } - if (pass !== passConfirm) { + if (password !== passConfirm) { return { status: 0, message: "Passwords don't match", }; } - const reauth = await reauthenticate("passwordFirst", pass); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -793,7 +897,7 @@ list.addPasswordAuth = new SimplePopup({ } try { - const credential = EmailAuthProvider.credential(email, pass); + const credential = EmailAuthProvider.credential(email, password); await linkWithCredential(reauth.user, credential); } catch (e) { const message = createErrorMessage( @@ -842,7 +946,7 @@ list.deleteAccount = new SimplePopup({ buttonText: "delete", onlineOnly: true, execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordFirst", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -891,7 +995,7 @@ list.resetAccount = new SimplePopup({ buttonText: "reset", onlineOnly: true, execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordFirst", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -942,7 +1046,7 @@ list.optOutOfLeaderboards = new SimplePopup({ buttonText: "opt out", onlineOnly: true, execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordFirst", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -1049,7 +1153,7 @@ list.resetPersonalBests = new SimplePopup({ buttonText: "reset", onlineOnly: true, execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordFirst", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -1126,7 +1230,7 @@ list.revokeAllTokens = new SimplePopup({ buttonText: "revoke all", onlineOnly: true, execFn: async (_thisPopup, password): Promise => { - const reauth = await reauthenticate("passwordFirst", password); + const reauth = await reauthenticate({ password }); if (reauth.status !== 1) { return { status: reauth.status, @@ -1541,14 +1645,6 @@ list.forgotPassword = new SimplePopup({ }, }); -function isUsingPasswordAuthentication(): boolean { - return ( - Auth?.currentUser?.providerData.find( - (p) => p?.providerId === "password" - ) !== undefined - ); -} - export function showPopup( key: PopupKey, showParams = [] as string[], @@ -1582,6 +1678,10 @@ $(".pageSettings #removeGoogleAuth").on("click", () => { showPopup("removeGoogleAuth"); }); +$(".pageSettings #removeGithubAuth").on("click", () => { + showPopup("removeGithubAuth"); +}); + $("#resetSettingsButton").on("click", () => { showPopup("resetSettings"); });