aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/login.html
blob: 74c9fd16e9f874e5c0b51cc0b8b78b605d235e34 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>continuum: log in</title>
<style>
  :root { color-scheme: light dark; --fg: #1d2330; --bg: #f6f7f9; --line: #c9ced8; --accent: #2a6fdb; --bad: #b3261e; }
  @media (prefers-color-scheme: dark) { :root { --fg: #e4e7ee; --bg: #14171d; --line: #3a404c; --accent: #6ea2ff; --bad: #ff8a80; } }
  body { margin: 0; font: 15px/1.45 system-ui, sans-serif; color: var(--fg); background: var(--bg); }
  main { max-width: 22rem; margin: 12vh auto; padding: 0 16px; }
  h1 { font-size: 1.3rem; margin: 0 0 1.2rem; }
  form { display: grid; gap: .6rem; margin-bottom: 1rem; }
  label { display: grid; gap: .2rem; font-size: .9rem; }
  input { font: inherit; padding: .45rem .55rem; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; }
  button { font: inherit; padding: .45rem .8rem; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; justify-self: start; }
  button.quiet { background: transparent; color: var(--accent); }
  .or { margin: 1.4rem 0 .6rem; font-size: .9rem; opacity: .8; }
  #why { min-height: 1.4em; color: var(--bad); }
  code { font-size: .85em; }
</style>
</head>
<body>
<main>
  <h1>Log in to continuum</h1>
  <form id="password">
    <label>User <input name="user" autocomplete="username" required></label>
    <label>Password <input name="password" type="password" autocomplete="current-password" required></label>
    <button>Log in</button>
  </form>
  <p class="or">or with a token, from <code>continuum-signed login</code>:</p>
  <form id="token">
    <label>Token <input name="token" autocomplete="off" spellcheck="false" required></label>
    <button>Use it</button>
  </form>
  <p id="why" role="alert"></p>
  <button id="out" class="quiet" type="button">Log out of this browser</button>
</main>
<script>
// Logs in by password (POST /auth/login) or takes a token, then keeps a session for this
// browser (POST /auth/session, a cookie no script reads) and opens the dashboard.
const why = document.getElementById("why");
function basic(user, password) {
  let text = "";
  for (const byte of new TextEncoder().encode(user + ":" + password)) text += String.fromCharCode(byte);
  return "Basic " + btoa(text);
}
async function call(url, method, authorization) {
  const headers = { "Content-Type": "application/json" };
  if (authorization) headers.Authorization = authorization;
  const response = await fetch(url, { method, headers, credentials: "same-origin",
    body: method === "POST" ? "{}" : undefined });
  const answer = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(answer.error || response.status + " " + response.statusText);
  return answer;
}
async function begin(token) {
  await call("auth/session", "POST", "Bearer " + token);
  location.href = "admin/dashboard";
}
function failed(error) { why.textContent = "Not logged in: " + error.message; }
document.getElementById("password").addEventListener("submit", async event => {
  event.preventDefault();
  why.textContent = "";
  const form = event.target;
  try {
    const answer = await call("auth/login", "POST", basic(form.user.value, form.password.value));
    await begin(answer.token);
  } catch (error) { failed(error); }
});
document.getElementById("token").addEventListener("submit", async event => {
  event.preventDefault();
  why.textContent = "";
  try { await begin(event.target.token.value.trim()); } catch (error) { failed(error); }
});
// Opened from another member's dashboard (`handOff`), sent here for want of a session:
// its token, reading the member list only, begins one; the rest goes on to the dashboard.
(async () => {
  const HAND_OFF = "#continuum=";
  if (!location.hash.startsWith(HAND_OFF)) return;
  const fragment = location.hash;
  try { history.replaceState(null, "", location.pathname + location.search); } catch (_) {}
  let handed;
  try { handed = JSON.parse(decodeURIComponent(fragment.slice(HAND_OFF.length))); } catch (_) { return; }
  if (!handed || typeof handed.token !== "string") return;
  try {
    await call("auth/session", "POST", "Bearer " + handed.token);
    delete handed.token;
    location.replace("admin/dashboard" + HAND_OFF + encodeURIComponent(JSON.stringify(handed)));
  } catch (error) {
    why.textContent = "The token handed over isn't taken (" + error.message + "): log in.";
  }
})();
document.getElementById("out").addEventListener("click", async () => {
  await fetch("auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {});
  why.textContent = "Logged out of this browser.";
});
</script>
</body>
</html>