From dbfb5500fc22c3e773221ca398547bc5b3dd35d0 Mon Sep 17 00:00:00 2001
From: pennae <github@quasiparticle.net>
Date: Sat, 23 Jul 2022 21:22:45 +0200
Subject: rewrite the ui using web components

this doesn't do much for functionality, but it makes extending things
easier. hopefully.
---
 web/js/main.js | 949 +++++++++++++++++++++++++++++++--------------------------
 1 file changed, 521 insertions(+), 428 deletions(-)

(limited to 'web/js')

diff --git a/web/js/main.js b/web/js/main.js
index 23610b6..cefb003 100644
--- a/web/js/main.js
+++ b/web/js/main.js
@@ -206,18 +206,8 @@ function wrapHandler(fn) {
 	};
 }
 
-function switchTo(id, tabID) {
-	for (const screen of document.getElementsByClassName("container")) {
-		screen.hidden = true;
-	}
-	$(id).hidden = false;
-
-	if (tabID) {
-		for (const tab of $(id).getElementsByClassName("tab")) {
-			tab.hidden = true;
-		}
-		$(tabID).hidden = false;
-	}
+function switchTo(v) {
+	$("content").replaceChildren(v);
 }
 
 function dateDiffText(when) {
@@ -243,520 +233,623 @@ function dateDiffText(when) {
 }
 
 //////////////////////////////////////////
-// initialization
+// credentials form
 //////////////////////////////////////////
 
-let client_config;
-var channel = new Channel();
-const isAndroid = /Android/.test(navigator.userAgent);
-
-document.body.onload = () => {
-	showMessage("Loading ...", "animate");
-
-	if (isAndroid) {
-		document.head.appendChild($("tpl-fenix-style").content);
-	}
-
- 	fetch("/.well-known/fxa-client-configuration")
-		.then(resp => {
-			if (!resp.ok) throw new Error(resp.statusText);
-			return resp.json();
-		})
-		.then(data => {
-			client_config = data;
-			hideMessage();
-			return initUI();
-		})
-		.catch(e => {
-			showError("initialization failed: ", e);
-		});
-};
-
-async function initUI() {
-	return isAndroid
-		? await initUIAndroid()
-		: await initUIDesktop();
-}
-
-async function initUIDesktop() {
-	let present = wrapHandler(async () => {
-		if (window.location.hash.startsWith("#/verify/")) {
-			verify_init(JSON.parse(urlatob(window.location.hash.substr(9))));
-		} else if (window.location.hash.startsWith("#/register/")) {
-			signup_init(window.location.hash.substr(11));
-		} else {
-			let data = await channel.getStatus();
-			if (!data.signedInUser) {
-				signin_init();
-			} else if (window.location.hash == "#/settings") {
-				settings_init(data);
-			} else if (window.location.hash == "#/settings/change-password") {
-				settings_chpw_init(data);
-			} else if (window.location.hash == "#/settings/destroy") {
-				settings_destroy_init(data);
-			} else if (window.location.hash == "#/force_auth") {
-				signin_init();
-			} else if (window.location.hash == "#/generate-invite") {
-				generate_invite_init(data);
-			} else {
-				switchTo("desktop-signedin");
-			}
-		}
-		hideMessage();
-	});
-
-	window.onpopstate = () => window.setTimeout(present, 0);
-
-	for (let a of $("desktop-settings").querySelectorAll("nav a")) {
-		a.onclick = async ev => {
+class CredentialsForm extends HTMLElement {
+	constructor() {
+		super();
+		this.replaceChildren($("btpl-credentials").content.cloneNode(true));
+		let frm = this.querySelector("form");
+		frm['submit'].value = this.getAttribute('submit-text');
+		frm.onsubmit = wrapHandler(async ev => {
 			ev.preventDefault();
-			window.location = ev.target.href;
-			await present();
-		};
+			this.dispatchEvent(new CustomEvent('confirm', {
+				detail: {
+					email: ev.target['email'].value,
+					password: ev.target['password'].value,
+				},
+			}));
+		});
 	}
-	await present();
-}
-
-async function initUIAndroid() {
-	fenix_signin_init();
-}
+};
+customElements.define('credentials-form', CredentialsForm);
 
 //////////////////////////////////////////
 // signin form
 //////////////////////////////////////////
 
-function signin_init() {
-	switchTo("desktop-signin");
-	let frm = $("frm-signin");
-	frm.onsubmit = wrapHandler(signin_run);
-	frm.getElementsByClassName("_signup")[0].onclick = wrapHandler(signin_signup_instead);
-	frm.getElementsByClassName("_reset")[0].onclick = wrapHandler(ev => {
-		ev.preventDefault();
-		password_reset_init();
-	});
-}
-
-function signin_signup_instead(ev) {
-	ev.preventDefault();
-	signup_init();
-}
-
-// NOTE it looks like firefox discards its session token before asking for reauth
-// (eg if oauth verification of a sync token fails). we can't use the reauth
-// endpoint for that, so we'll just always log in.
-async function signin_run(ev) {
-	ev.preventDefault();
-	let frm = ev.target;
-
-	if (frm[0].value == "" || !frm[0].validity.valid) {
-		return showRecoverableError("email is not valid");
-	}
-	if (frm[1].value == "" || !frm[1].validity.valid) {
-		return showRecoverableError("password is not valid");
-	}
-
-	let c = new AuthClient(client_config.auth_server_base_url);
-	try {
-		let session = await c.signIn(frm["email"].value, frm["password"].value, {
-			keys: true,
+class Signin extends HTMLElement {
+	constructor() {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.append(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-signin").content.cloneNode(true));
+		let frm = shadow.querySelector("credentials-form");
+		frm.addEventListener('confirm', wrapHandler(async ev => {
+			await this._signin(ev.detail.email, ev.detail.password);
+		}));
+		shadow.querySelector("a._signup").onclick = wrapHandler(async ev => {
+			ev.preventDefault();
+			window.location = ev.target.href;
+		});
+		shadow.querySelector("a._reset").onclick = wrapHandler(async ev => {
+			ev.preventDefault();
+			window.location = ev.target.href;
 		});
+	}
 
-		signin_confirm_init(frm["email"].value, session);
-	} catch (e) {
-		if (e.errno == 104) {
-			showRecoverableError(`
-              account is not verified. please verify or wait a few minutes and register again.
-            `.trim());
-		} else {
-			throw e;
+	// NOTE it looks like firefox discards its session token before asking for reauth
+	// (eg if oauth verification of a sync token fails). we can't use the reauth
+	// endpoint for that, so we'll just always log in.
+	async _signin(email, password) {
+		let c = new AuthClient(client_config.auth_server_base_url);
+		try {
+			let session = await c.signIn(email, password, { keys: true });
+			switchTo(new SigninConfirm(email, session));
+		} catch (e) {
+			if (e.errno == 104) {
+				showRecoverableError(`
+					account is not verified. please verify or wait a few minutes and register again.
+				`.trim());
+			} else {
+				throw e;
+			}
 		}
 	}
-}
+};
+customElements.define('do-signin', Signin);
 
 //////////////////////////////////////////
 // sign-in confirmation
 //////////////////////////////////////////
 
-function signin_confirm_init(email, session) {
-	let frm = $("frm-signin-confirm");
-	frm.getElementsByClassName("_resend")[0].onclick = wrapHandler(async ev => {
-		ev.preventDefault();
-
-		let c = new AuthClient(client_config.auth_server_base_url);
-		await c.sessionResendVerifyCode(session.sessionToken);
-		alert("code resent!");
-	});
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
-
-		let c = new AuthClient(client_config.auth_server_base_url);
-		c.sessionVerifyCode(session.sessionToken, frm["code"].value)
-			.then(resp => {
+class SigninConfirm extends HTMLElement {
+	constructor(email, session) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.append(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-signin-confirm").content.cloneNode(true));
+		let frm = shadow.querySelector("form");
+		frm.querySelector("a._resend").onclick = wrapHandler(async ev => {
+			ev.preventDefault();
+			let c = new AuthClient(client_config.auth_server_base_url);
+			await c.sessionResendVerifyCode(session.sessionToken);
+			alert("code resent!");
+		});
+		frm.onsubmit = wrapHandler(async ev => {
+			ev.preventDefault();
+			let c = new AuthClient(client_config.auth_server_base_url);
+			try {
+				await c.sessionVerifyCode(session.sessionToken, frm["code"].value);
 				channel.loadCredentials(email, session, { offered: [], declined: [] });
-				switchTo("desktop-signedin");
-			})
-			.catch(e => {
+				switchTo(new SignedIn());
+			} catch (e) {
 				showError("verification failed: ", e);
-			});
-	});
-	switchTo("desktop-signin-confirm");
-}
+			}
+		});
+	}
+};
+customElements.define('do-signin-confirm', SigninConfirm);
 
 //////////////////////////////////////////
 // password reset
 //////////////////////////////////////////
 
-function password_reset_init() {
-	let frm = $("frm-resetpw");
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
-
-		let c = new AuthClient(client_config.auth_server_base_url);
-		let token = await c.passwordForgotSendCode(frm["email"].value);
-		let code = prompt(`
-			please enter your password reset code from the email you've just received
-		`.trim());
-		if (code === null) {
-			return showRecoverableError("password reset aborted");
-		}
-
-		let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken);
-		password_reset_newpw(frm['email'].value, reset_token.accountResetToken);
-	});
-	switchTo("desktop-resetpw");
-}
-
-function password_reset_newpw(email, reset_token) {
-	let frm = $("frm-resetpw-newpw");
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
+class ResetPassword extends HTMLElement {
+	constructor() {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.append(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-resetpw").content.cloneNode(true));
+		let frm = shadow.querySelector("form");
+		frm.onsubmit = wrapHandler(async ev => {
+			ev.preventDefault();
 
-		let c = new AuthClient(client_config.auth_server_base_url);
-		if (frm['new'].value != frm['new-confirm'].value) {
-			return showRecoverableError("passwords don't match!");
-		}
+			let c = new AuthClient(client_config.auth_server_base_url);
+			let token = await c.passwordForgotSendCode(frm["email"].value);
+			let code = prompt("please enter your password reset code from the email you've just received");
+			if (code === null) {
+				return showRecoverableError("password reset aborted");
+			}
 
-		await c.accountReset(email, frm['new'].value, reset_token);
-		switchTo("desktop-signin");
-	});
-	switchTo("desktop-resetpw-newpw");
-}
+			let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken);
+			switchTo(new ResetPasswordCreate(frm['email'].value, reset_token.accountResetToken));
+		});
+	}
+};
+customElements.define('do-resetpw', ResetPassword);
+
+class ResetPasswordCreate extends HTMLElement {
+	constructor(email, reset_token) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.append(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-resetpw-newpw").content.cloneNode(true));
+		let frm = shadow.querySelector("form");
+		frm.onsubmit = async ev => {
+			ev.preventDefault();
+			if (frm['new'].value != frm['new-confirm'].value) {
+				return showRecoverableError("passwords don't match!");
+			}
+			let c = new AuthClient(client_config.auth_server_base_url);
+			await c.accountReset(email, frm['new'].value, reset_token);
+			switchTo(new Signin());
+		};
+	}
+};
+customElements.define('do-resetpw-create', ResetPasswordCreate);
 
 //////////////////////////////////////////
 // signup form
 //////////////////////////////////////////
 
-function signup_init(code) {
-	let frm = $("frm-signup");
-	switchTo("desktop-signup");
-	frm.onsubmit = wrapHandler(async ev => signup_run(ev, code));
-}
-
-async function signup_run(ev, code) {
-	ev.preventDefault();
-	let frm = ev.target;
-
-	if (frm[0].value == "" || !frm[0].validity.valid) {
-		return showRecoverableError("email is not valid");
-	}
-	if (frm[1].value == "" || !frm[1].validity.valid) {
-		return showRecoverableError("password is not valid");
+class Signup extends HTMLElement {
+	constructor(code) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-signup").content.cloneNode(true));
+		let frm = shadow.querySelector("credentials-form");
+		frm.addEventListener('confirm', async ev => {
+			await this._signup(ev.detail.email, ev.detail.password, code);
+		})
 	}
 
-	let c = new AuthClient(client_config.auth_server_base_url);
-	let session = await c.signUp(frm["email"].value, frm["password"].value, {
-		keys: true,
-		style: code,
-	});
-
-	cwts_continue(frm["email"].value, session);
-}
+	async _signup(email, password, code) {
+		let c = new AuthClient(client_config.auth_server_base_url);
+		let session = await c.signUp(email, password, {
+			keys: true,
+			style: code,
+		});
 
-//////////////////////////////////////////
-// choose-what-to-sync form
-//////////////////////////////////////////
+		switchTo(new CWTS(email, session));
+	}
+};
+customElements.define('do-signup', Signup);
+
+class CWTS extends HTMLElement {
+	constructor(email, session) {
+		super();
+		// TODO we don't query browser capabilities, but we probably should
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-cwts").content.cloneNode(true));
+		let frm = shadow.querySelector("form");
+		frm.onsubmit = wrapHandler((ev) => {
+			ev.preventDefault();
 
-function cwts_continue(email, session) {
-	// TODO we don't query browser capabilities, but we probably should
-	let frm = $("frm-cwts");
-	frm.onsubmit = wrapHandler((ev) => {
-		ev.preventDefault();
+			let offered = [], declined = [];
+			for (const engine of frm.querySelectorAll("input[type=checkbox]")) {
+				if (!engine.checked) declined.push(engine.name);
+				else offered.push(engine.name);
+			}
+			channel.loadCredentials(email, session, { offered, declined });
 
-		let offered = [], declined = [];
-		for (const engine of frm.querySelectorAll("input[type=checkbox]")) {
-			if (!engine.checked) declined.push(engine.name);
-			else offered.push(engine.name);
-		}
-		channel.loadCredentials(email, session, { offered, declined });
+			switchTo(new SignupUnverified());
+		});
+	}
+};
+customElements.define('do-cwts', CWTS);
 
-		switchTo("desktop-signup-unverified");
-	});
-	switchTo("desktop-cwts");
+class SignupUnverified extends HTMLElement {
+	constructor() {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-signup-unverified").content.cloneNode(true));
+	}
 }
+customElements.define('do-signup-unverified', SignupUnverified);
 
 //////////////////////////////////////////
 // verification
 //////////////////////////////////////////
 
-function verify_init(context) {
+class SignedIn extends HTMLElement {
+	constructor() {
+		super();
+		this.replaceChildren($("tpl-desktop-signedin").content.cloneNode(true));
+	}
+};
+customElements.define('signed-in', SignedIn);
+
+async function verify(context) {
 	let c = new AuthClient(client_config.auth_server_base_url);
-	c.verifyCode(context.uid, context.code)
-		.then(resp => {
-			switchTo("desktop-signedin");
-		})
-		.catch(e => {
-			showError("verification failed: ", e);
-		});
+	try {
+		await c.verifyCode(context.uid, context.code)
+		switchTo(new SignedIn());
+	} catch(e) {
+		showError("verification failed: ", e);
+	}
 }
 
 //////////////////////////////////////////
-// settings root
+// settings
 //////////////////////////////////////////
 
-function settings_init(session) {
-	switchTo("desktop-settings", "desktop-settings-main");
+class Settings extends HTMLElement {
+	constructor(session) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		this.session = session;
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-settings").content.cloneNode(true));
+
+		for (let a of shadow.querySelectorAll("nav a")) {
+			a.onclick = wrapHandler(async ev => {
+				ev.preventDefault();
+				window.location = ev.target.href;
+				await this._display();
+			});
+		}
+
+		this._display();
+	}
+
+	async _display() {
+		let tab = SettingsMain;
+		if (window.location.hash == "#/settings/change-password") {
+			tab = SettingsChangePassword;
+		} else if (window.location.hash == "#/settings/destroy") {
+			tab = SettingsDestroy;
+		}
+
+		try {
+			this.shadowRoot.querySelector(".tab").replaceChildren(new tab(this.session));
+		} catch(e) {
+			showError("initialization failed: ", e);
+		}
+	}
+};
+customElements.define('do-settings', Settings);
+
+class SettingsMain extends HTMLElement {
+	constructor(session) {
+		super();
+		this.session = session;
+		this.replaceChildren($("tpl-desktop-settings-main").content.cloneNode(true));
+		this._display();
+	}
 
-	let inner = async () => {
+	async _display() {
 		showMessage("Loading ...", "animate");
 
 		let ac = new AuthClient(client_config.auth_server_base_url)
-		let pc = new ProfileClient(ac, session);
+		let pc = new ProfileClient(ac, this.session);
 
 		let initProfile = async () => {
 			let profile = await pc.getProfile();
-			$("settings-name").value = profile.displayName || "";
-			$("frm-settings-name").onsubmit = wrapHandler(async ev => {
-				showMessage("Applying ...")
+			this.querySelector("#name").value = profile.displayName || "";
+			this.querySelector("#frm-name").onsubmit = wrapHandler(async ev => {
 				ev.preventDefault();
-				await pc.setDisplayName($("settings-name").value);
+				showMessage("Applying ...")
+				await pc.setDisplayName(ev.target['name'].value);
 				hideMessage();
 			});
-			$("settings-avatar-img").src = profile.avatar;
-			$("frm-settings-avatar").onsubmit = wrapHandler(async ev => {
-				showMessage("Saving ...")
+			this.querySelector("#avatar-img").src = profile.avatar;
+			this.querySelector("#frm-avatar").onsubmit = wrapHandler(async ev => {
 				ev.preventDefault();
-				await pc.setAvatar($("settings-avatar").files[0]);
-				settings_init(session);
+				showMessage("Saving ...")
+				await pc.setAvatar(ev.target['avatar'].files[0]);
+				await this._display();
 			});
 		};
 
 		await Promise.all([
 			initProfile(),
-			settings_populateClients(ac, session),
+			this._populateClients(ac),
 		]);
 
 		hideMessage();
-	};
-
-	inner().catch(e => {
-		showError("initialization failed: ", e);
-	});
-}
+	}
 
-async function settings_populateClients(authClient, session) {
-	let clients = await authClient.attachedClients(session.signedInUser.sessionToken);
-
-	let body = $("settings-clients").getElementsByTagName("tbody")[0];
-	body.innerHTML = "";
-	for (const c of clients) {
-		let row = document.createElement("tr");
-		let add = (val, tip) => {
-			let cell = document.createElement("td");
-			cell.innerText = val || "";
-			if (tip) cell.title = tip;
-			row.appendChild(cell);
-		};
-		let addDateDiff = val => {
-			let text = dateDiffText(new Date(val));
-			add(text, new Date(val));
-		};
-		add(c.name);
-		add(c.deviceType);
-		addDateDiff(c.createdTime * 1000);
-		addDateDiff(c.lastAccessTime * 1000);
-		add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n"));
-		if (c.isCurrentSession) {
-			let cell = document.createElement("td");
-			cell.innerHTML = `<span class="disabled">current session</span>`;
-			row.appendChild(cell);
-		} else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) {
-			let remove = document.createElement("button");
-			remove.innerText = 'remove';
-			remove.onclick = wrapHandler(async ev => {
-				ev.preventDefault();
-				let info = { clientId: c.clientId };
-				if (c.deviceId)
-					info.deviceId = c.deviceId;
-				else if (c.sessionTokenId)
-					info.sessionTokenId = c.sessionTokenId;
-				else if (c.refreshTokenId)
-					info.refreshTokenId = c.refreshTokenId;
-				showMessage("Processing ...", "animate");
-				await authClient.attachedClientDestroy(session.signedInUser.sessionToken, info);
-				await settings_populateClients(authClient, session);
-				hideMessage();
-			});
-			row.appendChild(remove);
+	async _populateClients(authClient) {
+		let clients = await authClient.attachedClients(this.session.signedInUser.sessionToken);
+
+		let body = this.querySelector("#clients tbody");
+		body.innerHTML = "";
+		for (const c of clients) {
+			let row = document.createElement("tr");
+			let add = (val, tip) => {
+				let cell = document.createElement("td");
+				cell.innerText = val || "";
+				if (tip) cell.title = tip;
+				row.appendChild(cell);
+			};
+			let addDateDiff = val => {
+				let text = dateDiffText(new Date(val));
+				add(text, new Date(val));
+			};
+			add(c.name);
+			add(c.deviceType);
+			addDateDiff(c.createdTime * 1000);
+			addDateDiff(c.lastAccessTime * 1000);
+			add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n"));
+			if (c.isCurrentSession) {
+				let cell = document.createElement("td");
+				cell.innerHTML = `<span class="disabled">current session</span>`;
+				row.appendChild(cell);
+			} else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) {
+				let remove = document.createElement("button");
+				remove.innerText = 'remove';
+				remove.onclick = wrapHandler(async ev => {
+					ev.preventDefault();
+					let info = { clientId: c.clientId };
+					if (c.deviceId)
+						info.deviceId = c.deviceId;
+					else if (c.sessionTokenId)
+						info.sessionTokenId = c.sessionTokenId;
+					else if (c.refreshTokenId)
+						info.refreshTokenId = c.refreshTokenId;
+					showMessage("Processing ...", "animate");
+					await authClient.attachedClientDestroy(this.session.signedInUser.sessionToken, info);
+					await this._populateClients(authClient, this.session);
+					hideMessage();
+				});
+				row.appendChild(remove);
+			}
+			body.appendChild(row);
 		}
-		body.appendChild(row);
 	}
-}
+};
+customElements.define('do-settings-main', SettingsMain);
 
-//////////////////////////////////////////
-// settings change password
-//////////////////////////////////////////
+class SettingsChangePassword extends HTMLElement {
+	constructor(session) {
+		super();
+		this.replaceChildren($("tpl-desktop-settings-chpw").content.cloneNode(true));
 
-function settings_chpw_init(session) {
-	switchTo("desktop-settings", "desktop-settings-chpw");
-	let frm = $("frm-settings-chpw");
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
-		let frm = ev.target;
+		let frm = this.querySelector("form");
+		frm.onsubmit = wrapHandler(async ev => {
+			ev.preventDefault();
+			if (frm['new'].value != frm['new-confirm'].value) {
+				return showRecoverableError("passwords don't match!");
+			}
 
-		if (frm["new"].value != frm["new-confirm"].value) {
-			showRecoverableError("passwords don't match");
-			return;
-		}
+			let c = new AuthClient(client_config.auth_server_base_url);
+			await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {});
 
-		let c = new AuthClient(client_config.auth_server_base_url);
-		let resp = await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {});
+			channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid);
+			alert("password changed");
+		});
+	}
+};
+customElements.define('do-settings-chpw', SettingsChangePassword);
 
-		channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid);
-		alert("password changed");
-	});
-}
+class SettingsDestroy extends HTMLElement {
+	constructor(session) {
+		super();
+		this.replaceChildren($("tpl-desktop-settings-destroy").content.cloneNode(true));
 
-//////////////////////////////////////////
-// settings destroy
-//////////////////////////////////////////
+		this.querySelector("credentials-form").addEventListener('confirm', wrapHandler(async ev => {
+			let c = new AuthClient(client_config.auth_server_base_url);
+			await c.accountDestroy(ev.detail.email, ev.detail.password);
 
-function settings_destroy_init(session) {
-	switchTo("desktop-settings", "desktop-settings-destroy");
-	let frm = $("frm-settings-destroy");
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
-		let frm = ev.target;
+			channel.accountDestroyed(ev.detail.email, session.signedInUser.uid);
+			switchTo(new AccountDestroyed());
+		}));
+	}
+};
+customElements.define('do-settings-destroy', SettingsDestroy);
 
-		if (frm[0].value == "" || !frm[0].validity.valid) {
-			return showRecoverableError("email is not valid");
-		}
-		if (frm[1].value == "" || !frm[1].validity.valid) {
-			return showRecoverableError("password is not valid");
-		}
+class AccountDestroyed extends HTMLElement {
+	constructor() {
+		super();
+		this.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-deleted").content.cloneNode(true));
+	}
+};
+customElements.define('account-destroyed', AccountDestroyed);
 
-		let c = new AuthClient(client_config.auth_server_base_url);
-		await c.accountDestroy(frm["email"].value, frm["password"].value);
+//////////////////////////////////////////
+// generate invite
+//////////////////////////////////////////
 
-		channel.accountDestroyed(frm["email"], session.signedInUser.uid);
-		switchTo("desktop-deleted");
-	});
-}
+class GenerateInvite extends HTMLElement {
+	constructor(session) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-desktop-generate-invite").content.cloneNode(true));
+
+		let frm = shadow.querySelector("form");
+		shadow.querySelector("#result").hidden = true;
+		frm.onsubmit = wrapHandler(async ev => {
+			ev.preventDefault();
+
+			let server_url = new URL(client_config.auth_server_base_url);
+			server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite";
+			let c = new AuthClient(server_url.toString());
+			let resp = await c.sessionPost(
+				"/generate",
+				session.signedInUser.sessionToken,
+				{ 'ttl_hours': parseInt(frm["ttl"].value) });
+
+			shadow.querySelector("#link").href = resp.url;
+			shadow.querySelector("#link").innerText = resp.url;
+			shadow.querySelector("#result").hidden = false;
+		});
+	}
+};
+customElements.define('do-generate-invite', GenerateInvite);
 
 //////////////////////////////////////////
-// generate invite
+// fenix signin
 //////////////////////////////////////////
 
-function generate_invite_init(session) {
-	let frm = $("frm-generate-invite");
-	$("desktop-generate-invite-result").hidden = true;
-	frm.onsubmit = wrapHandler(async ev => {
-		ev.preventDefault();
+class FenixSignin extends HTMLElement {
+	constructor(session) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-fenix-signin-warning").content.cloneNode(true));
+		shadow.querySelector("#signin").onclick = wrapHandler(async ev => {
+			ev.preventDefault();
+			switchTo(new FenixSigninEnter());
+		});
+	}
+}
+customElements.define('do-fenix-signin', FenixSignin);
+
+class FenixSigninEnter extends HTMLElement {
+	constructor(session) {
+		super();
+		const shadow = this.attachShadow({mode: 'open'});
+		shadow.replaceChildren(
+			$("styles").cloneNode(true),
+			$("tpl-fenix-signin").content.cloneNode(true));
+		shadow.querySelector("credentials-form").addEventListener('confirm', wrapHandler(async ev => {
+			ev.preventDefault();
+			await this._step2(ev.detail.email, ev.detail.password);
+		}));
+	}
 
-		let server_url = new URL(client_config.auth_server_base_url);
-		server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite";
-		let c = new AuthClient(server_url.toString());
-		let resp = await c.sessionPost(
-			"/generate",
-			session.signedInUser.sessionToken,
-			{ 'ttl_hours': parseInt(frm["ttl"].value) });
+	async _step2(email, password) {
+		let url = new URL(window.location);
+		let params = new URLSearchParams(url.search);
+		let param = (p) => {
+			let val = params.get(p);
+			if (val === undefined) throw `missing parameter ${p}`;
+			return val;
+		};
 
-		$("desktop-generate-invite-result-link").href = resp.url;
-		$("desktop-generate-invite-result-link").innerText = resp.url;
-		$("desktop-generate-invite-result").hidden = false;
+		let c = new AuthClient(client_config.auth_server_base_url);
+		let session = await c.signIn(email, password, { keys: true });
+		let verifyCode = prompt("enter verify code");
+		await c.sessionVerifyCode(session.sessionToken, verifyCode);
+		try {
+			let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey);
+			let scoped_keys = await c.getOAuthScopedKeyData(
+				session.sessionToken,
+				param("client_id"),
+				param("scope"));
+			for (var scope in scoped_keys) {
+				scoped_keys[scope] = await deriveScopedKey(
+					keys.kB,
+					session.uid,
+					scope,
+					scoped_keys[scope].keyRotationSecret,
+					scoped_keys[scope].keyRotationTimestamp);
+			}
 
-	});
-	switchTo("desktop-generate-invite");
+			let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk"));
+
+			let code = await c.createOAuthCode(
+				session.sessionToken,
+				param("client_id"),
+				param("state"),
+				{
+					access_type: param("access_type"),
+					keys_jwe,
+					response_type: param("response_type"),
+					scope: param("scope"),
+					code_challenge_method: param("code_challenge_method"),
+					code_challenge: param("code_challenge"),
+				});
+
+			console.log(`browser.tabs.executeScript({code: \`
+				port = browser.runtime.connectNative("mozacWebchannel");
+				port.postMessage({
+					id: "account_updates",
+					message: {
+						command: "fxaccounts:oauth_login",
+						data: {
+							"code": "${code.code}",
+							"state": "${code.state}",
+							"redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
+							"action": "signin"
+						},
+					messageId: 1,
+				}});
+			\`});`);
+
+			switchTo(new FenixSignin());
+		} finally {
+			c.sessionDestroy(session.sessionToken);
+		}
+	}
 }
+customElements.define('do-fenix-signin-enter', FenixSigninEnter);
 
 //////////////////////////////////////////
-// fenix signin
+// initialization
 //////////////////////////////////////////
 
-function fenix_signin_init() {
-	switchTo("fenix-signin-warning");
-	$("fenix-signin-dialog-show").onclick = wrapHandler(async ev => {
-		ev.preventDefault();
-		switchTo("fenix-signin");
-		$("frm-fenix-signin").onsubmit = wrapHandler(fenix_signin_step2);
-	});
-}
+let client_config;
+var channel = new Channel();
+const isAndroid = /Android/.test(navigator.userAgent);
+
+document.body.onload = async () => {
+	showMessage("Loading ...", "animate");
 
-async function fenix_signin_step2(ev) {
-	ev.preventDefault();
+	if (isAndroid) {
+		document.head.appendChild($("tpl-fenix-style").content);
+	}
 
-	let url = new URL(window.location);
-	let params = new URLSearchParams(url.search);
-	let param = (p) => {
-		let val = params.get(p);
-		if (val === undefined) throw `missing parameter ${p}`;
-		return val;
-	};
+ 	try {
+		let resp = await fetch("/.well-known/fxa-client-configuration");
+		if (!resp.ok) throw new Error(resp.statusText);
+		client_config = await resp.json();
+		hideMessage();
+		initUI();
+	} catch(e) {
+		showError("initialization failed: ", e);
+	}
+};
 
-	let frm = ev.target;
-	let c = new AuthClient(client_config.auth_server_base_url);
-	let session = await c.signIn(frm["email"].value, frm["password"].value, {
-		keys: true,
-	});
-	let verifyCode = prompt("enter verify code");
-	await c.sessionVerifyCode(session.sessionToken, verifyCode);
-	try {
-		let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey);
-		let scoped_keys = await c.getOAuthScopedKeyData(
-			session.sessionToken,
-			param("client_id"),
-			param("scope"));
-		for (var scope in scoped_keys) {
-			scoped_keys[scope] = await deriveScopedKey(
-				keys.kB,
-				session.uid,
-				scope,
-				scoped_keys[scope].keyRotationSecret,
-				scoped_keys[scope].keyRotationTimestamp);
+async function initUI() {
+	if (isAndroid) {
+		await initUIAndroid();
+	} else {
+		await initUIDesktop();
+	}
+}
+
+async function initUIDesktop() {
+	let present = wrapHandler(async () => {
+		if (window.location.hash.startsWith("#/verify/")) {
+			await verify(JSON.parse(urlatob(window.location.hash.substr(9))));
+		} else if (window.location.hash.startsWith("#/register/")) {
+			switchTo(new Signup(window.location.hash.substr(11)));
+		} else {
+			let data = await channel.getStatus();
+			if (window.location.hash == "#/signup") {
+				switchTo(new Signup());
+			} else if (window.location.hash == "#/reset-password") {
+				switchTo(new ResetPassword());
+			} else if (!data.signedInUser
+				|| window.location.hash == "#/force_auth") {
+				switchTo(new Signin());
+			} else if (window.location.hash == "#/settings"
+					   || window.location.hash == "#/settings/change-password"
+					   || window.location.hash == "#/settings/destroy") {
+				switchTo(new Settings(data));
+			} else if (window.location.hash == "#/generate-invite") {
+				switchTo(new GenerateInvite(data));
+			} else {
+				switchTo(new SignedIn());
+			}
 		}
+		hideMessage();
+	});
 
-		let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk"));
-
-		let code = await c.createOAuthCode(
-			session.sessionToken,
-			param("client_id"),
-			param("state"),
-			{
-				access_type: param("access_type"),
-				keys_jwe,
-				response_type: param("response_type"),
-				scope: param("scope"),
-				code_challenge_method: param("code_challenge_method"),
-				code_challenge: param("code_challenge"),
-			});
+	window.onpopstate = () => window.setTimeout(present, 0);
+	window.onhashchange = () => window.setTimeout(present, 0);
+	await present();
+}
 
-		console.log(`browser.tabs.executeScript({code: \`
-            port = browser.runtime.connectNative("mozacWebchannel");
-            port.postMessage({
-                id: "account_updates",
-	     		message: {
-                    command: "fxaccounts:oauth_login",
-                    data: {
-                        "code": "${code.code}",
-                        "state": "${code.state}",
-                        "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
-                        "action": "signin"
-                    },
-                messageId: 1,
-            }});
-        \`});`);
-
-		switchTo("fenix-signin-warning");
-	} finally {
-		c.sessionDestroy(session.sessionToken);
-	}
+async function initUIAndroid() {
+	switchTo(new FenixSignin());
 }
-- 
cgit v1.2.3