From 54255cc118559b25082b8bd012d806879cab4dd0 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Fri, 5 Apr 2024 09:11:21 -0400 Subject: [PATCH] implement account authentication (webapp) --- Config.ts | 6 +- src/css/style.css | 12 ++ src/html/index.html | 92 +++++++-- src/ts/AuthManager.ts | 211 ++++++++++++++++++++ src/ts/i18n.ts | 12 ++ src/ts/main.ts | 310 +++++++++++++++++++++++++++++- src/ts/protocol/CollabVMClient.ts | 28 +++ src/ts/protocol/Permissions.ts | 1 + static/lang/en-us.json | 13 +- tsconfig.json | 4 +- 10 files changed, 669 insertions(+), 20 deletions(-) create mode 100644 src/ts/AuthManager.ts diff --git a/Config.ts b/Config.ts index 439af6b..65f8d7f 100644 --- a/Config.ts +++ b/Config.ts @@ -10,5 +10,9 @@ export const Config = { "wss://computernewb.com/collab-vm/vm6", "wss://computernewb.com/collab-vm/vm7", "wss://computernewb.com/collab-vm/vm8", - ] + ], + Auth: { + Enabled: false, + APIEndpoint: "http://127.0.0.1:5858" + } } \ No newline at end of file diff --git a/src/css/style.css b/src/css/style.css index 9cac47e..900d225 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -95,6 +95,14 @@ tr.user-moderator > td, .chat-username-moderator, .username-moderator { color: #00FF00 !important; } +tr.user-unregistered > td, .chat-username-unregistered, .username-unregistered { + color: #b1b1b1 !important; +} + +tr.user-registered > td, .chat-username-registered, .username-registered { + color: #FFFFFF !important; +} + tr.user-turn > td { background-color: #cfe2ff !important; --bs-table-bg-state: #cfe2ff !important; @@ -290,3 +298,7 @@ div[data-cvm-node=vm0b0t] { filter:blur(40px)!important; } } + +#accountDropdownMenuLink, #accountModalError { + display: none; +} \ No newline at end of file diff --git a/src/html/index.html b/src/html/index.html index e3a7d4d..4317469 100644 --- a/src/html/index.html +++ b/src/html/index.html @@ -97,15 +97,74 @@ -
- Username + Username
@@ -215,7 +285,7 @@
- + diff --git a/src/ts/AuthManager.ts b/src/ts/AuthManager.ts new file mode 100644 index 0000000..888da39 --- /dev/null +++ b/src/ts/AuthManager.ts @@ -0,0 +1,211 @@ +export default class AuthManager { + apiEndpoint : string; + info : AuthServerInformation | null; + account : Account | null; + constructor(apiEndpoint : string) { + this.apiEndpoint = apiEndpoint; + this.info = null; + this.account = null; + } + + getAPIInformation() : Promise { + return new Promise(async res => { + var data = await fetch(this.apiEndpoint + "/api/v1/info"); + this.info = await data.json(); + res(this.info!); + }) + } + + login(username : string, password : string, captchaToken : string | undefined) : Promise { + return new Promise(async (res,rej) => { + if (!this.info) throw new Error("Cannot login before fetching API information."); + if (!captchaToken && this.info.hcaptcha.required) throw new Error("This API requires a valid hCaptcha token."); + var data = await fetch(this.apiEndpoint + "/api/v1/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: username, + password: password, + captchaToken: captchaToken + }) + }); + var json = await data.json() as AccountLoginResult; + if (!json) throw new Error("data.json() gave null or undefined result"); + if (json.success && !json.verificationRequired) { + this.account = { + username: json.username!, + email: json.email!, + sessionToken: json.token! + } + } + res(json); + }) + } + + loadSession(token : string) { + return new Promise(async (res, rej) => { + var data = await fetch(this.apiEndpoint + "/api/v1/session", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: token, + }) + }); + var json = await data.json() as SessionResult; + if (json.success) { + this.account = { + sessionToken: token, + username: json.username!, + email: json.email!, + }; + } + res(json); + }) + } + + register(username : string, password : string, email : string, captchaToken : string | undefined) : Promise { + return new Promise(async (res, rej) => { + if (!this.info) throw new Error("Cannot login before fetching API information."); + if (!captchaToken && this.info.hcaptcha.required) throw new Error("This API requires a valid hCaptcha token."); + var data = await fetch(this.apiEndpoint + "/api/v1/register", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: username, + password: password, + email: email, + captchatoken: captchaToken + }) + }); + res(await data.json() as AccountRegisterResult); + }); + } + + logout() { + return new Promise(async res => { + if (!this.account) throw new Error("Cannot log out without logging in first"); + var data = await fetch(this.apiEndpoint + "/api/v1/logout", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: this.account.sessionToken + }) + }); + var json = await data.json() as LogoutResult; + this.account = null; + res(json); + }) + } + + verifyEmail(username : string, password : string, code : string) { + return new Promise(async res => { + var data = await fetch(this.apiEndpoint + "/api/v1/verify", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + username: username, + password: password, + code: code, + }) + }); + res(await data.json() as VerifyEmailResult); + }); + } + + updateAccount(currentPassword : string, newEmail : string | undefined, newUsername : string | undefined, newPassword : string | undefined) { + return new Promise(async res => { + if (!this.account) throw new Error("Cannot update account without being logged in."); + if (!newEmail && !newUsername && !newPassword) throw new Error("Cannot update account without any new information."); + var data = await fetch(this.apiEndpoint + "/api/v1/update", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: this.account!.sessionToken, + currentPassword: currentPassword, + newPassword: newPassword, + username: newUsername, + email: newEmail, + }) + }); + var json = await data.json() as UpdateAccountResult; + if (json.success) { + if (this.account!.email !== newEmail) this.account!.email = newEmail!; + if (this.account!.username !== newUsername) this.account!.username = newUsername!; + if (json.sessionExpired || json.verificationRequired) { + this.account = null; + } + } + res(json); + }); + } +} + +export interface AuthServerInformation { + registrationOpen : boolean; + hcaptcha : { + required : boolean; + siteKey : string | undefined; + }; +} + +export interface AccountRegisterResult { + success : boolean; + error : string | undefined; + verificationRequired : boolean | undefined; + username : string | undefined; + email : string | undefined; + sessionToken : string | undefined; +} + +export interface AccountLoginResult { + success : boolean; + token : string | undefined; + error : string | undefined; + verificationRequired : boolean | undefined; + email : string | undefined; + username : string | undefined; +} + +export interface SessionResult { + success : boolean; + error : string | undefined; + banned : boolean; + username : string | undefined; + email : string | undefined; +} + +export interface VerifyEmailResult { + success : boolean; + error : string | undefined; + sessionToken : string | undefined; +} + +export interface LogoutResult { + success : boolean; + error : string | undefined; +} + +export interface Account { + username : string; + email : string; + sessionToken : string; +} + +export interface UpdateAccountResult { + success : boolean; + error : string | undefined; + verificationRequired : boolean | undefined; + sessionExpired : boolean | undefined; +} \ No newline at end of file diff --git a/src/ts/i18n.ts b/src/ts/i18n.ts index 4db684e..a7db4be 100644 --- a/src/ts/i18n.ts +++ b/src/ts/i18n.ts @@ -43,6 +43,18 @@ export enum I18nStringKey { kError_UsernameTaken = 'kError_UsernameTaken', kError_UsernameInvalid = 'kError_UsernameInvalid', kError_UsernameBlacklisted = 'kError_UsernameBlacklisted', + + // Auth + kAccountModal_Login = 'kAccountModal_Login', + kAccountModal_Register = 'kAccountModal_Register', + kAccountModal_Verify = 'kAccountModal_Verify', + kAccountModal_AccountSettings = 'kAccountModal_AccountSettings', + + kAccountModal_VerifyText = 'kAccountModal_VerifyText', + kMissingCaptcha = 'kMissingCaptcha', + kPasswordsMustMatch = 'kPasswordsMustMatch', + + kNotLoggedIn = 'kNotLoggedIn', } // This models the JSON structure. diff --git a/src/ts/main.ts b/src/ts/main.ts index e2d549d..ebf1148 100644 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -13,6 +13,7 @@ import MuteState from './protocol/MuteState.js'; import { Unsubscribe } from 'nanoevents'; import { I18nStringKey, TheI18n } from './i18n.js'; import { Format } from './format.js'; +import AuthManager from './AuthManager.js'; // Elements const w = window as any; @@ -65,9 +66,49 @@ const elements = { indefTurnBtn: document.getElementById('indefTurnBtn') as HTMLButtonElement, qemuMonitorInput: document.getElementById('qemuMonitorInput') as HTMLInputElement, qemuMonitorSendBtn: document.getElementById('qemuMonitorSendBtn') as HTMLButtonElement, - qemuMonitorOutput: document.getElementById('qemuMonitorOutput') as HTMLTextAreaElement + qemuMonitorOutput: document.getElementById('qemuMonitorOutput') as HTMLTextAreaElement, + // Auth + accountDropdownUsername: document.getElementById("accountDropdownUsername") as HTMLSpanElement, + accountDropdownMenuLink: document.getElementById("accountDropdownMenuLink") as HTMLDivElement, + accountLoginButton: document.getElementById("accountLoginButton") as HTMLAnchorElement, + accountRegisterButton: document.getElementById("accountRegisterButton") as HTMLAnchorElement, + accountSettingsButton: document.getElementById("accountSettingsButton") as HTMLAnchorElement, + accountLogoutButton: document.getElementById("accountLogoutButton") as HTMLAnchorElement, + accountModal: document.getElementById("accountModal") as HTMLDivElement, + accountModalError: document.getElementById("accountModalError") as HTMLDivElement, + accountModalErrorText: document.getElementById("accountModalErrorText") as HTMLSpanElement, + accountModalErrorDismiss: document.getElementById("accountModalErrorDismiss") as HTMLButtonElement, + accountLoginSection: document.getElementById("accountLoginSection") as HTMLDivElement, + accountRegisterSection: document.getElementById("accountRegisterSection") as HTMLDivElement, + accountVerifyEmailSection: document.getElementById("accountVerifyEmailSection") as HTMLDivElement, + accountVerifyEmailText: document.getElementById("accountVerifyEmailText") as HTMLParagraphElement, + accountModalTitle: document.getElementById("accountModalTitle") as HTMLHeadingElement, + accountLoginForm: document.getElementById("accountLoginForm") as HTMLFormElement, + accountRegisterForm: document.getElementById("accountRegisterForm") as HTMLFormElement, + accountVerifyEmailForm: document.getElementById("accountVerifyEmailForm") as HTMLFormElement, + accountLoginCaptcha: document.getElementById("accountLoginCaptcha") as HTMLDivElement, + accountRegisterCaptcha: document.getElementById("accountRegisterCaptcha") as HTMLDivElement, + + accountLoginUsername: document.getElementById("accountLoginUsername") as HTMLInputElement, + accountLoginPassword: document.getElementById("accountLoginPassword") as HTMLInputElement, + accountRegisterEmail: document.getElementById("accountRegisterEmail") as HTMLInputElement, + accountRegisterUsername: document.getElementById("accountRegisterUsername") as HTMLInputElement, + accountRegisterPassword: document.getElementById("accountRegisterPassword") as HTMLInputElement, + accountRegisterConfirmPassword: document.getElementById("accountRegisterConfirmPassword") as HTMLInputElement, + accountVerifyEmailCode: document.getElementById("accountVerifyEmailCode") as HTMLInputElement, + accountVerifyEmailPassword: document.getElementById("accountVerifyEmailPassword") as HTMLInputElement, + + accountSettingsSection: document.getElementById("accountSettingsSection") as HTMLDivElement, + accountSettingsForm: document.getElementById("accountSettingsForm") as HTMLFormElement, + accountSettingsEmail: document.getElementById("accountSettingsEmail") as HTMLInputElement, + accountSettingsUsername: document.getElementById("accountSettingsUsername") as HTMLInputElement, + accountSettingsNewPassword: document.getElementById("accountSettingsNewPassword") as HTMLInputElement, + accountSettingsConfirmNewPassword: document.getElementById("accountSettingsConfirmNewPassword") as HTMLInputElement, + accountSettingsCurrentPassword: document.getElementById("accountSettingsCurrentPassword") as HTMLInputElement, }; +let auth : AuthManager|null = null; + /* Start OSK */ let commonKeyboardOptions = { onKeyPress: (button: string) => onKeyPress(button), @@ -353,12 +394,23 @@ async function openVM(vm: VM): Promise { closeVM(); }); + // auth + VM!.on('auth', async server => { + elements.changeUsernameBtn.style.display = "none"; + if (Config.Auth.Enabled && Config.Auth.APIEndpoint === server && auth!.account) { + VM!.loginAccount(auth!.account.sessionToken); + } else if (!Config.Auth.Enabled || Config.Auth.APIEndpoint !== server) { + auth = new AuthManager(server); + await renderAuth(); + } + }); + // Wait for the client to open await VM!.WaitForOpen(); // Connect to node chatMessage('', `${vm.id}
`); - let username = localStorage.getItem('username'); + let username = Config.Auth.Enabled ? null : localStorage.getItem('username'); let connected = await VM.connect(vm.id, username); elements.adminInputVMID.value = vm.id; w.VMName = vm.id; @@ -411,8 +463,18 @@ function closeVM() { elements.voteYesLabel.innerText = '0'; elements.voteNoLabel.innerText = '0'; elements.xssCheckbox.checked = false; - elements.username.classList.remove('username-admin', 'username-moderator'); - elements.username.classList.add('text-light'); + elements.username.classList.remove('username-admin', 'username-moderator', 'username-registered'); + elements.username.classList.add('username-unregistered'); + // Reset rename button + elements.changeUsernameBtn.style.display = "inline-block"; + // Reset auth if it was changed by the VM + if (Config.Auth.Enabled && auth?.apiEndpoint !== Config.Auth.APIEndpoint) { + auth = new AuthManager(Config.Auth.APIEndpoint); + renderAuth(); + } else if (auth && !Config.Auth.Enabled) { + auth = null; + elements.accountDropdownMenuLink.style.display = "none"; + } } async function loadList() { @@ -472,6 +534,10 @@ function chatMessage(username: string, message: string) { userclass = 'chat-username-unregistered'; msgclass = 'chat-unregistered'; break; + case Rank.Registered: + userclass = 'chat-username-registered'; + msgclass = 'chat-registered'; + break; case Rank.Admin: userclass = 'chat-username-admin'; msgclass = 'chat-admin'; @@ -510,6 +576,9 @@ function addUser(user: User) { case Rank.Moderator: tr.classList.add('user-moderator'); break; + case Rank.Registered: + tr.classList.add('user-registered'); + break; case Rank.Unregistered: tr.classList.add('user-unregistered'); break; @@ -517,7 +586,7 @@ function addUser(user: User) { if (user.username === w.username) tr.classList.add('user-current'); tr.appendChild(td); let u = { user: user, element: tr }; - if (rank !== Rank.Unregistered) userModOptions(u); + if (rank === Rank.Admin || rank === Rank.Moderator) userModOptions(u); elements.userlist.appendChild(tr); if (olduser !== undefined) olduser.element = tr; else users.push(u); @@ -581,6 +650,7 @@ function turnUpdate(status: TurnStatus) { } if (turn === -1) elements.turnstatus.innerText = ''; else { + //@ts-ignore turnInterval = setInterval(() => turnIntervalCb(), 1000); setTurnStatus(); } @@ -593,6 +663,7 @@ function voteUpdate(status: VoteStatus) { elements.voteYesLabel.innerText = status.yesVotes.toString(); elements.voteNoLabel.innerText = status.noVotes.toString(); voteTimer = Math.floor(status.timeToEnd / 1000); + //@ts-ignore voteInterval = setInterval(() => updateVoteEndTime(), 1000); updateVoteEndTime(); } @@ -670,6 +741,7 @@ let usernameClick = false; const loginModal = new bootstrap.Modal(elements.loginModal); elements.loginModal.addEventListener('shown.bs.modal', () => elements.adminPassword.focus()); elements.username.addEventListener('click', () => { + if (auth) return; if (!usernameClick) { usernameClick = true; setInterval(() => (usernameClick = false), 1000); @@ -702,6 +774,7 @@ function onLogin(_rank: Rank, _perms: Permissions) { elements.username.classList.remove('text-dark', 'text-light'); if (rank === Rank.Admin) elements.username.classList.add('username-admin'); if (rank === Rank.Moderator) elements.username.classList.add('username-moderator'); + if (rank === Rank.Registered) elements.username.classList.add('username-registered'); elements.staffbtns.style.display = 'block'; if (_perms.restore) elements.restoreBtn.style.display = 'inline-block'; if (_perms.reboot) elements.rebootBtn.style.display = 'inline-block'; @@ -716,7 +789,8 @@ function onLogin(_rank: Rank, _perms: Permissions) { } if (_perms.xss) elements.xssCheckboxContainer.style.display = 'inline-block'; if (_perms.forcevote) elements.forceVotePanel.style.display = 'block'; - for (const user of users) userModOptions(user); + if (rank !== Rank.Registered) + for (const user of users) userModOptions(user); } function userModOptions(user: { user: User; element: HTMLTableRowElement }) { @@ -788,6 +862,224 @@ elements.qemuMonitorSendBtn.addEventListener('click', () => sendQEMUCommand()); elements.qemuMonitorInput.addEventListener('keypress', (e) => e.key === 'Enter' && sendQEMUCommand()); elements.osk.addEventListener('click', () => elements.oskContainer.classList.toggle('d-none')); +// Auth stuff +async function renderAuth() { + if (auth === null) throw new Error("Cannot renderAuth when auth is null."); + await auth.getAPIInformation(); + elements.accountDropdownUsername.innerText = TheI18n.GetString(I18nStringKey.kNotLoggedIn); + elements.accountDropdownMenuLink.style.display = "block"; + if (!auth!.info!.registrationOpen) + elements.accountRegisterButton.style.display = "none"; + else + elements.accountRegisterButton.style.display = "block"; + elements.accountLoginButton.style.display = "block"; + elements.accountSettingsButton.style.display = "none"; + elements.accountLogoutButton.style.display = "none"; + elements.accountRegisterCaptcha.innerHTML = ""; + elements.accountLoginCaptcha.innerHTML = ""; + if (auth!.info!.hcaptcha.required) { + var hconfig = {sitekey: auth!.info!.hcaptcha.siteKey!}; + hcaptcha.render(elements.accountRegisterCaptcha, hconfig); + hcaptcha.render(elements.accountLoginCaptcha, hconfig); + } + var token = localStorage.getItem("collabvm_session_" + new URL(auth!.apiEndpoint).host); + if (token) { + var result = await auth!.loadSession(token); + if (result.success) { + loadAccount(); + } else { + localStorage.removeItem("collabvm_session_" + new URL(auth!.apiEndpoint).host); + } + } +} +function loadAccount() { + if (auth === null || auth.account === null) throw new Error("Cannot loadAccount when auth or auth.account is null."); + elements.accountDropdownUsername.innerText = auth!.account!.username; + elements.accountLoginButton.style.display = "none"; + elements.accountRegisterButton.style.display = "none"; + elements.accountSettingsButton.style.display = "block"; + elements.accountLogoutButton.style.display = "block"; + if (VM) VM.loginAccount(auth.account.sessionToken); +} +const accountModal = new bootstrap.Modal(elements.accountModal); +elements.accountModalErrorDismiss.addEventListener('click', () => elements.accountModalError.style.display = "none"); +elements.accountLoginButton.addEventListener("click", () => { + elements.accountModalTitle.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_Login); + elements.accountRegisterSection.style.display = "none"; + elements.accountVerifyEmailSection.style.display = "none"; + elements.accountLoginSection.style.display = "block"; + elements.accountSettingsSection.style.display = "none"; + accountModal.show(); +}); +elements.accountRegisterButton.addEventListener("click", () => { + elements.accountModalTitle.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_Register); + elements.accountRegisterSection.style.display = "block"; + elements.accountVerifyEmailSection.style.display = "none"; + elements.accountLoginSection.style.display = "none"; + elements.accountSettingsSection.style.display = "none"; + accountModal.show(); +}); +elements.accountSettingsButton.addEventListener("click", () => { + elements.accountModalTitle.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_AccountSettings); + elements.accountRegisterSection.style.display = "none"; + elements.accountVerifyEmailSection.style.display = "none"; + elements.accountLoginSection.style.display = "none"; + elements.accountSettingsSection.style.display = "block"; + // Fill fields + elements.accountSettingsUsername.value = auth!.account!.username; + elements.accountSettingsEmail.value = auth!.account!.email; + accountModal.show(); +}); +elements.accountLogoutButton.addEventListener('click', async () => { + if (!auth?.account) return; + await auth.logout(); + localStorage.removeItem("collabvm_session_" + new URL(auth!.apiEndpoint).host); + if (VM) closeVM(); + renderAuth(); +}); +// i dont know if theres a better place to put this +let accountBeingVerified; +elements.accountLoginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + var hcaptchaToken = undefined; + var hcaptchaID = undefined; + if (auth!.info!.hcaptcha.required) { + hcaptchaID = elements.accountLoginCaptcha.children[0].getAttribute("data-hcaptcha-widget-id")! + var response = hcaptcha.getResponse(hcaptchaID); + if (response === "") { + elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha); + elements.accountModalError.style.display = "block"; + return false; + } + hcaptchaToken = response; + } + var username = elements.accountLoginUsername.value; + var password = elements.accountLoginPassword.value; + var result = await auth!.login(username, password, hcaptchaToken); + if (auth!.info!.hcaptcha.required) hcaptcha.reset(hcaptchaID); + if (result.success) { + elements.accountLoginUsername.value = ""; + elements.accountLoginPassword.value = ""; + if (result.verificationRequired) { + accountBeingVerified = result.username; + elements.accountVerifyEmailText.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_VerifyText, result.email!); + elements.accountLoginSection.style.display = "none"; + elements.accountVerifyEmailSection.style.display = "block"; + return false; + } + localStorage.setItem("collabvm_session_" + new URL(auth!.apiEndpoint).host, result.token!); + loadAccount(); + accountModal.hide(); + } else { + elements.accountModalErrorText.innerHTML = result.error!; + elements.accountModalError.style.display = "block"; + } + return false; +}); +elements.accountRegisterForm.addEventListener('submit', async (e) => { + e.preventDefault(); + var hcaptchaToken = undefined; + var hcaptchaID = undefined; + if (auth!.info!.hcaptcha.required) { + hcaptchaID = elements.accountRegisterCaptcha.children[0].getAttribute("data-hcaptcha-widget-id")! + var response = hcaptcha.getResponse(hcaptchaID); + if (response === "") { + elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kMissingCaptcha); + elements.accountModalError.style.display = "block"; + return false; + } + hcaptchaToken = response; + } + var username = elements.accountRegisterUsername.value; + var password = elements.accountRegisterPassword.value; + var email = elements.accountRegisterEmail.value; + if (password !== elements.accountRegisterConfirmPassword.value) { + elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kPasswordsMustMatch); + elements.accountModalError.style.display = "block"; + return false; + } + var result = await auth!.register(username, password, email, hcaptchaToken); + if (auth!.info!.hcaptcha.required) hcaptcha.reset(hcaptchaID); + if (result.success) { + elements.accountRegisterUsername.value = ""; + elements.accountRegisterEmail.value = ""; + elements.accountRegisterPassword.value = ""; + elements.accountRegisterConfirmPassword.value = ""; + if (result.verificationRequired) { + accountBeingVerified = result.username; + elements.accountVerifyEmailText.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_VerifyText, result.email!); + elements.accountRegisterSection.style.display = "none"; + elements.accountVerifyEmailSection.style.display = "block"; + return false; + } + localStorage.setItem("collabvm_session_" + new URL(auth!.apiEndpoint).host, result.sessionToken!); + await auth!.loadSession(result.sessionToken!); + loadAccount(); + accountModal.hide(); + } else { + elements.accountModalErrorText.innerHTML = result.error!; + elements.accountModalError.style.display = "block"; + } + return false; +}); +elements.accountVerifyEmailForm.addEventListener('submit', async e => { + e.preventDefault(); + var username = accountBeingVerified!; + var code = elements.accountVerifyEmailCode.value; + var password = elements.accountVerifyEmailPassword.value; + var result = await auth!.verifyEmail(username, password, code); + if (result.success) { + elements.accountVerifyEmailCode.value = ""; + elements.accountVerifyEmailPassword.value = ""; + localStorage.setItem("collabvm_session_" + new URL(auth!.apiEndpoint).host, result.sessionToken!); + await auth!.loadSession(result.sessionToken!); + loadAccount(); + accountModal.hide(); + } else { + elements.accountModalErrorText.innerHTML = result.error!; + elements.accountModalError.style.display = "block"; + } + return false; +}); +elements.accountSettingsForm.addEventListener('submit', async e => { + e.preventDefault(); + var oldUsername = auth!.account!.username; + var oldEmail = auth!.account!.email; + var username = elements.accountSettingsUsername.value === auth!.account!.username ? undefined : elements.accountSettingsUsername.value; + var email = elements.accountSettingsEmail.value === auth!.account!.email ? undefined : elements.accountSettingsEmail.value; + var password = elements.accountSettingsNewPassword.value === "" ? undefined : elements.accountSettingsNewPassword.value; + var currentPassword = elements.accountSettingsCurrentPassword.value; + if (password && password !== elements.accountSettingsConfirmNewPassword.value) { + elements.accountModalErrorText.innerHTML = TheI18n.GetString(I18nStringKey.kPasswordsMustMatch); + elements.accountModalError.style.display = "block"; + return false; + } + var result = await auth!.updateAccount(currentPassword, email, username, password); + if (result.success) { + elements.accountSettingsNewPassword.value = ""; + elements.accountSettingsConfirmNewPassword.value = ""; + elements.accountSettingsCurrentPassword.value = ""; + if (result.verificationRequired) { + renderAuth(); + accountBeingVerified = username ?? oldUsername; + elements.accountVerifyEmailText.innerText = TheI18n.GetString(I18nStringKey.kAccountModal_VerifyText, email ?? oldEmail); + elements.accountSettingsSection.style.display = "none"; + elements.accountVerifyEmailSection.style.display = "block"; + return false; + } else if (result.sessionExpired) { + accountModal.hide(); + localStorage.removeItem("collabvm_session_" + new URL(auth!.apiEndpoint).host); + if (VM) closeVM(); + renderAuth(); + } else { + accountModal.hide(); + } + } else { + elements.accountModalErrorText.innerHTML = result.error!; + elements.accountModalError.style.display = "block"; + } + return false; +}); // Public API w.collabvm = { @@ -838,6 +1130,12 @@ document.addEventListener('DOMContentLoaded', async () => { // Initalize the i18n system await TheI18n.Init(); + // Initialize authentication if enabled + if (Config.Auth.Enabled) { + auth = new AuthManager(Config.Auth.APIEndpoint); + renderAuth(); + } + document.title = TheI18n.GetString(I18nStringKey.kGeneric_CollabVM); // Load all VMs diff --git a/src/ts/protocol/CollabVMClient.ts b/src/ts/protocol/CollabVMClient.ts index 46c81d2..ba9eb39 100644 --- a/src/ts/protocol/CollabVMClient.ts +++ b/src/ts/protocol/CollabVMClient.ts @@ -33,6 +33,10 @@ export interface CollabVMClientEvents { badpw: () => void; login: (rank: Rank, perms: Permissions) => void; + + // Auth stuff + auth: (server: string) => void; + accountlogin: (success: boolean) => void; } // types for private emitter @@ -58,6 +62,7 @@ export default class CollabVMClient { private perms: Permissions = new Permissions(0); private voteStatus: VoteStatus | null = null; private node: string | null = null; + private auth: boolean = false; // events that are used internally and not exposed private internalEmitter: Emitter; // public events @@ -341,6 +346,20 @@ export default class CollabVMClient { break; } } + // auth stuff + case 'auth': { + this.publicEmitter.emit('auth', msgArr[1]); + this.auth = true; + break; + } + case 'login': { + if (msgArr[1] === "1") { + this.rank = Rank.Registered; + this.publicEmitter.emit('login', Rank.Registered, new Permissions(0)); + } + this.publicEmitter.emit('accountlogin', msgArr[1] === "1"); + break; + } case 'admin': { switch (msgArr[1]) { case '0': { @@ -592,6 +611,15 @@ export default class CollabVMClient { this.send('admin', AdminOpcode.HideScreen, hidden ? '1' : '0'); } + // Login to account + loginAccount(token: string) { + this.send('login', token); + } + + usesAccountAuth() { + return this.auth; + } + private onInternal(event: E, callback: CollabVMClientPrivateEvents[E]): Unsubscribe { return this.internalEmitter.on(event, callback); } diff --git a/src/ts/protocol/Permissions.ts b/src/ts/protocol/Permissions.ts index 9d3e63b..7d59a2a 100644 --- a/src/ts/protocol/Permissions.ts +++ b/src/ts/protocol/Permissions.ts @@ -30,6 +30,7 @@ export class Permissions { export enum Rank { Unregistered = 0, + Registered = 1, Admin = 2, Moderator = 3 } diff --git a/static/lang/en-us.json b/static/lang/en-us.json index 63578f7..33d4507 100644 --- a/static/lang/en-us.json +++ b/static/lang/en-us.json @@ -39,6 +39,17 @@ "kError_UnexpectedDisconnection": "You have been disconnected from the server.", "kError_UsernameTaken": "That username is already taken", "kError_UsernameInvalid": "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.", - "kError_UsernameBlacklisted": "That username has been blacklisted." + "kError_UsernameBlacklisted": "That username has been blacklisted.", + + "kAccountModal_Login": "Login", + "kAccountModal_Register": "Register", + "kAccountModal_Verify": "Verify E-Mail", + "kAccountModal_AccountSettings": "Account Settings", + + "kMissingCaptcha": "Please fill out the captcha.", + "kPasswordsMustMatch": "Passwords must match.", + "kAccountModal_VerifyText": "We sent an E-Mail to {0}. To verify your account, please enter the 8-digit code from the E-Mail below.", + + "kNotLoggedIn": "Not Logged in" } } diff --git a/tsconfig.json b/tsconfig.json index c40a31e..c407b1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,9 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": [ + "node_modules/@hcaptcha" + ], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */