rework frontend

This commit is contained in:
1e99 2024-11-10 10:46:40 +01:00
parent 55191c0269
commit ad6a731fd4
9 changed files with 298 additions and 293 deletions

View file

@ -1,126 +1,111 @@
<!doctype html>
<html lang="">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<!--<link rel="icon" href="favicon.png" />-->
<title>PassED</title>
<script src="/crypto.js"></script>
<script src="/api.js"></script>
<script src="/index.js" defer></script>
<link rel="stylesheet" href="/pico.min.css" />
<link rel="stylesheet" href="/css/pico.min.css" />
<script src="/js/lang.js"></script>
<script src="/js/api.js"></script>
<script src="/js/crypto.js"></script>
<script src="/js/main.js"></script>
</head>
<nav class="container-fluid">
<nav class="container">
<ul>
<li>
<strong>PassED</strong>
<strong t="title"></strong>
</li>
</ul>
<ul>
<li>
<a href="https://git.1e99.eu/1e99/passed">Git</a>
<a href="https://git.1e99.eu/1e99/passed/" t="source-code"></a>
</li>
<li>
<details class="dropdown">
<summary t="language"></summary>
<ul dir="rtl">
<li t="language-en" class="select-language" data-lang="en"></li>
<li t="language-de" class="select-language" data-lang="de"></li>
</ul>
</details>
</li>
</ul>
</nav>
<main class="container">
<article>
<header>Enter Password</header>
<header t="enter-password"></header>
<form id="enter-password">
<fieldset id="enter-password">
<label>
Password
<span t="password"></span>
<textarea name="password"></textarea>
</label>
<label>
Expires in
<span t="expires-in"></span>
<select name="expires-in">
<option value="3600" selected>1 Hour</option>
<option value="43200">12 Hours</option>
<option value="86400">1 Day</option>
<option value="604800">1 Week</option>
<option value="1209600">2 Weeks</option>
<option value="3600" selected t="expires-in.1-hour"></option>
<option value="43200" t="expires-in.12-hours"></option>
<option value="86400" t="expires-in.1-day"></option>
<option value="604800" t="expires-in.1-week"></option>
<option value="1209600" t="expires-in.2-weeks"></option>
</select>
</label>
</fieldset>
<button type="submit">Generate URL</button>
<button type="submit" id="enter-password" t="generate-link"></button>
</form>
</article>
</main>
<dialog id="url-dialog">
<dialog id="link">
<article>
<header>Password URL</header>
<header t="share-link"></header>
<fieldset role="group">
<input id="url" readonly autofocus />
<button id="url-copy">Copy</button>
<input readonly id="link"/>
<button t="copy" id="link-copy"></button>
</fieldset>
<footer>
<button id="url-close">Close</button>
</footer>
</article>
</dialog>
<dialog id="view-dialog">
<article>
<header>View Password</header>
<textarea readonly id="view-password" input></textarea>
<footer>
<button id="view-close">Close</button>
</footer>
</article>
</dialog>
<dialog id="confirm-view-dialog">
<article>
<header>View Password</header>
<p>You may only reveal the password once.</p>
<footer>
<button id="view-cancel" class="secondary">
Cancel
</button>
<button id="view-confirm">Confirm</button>
<button class="secondary" id="link-close" t="close"></button>
</footer>
</article>
</dialog>
<dialog id="not-found">
<article>
<header>Password does not exist</header>
<p>
The password you requested may have expired, been viewed
before or never even existed in the first place.
</p>
<header t="not-found"></header>
<p t="not-found-reason"></p>
<footer>
<button id="not-found-close" class="secondary">Close</button>
<button t="close" id="not-found-close"></button>
</footer>
</article>
</dialog>
<dialog id="loading-dialog">
<h1>Loading...</h1>
<dialog id="confirm">
<article>
<header t="reveal-password"></header>
<p t="reveal-password-once"></p>
<footer>
<button class="secondary" t="close" id="confirm-close"></button>
<button t="ok" id="confirm-ok"></button>
</footer>
</article>
</dialog>
<dialog id="error-dialog">
<dialog id="view">
<article>
<header>Error</header>
<textarea id="error" readonly></textarea>
<header t="password"></header>
<textarea readonly id="view-password"></textarea>
<footer>
<button id="error-reload">Reload the page</button>
<button t="close" id="view-close"></button>
</footer>
</article>
</dialog>

View file

@ -1,218 +0,0 @@
const loadingDialog = {
dialog: document.querySelector("dialog#loading-dialog"),
init() {
this.dialog.addEventListener("cancel", (ev) => {
ev.preventDefault();
});
},
show() {
this.dialog.showModal();
},
close() {
this.dialog.close();
},
};
const errorDialog = {
dialog: document.querySelector("dialog#error-dialog"),
error: document.querySelector("textarea#error"),
reload: document.querySelector("button#error-reload"),
init() {
this.dialog.addEventListener("close", (ev) => {
window.location.reload();
});
this.reload.addEventListener("click", (ev) => {
window.location.href = "/";
});
},
show(err) {
this.error.value = err;
console.error(err);
this.dialog.showModal();
},
};
const urlDialog = {
dialog: document.querySelector("dialog#url-dialog"),
url: document.querySelector("input#url"),
urlCopy: document.querySelector("button#url-copy"),
close: document.querySelector("button#url-close"),
init() {
this.dialog.addEventListener("close", (ev) => {});
this.urlCopy.addEventListener("click", (ev) => {
this.url.select();
this.url.setSelectionRange(0, 99999);
navigator.clipboard.writeText(this.url.value);
});
this.close.addEventListener("click", (ev) => {
this.dialog.close();
});
},
show(url) {
this.url.value = url;
this.dialog.showModal();
},
};
const notFoundDialog = {
dialog: document.querySelector("dialog#not-found"),
close: document.querySelector("button#not-found-close"),
init() {
this.dialog.addEventListener("close", (ev) => {
window.location.hash = "";
window.location.reload();
});
this.close.addEventListener("click", (ev) => {
window.location.hash = "";
window.location.reload();
});
},
show() {
this.dialog.showModal();
},
};
const viewDialog = {
dialog: document.querySelector("dialog#view-dialog"),
password: document.querySelector("textarea#view-password"),
close: document.querySelector("button#view-close"),
init() {
this.dialog.addEventListener("close", (ev) => {
window.location.hash = "";
window.location.reload();
});
this.close.addEventListener("click", (ev) => {
this.dialog.close();
});
},
show(password) {
this.password.value = password;
this.dialog.showModal();
},
};
const confirmViewDialog = {
dialog: document.querySelector("dialog#confirm-view-dialog"),
cancel: document.querySelector("button#view-cancel"),
confirm: document.querySelector("button#view-confirm"),
resolve: null,
reject: null,
init() {
this.dialog.addEventListener("cancel", (ev) => {
this.dialog.close();
this.resolve(false);
});
this.cancel.addEventListener("click", (ev) => {
this.dialog.close();
this.resolve(false);
});
this.confirm.addEventListener("click", (ev) => {
this.dialog.close();
this.resolve(true);
});
},
show() {
return new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
this.dialog.showModal();
});
},
};
async function viewPassword() {
try {
loadingDialog.show();
let id, key, iv;
// We need to be backwards compatible with old links that still use the query
if (window.location.search.trim() != "") {
const params = new URLSearchParams(window.location.search);
id = params.get("id");
key = params.get("key");
iv = params.get("iv");
} else {
// Need to remove leading "#"
const hash = window.location.hash.substring(1);
const split = hash.split(":");
id = split[0];
key = split[1];
iv = split[2];
}
const exists = await hasPassword(id);
if (!exists) {
notFoundDialog.show();
return;
}
const shouldView = await confirmViewDialog.show();
if (!shouldView) {
// This is needed for the redirect, otherwise the user won't get redirected
setTimeout(() => {
window.location.hash = "";
window.location.reload();
}, 0);
return;
}
const encrypted = await getPassword(id);
const password = await decryptPassword(encrypted, key, iv);
viewDialog.show(password);
} catch (error) {
errorDialog.show(error);
} finally {
loadingDialog.close();
}
}
const enterPassword = document.querySelector("form#enter-password");
enterPassword.addEventListener("submit", async (ev) => {
try {
loadingDialog.show();
ev.preventDefault();
const data = new FormData(ev.target);
const password = await encryptPassword(data.get("password"));
const id = await uploadPassword(
password.password,
parseInt(data.get("expires-in")),
);
const url = new URL(window.location);
url.hash = [id, password.key, password.iv].join(":");
urlDialog.show(url.toString());
} catch (error) {
errorDialog.show(error);
} finally {
loadingDialog.close();
}
});
loadingDialog.init();
errorDialog.init();
urlDialog.init();
notFoundDialog.init();
viewDialog.init();
confirmViewDialog.init();
const hash = window.location.hash;
if (hash.trim() != "") {
viewPassword();
}
// We need to be backwards compatible with the old links
const query = window.location.search;
if (query.trim() != "") {
viewPassword();
}

View file

@ -1,6 +1,7 @@
/** Upload a password to the server
* @param {string} password Encryptes password
* @param {Promise<number>} expiresIn Number of seconds in which the password will expire
* @param {number} expiresIn Number of seconds in which the password will expire
* @returns {Promise<string>}
*/
async function uploadPassword(password, expiresIn) {
const res = await fetch("/api/password", {
@ -36,6 +37,7 @@ async function hasPassword(id) {
throw new Error(`Failed to check if password exists: ${res.status}: ${msg}`);
}
/** Get a password from the server
* @param {string} id Password ID
* @returns {Promise<string>}

70
static/js/lang.js Normal file
View file

@ -0,0 +1,70 @@
const LANGUAGE_KEY = "language";
const languages = {};
let language = "en";
/**
* @param {string} lang
*/
async function setLanguage(lang) {
if (languages[lang] == undefined) {
const res = await fetch(`/lang/${lang}.json`);
const json = await res.json();
languages[lang] = json;
}
language = lang;
translatePage();
localStorage.setItem(LANGUAGE_KEY, lang);
}
/** Translates a key
* @param {string} key
* @param {any} args
* @returns {string}
*/
function translate(key, ...args) {
const bundle = languages[language];
let value = bundle[key];
if (value == undefined) {
value = key;
}
for (let i = 0; i < args.length; i++) {
value = value.replaceAll(`{${i}}`, args[i]);
}
return value;
}
function translatePage() {
const elements = document.getElementsByTagName("*");
for (const element of elements) {
const key = element.getAttribute("t");
if (key == null) {
continue;
}
element.innerHTML = translate(key);
}
}
function initLanguageButtons() {
const buttons = document.querySelectorAll("li.select-language");
for (const button of buttons) {
button.addEventListener("click", async (ev) => {
const self = ev.target;
const lang = self.getAttribute("data-lang");
await setLanguage(lang);
});
}
}
window.addEventListener("load", async () => {
const lang = localStorage.getItem(LANGUAGE_KEY);
if (lang != null) {
await setLanguage(lang);
}
initLanguageButtons();
});

118
static/js/main.js Normal file
View file

@ -0,0 +1,118 @@
function initEnterPassword() {
const form = document.querySelector("form#enter-password");
const fieldset = document.querySelector("fieldset#enter-password");
const submit = document.querySelector("button#enter-password");
const dialog = document.querySelector("dialog#link");
const link = document.querySelector("input#link");
const copy = document.querySelector("button#link-copy");
const close = document.querySelector("button#link-close");
form.addEventListener("submit", async (ev) => {
const data = new FormData(ev.target);
ev.preventDefault();
try {
fieldset.disabled = true;
submit.ariaBusy = "true";
const password = await encryptPassword(data.get("password"));
const id = await uploadPassword(
password.password,
parseInt(data.get("expires-in")),
);
const url = new URL(window.location.toString());
url.hash = [id, password.key, password.iv].join(":");
link.value = url.toString();
dialog.showModal();
} finally {
fieldset.disabled = false;
submit.ariaBusy = "false";
ev.target.reset();
}
});
dialog.addEventListener("close", (ev) => {
this.link.value = "";
});
copy.addEventListener("click", (ev) => {
link.select();
link.setSelectionRange(0, 99999);
navigator.clipboard.writeText(link.value);
});
close.addEventListener("click", (ev) => {
dialog.close();
});
}
async function confirmViewPassword() {
const dialog = document.querySelector("dialog#confirm");
const close = document.querySelector("button#confirm-close");
const ok = document.querySelector("button#confirm-ok");
const notFoundDialog = document.querySelector("dialog#not-found");
const notFoundClose = document.querySelector("button#not-found-close");
let hash = window.location.hash;
hash = hash.substring(1);
if (hash.trim() == "") {
return;
}
const [id, key, iv] = hash.split(":");
const has = await hasPassword(id);
if (!has) {
notFoundClose.addEventListener("click", () => notFoundDialog.close());
notFoundDialog.addEventListener("close", () => (window.location.hash = ""));
notFoundDialog.showModal();
return;
}
dialog.addEventListener("close", () => (window.location.hash = ""));
close.addEventListener("click", () => {
dialog.close();
});
ok.addEventListener("click", async () => {
try {
close.disabled = true;
ok.disabled = true;
ok.ariaBusy = "true";
const encryptedPassword = await getPassword(id);
const password = await decryptPassword(encryptedPassword, key, iv);
viewPassword(password);
} finally {
close.disabled = false;
ok.disabled = false;
ok.ariaBusy = "false";
dialog.close();
}
});
dialog.showModal();
}
async function viewPassword(password) {
const dialog = document.querySelector("dialog#view");
const viewPassword = document.querySelector("textarea#view-password");
const close = document.querySelector("button#view-close");
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => (window.location.hash = ""), 0);
viewPassword.value = password;
dialog.showModal();
}
window.addEventListener("load", () => {
initEnterPassword();
confirmViewPassword();
});

24
static/lang/de.json Normal file
View file

@ -0,0 +1,24 @@
{
"title": "PassED",
"source-code": "Quellcode",
"language": "Sprache",
"language-en": "Englisch",
"language-de": "Deutsch",
"enter-password": "Passwort eingeben",
"password": "Passwort",
"expires-in": "Ablaufzeit",
"expires-in.1-hour": "1 Stunde",
"expires-in.12-hours": "12 Stunden",
"expires-in.1-day": "1 Tag",
"expires-in.1-week": "1 Woche",
"expires-in.2-weeks": "2 Wochen",
"generate-link": "Link erstellen",
"share-link": "Link teilen",
"not-found": "Passwort wurde nicht gefunden",
"not-found-reason": "Das von Ihnen angefragte Passwort ist vielleicht abgelaufen, wurde bereits angeschaut oder hat noch nie existiert.",
"reveal-password": "Passwort anschauen",
"reveal-password-once": "Sie können sich das Passwort nur einmal anschauen.",
"copy": "Kopieren",
"close": "Schließen",
"ok": "OK"
}

24
static/lang/en.json Normal file
View file

@ -0,0 +1,24 @@
{
"title": "PassED",
"source-code": "Source",
"language": "Language",
"language-en": "English",
"language-de": "German",
"enter-password": "Enter Password",
"password": "Password",
"expires-in": "Expires in",
"expires-in.1-hour": "1 Hour",
"expires-in.12-hours": "2 Hours",
"expires-in.1-day": "1 Day",
"expires-in.1-week": "1 Week",
"expires-in.2-weeks": "2 Weeks",
"generate-link": "Generate Link",
"share-link": "Share Link",
"not-found": "Password not found",
"not-found-reason": "The password you requested may have expired, been viewed before or never even existed in the first place.",
"reveal-password": "Reveal Password",
"reveal-password-once": "You may only reveal the password once",
"copy": "Copy",
"close": "Close",
"ok": "OK"
}