implement account authentication (webapp)

This commit is contained in:
Elijah R 2024-04-05 09:11:21 -04:00
parent 999bdd0809
commit 54255cc118
10 changed files with 669 additions and 20 deletions

View File

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

View File

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

View File

@ -97,15 +97,74 @@
</div>
</div>
</div>
<div class="modal fade" id="hcaptchaModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark text-light">
<div class="modal-body">
<div id="captcha-box"></div>
<div class="modal fade" id="accountModal" tabindex="-1" aria-labelledby="accountModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark text-light">
<div class="modal-header">
<h5 class="modal-title" id="accountModalTitle">Login</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger alert-dismissible" id="accountModalError" role="alert">
<span id="accountModalErrorText"></span>
<button type="button" class="btn-close" aria-label="Close" id="accountModalErrorDismiss"></button>
</div>
</div>
</div>
</div>
<div id="accountLoginSection">
<form id="accountLoginForm">
<label for="accountLoginUsername">Username</label><br/>
<input id="accountLoginUsername" type="text" class="form-control bg-dark text-light" placeholder="Username" name="username" required/><br>
<label for="accountLoginPassword">Password</label><br/>
<input id="accountLoginPassword" type="password" class="form-control bg-dark text-light" placeholder="Password" name="password" required><br>
<div id="accountLoginCaptcha"></div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
<div id="accountRegisterSection">
<form id="accountRegisterForm">
<label for="accountRegisterEmail">E-Mail</label><br/>
<input id="accountRegisterEmail" type="email" class="form-control bg-dark text-light" placeholder="E-Mail" name="email" required/><br>
<label for="accountRegisterUsername">Username</label><br/>
<input id="accountRegisterUsername" type="text" class="form-control bg-dark text-light" placeholder="Username" name="username" required/><br>
<label for="accountRegisterPassword">Password</label><br/>
<input id="accountRegisterPassword" type="password" class="form-control bg-dark text-light" placeholder="Password" name="password" required><br>
<label for="accountRegisterConfirmPassword">Confirm Password</label><br/>
<input id="accountRegisterConfirmPassword" type="password" class="form-control bg-dark text-light" placeholder="Confirm Password" name="confirmpassword" required><br>
<div id="accountRegisterCaptcha"></div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
</div>
<div id="accountVerifyEmailSection">
<center>
<i class="fa-solid fa-envelope" style="font-size: 12rem"></i>
<p id="accountVerifyEmailText"></p>
<form id="accountVerifyEmailForm">
<label for="accountVerifyEmailCode">Code</label><br>
<input id="accountVerifyEmailCode" type="text" class="form-control bg-dark text-light" name="code" required><br>
<label for="accountVerifyEmailPassword">Your password</label><br>
<input id="accountVerifyEmailPassword" type="password" class="form-control bg-dark text-light" placeholder="Password" name="password" required/><br/>
<button type="submit" class="btn btn-primary">Verify</button>
</form>
</center>
</div>
<div id="accountSettingsSection">
<form id="accountSettingsForm">
<label for="accountSettingsEmail">E-Mail</label><br>
<input id="accountSettingsEmail" type="email" class="form-control bg-dark text-light" placeholder="E-Mail" name="email" required/><br/>
<label for="accountSettingsUsername">Username</label><br>
<input id="accountSettingsUsername" type="text" class="form-control bg-dark text-light" placeholder="Username" name="username" required/><br/>
<label for="accountSettingsNewPassword">New Password</label>
<input id="accountSettingsNewPassword" type="password" class="form-control bg-dark text-light" placeholder="New Password" name="password"/><br/>
<label for="accountSettingsConfirmNewPassword">Confirm New Password</label>
<input id="accountSettingsConfirmNewPassword" type="password" class="form-control bg-dark text-light" placeholder="Confirm New Password" name="confirmpassword"/><br/>
<label for="accountSettingsCurrentPassword">Current Password</label>
<input id="accountSettingsCurrentPassword" type="password" class="form-control bg-dark text-light" placeholder="Current Password" name="currentpassword" required/><br/>
<button type="submit" class="btn btn-primary">Update</button>
</form>
</div>
</div>
</div>
</div>
</div>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="#"><span id="siteNameText"></span></a>
@ -113,7 +172,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a id="homeBtn" href="#" class="nav-link active" aria-current="page"><i class="fa-solid fa-house"></i> <span id="homeBtnText"></span></a>
</li>
@ -136,6 +195,17 @@
<a href="https://computernewb.com/collab-vm/user-vm" class="nav-link"><i class="fa-solid fa-user"></i> UserVM</a>
</li>
</ul>
<div class="navbar-text dropdown">
<a class="nav-link dropdown-toggle" href="#" id="accountDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-user"></i> <span id="accountDropdownUsername">Not Logged in</span>
</a>
<div class="dropdown-menu dropdown-menu-end bg-dark text-light" aria-labelledby="accountDropdownMenuLink">
<a class="dropdown-item bg-dark text-light" href="#" id="accountLoginButton">Log in</a>
<a class="dropdown-item bg-dark text-light" href="#" id="accountRegisterButton">Register</a>
<a class="dropdown-item bg-dark text-light" href="#" id="accountSettingsButton">Account Settings</a>
<a class="dropdown-item bg-dark text-light" href="#" id="accountLogoutButton">Logout</a>
</div>
</div>
</div>
</div>
</nav>
@ -204,7 +274,7 @@
</table>
</div>
<div class="input-group">
<span class="input-group-text bg-dark text-light" id="username">Username</span>
<span class="input-group-text bg-dark username-unregistered" id="username">Username</span>
<input type="text" class="form-control bg-dark text-light" id="chat-input"/>
<div class="input-group-text bg-dark text-light" id="xssCheckboxContainer">
<input class="form-check-input" type="checkbox" value="" id="xssCheckbox"/>
@ -215,7 +285,7 @@
</div>
</div>
</div>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<script src="https://js.hcaptcha.com/1/api.js"></script>
<script type="module" src="../ts/main.ts" type="application/javascript"></script>
</body>
</html>

211
src/ts/AuthManager.ts Normal file
View File

@ -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<AuthServerInformation> {
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<AccountLoginResult> {
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<SessionResult>(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<AccountRegisterResult> {
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<LogoutResult>(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<VerifyEmailResult>(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<UpdateAccountResult>(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;
}

View File

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

View File

@ -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<void> {
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('', `<b>${vm.id}</b><hr>`);
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

View File

@ -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<CollabVMClientPrivateEvents>;
// 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<E extends keyof CollabVMClientPrivateEvents>(event: E, callback: CollabVMClientPrivateEvents[E]): Unsubscribe {
return this.internalEmitter.on(event, callback);
}

View File

@ -30,6 +30,7 @@ export class Permissions {
export enum Rank {
Unregistered = 0,
Registered = 1,
Admin = 2,
Moderator = 3
}

View File

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

View File

@ -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. */