@makerclay/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
@@ -0,0 +1,145 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>My files</title>
7
+ <script src="/_/clay.js"></script>
8
+ <style>
9
+ /* This is your file. Change any of it, save, and it stays changed. */
10
+ :root { --bg:#fbfaf8; --panel:#fff; --ink:#1c1b19; --muted:#6b6862; --line:#e3dfd8; --accent:#9a5b28;
11
+ color-scheme: light dark; }
12
+ @media (prefers-color-scheme: dark) {
13
+ :root { --bg:#171614; --panel:#1f1e1b; --ink:#ece8e1; --muted:#9b968d; --line:#322f2a; --accent:#d98b4a; }
14
+ }
15
+ * { box-sizing: border-box; }
16
+ body { margin:0; background:var(--bg); color:var(--ink);
17
+ font:15px/1.55 ui-sans-serif, system-ui, sans-serif; }
18
+ .wrap { max-width:56rem; margin:0 auto; padding:2.5rem 1.25rem 5rem; }
19
+ h1 { font-size:1.5rem; margin:0 0 .2rem; }
20
+ .tagline { color:var(--muted); margin:0 0 2rem; }
21
+ h2 { font-size:.78rem; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:2rem 0 .6rem; }
22
+ ul { list-style:none; margin:0; padding:0; }
23
+ li { display:flex; gap:.6rem; align-items:baseline; padding:.35rem .5rem; border-bottom:1px solid var(--line); }
24
+ li a { color:var(--ink); text-decoration:none; flex:1; }
25
+ li a:hover { color:var(--accent); }
26
+ .when { color:var(--muted); font-size:.85rem; font-variant-numeric:tabular-nums; }
27
+ button { font:inherit; font-size:.8rem; background:none; border:1px solid var(--line); color:var(--muted);
28
+ border-radius:6px; padding:.1rem .45rem; cursor:pointer; }
29
+ button:hover { color:var(--ink); border-color:var(--muted); }
30
+ .empty { color:var(--muted); padding:1.5rem .5rem; }
31
+ html[editmode="false"] button { display:none; }
32
+
33
+ /* Two layouts. `body[layout="grid"]` is saved into the file, so the one you
34
+ picked is the one that loads next time, on any machine. */
35
+ body[layout="grid"] ul { display:grid; grid-template-columns:repeat(auto-fill, minmax(14rem, 1fr)); gap:.4rem; }
36
+ body[layout="grid"] li { border:1px solid var(--line); border-radius:8px; background:var(--panel); }
37
+ body[layout="grid"] .when { display:none; }
38
+ </style>
39
+ </head>
40
+ <body layout="list">
41
+ <div class="wrap">
42
+
43
+ <h1 contenteditable>My files</h1>
44
+ <p class="tagline" contenteditable>This page is one of them. Open it in the editor and it
45
+ changes shape.</p>
46
+
47
+ <h2>Pinned</h2>
48
+ <!-- Pinned rows are ordinary elements, so they ARE saved into this file. -->
49
+ <ul id="pinned"></ul>
50
+ <p class="empty" id="no-pins">Nothing pinned yet. Pin something below.</p>
51
+
52
+ <h2>Everything <button onclick="toggleLayout()">layout</button></h2>
53
+ <!-- The live listing is built inside here at runtime, inside a `no-save`
54
+ island, so a listing that was true thirty seconds ago never gets written
55
+ into this file as if it were content. -->
56
+ <div id="listing"><p class="empty">Reading…</p></div>
57
+
58
+ </div>
59
+
60
+ <script>
61
+ // The same /_/nodes API the shipped dashboard uses, called with your own
62
+ // session. If this page can read it, so can anything else you write.
63
+ async function load() {
64
+ const listing = document.getElementById('listing');
65
+ let body;
66
+ try {
67
+ const res = await fetch('/_/nodes', { headers: { Accept: 'application/json' } });
68
+ body = await res.json();
69
+ if (!res.ok) throw new Error(body.msg || res.statusText);
70
+ } catch (problem) {
71
+ listing.replaceChildren(say('Could not read your files: ' + problem.message
72
+ + '. Sign in as the owner and reload.'));
73
+ return;
74
+ }
75
+
76
+ const island = document.createElement('div');
77
+ island.setAttribute('clay', 'no-save');
78
+ const list = document.createElement('ul');
79
+ const pinned = new Set([...document.querySelectorAll('#pinned li')].map((li) => li.dataset.path));
80
+
81
+ for (const entry of body.entries) {
82
+ if (pinned.has(entry.path)) continue;
83
+ list.append(row(entry, 'Pin', () => pin(entry)));
84
+ }
85
+ island.append(list.children.length ? list : say('This folder is empty.'));
86
+ listing.replaceChildren(island);
87
+ drawPins();
88
+ }
89
+
90
+ function row(entry, label, action) {
91
+ const li = document.createElement('li');
92
+ li.dataset.path = entry.path;
93
+
94
+ const link = document.createElement('a');
95
+ link.href = '/' + entry.path.split('/').map(encodeURIComponent).join('/');
96
+ link.textContent = entry.name;
97
+
98
+ const when = document.createElement('span');
99
+ when.className = 'when';
100
+ when.textContent = entry.modified ? new Date(entry.modified).toLocaleDateString() : '';
101
+
102
+ const button = document.createElement('button');
103
+ button.textContent = label;
104
+ button.addEventListener('click', action);
105
+
106
+ li.append(link, when, button);
107
+ return li;
108
+ }
109
+
110
+ function say(text) {
111
+ const p = document.createElement('p');
112
+ p.className = 'empty';
113
+ p.textContent = text;
114
+ return p;
115
+ }
116
+
117
+ // A pinned row is written into THIS document. Save, and it is still there
118
+ // tomorrow, in the file, with no database anywhere holding your layout.
119
+ function pin(entry) {
120
+ const li = row(entry, 'Unpin', null);
121
+ li.querySelector('button').setAttribute('onclick', 'unpin(this)');
122
+ document.getElementById('pinned').append(li);
123
+ load();
124
+ }
125
+
126
+ function unpin(button) {
127
+ button.closest('li').remove();
128
+ drawPins();
129
+ load();
130
+ }
131
+
132
+ function drawPins() {
133
+ const any = document.querySelectorAll('#pinned li').length > 0;
134
+ document.getElementById('no-pins').hidden = any;
135
+ }
136
+
137
+ function toggleLayout() {
138
+ const body = document.body;
139
+ body.setAttribute('layout', body.getAttribute('layout') === 'grid' ? 'list' : 'grid');
140
+ }
141
+
142
+ load();
143
+ </script>
144
+ </body>
145
+ </html>
@@ -0,0 +1,85 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Board</title>
7
+ <script src="/_/clay.js?plugins=sortable,undo"></script>
8
+ <style>
9
+ :root { color-scheme: light dark; --ink: #1b1b1f; --paper: #f6f6f4; --card: #fff;
10
+ --line: #e1e0dc; --muted: #74736e; --accent: #b0562a; }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root { --ink: #e9e8e4; --paper: #131315; --card: #1c1c20; --line: #2c2c31; --muted: #98968f; --accent: #d98b4a; }
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { font: 15px/1.5 system-ui, sans-serif; color: var(--ink); background: var(--paper);
16
+ margin: 0; padding: 2rem 1.5rem 4rem; }
17
+ h1 { font-size: 1.4rem; margin: 0 0 1.5rem; }
18
+ .board { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; align-items: start; }
19
+ .column { background: color-mix(in oklab, var(--card) 55%, transparent);
20
+ border: 1px solid var(--line); border-radius: 10px; padding: .75rem; }
21
+ .column > h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .07em;
22
+ color: var(--muted); margin: 0 0 .7rem; }
23
+ .cards { min-height: 2.5rem; display: flex; flex-direction: column; gap: .5rem; }
24
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 7px;
25
+ padding: .6rem .7rem; cursor: grab; }
26
+ .card:focus-within { border-color: var(--accent); }
27
+ button { font: inherit; background: none; border: 1px dashed var(--line); color: var(--muted);
28
+ border-radius: 7px; padding: .45rem; width: 100%; margin-top: .5rem; cursor: pointer; }
29
+ button:hover { color: var(--ink); border-color: var(--muted); }
30
+ /* clayjs stamps editmode="true|false" on <html>, so a reader never sees the
31
+ affordances they cannot use. */
32
+ html[editmode="false"] button { display: none; }
33
+ html[editmode="false"] .card { cursor: default; }
34
+ </style>
35
+ </head>
36
+ <body>
37
+
38
+ <h1 contenteditable>What I am working on</h1>
39
+
40
+ <div class="board">
41
+ <section class="column">
42
+ <h2 contenteditable>To do</h2>
43
+ <div class="cards" sortable="board">
44
+ <div class="card" contenteditable>Write the thing down before it escapes</div>
45
+ <div class="card" contenteditable>Drag me into another column</div>
46
+ </div>
47
+ <button onclick="addCard(this)">+ Add a card</button>
48
+ </section>
49
+
50
+ <section class="column">
51
+ <h2 contenteditable>Doing</h2>
52
+ <div class="cards" sortable="board">
53
+ <div class="card" contenteditable>Exactly one thing, ideally</div>
54
+ </div>
55
+ <button onclick="addCard(this)">+ Add a card</button>
56
+ </section>
57
+
58
+ <section class="column">
59
+ <h2 contenteditable>Done</h2>
60
+ <div class="cards" sortable="board"></div>
61
+ <button onclick="addCard(this)">+ Add a card</button>
62
+ </section>
63
+ </div>
64
+
65
+ <script>
66
+ function addCard(button) {
67
+ const card = document.createElement('div');
68
+ card.className = 'card';
69
+ card.setAttribute('contenteditable', '');
70
+ card.textContent = 'New card';
71
+ button.previousElementSibling.appendChild(card);
72
+ card.focus();
73
+ getSelection().selectAllChildren(card);
74
+ }
75
+
76
+ // An emptied card is a deleted card, which is less fiddly than a delete button
77
+ // on every one of them.
78
+ document.addEventListener('blur', (event) => {
79
+ const card = event.target.closest?.('.card');
80
+ if (card && !card.textContent.trim()) card.remove();
81
+ }, true);
82
+ </script>
83
+
84
+ </body>
85
+ </html>
@@ -0,0 +1,108 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>A thing worth telling people about</title>
7
+ <script src="/_/clay.js"></script>
8
+ <style>
9
+ :root { color-scheme: light dark; --ink: #17171a; --paper: #fdfcfa; --line: #e4e3df;
10
+ --muted: #6f6e69; --accent: #b0562a; }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root { --ink: #ecebe7; --paper: #111113; --line: #2a2a2f; --muted: #9a988f; --accent: #d98b4a; }
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { font: 17px/1.65 system-ui, sans-serif; color: var(--ink); background: var(--paper);
16
+ margin: 0; padding: 0 1.25rem 6rem; }
17
+ .wrap { max-width: 44rem; margin: 0 auto; }
18
+ .hero { padding: 6rem 0 4rem; }
19
+ .hero h1 { font-size: clamp(2rem, 6vw, 3.2rem); line-height: 1.1; margin: 0 0 1rem; letter-spacing: -.02em; }
20
+ .hero p { font-size: 1.2rem; color: var(--muted); margin: 0 0 2rem; max-width: 30rem; }
21
+ .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
22
+ gap: 2rem; padding: 3rem 0; border-top: 1px solid var(--line); }
23
+ .features h3 { margin: 0 0 .4rem; font-size: 1rem; }
24
+ .features p { margin: 0; color: var(--muted); font-size: .95rem; }
25
+ form { border-top: 1px solid var(--line); padding-top: 3rem; display: flex; gap: .6rem; flex-wrap: wrap; }
26
+ input { font: inherit; padding: .7rem .8rem; border: 1px solid var(--line); border-radius: 8px;
27
+ background: var(--paper); color: var(--ink); flex: 1 1 15rem; }
28
+ input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
29
+ button { font: inherit; padding: .7rem 1.3rem; border: none; border-radius: 8px;
30
+ background: var(--accent); color: #fff; cursor: pointer; }
31
+ .said { margin-top: .9rem; color: var(--muted); font-size: .93rem; min-height: 1.2em; }
32
+ .setup { background: color-mix(in oklab, var(--accent) 12%, transparent);
33
+ border: 1px solid var(--accent); border-radius: 8px; padding: .9rem 1rem;
34
+ font-size: .92rem; margin-top: 1rem; }
35
+ html[editmode="false"] .setup { display: none; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div class="wrap">
40
+
41
+ <section class="hero">
42
+ <h1 contenteditable>A thing worth telling people about</h1>
43
+ <p contenteditable>One sentence on what it is and who it is for. Resist the
44
+ urge to write three.</p>
45
+ </section>
46
+
47
+ <section class="features">
48
+ <div>
49
+ <h3 contenteditable>It is one file</h3>
50
+ <p contenteditable>Everything on this page lives in the HTML you are reading.</p>
51
+ </div>
52
+ <div>
53
+ <h3 contenteditable>It edits itself</h3>
54
+ <p contenteditable>Sign in, click anything, type. Save when you like it.</p>
55
+ </div>
56
+ <div>
57
+ <h3 contenteditable>It goes with you</h3>
58
+ <p contenteditable>Download it and it still works, on any machine, offline.</p>
59
+ </div>
60
+ </section>
61
+
62
+ <form onsubmit="return signUp(event)">
63
+ <input name="email" type="email" placeholder="you@example.com" required aria-label="Email">
64
+ <button type="submit" contenteditable>Keep me posted</button>
65
+ </form>
66
+ <p class="said" id="said"></p>
67
+
68
+ <p class="setup">
69
+ To collect these: press <b>Collect</b> next to this file on your dashboard,
70
+ then copy the two attributes it gives you onto the <code>&lt;form&gt;</code>
71
+ above. Until you do, the form says so instead of pretending to work.
72
+ </p>
73
+
74
+ </div>
75
+
76
+ <script>
77
+ // The collection slug and submit token are ATTRIBUTES on the form, so they
78
+ // travel with the file and survive a download. The token only permits posting
79
+ // a record: it reads nothing back.
80
+ async function signUp(event) {
81
+ event.preventDefault();
82
+ const form = event.target;
83
+ const said = document.getElementById('said');
84
+ const slug = form.getAttribute('collection');
85
+ const token = form.getAttribute('collection-token');
86
+
87
+ if (!slug || !token) {
88
+ said.textContent = 'This form has no collection yet, so nothing was sent.';
89
+ return false;
90
+ }
91
+ try {
92
+ const res = await fetch('/_/collection/' + slug + '/records', {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ token, data: { email: form.email.value } }),
96
+ });
97
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).msg || 'Something went wrong.');
98
+ said.textContent = 'Got it. Thank you.';
99
+ form.reset();
100
+ } catch (problem) {
101
+ said.textContent = problem.message;
102
+ }
103
+ return false;
104
+ }
105
+ </script>
106
+
107
+ </body>
108
+ </html>
@@ -0,0 +1,50 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Untitled</title>
7
+ <script src="/_/clay.js"></script>
8
+ <style>
9
+ :root { color-scheme: light dark; --ink: #1c1b19; --paper: #fbfaf7; --muted: #6d6a63; }
10
+ @media (prefers-color-scheme: dark) { :root { --ink: #e8e6e1; --paper: #17171a; --muted: #97948d; } }
11
+ body {
12
+ font: 19px/1.7 Georgia, "Iowan Old Style", serif;
13
+ color: var(--ink); background: var(--paper);
14
+ max-width: 36rem; margin: 0 auto; padding: 5rem 1.25rem 8rem;
15
+ }
16
+ h1 { font-size: 2.1rem; line-height: 1.2; margin: 0 0 .35em; }
17
+ .byline { color: var(--muted); font-size: .95rem; margin: 0 0 3rem; }
18
+ p { margin: 0 0 1.4em; }
19
+ [contenteditable]:focus { outline: 2px solid color-mix(in oklab, var(--ink) 25%, transparent); outline-offset: .4rem; }
20
+ footer { position: fixed; bottom: 0; left: 0; right: 0; padding: .6rem 1rem;
21
+ font: 13px/1 system-ui, sans-serif; color: var(--muted); text-align: right; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+
26
+ <h1 contenteditable>An untitled piece</h1>
27
+ <p class="byline" contenteditable>by someone, one afternoon</p>
28
+
29
+ <article contenteditable>
30
+ <p>Start typing. Everything you write is stored in this file, in the paragraphs
31
+ you are reading, so there is no database to lose and nothing to export.</p>
32
+
33
+ <p>Press Enter for a new paragraph. Save when you are done.</p>
34
+ </article>
35
+
36
+ <footer><span id="count"></span></footer>
37
+
38
+ <script>
39
+ // Derived, never stored: a word count saved into the file would be one more
40
+ // thing that can disagree with the words.
41
+ const recount = () => {
42
+ const words = (document.querySelector('article').innerText.match(/\S+/g) || []).length;
43
+ document.getElementById('count').textContent = words + (words === 1 ? ' word' : ' words');
44
+ };
45
+ document.addEventListener('input', recount);
46
+ recount();
47
+ </script>
48
+
49
+ </body>
50
+ </html>
@@ -0,0 +1,172 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="/_/ui.css">
8
+ <style>
9
+ .state { font-size: .82rem; padding: .1rem .45rem; border-radius: 4px; border: 1px solid var(--line); }
10
+ .state.waiting { color: var(--accent); border-color: var(--accent); }
11
+ .state.suspended { color: var(--danger); border-color: var(--danger); }
12
+ .usage { color: var(--muted); font-size: .86rem; white-space: nowrap; }
13
+ .code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 1.05rem; letter-spacing: .04em; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+
18
+ <header class="bar">
19
+ <a class="btn" href="/_/" title="Back to your files">←</a>
20
+ <span class="brand">People</span>
21
+ <span class="spacer"></span>
22
+ <span class="usage" id="count"></span>
23
+ </header>
24
+
25
+ <main class="wrap">
26
+ <table>
27
+ <thead>
28
+ <tr><th>Username</th><th>State</th><th>Using</th><th>Joined</th><th class="actions">Actions</th></tr>
29
+ </thead>
30
+ <tbody id="rows"></tbody>
31
+ </table>
32
+ <div class="empty" id="empty" hidden>Nobody has signed up yet.</div>
33
+ <p class="hint" style="margin-top:1.5rem">
34
+ Each person gets their own copy of the document they signed up to, at
35
+ <code>/~username/…</code>, and their own login. You can read what they have
36
+ made and you can suspend or remove them, but you cannot edit their copy: it
37
+ is their document. There is no email on this server, so a password reset is a
38
+ recovery code you read out to them.
39
+ </p>
40
+ </main>
41
+
42
+ <div class="toast" id="toast"></div>
43
+
44
+ <script>
45
+ const rows = document.getElementById('rows');
46
+ const empty = document.getElementById('empty');
47
+ const count = document.getElementById('count');
48
+ const toastEl = document.getElementById('toast');
49
+
50
+ function toast(message, isError) {
51
+ toastEl.textContent = message;
52
+ toastEl.className = 'toast show' + (isError ? ' error' : '');
53
+ clearTimeout(toast.timer);
54
+ toast.timer = setTimeout(() => { toastEl.className = 'toast'; }, 4000);
55
+ }
56
+
57
+ async function api(path, body) {
58
+ const res = await fetch(path, {
59
+ method: body === undefined ? 'GET' : 'POST',
60
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
61
+ body: body ? JSON.stringify(body) : undefined,
62
+ });
63
+ const parsed = await res.json().catch(() => ({}));
64
+ if (!res.ok) throw new Error(parsed.msg || res.statusText);
65
+ return parsed;
66
+ }
67
+
68
+ function bytes(value) {
69
+ if (!value) return '0 B';
70
+ const units = ['B', 'KB', 'MB', 'GB'];
71
+ let size = value;
72
+ let unit = 0;
73
+ while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
74
+ return (size >= 10 || unit === 0 ? Math.round(size) : size.toFixed(1)) + ' ' + units[unit];
75
+ }
76
+
77
+ function button(label, className, onClick) {
78
+ const el = document.createElement('button');
79
+ el.textContent = label;
80
+ if (className) el.className = className;
81
+ el.addEventListener('click', onClick);
82
+ return el;
83
+ }
84
+
85
+ function cell(content) {
86
+ const td = document.createElement('td');
87
+ if (typeof content === 'string') td.textContent = content;
88
+ else td.append(content);
89
+ return td;
90
+ }
91
+
92
+ async function act(path, message) {
93
+ try {
94
+ const result = await api(path, {});
95
+ toast(typeof message === 'function' ? message(result) : message);
96
+ await load();
97
+ } catch (problem) {
98
+ toast(problem.message, true);
99
+ }
100
+ }
101
+
102
+ async function load() {
103
+ try {
104
+ const body = await api('/_/tenants');
105
+ count.textContent = body.tenants.length + ' of ' + body.limits.maxTenants;
106
+ empty.hidden = body.tenants.length > 0;
107
+
108
+ rows.replaceChildren(...body.tenants.map((person) => {
109
+ const tr = document.createElement('tr');
110
+
111
+ const name = document.createElement('a');
112
+ name.href = '/~' + person.username + '/';
113
+ name.textContent = person.username;
114
+ tr.append(cell(name));
115
+
116
+ const state = document.createElement('span');
117
+ state.className = 'state ' + person.state;
118
+ state.textContent = person.state;
119
+ tr.append(cell(state));
120
+
121
+ const usage = document.createElement('span');
122
+ usage.className = 'usage';
123
+ usage.textContent = person.usage.docs + ' / ' + body.limits.maxDocsPerTenant
124
+ + ' · ' + bytes(person.usage.bytes);
125
+ tr.append(cell(usage));
126
+
127
+ tr.append(cell(new Date(person.createdAt).toLocaleDateString()));
128
+
129
+ const actions = document.createElement('td');
130
+ actions.className = 'actions';
131
+ const base = '/_/tenants/' + encodeURIComponent(person.username);
132
+
133
+ if (person.state === 'waiting') {
134
+ actions.append(button('Approve', 'primary', () =>
135
+ act(base + '/approve', (result) => 'Approved. Their copy is at ' + result.tenant.instance.url)));
136
+ } else if (person.state === 'suspended') {
137
+ actions.append(button('Unsuspend', '', () => act(base + '/resume', 'They can sign in again.')));
138
+ } else {
139
+ actions.append(button('Suspend', '', () => act(base + '/suspend', 'Suspended and signed out.')));
140
+ }
141
+
142
+ actions.append(button('Reset password', '', () => act(base + '/reset', (result) =>
143
+ 'Read this to them, it works once: ' + result.recoveryCode)));
144
+
145
+ actions.append(button('Remove', 'danger', () => {
146
+ if (!confirm('Remove ' + person.username + '? Their files go to the trash, so this is undoable.')) return;
147
+ return act(base + '/delete', 'Removed. Their files are in the trash.');
148
+ }));
149
+
150
+ tr.append(actions);
151
+ return tr;
152
+ }));
153
+ } catch (problem) {
154
+ toast(problem.message, true);
155
+ }
156
+ }
157
+
158
+ load();
159
+
160
+ // The same SSE feed the dashboard uses, so a signup that arrives while this page
161
+ // is open lands in the table without a reload.
162
+ const events = new EventSource('/_/events');
163
+ let pending = null;
164
+ for (const name of ['tenant-created', 'tenant-approved', 'tenant-updated', 'tenant-removed']) {
165
+ events.addEventListener(name, () => {
166
+ clearTimeout(pending);
167
+ pending = setTimeout(load, 120);
168
+ });
169
+ }
170
+ </script>
171
+ </body>
172
+ </html>