v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
This commit is contained in:
1 parent
9ab8c356d2
commit
71d1b82f83
102 files changed
+7102
-2178
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { login } from "./scram.js";
|
||||
import { cookiesUsable, login, loginUnavailable } from "./scram.js";
|
||||
|
||||
const base = new URL("..", location.href);
|
||||
const form = document.getElementById("login");
|
||||
@@ -44,5 +44,20 @@ form.addEventListener("submit", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
// A sign-in here only sets a cookie; the viewer, when the sink serves it,
|
||||
// signs in by itself and keeps a token in memory instead
|
||||
async function cookiesUnavailable() {
|
||||
status.textContent = "This browser keeps no cookie for this site, so a sign-in here would not last.";
|
||||
const viewer = await fetch("view", { method: "HEAD", cache: "no-store", redirect: "error" }).catch(() => null);
|
||||
if (!viewer?.ok) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = "view";
|
||||
link.textContent = "Sign in on the viewer instead.";
|
||||
status.append(" ", link);
|
||||
}
|
||||
|
||||
// Enabled only once this script runs, so a failed load cannot submit the form natively
|
||||
button.disabled = false;
|
||||
const unavailable = loginUnavailable();
|
||||
if (unavailable) status.textContent = unavailable;
|
||||
else if (cookiesUsable()) button.disabled = false;
|
||||
else cookiesUnavailable();
|
||||
+122
-14
@@ -16,11 +16,14 @@ const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
|
||||
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
|
||||
* an HttpOnly cookie; the promise resolves only once the server has proved it
|
||||
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
|
||||
* logwisp's doc/scram-auth-plan.md; it resolves only once the server has proved
|
||||
* it holds this user's verifier. The session is an HttpOnly cookie, or with
|
||||
* session "token" the returned token. onProgress(fraction) follows Argon2.
|
||||
*/
|
||||
export async function login(base, username, password, { onProgress } = {}) {
|
||||
export async function login(base, username, password, { onProgress, session = "cookie" } = {}) {
|
||||
if (session !== "cookie" && session !== "token") {
|
||||
throw new Error('session must be "cookie" or "token"');
|
||||
}
|
||||
const pw = checkCredentials(username, password);
|
||||
subtle(); // an insecure page fails before anything is sent
|
||||
const url = new URL("auth", base);
|
||||
@@ -29,7 +32,8 @@ export async function login(base, username, password, { onProgress } = {}) {
|
||||
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
|
||||
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
|
||||
MIN_TIME, MIN_MEMORY, onProgress);
|
||||
const step = await post(url, { proof, session: "cookie" });
|
||||
// Without "session" the server answers with the token in the body
|
||||
const step = await post(url, session === "cookie" ? { proof, session } : { proof });
|
||||
const final = step.final;
|
||||
if (!final || typeof final !== "object") {
|
||||
throw new Error("server sent no final message");
|
||||
@@ -40,15 +44,19 @@ export async function login(base, username, password, { onProgress } = {}) {
|
||||
throw new Error("server could not prove it holds this user's verifier");
|
||||
}
|
||||
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
|
||||
return { username, expiresIn };
|
||||
if (session === "cookie") return { username, expiresIn };
|
||||
if (typeof step.token !== "string" || step.token === "") {
|
||||
throw new Error("server issued no token");
|
||||
}
|
||||
return { username, expiresIn, token: step.token };
|
||||
}
|
||||
|
||||
// Logout posts to auth itself: a clearing cookie from any other path would
|
||||
// get another default path and miss the session cookie
|
||||
export async function logout(base) {
|
||||
// get another default path and miss the session cookie. A token is revoked.
|
||||
export async function logout(base, { token } = {}) {
|
||||
const res = await send(new URL("auth", base), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...bearer(token) },
|
||||
body: JSON.stringify({ logout: true }),
|
||||
});
|
||||
if (res.status !== 200 && res.status !== 204) {
|
||||
@@ -56,6 +64,101 @@ export async function logout(base) {
|
||||
}
|
||||
}
|
||||
|
||||
// Why no login can run on this page, or "": an insecure page refuses the probe's
|
||||
// Secure cookie too, so a page asks this before cookiesUsable().
|
||||
export function loginUnavailable() {
|
||||
return globalThis.crypto?.subtle ? "" : "WebCrypto unavailable: the page must be served over HTTPS";
|
||||
}
|
||||
|
||||
// Whether the session cookie would stick. Chromium blocking every cookie still
|
||||
// reports navigator.cookieEnabled, so a cookie with the session's attributes
|
||||
// must also come back.
|
||||
export function cookiesUsable() {
|
||||
if (!globalThis.navigator?.cookieEnabled) return false;
|
||||
document.cookie = "logwisp_probe=1; Secure; SameSite=Strict";
|
||||
const usable = document.cookie.split("; ").includes("logwisp_probe=1");
|
||||
document.cookie = "logwisp_probe=; Max-Age=0; Secure; SameSite=Strict";
|
||||
return usable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an event stream through fetch, which can send the bearer EventSource
|
||||
* cannot. onEvent({type, data, lastEventId}) gets each event EventSource would
|
||||
* dispatch. Resolves to {lastEventId, retry} when the server ends the stream;
|
||||
* rejects with err.status on an answer other than 200, or on signal's abort.
|
||||
*/
|
||||
export async function stream(url, { token, signal, onEvent } = {}) {
|
||||
const res = await send(new URL(url, globalThis.location?.href), {
|
||||
headers: { Accept: "text/event-stream", ...bearer(token) },
|
||||
signal,
|
||||
});
|
||||
const type = res.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
|
||||
if (res.status !== 200 || type !== "text/event-stream") {
|
||||
res.body?.cancel().catch(() => {});
|
||||
const err = new Error(res.status !== 200 ? `stream refused: ${res.status}` : "not an event stream");
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const parser = eventParser(onEvent);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder(); // UTF-8, dropping a leading BOM as the spec asks
|
||||
try {
|
||||
for (let r = await reader.read(); !r.done; r = await reader.read()) {
|
||||
parser.feed(decoder.decode(r.value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {});
|
||||
}
|
||||
return parser.result();
|
||||
}
|
||||
|
||||
// The event stream interpretation of the HTML standard; a CR that ends one
|
||||
// chunk may be the first half of a CRLF the next chunk completes.
|
||||
function eventParser(onEvent) {
|
||||
let line = "", afterCR = false, data = "", type = "", idBuffer = "", lastEventId = "", retry = null;
|
||||
const field = (name, value) => {
|
||||
switch (name) {
|
||||
case "event": type = value; break;
|
||||
case "data": data += value + "\n"; break;
|
||||
case "id": if (!value.includes("\0")) idBuffer = value; break;
|
||||
case "retry": if (/^[0-9]+$/.test(value)) retry = Number(value); break;
|
||||
}
|
||||
};
|
||||
const dispatch = () => {
|
||||
lastEventId = idBuffer;
|
||||
if (data !== "") onEvent?.({ type: type || "message", data: data.slice(0, -1), lastEventId });
|
||||
data = type = "";
|
||||
};
|
||||
const interpret = (l) => {
|
||||
if (l === "") return dispatch();
|
||||
if (l.startsWith(":")) return;
|
||||
const colon = l.indexOf(":");
|
||||
if (colon < 0) return field(l, "");
|
||||
const value = l.slice(colon + 1);
|
||||
field(l.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value);
|
||||
};
|
||||
const breaks = /[\r\n]/g;
|
||||
return {
|
||||
feed(text) {
|
||||
let i = 0;
|
||||
if (afterCR && text.startsWith("\n")) i = 1;
|
||||
if (text !== "") afterCR = false;
|
||||
for (breaks.lastIndex = i; breaks.exec(text); i = breaks.lastIndex) {
|
||||
const end = breaks.lastIndex - 1;
|
||||
interpret(line + text.slice(i, end));
|
||||
line = "";
|
||||
if (text[end] === "\r") {
|
||||
if (end + 1 === text.length) afterCR = true;
|
||||
else if (text[end + 1] === "\n") breaks.lastIndex++;
|
||||
}
|
||||
}
|
||||
line += text.slice(i);
|
||||
},
|
||||
// An event the stream ended before its blank line is discarded
|
||||
result: () => ({ lastEventId, retry }),
|
||||
};
|
||||
}
|
||||
|
||||
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
|
||||
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
|
||||
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
|
||||
@@ -135,19 +238,24 @@ async function post(url, body) {
|
||||
return step;
|
||||
}
|
||||
|
||||
// A redirect would replay the proof to wherever it points
|
||||
// A redirect would replay the proof or the token to wherever it points
|
||||
async function send(url, init) {
|
||||
try {
|
||||
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError") throw err;
|
||||
throw new Error(`cannot reach ${url.pathname}`);
|
||||
}
|
||||
}
|
||||
|
||||
function bearer(token) {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function subtle() {
|
||||
const s = globalThis.crypto?.subtle;
|
||||
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
|
||||
return s;
|
||||
const reason = loginUnavailable();
|
||||
if (reason) throw new Error(reason);
|
||||
return crypto.subtle;
|
||||
}
|
||||
|
||||
async function hmac(key, message) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { argon2id, _proofForTest } from "./scram.js";
|
||||
import { argon2id, _proofForTest, login, logout, stream } from "./scram.js";
|
||||
|
||||
const hex = (b) => Buffer.from(b).toString("hex");
|
||||
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
|
||||
@@ -42,3 +42,101 @@ test("proof and server signature match lixenwraith/auth", async () => {
|
||||
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
|
||||
});
|
||||
});
|
||||
|
||||
const STREAM = "https://site.test/logs/stream";
|
||||
|
||||
// A fetch answering with an event stream sent in the given chunks
|
||||
function sseFetch(t, chunks, { status = 200, type = "text/event-stream" } = {}) {
|
||||
return t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({
|
||||
start(c) {
|
||||
for (const chunk of chunks) c.enqueue(new TextEncoder().encode(chunk));
|
||||
c.close();
|
||||
},
|
||||
}), { status, headers: { "Content-Type": type } }));
|
||||
}
|
||||
|
||||
async function events(t, chunks) {
|
||||
sseFetch(t, chunks);
|
||||
const got = [];
|
||||
const end = await stream(STREAM, { onEvent: (e) => got.push(e) });
|
||||
return { got, end };
|
||||
}
|
||||
|
||||
// A split CRLF is one break, so "d" and "e" stay one event; a kept BOM would
|
||||
// rename the first field and drop "a".
|
||||
test("stream splits lines at CRLF, LF and a lone CR, across chunks", async (t) => {
|
||||
const { got } = await events(t, ["\uFEFFdata: a\r\ndata: b\rdata: c\n\r", "\n", "data: d\r", "\ndata: e\r", "\r\n"]);
|
||||
assert.deepEqual(got, [
|
||||
{ type: "message", data: "a\nb\nc", lastEventId: "" },
|
||||
{ type: "message", data: "d\ne", lastEventId: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("stream interprets fields as the HTML standard does", async (t) => {
|
||||
const { got, end } = await events(t, [
|
||||
": comment\nevent: connected\ndata:{\"a\":1}\nid: 7\n\n",
|
||||
"data\n\n",
|
||||
"data: one space kept\nunknown: x\nid: 8\0\nretry: 3000\nretry: 15x\n\n",
|
||||
"id: 8\nevent: no-data\n\n",
|
||||
"id\ndata: never ended\n",
|
||||
]);
|
||||
assert.deepEqual(got, [
|
||||
{ type: "connected", data: "{\"a\":1}", lastEventId: "7" },
|
||||
{ type: "message", data: "", lastEventId: "7" },
|
||||
{ type: "message", data: " one space kept", lastEventId: "7" },
|
||||
]);
|
||||
assert.deepEqual(end, { lastEventId: "8", retry: 3000 });
|
||||
});
|
||||
|
||||
test("stream presents the bearer and refuses anything but a 200 event stream", async (t) => {
|
||||
const fetch = sseFetch(t, []);
|
||||
await stream(STREAM, { token: "tok" });
|
||||
const init = fetch.mock.calls[0].arguments[1];
|
||||
assert.equal(init.headers.Authorization, "Bearer tok");
|
||||
assert.equal(init.redirect, "error");
|
||||
fetch.mock.restore();
|
||||
sseFetch(t, [], { status: 401 });
|
||||
await assert.rejects(stream(STREAM, { token: "tok" }), { status: 401 });
|
||||
sseFetch(t, [], { type: "text/html" });
|
||||
await assert.rejects(stream(STREAM), { message: "not an event stream" });
|
||||
});
|
||||
|
||||
test("stream rejects with the AbortError once its signal aborts", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async (url, { signal }) => signal.throwIfAborted() ?? new Response(new ReadableStream({
|
||||
start(c) {
|
||||
c.enqueue(new TextEncoder().encode("data: first\n\n"));
|
||||
signal.addEventListener("abort", () => c.error(signal.reason));
|
||||
},
|
||||
}), { headers: { "Content-Type": "text/event-stream" } }));
|
||||
const ac = new AbortController();
|
||||
await assert.rejects(stream(STREAM, { signal: ac.signal, onEvent: () => ac.abort() }), { name: "AbortError" });
|
||||
await assert.rejects(stream(STREAM, { signal: AbortSignal.abort() }), { name: "AbortError" });
|
||||
});
|
||||
|
||||
// The mock server signs with the client's own derivation at the Argon2 floor
|
||||
test("token mode returns the token, and logout presents it", async (t) => {
|
||||
const challenge = { salt: Buffer.alloc(16, 9).toString("base64"), argon_time: 3, argon_memory: 65536, argon_threads: 1 };
|
||||
const sent = [];
|
||||
t.mock.method(globalThis, "fetch", async (url, init) => {
|
||||
const body = JSON.parse(init.body);
|
||||
sent.push({ body, headers: init.headers });
|
||||
if (body.scram) {
|
||||
challenge.client_nonce = body.scram.client_nonce;
|
||||
challenge.full_nonce = body.scram.client_nonce + "server-nonce";
|
||||
return Response.json({ challenge });
|
||||
}
|
||||
if (body.proof) {
|
||||
const want = await _proofForTest("alice", "password123", challenge.client_nonce, challenge, 3, 65536);
|
||||
challenge.proof_ok = body.proof.client_proof === want.client_proof;
|
||||
return Response.json({ final: { username: "alice", server_signature: want.server_signature }, token: "tok", expires_in: 900 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const base = "https://site.test/logs/";
|
||||
assert.deepEqual(await login(base, "alice", "password123", { session: "token" }),
|
||||
{ username: "alice", expiresIn: 900, token: "tok" });
|
||||
assert.ok(challenge.proof_ok);
|
||||
assert.equal("session" in sent[1].body, false);
|
||||
await logout(base, { token: "tok" });
|
||||
assert.equal(sent[2].headers.Authorization, "Bearer tok");
|
||||
});
|
||||
@@ -115,3 +115,12 @@ progress {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -14,6 +14,14 @@
|
||||
<span id="state" role="status">connecting…</span>
|
||||
<button id="logout" type="button">Sign out</button>
|
||||
</header>
|
||||
<form id="login" class="card" method="post" hidden>
|
||||
<p class="note">This browser keeps no cookie for this site, so the session lives in this page and ends when it closes.</p>
|
||||
<label>Username <input name="username" autocomplete="username" required></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Sign in</button>
|
||||
<progress id="progress" max="1" hidden></progress>
|
||||
<p id="status" role="status"></p>
|
||||
</form>
|
||||
<main id="log"></main>
|
||||
</body>
|
||||
</html>
|
||||
+127
-22
@@ -1,4 +1,4 @@
|
||||
import { logout } from "./scram.js";
|
||||
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
|
||||
|
||||
const base = new URL("..", location.href);
|
||||
// The server writes its status path here, relative to the mount
|
||||
@@ -6,7 +6,22 @@ const statusPath = document.querySelector('meta[name="logwisp-status"]').content
|
||||
const MAX_LINES = 5000;
|
||||
const log = document.getElementById("log");
|
||||
const state = document.getElementById("state");
|
||||
let source = null;
|
||||
const signOut = document.getElementById("logout");
|
||||
const form = document.getElementById("login");
|
||||
const progress = document.getElementById("progress");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
const unavailable = loginUnavailable();
|
||||
// Without cookies the session is a token in this variable only: never stored,
|
||||
// so it ends with the page, or earlier with its lifetime or sign-out
|
||||
let tokenMode = !unavailable && !cookiesUsable();
|
||||
let token = null;
|
||||
// The login page comes back with this fragment: a 401 now means the browser
|
||||
// dropped the session cookie
|
||||
let fresh = location.hash === "#signed-in";
|
||||
if (fresh) history.replaceState(null, "", location.pathname + location.search);
|
||||
let stopStream = null;
|
||||
let timer = null;
|
||||
|
||||
function append(line) {
|
||||
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
|
||||
@@ -18,50 +33,140 @@ function append(line) {
|
||||
}
|
||||
|
||||
// The status answer names the stream path; its 401 is the only way to tell an
|
||||
// expired session apart, since EventSource hides the status of a failure.
|
||||
// ended session apart, since EventSource hides the status of a failure.
|
||||
async function connect() {
|
||||
if (tokenMode && !token) return showLogin("");
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
|
||||
res = await fetch(new URL("./" + statusPath, base), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: "same-origin", cache: "no-store", redirect: "error",
|
||||
});
|
||||
} catch {
|
||||
return retry("server unreachable");
|
||||
}
|
||||
if (res.status === 401) {
|
||||
location.replace(new URL("login?next=view", location.href).href);
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) return sessionEnded();
|
||||
fresh = false;
|
||||
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
|
||||
if (typeof path !== "string" || !path.startsWith("/")) {
|
||||
return retry(`status unavailable (${res.status})`);
|
||||
}
|
||||
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
|
||||
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
|
||||
source.addEventListener("connected", () => { state.textContent = "live"; });
|
||||
source.addEventListener("disconnect", () => {
|
||||
source.close();
|
||||
const url = new URL("./" + path.replace(/^\/+/, ""), base);
|
||||
if (tokenMode) streamWithToken(url);
|
||||
else streamWithCookie(url);
|
||||
}
|
||||
|
||||
function onEvent(type, data) {
|
||||
if (type === "connected") state.textContent = "live";
|
||||
if (type === "disconnect") {
|
||||
stopStream();
|
||||
retry("server shut down");
|
||||
});
|
||||
source.onmessage = (event) => event.data.split("\n").forEach(append);
|
||||
}
|
||||
if (type === "message") data.split("\n").forEach(append);
|
||||
}
|
||||
|
||||
function streamWithCookie(url) {
|
||||
const source = new EventSource(url);
|
||||
stopStream = () => source.close();
|
||||
for (const type of ["connected", "disconnect", "message"]) {
|
||||
source.addEventListener(type, (event) => onEvent(type, event.data));
|
||||
}
|
||||
source.onerror = () => {
|
||||
if (source.readyState === EventSource.CLOSED) retry("disconnected");
|
||||
else state.textContent = "reconnecting…";
|
||||
};
|
||||
}
|
||||
|
||||
function retry(reason) {
|
||||
state.textContent = `${reason}, retrying…`;
|
||||
setTimeout(connect, 5000);
|
||||
async function streamWithToken(url) {
|
||||
const abort = new AbortController();
|
||||
stopStream = () => abort.abort();
|
||||
let refused = 0;
|
||||
try {
|
||||
await stream(url, { token, signal: abort.signal, onEvent: (event) => onEvent(event.type, event.data) });
|
||||
} catch (err) {
|
||||
refused = err.status ?? 0;
|
||||
}
|
||||
if (abort.signal.aborted) return;
|
||||
if (refused === 401) sessionEnded();
|
||||
else retry(refused ? `stream refused (${refused})` : "disconnected");
|
||||
}
|
||||
|
||||
document.getElementById("logout").addEventListener("click", async () => {
|
||||
source?.close();
|
||||
function sessionEnded() {
|
||||
if (!tokenMode && !fresh) {
|
||||
location.replace(new URL("login?next=" + encodeURIComponent("view#signed-in"), location.href).href);
|
||||
return;
|
||||
}
|
||||
const expired = token !== null;
|
||||
tokenMode = true;
|
||||
token = null;
|
||||
showLogin(expired ? "The session ended; sign in again." : "");
|
||||
}
|
||||
|
||||
function showLogin(message) {
|
||||
stopStream?.();
|
||||
clearTimeout(timer);
|
||||
state.textContent = "signed out";
|
||||
signOut.hidden = true;
|
||||
status.textContent = message;
|
||||
form.hidden = false;
|
||||
form.elements.username.focus();
|
||||
}
|
||||
|
||||
function retry(reason) {
|
||||
state.textContent = `${reason}, retrying…`;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(connect, 5000);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const fields = new FormData(form);
|
||||
const button = form.querySelector("button");
|
||||
button.disabled = true;
|
||||
progress.removeAttribute("value");
|
||||
progress.hidden = false;
|
||||
status.textContent = "Deriving the key, this takes a few seconds…";
|
||||
try {
|
||||
await logout(base);
|
||||
({ token } = await login(base, fields.get("username"), fields.get("password"), {
|
||||
session: "token",
|
||||
onProgress: (fraction) => { progress.value = fraction; },
|
||||
}));
|
||||
} catch (err) {
|
||||
status.textContent = err.message;
|
||||
return;
|
||||
} finally {
|
||||
progress.hidden = true;
|
||||
button.disabled = false;
|
||||
}
|
||||
form.elements.password.value = "";
|
||||
form.hidden = true;
|
||||
signOut.hidden = false;
|
||||
state.textContent = "connecting…";
|
||||
connect();
|
||||
});
|
||||
|
||||
signOut.addEventListener("click", async () => {
|
||||
stopStream?.();
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
await logout(base, { token });
|
||||
} catch (err) {
|
||||
state.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
location.replace(new URL("login", location.href).href);
|
||||
if (!tokenMode) {
|
||||
location.replace(new URL("login", location.href).href);
|
||||
return;
|
||||
}
|
||||
token = null;
|
||||
showLogin("Signed out.");
|
||||
});
|
||||
|
||||
connect();
|
||||
// No session of either kind can start on this page, so it offers no form
|
||||
if (unavailable) {
|
||||
state.textContent = unavailable;
|
||||
signOut.hidden = true;
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
Reference in new issue
Block a user