@wilm-ai/wilma-cli 2.3.2 → 2.4.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.
- package/README.md +1 -1
- package/dist/access.d.ts +18 -0
- package/dist/access.js +48 -3
- package/dist/agent-data.d.ts +184 -14
- package/dist/agent-data.js +195 -16
- package/dist/cli-args.js +1 -0
- package/dist/cli-help.js +23 -12
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +11 -4
- package/dist/config.js +4 -0
- package/dist/credentials.js +2 -2
- package/dist/index.js +37 -42
- package/dist/login-server.d.ts +26 -6
- package/dist/login-server.js +444 -227
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +2 -14
- package/package.json +2 -2
package/dist/login-server.js
CHANGED
|
@@ -2,20 +2,29 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { AuthenticationError } from "@wilm-ai/wilma-client";
|
|
5
|
-
import { getConfigPath, loadConfig } from "./config.js";
|
|
6
|
-
import { adoptSession } from "./agent-data.js";
|
|
5
|
+
import { getConfigPath, loadConfig, saveConfig } from "./config.js";
|
|
6
|
+
import { adoptSession, roleOf } from "./agent-data.js";
|
|
7
|
+
import { removeLogin, setHiddenChildren } from "./access.js";
|
|
7
8
|
import { saveLogin, TotpSecretInvalidError, TotpSecretRequiredError, verifyLoginSession } from "./credentials.js";
|
|
8
9
|
import { normalizeTenantUrl, searchTenants } from "./tenant-search.js";
|
|
9
10
|
const MAX_ATTEMPTS = 10;
|
|
10
11
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
11
12
|
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
12
|
-
/**
|
|
13
|
+
/** Each saved login, as the page shows it: its Wilma, username, whose it is, and its children (unticked ones hidden). */
|
|
13
14
|
function accountsSummary(profiles) {
|
|
14
|
-
return profiles.map((p) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
return profiles.map((p) => {
|
|
16
|
+
const role = roleOf(p.students ?? []);
|
|
17
|
+
return {
|
|
18
|
+
id: p.id,
|
|
19
|
+
wilma: p.tenantName ?? p.tenantUrl,
|
|
20
|
+
url: p.tenantUrl,
|
|
21
|
+
username: p.username,
|
|
22
|
+
...(role ? { role } : {}),
|
|
23
|
+
children: (p.students ?? []).map((s) => ({ number: s.studentNumber, name: s.name, ...(p.hidden?.includes(s.studentNumber) ? { hidden: true } : {}) })),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
18
26
|
}
|
|
27
|
+
const savedLogins = async () => accountsSummary((await loadConfig()).profiles);
|
|
19
28
|
/** A plain app name from the name an MCP client reports, or undefined if unknown. */
|
|
20
29
|
export function appName(client) {
|
|
21
30
|
const raw = `${client?.title ?? ""} ${client?.name ?? ""}`.toLowerCase();
|
|
@@ -76,7 +85,9 @@ export async function startLoginServer(opts = {}) {
|
|
|
76
85
|
"base-uri 'none'",
|
|
77
86
|
].join("; "),
|
|
78
87
|
});
|
|
79
|
-
|
|
88
|
+
// The logins already saved, so adding another Wilma (or logging in again) starts from what's there.
|
|
89
|
+
const accounts = await savedLogins().catch(() => []);
|
|
90
|
+
res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath(), openedBy: opts.openedBy, accounts }));
|
|
80
91
|
return;
|
|
81
92
|
}
|
|
82
93
|
if (url.pathname === `${base}/api/tenants` && req.method === "GET") {
|
|
@@ -93,6 +104,29 @@ export async function startLoginServer(opts = {}) {
|
|
|
93
104
|
setTimeout(() => close(), 300).unref();
|
|
94
105
|
return;
|
|
95
106
|
}
|
|
107
|
+
// Remove a saved login (its Wilma session ends), or untick children: the page's own buttons.
|
|
108
|
+
if ((url.pathname === `${base}/api/remove` || url.pathname === `${base}/api/children`) && req.method === "POST") {
|
|
109
|
+
if (!fromOwnPage(req)) {
|
|
110
|
+
sendJson(res, 403, { status: "error", message: "Forbidden" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const body = (await readJson(req));
|
|
114
|
+
if (typeof body.id !== "string") {
|
|
115
|
+
sendJson(res, 400, { status: "error", code: "missing_fields" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
resetIdle();
|
|
119
|
+
if (url.pathname.endsWith("/remove")) {
|
|
120
|
+
await removeLogin(body.id);
|
|
121
|
+
saved.splice(0, saved.length, ...saved.filter((p) => p.id !== body.id));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const hidden = Array.isArray(body.hidden) ? body.hidden.filter((x) => typeof x === "string") : [];
|
|
125
|
+
await setHiddenChildren(body.id, hidden);
|
|
126
|
+
}
|
|
127
|
+
sendJson(res, 200, { status: "ok", accounts: await savedLogins() });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
96
130
|
if (url.pathname === `${base}/api/login` && req.method === "POST") {
|
|
97
131
|
if (!fromOwnPage(req)) {
|
|
98
132
|
sendJson(res, 403, { status: "error", message: "Forbidden" });
|
|
@@ -114,26 +148,35 @@ export async function startLoginServer(opts = {}) {
|
|
|
114
148
|
sendJson(res, 400, { status: "error", code: "missing_fields" });
|
|
115
149
|
return;
|
|
116
150
|
}
|
|
151
|
+
// One login per Wilma, as in WilmAI Home: another user for a Wilma already saved goes through its row (Log in again).
|
|
152
|
+
const before = await loadConfig();
|
|
153
|
+
const wilmaUrl = normalizeTenantUrl(tenantUrl);
|
|
154
|
+
const onWilma = (p) => normalizeTenantUrl(p.tenantUrl) === wilmaUrl;
|
|
155
|
+
const replaced = before.profiles.find((p) => p.id === body.replace);
|
|
156
|
+
const again = before.profiles.some((p) => onWilma(p) && p.username.toLowerCase() === username.toLowerCase());
|
|
157
|
+
if (!again && before.profiles.some((p) => onWilma(p) && p !== replaced)) {
|
|
158
|
+
sendJson(res, 200, { status: "error", code: "taken" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
117
161
|
try {
|
|
118
162
|
const { client, students } = await verifyLoginSession({ tenantUrl, username, password, totpSecret });
|
|
119
163
|
// Keep this verified session for the assistant's first questions (no second login or code).
|
|
120
|
-
adoptSession({ baseUrl:
|
|
164
|
+
adoptSession({ baseUrl: wilmaUrl, username, password }, client);
|
|
121
165
|
const config = await loadConfig();
|
|
122
166
|
const stored = await saveLogin(config, {
|
|
123
|
-
tenantUrl:
|
|
167
|
+
tenantUrl: wilmaUrl,
|
|
124
168
|
tenantName: body.tenantName ?? null,
|
|
125
169
|
username,
|
|
126
170
|
password,
|
|
127
171
|
totpSecret,
|
|
128
172
|
students,
|
|
129
173
|
});
|
|
174
|
+
if (replaced && replaced.id !== stored.id)
|
|
175
|
+
await takePlace(stored.id, replaced.id);
|
|
130
176
|
// Every Wilma login saved so far, so the page can show the whole family.
|
|
131
|
-
sendJson(res, 200, {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
accounts: accountsSummary(config.profiles),
|
|
135
|
-
});
|
|
136
|
-
saved.push(stored);
|
|
177
|
+
sendJson(res, 200, { status: "ok", accounts: await savedLogins() });
|
|
178
|
+
// What this page saved, for `wilma login` to report (a replaced login isn't).
|
|
179
|
+
saved.splice(0, saved.length, ...saved.filter((p) => p.id !== stored.id && p.id !== replaced?.id), stored);
|
|
137
180
|
attempts = 0;
|
|
138
181
|
resetIdle();
|
|
139
182
|
settleFirst(stored);
|
|
@@ -188,6 +231,18 @@ export async function startLoginServer(opts = {}) {
|
|
|
188
231
|
}
|
|
189
232
|
return { url: `${origin}${base}/`, result, closed, close };
|
|
190
233
|
}
|
|
234
|
+
/** A new login in the place of the one it replaces (Log in again with another user or Wilma), which is removed. */
|
|
235
|
+
async function takePlace(id, replacedId) {
|
|
236
|
+
const config = await loadConfig();
|
|
237
|
+
const login = config.profiles.find((p) => p.id === id);
|
|
238
|
+
const at = config.profiles.findIndex((p) => p.id === replacedId);
|
|
239
|
+
if (login && at !== -1) {
|
|
240
|
+
config.profiles = config.profiles.filter((p) => p !== login);
|
|
241
|
+
config.profiles.splice(at, 0, login);
|
|
242
|
+
await saveConfig(config);
|
|
243
|
+
}
|
|
244
|
+
await removeLogin(replacedId);
|
|
245
|
+
}
|
|
191
246
|
/** Best-effort: open a URL in the user's default browser. */
|
|
192
247
|
export function openBrowser(url, opts = {}) {
|
|
193
248
|
if (process.env.WILMAI_NO_BROWSER)
|
|
@@ -239,41 +294,60 @@ function jsonForScript(value) {
|
|
|
239
294
|
function escapeHtml(value) {
|
|
240
295
|
return value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
241
296
|
}
|
|
242
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* The login page, laid out like WilmAI Home's Wilma page. The API answers
|
|
299
|
+
* {status: "ok", accounts} after a login, a removal or a tick.
|
|
300
|
+
*/
|
|
243
301
|
export function renderLoginPage(opts) {
|
|
244
302
|
const strings = {
|
|
245
303
|
en: {
|
|
246
304
|
title: "Log in to Wilma — WilmAI",
|
|
247
305
|
heading: "Log in to Wilma",
|
|
248
306
|
intro: "Connect your Wilma account once. Your assistant can then check schedules, homework, exams, messages and news for you.",
|
|
307
|
+
introApp: "{app} asked to connect to your Wilma. Log in once, and {app} can then check schedules, homework, exams, messages and news for you.",
|
|
308
|
+
introMore: "Add another school's Wilma, or log in again if a password has changed. The logins already saved stay as they are.",
|
|
309
|
+
introMoreApp: "{app} opened this page. Add another school's Wilma, or log in again if a password has changed. The logins already saved stay as they are.",
|
|
310
|
+
who: "Who uses WilmAI",
|
|
311
|
+
parent: "A parent",
|
|
312
|
+
student: "A student",
|
|
313
|
+
whoParent: "All your children, from every school's Wilma.",
|
|
314
|
+
whoStudent: "Your own lessons, exams and homework, from your Wilma.",
|
|
315
|
+
oneWilma: "A student has one Wilma. Remove the others first.",
|
|
249
316
|
school: "Your school's Wilma",
|
|
317
|
+
otherSchool: "The other school's Wilma",
|
|
250
318
|
schoolHint: "Type your city or school name",
|
|
251
319
|
schoolHelp: "Start typing, then pick your Wilma from the list. Or tap your city:",
|
|
252
320
|
change: "Change",
|
|
253
321
|
noResults: "No Wilma found. Many schools use their city's Wilma, so try the city or municipality. You can also paste the address, e.g. https://yourcity.inschool.fi",
|
|
322
|
+
taken: "This Wilma is here already. To use another username there, press Log in again on its row.",
|
|
254
323
|
username: "Wilma username",
|
|
255
324
|
password: "Password",
|
|
256
325
|
mfaTitle: "Two-step verification",
|
|
257
326
|
mfaBody: "Your account uses two-step verification. So your assistant can check Wilma without asking you for a code each time, paste the setup key from your authenticator app (a base32 key or an otpauth:// link).",
|
|
258
327
|
mfaLabel: "Authenticator setup key",
|
|
259
328
|
mfaHelp: "Where to find it: some authenticator apps (and password managers like 1Password or Bitwarden) can show the key for an existing entry. If yours can't, set up two-step verification again in Wilma and copy the key while the QR code is on screen — setup screens usually offer it as text. Add it to your authenticator app as usual and paste the same key here. WilmAI then makes the 6-digit codes itself.",
|
|
329
|
+
saved: "Saved",
|
|
330
|
+
ownLogin: "student's own login",
|
|
331
|
+
again: "Log in again",
|
|
332
|
+
cancel: "Cancel",
|
|
333
|
+
remove: "Remove",
|
|
334
|
+
removeAsk: "Remove {school}? Its Wilma session ends, and your assistant no longer reads it.",
|
|
335
|
+
kidsLabel: "Children to read from {school}",
|
|
336
|
+
twice: "Wilma lists {name} twice: a new school gives a new profile. Untick the one you don't need.",
|
|
337
|
+
noChildren: "No children found on this login.",
|
|
338
|
+
addAnother: "Add another school's Wilma",
|
|
339
|
+
addAnotherHint: "For a child at a school with its own Wilma: another town, an upper secondary school, a private school.",
|
|
260
340
|
submit: "Log in",
|
|
261
341
|
working: "Checking with Wilma…",
|
|
262
|
-
|
|
263
|
-
doneIntro: "Found on this login:",
|
|
264
|
-
addAnother: "Add another Wilma",
|
|
265
|
-
addAnotherHint: "If one of your children goes to a school with a different Wilma, add that login too.",
|
|
266
|
-
finish: "Done",
|
|
342
|
+
done: "Done",
|
|
267
343
|
finishedTitle: "All set",
|
|
268
344
|
finishedBody: "You can close this tab and go back to your assistant.",
|
|
269
|
-
noChildren: "No children found on this login.",
|
|
270
345
|
badCredentials: "Wilma didn't accept that username and password.",
|
|
271
346
|
badTotp: "Wilma didn't accept the two-step verification. Paste the authenticator setup key (a long base32 key or an otpauth:// link), not a 6-digit code.",
|
|
272
347
|
tooMany: "Too many attempts. Close this tab and start again from your assistant.",
|
|
273
|
-
missing: "
|
|
348
|
+
missing: "Choose the school's Wilma, and type the username and password.",
|
|
274
349
|
expired: "This login page is no longer active. Start the login again from your assistant, or run wilma login.",
|
|
275
350
|
privacy: "Your login is saved only on this computer, in {path}, readable only by your user account. To remove it, run wilma config clear or delete that file.",
|
|
276
|
-
introApp: "{app} asked to connect to your Wilma. Log in once, and {app} can then check schedules, homework, exams, messages and news for you.",
|
|
277
351
|
trustLocalTitle: "On your own computer",
|
|
278
352
|
trustLocal: "This page isn't a website. It runs on this computer (that's what 127.0.0.1 in the address bar means), and WilmAI has no server.",
|
|
279
353
|
trustWilmaTitle: "Only to your school's Wilma",
|
|
@@ -290,34 +364,50 @@ export function renderLoginPage(opts) {
|
|
|
290
364
|
title: "Kirjaudu Wilmaan — WilmAI",
|
|
291
365
|
heading: "Kirjaudu Wilmaan",
|
|
292
366
|
intro: "Yhdistä Wilma-tunnuksesi kerran. Sen jälkeen avustajasi voi tarkistaa lukujärjestykset, läksyt, kokeet, viestit ja tiedotteet puolestasi.",
|
|
367
|
+
introApp: "{app} pyysi yhteyttä Wilmaasi. Kirjaudu kerran, niin {app} voi sen jälkeen tarkistaa lukujärjestykset, läksyt, kokeet, viestit ja tiedotteet puolestasi.",
|
|
368
|
+
introMore: "Lisää toisen koulun Wilma tai kirjaudu uudelleen, jos salasana on vaihtunut. Jo tallennetut tunnukset säilyvät ennallaan.",
|
|
369
|
+
introMoreApp: "{app} avasi tämän sivun. Lisää toisen koulun Wilma tai kirjaudu uudelleen, jos salasana on vaihtunut. Jo tallennetut tunnukset säilyvät ennallaan.",
|
|
370
|
+
who: "Kuka käyttää WilmAI:ta",
|
|
371
|
+
parent: "Huoltaja",
|
|
372
|
+
student: "Oppilas",
|
|
373
|
+
whoParent: "Kaikki lapsesi, jokaisen koulun Wilmasta.",
|
|
374
|
+
whoStudent: "Omat tuntisi, kokeesi ja läksysi Wilmastasi.",
|
|
375
|
+
oneWilma: "Oppilaalla on yksi Wilma. Poista ensin muut.",
|
|
293
376
|
school: "Koulusi Wilma",
|
|
377
|
+
otherSchool: "Toisen koulun Wilma",
|
|
294
378
|
schoolHint: "Kirjoita kaupunki tai koulun nimi",
|
|
295
379
|
schoolHelp: "Ala kirjoittaa ja valitse Wilmasi listalta. Tai napauta kaupunkiasi:",
|
|
296
380
|
change: "Vaihda",
|
|
297
381
|
noResults: "Wilmaa ei löytynyt. Monen koulun Wilma löytyy kaupungin tai kunnan nimellä, joten kokeile sitä. Voit myös liittää osoitteen, esim. https://kaupunki.inschool.fi",
|
|
382
|
+
taken: "Tämä Wilma on jo täällä. Jos haluat käyttää siinä toista käyttäjätunnusta, paina sen kohdalla Kirjaudu uudelleen.",
|
|
298
383
|
username: "Wilma-käyttäjätunnus",
|
|
299
384
|
password: "Salasana",
|
|
300
385
|
mfaTitle: "Kaksivaiheinen tunnistautuminen",
|
|
301
386
|
mfaBody: "Tunnuksessasi on kaksivaiheinen tunnistautuminen. Jotta avustajasi voi tarkistaa Wilman kysymättä sinulta joka kerta koodia, liitä todennussovelluksesi asetusavain (base32-avain tai otpauth://-linkki).",
|
|
302
387
|
mfaLabel: "Todennussovelluksen asetusavain",
|
|
303
388
|
mfaHelp: "Mistä sen löytää: jotkin todennussovellukset (ja salasanojen hallintaohjelmat, kuten 1Password tai Bitwarden) näyttävät avaimen olemassa olevalle tunnukselle. Jos sovelluksesi ei näytä sitä, ota kaksivaiheinen tunnistautuminen uudelleen käyttöön Wilmassa ja kopioi avain, kun QR-koodi on näkyvissä — käyttöönottonäkymä tarjoaa sen yleensä myös tekstinä. Lisää se todennussovellukseesi tavalliseen tapaan ja liitä sama avain tähän. WilmAI tekee sen jälkeen 6-numeroiset koodit itse.",
|
|
389
|
+
saved: "Tallennettu",
|
|
390
|
+
ownLogin: "oppilaan omat tunnukset",
|
|
391
|
+
again: "Kirjaudu uudelleen",
|
|
392
|
+
cancel: "Peru",
|
|
393
|
+
remove: "Poista",
|
|
394
|
+
removeAsk: "Poistetaanko {school}? Sen Wilma-istunto päättyy, eikä avustajasi enää lue sitä.",
|
|
395
|
+
kidsLabel: "Luettavat lapset: {school}",
|
|
396
|
+
twice: "Wilma näyttää lapsen {name} kahdesti: uusi koulu antaa uuden profiilin. Poista valinta siltä, jota et tarvitse.",
|
|
397
|
+
noChildren: "Näillä tunnuksilla ei löytynyt lapsia.",
|
|
398
|
+
addAnother: "Lisää toisen koulun Wilma",
|
|
399
|
+
addAnotherHint: "Lapselle, jonka koululla on oma Wilma: toinen paikkakunta, lukio tai yksityinen koulu.",
|
|
304
400
|
submit: "Kirjaudu",
|
|
305
401
|
working: "Tarkistetaan Wilmasta…",
|
|
306
|
-
|
|
307
|
-
doneIntro: "Näillä tunnuksilla löytyi:",
|
|
308
|
-
addAnother: "Lisää toinen Wilma",
|
|
309
|
-
addAnotherHint: "Jos joku lapsistasi käy koulua, jolla on eri Wilma, lisää myös sen tunnukset.",
|
|
310
|
-
finish: "Valmis",
|
|
402
|
+
done: "Valmis",
|
|
311
403
|
finishedTitle: "Kaikki valmista",
|
|
312
404
|
finishedBody: "Voit sulkea tämän välilehden ja palata avustajaasi.",
|
|
313
|
-
noChildren: "Näillä tunnuksilla ei löytynyt lapsia.",
|
|
314
405
|
badCredentials: "Wilma ei hyväksynyt käyttäjätunnusta ja salasanaa.",
|
|
315
406
|
badTotp: "Wilma ei hyväksynyt kaksivaiheista tunnistautumista. Liitä todennussovelluksen asetusavain (pitkä base32-avain tai otpauth://-linkki), ei 6-numeroista koodia.",
|
|
316
407
|
tooMany: "Liian monta yritystä. Sulje välilehti ja aloita uudelleen avustajasi kautta.",
|
|
317
|
-
missing: "Valitse
|
|
408
|
+
missing: "Valitse koulun Wilma ja kirjoita käyttäjätunnus ja salasana.",
|
|
318
409
|
expired: "Tämä kirjautumissivu ei ole enää käytössä. Aloita kirjautuminen uudelleen avustajasi kautta tai komennolla wilma login.",
|
|
319
410
|
privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}, jota vain sinun käyttäjätilisi voi lukea. Voit poistaa ne komennolla wilma config clear tai poistamalla tiedoston.",
|
|
320
|
-
introApp: "{app} pyysi yhteyttä Wilmaasi. Kirjaudu kerran, niin {app} voi sen jälkeen tarkistaa lukujärjestykset, läksyt, kokeet, viestit ja tiedotteet puolestasi.",
|
|
321
411
|
trustLocalTitle: "Omalla koneellasi",
|
|
322
412
|
trustLocal: "Tämä sivu ei ole verkkosivusto. Se toimii tällä koneella (sitä osoiterivin 127.0.0.1 tarkoittaa), eikä WilmAI:lla ole palvelinta.",
|
|
323
413
|
trustWilmaTitle: "Vain koulusi Wilmaan",
|
|
@@ -355,32 +445,51 @@ export function renderLoginPage(opts) {
|
|
|
355
445
|
p { margin:0 0 16px; }
|
|
356
446
|
.muted { color:var(--muted); font-size:14px; }
|
|
357
447
|
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin:20px 0; }
|
|
358
|
-
label { display:block; font-weight:600; margin:14px 0 6px; }
|
|
359
|
-
|
|
448
|
+
label, .label { display:block; font-weight:600; margin:14px 0 6px; }
|
|
449
|
+
.school-top { display:flex; justify-content:space-between; align-items:center; gap:12px; margin:0 0 10px; }
|
|
450
|
+
.school-top label, .school-top .label { margin:0; }
|
|
360
451
|
input { width:100%; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--paper); color:var(--ink); }
|
|
361
452
|
input:focus { outline:2px solid var(--teal); outline-offset:1px; }
|
|
453
|
+
input[type=checkbox] { width:auto; margin:0; accent-color:var(--teal); }
|
|
362
454
|
ul.results { list-style:none; margin:6px 0 0; padding:0; border:1.5px solid var(--teal); border-radius:10px; max-height:340px; overflow:auto; background:var(--card); box-shadow:0 8px 20px rgba(0,0,0,.08); }
|
|
363
455
|
ul.results:empty { display:none; }
|
|
364
456
|
ul.results button { display:block; width:100%; text-align:left; background:none; border:0; border-bottom:1px solid var(--line); padding:11px 14px; font:inherit; font-weight:600; line-height:1.35; color:inherit; cursor:pointer; }
|
|
365
457
|
ul.results li:last-child button { border-bottom:0; }
|
|
366
458
|
ul.results button:hover, ul.results button:focus, ul.results button.hl { background:var(--paper); }
|
|
367
459
|
ul.results button.hl { box-shadow: inset 3px 0 0 var(--teal); }
|
|
460
|
+
ul.results small { display:block; margin-top:2px; font-weight:400; font-size:13px; color:var(--muted); }
|
|
368
461
|
.help { font-size:14px; color:var(--muted); margin:8px 0 6px; }
|
|
369
462
|
.chips { display:flex; flex-wrap:wrap; gap:6px; }
|
|
370
463
|
.chips button { font:inherit; font-size:14px; padding:4px 12px; border-radius:999px; border:1px solid var(--line); background:var(--paper); color:var(--ink); cursor:pointer; }
|
|
371
464
|
.chips button:hover, .chips button:focus { border-color:var(--teal); }
|
|
372
|
-
ul.results small { display:block; margin-top:2px; font-weight:400; font-size:13px; color:var(--muted); }
|
|
373
465
|
.chosen { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px 12px; border:1px solid var(--teal); border-radius:8px; }
|
|
374
466
|
.chosen small { display:block; color:var(--muted); word-break:break-all; }
|
|
375
467
|
.link { background:none; border:0; color:var(--teal); font:inherit; cursor:pointer; padding:0; text-decoration:underline; }
|
|
376
|
-
.
|
|
468
|
+
.quiet { background:none; border:1px solid var(--line); border-radius:8px; color:var(--ink); font:inherit; font-size:14px; padding:4px 10px; cursor:pointer; white-space:nowrap; }
|
|
469
|
+
.quiet:hover, .quiet:focus { border-color:var(--teal); }
|
|
470
|
+
.danger { background:var(--err); border:0; border-radius:8px; color:#fff; font:inherit; font-size:14px; font-weight:700; padding:5px 12px; cursor:pointer; }
|
|
471
|
+
.primary { width:100%; margin-top:18px; padding:12px; font:inherit; font-weight:700; border:0; border-radius:8px; background:var(--teal); color:#fff; cursor:pointer; }
|
|
377
472
|
.primary:disabled { opacity:.6; cursor:wait; }
|
|
378
|
-
.secondary {
|
|
379
|
-
.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
.
|
|
383
|
-
.
|
|
473
|
+
.secondary { padding:8px 14px; font:inherit; font-size:15px; font-weight:700; border:1px solid var(--teal); border-radius:8px; background:transparent; color:var(--teal); cursor:pointer; }
|
|
474
|
+
.who { display:flex; gap:0; border:1px solid var(--line); border-radius:999px; padding:3px; width:fit-content; }
|
|
475
|
+
.who button { font:inherit; font-size:15px; padding:5px 16px; border:0; border-radius:999px; background:transparent; color:var(--muted); cursor:pointer; }
|
|
476
|
+
.who button[aria-pressed="true"] { background:var(--teal); color:#fff; font-weight:700; }
|
|
477
|
+
.who-field { margin-bottom:16px; }
|
|
478
|
+
.who-field .label { margin-top:0; }
|
|
479
|
+
.wilma-row { position:relative; border:1px solid var(--line); border-radius:10px; padding:14px; margin:0 0 12px; }
|
|
480
|
+
.wilma-row.new { border-style:dashed; }
|
|
481
|
+
.wilma-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
|
|
482
|
+
.wilma-head small { display:block; color:var(--muted); font-size:14px; }
|
|
483
|
+
.kids { margin-top:10px; display:flex; flex-direction:column; gap:6px; }
|
|
484
|
+
.kid { display:flex; align-items:center; gap:10px; font-weight:400; margin:0; cursor:pointer; }
|
|
485
|
+
.kids .help { margin:2px 0 0; }
|
|
486
|
+
.remove { margin-top:10px; font-size:14px; }
|
|
487
|
+
.confirm { display:flex; flex-wrap:wrap; align-items:center; gap:8px; color:var(--err); }
|
|
488
|
+
.confirm span { flex:1 1 100%; }
|
|
489
|
+
.mfa { margin-top:6px; }
|
|
490
|
+
.add { margin-top:22px; padding-top:16px; border-top:1px solid var(--line); }
|
|
491
|
+
.add .help { margin-top:8px; }
|
|
492
|
+
.error { color:var(--err); margin:10px 0 0; font-size:15px; }
|
|
384
493
|
.done h2 { margin:0 0 8px; }
|
|
385
494
|
.top { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:28px; }
|
|
386
495
|
.brand { font-weight:800; font-size:18px; letter-spacing:-0.01em; }
|
|
@@ -417,40 +526,22 @@ export function renderLoginPage(opts) {
|
|
|
417
526
|
</ul>
|
|
418
527
|
</div>
|
|
419
528
|
<div class="forms">
|
|
420
|
-
<section class="card" id="
|
|
421
|
-
<div
|
|
422
|
-
<
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
<p class="
|
|
529
|
+
<section class="card" id="wilmas">
|
|
530
|
+
<div class="who-field">
|
|
531
|
+
<span class="label" id="who-label"></span>
|
|
532
|
+
<div class="who" role="group" aria-labelledby="who-label">
|
|
533
|
+
<button type="button" id="who-guardian"></button>
|
|
534
|
+
<button type="button" id="who-student"></button>
|
|
535
|
+
</div>
|
|
536
|
+
<p class="help" id="who-help"></p>
|
|
428
537
|
</div>
|
|
429
|
-
<div id="
|
|
430
|
-
|
|
431
|
-
|
|
538
|
+
<div id="rows"></div>
|
|
539
|
+
<p class="error" id="note" role="alert" hidden></p>
|
|
540
|
+
<button class="primary" id="go" type="button"></button>
|
|
541
|
+
<div class="add" id="add-box">
|
|
542
|
+
<button class="secondary" id="add" type="button"></button>
|
|
543
|
+
<p class="help" id="add-help"></p>
|
|
432
544
|
</div>
|
|
433
|
-
<label for="username" id="username-label"></label>
|
|
434
|
-
<input id="username" autocomplete="username" autocapitalize="none" spellcheck="false">
|
|
435
|
-
<label for="password" id="password-label"></label>
|
|
436
|
-
<input id="password" type="password" autocomplete="current-password">
|
|
437
|
-
<p class="sent-to" id="sent-to" hidden></p>
|
|
438
|
-
<div id="mfa" hidden>
|
|
439
|
-
<label id="mfa-title"></label>
|
|
440
|
-
<p class="muted" id="mfa-body"></p>
|
|
441
|
-
<label for="totp" id="mfa-label"></label>
|
|
442
|
-
<input id="totp" autocomplete="off" spellcheck="false">
|
|
443
|
-
<p class="muted" id="mfa-help"></p>
|
|
444
|
-
</div>
|
|
445
|
-
<p class="error" id="error" role="alert" hidden></p>
|
|
446
|
-
<button class="primary" id="submit" type="button"></button>
|
|
447
|
-
</section>
|
|
448
|
-
<section class="card done" id="done" hidden>
|
|
449
|
-
<h2 id="done-title"></h2>
|
|
450
|
-
<div id="accounts"></div>
|
|
451
|
-
<button class="primary" id="finish" type="button"></button>
|
|
452
|
-
<button class="secondary" id="add-another" type="button"></button>
|
|
453
|
-
<p class="muted add-hint" id="add-hint"></p>
|
|
454
545
|
</section>
|
|
455
546
|
<section class="card done" id="finished" hidden>
|
|
456
547
|
<h2 id="finished-title"></h2>
|
|
@@ -470,15 +561,39 @@ export function renderLoginPage(opts) {
|
|
|
470
561
|
const API = ${jsonForScript(opts.apiBase)};
|
|
471
562
|
const CONFIG_PATH = ${jsonForScript(opts.configPath ?? "")};
|
|
472
563
|
const OPENED_BY = ${jsonForScript(opts.openedBy ?? null)};
|
|
564
|
+
// Every saved login, as the page opened and after each change here.
|
|
565
|
+
let accounts = ${jsonForScript(opts.accounts ?? [])};
|
|
566
|
+
// Opened with logins saved already: the page is for another Wilma, or logging in again.
|
|
567
|
+
const more = accounts.length > 0;
|
|
473
568
|
let lang = (navigator.language || "en").toLowerCase().startsWith("fi") ? "fi" : "en";
|
|
474
569
|
let t = STRINGS[lang];
|
|
475
|
-
|
|
476
|
-
let
|
|
477
|
-
let
|
|
478
|
-
|
|
570
|
+
// Rows being filled in: new Wilmas, and saved ones logging in again (replace: the saved login's id).
|
|
571
|
+
let drafts = [];
|
|
572
|
+
let lastId = 0;
|
|
573
|
+
const newDraft = (replace = null, tenant = null, username = "") => ({ id: ++lastId, replace, tenant, username, password: "", totp: "", mfa: false, error: "" });
|
|
574
|
+
if (!accounts.length) drafts.push(newDraft());
|
|
575
|
+
let who = accounts.length && accounts.every((a) => a.role === "student") ? "student" : "guardian";
|
|
576
|
+
let note = "";
|
|
577
|
+
let busy = false;
|
|
578
|
+
|
|
479
579
|
const $ = (id) => document.getElementById(id);
|
|
480
580
|
const text = (id, value) => { $(id).textContent = value; };
|
|
481
581
|
const hostOf = (url) => { try { return new URL(url).host; } catch { return url; } };
|
|
582
|
+
const focus = (id) => setTimeout(() => $(id) && $(id).focus(), 0);
|
|
583
|
+
const el = (tag, props = {}, ...children) => {
|
|
584
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
585
|
+
node.append(...children.filter((child) => child !== null && child !== undefined));
|
|
586
|
+
return node;
|
|
587
|
+
};
|
|
588
|
+
const post = async (path, body) => {
|
|
589
|
+
const res = await fetch(API + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
590
|
+
return res.json();
|
|
591
|
+
};
|
|
592
|
+
const sameWilma = (a, b) => a.replace(/\\/$/, "").toLowerCase() === b.replace(/\\/$/, "").toLowerCase();
|
|
593
|
+
// One login per Wilma, as in WilmAI Home: a Wilma another row has already is taken.
|
|
594
|
+
const taken = (url, draft) =>
|
|
595
|
+
accounts.some((a) => a.id !== draft.replace && sameWilma(a.url, url)) || drafts.some((d) => d !== draft && d.tenant && sameWilma(d.tenant.url, url));
|
|
596
|
+
const filled = (d) => Boolean(d.tenant || d.username.trim() || d.password);
|
|
482
597
|
|
|
483
598
|
// Every text on the page, in the chosen language (the switch at the top re-runs this).
|
|
484
599
|
function applyText() {
|
|
@@ -487,187 +602,289 @@ export function renderLoginPage(opts) {
|
|
|
487
602
|
document.title = t.title;
|
|
488
603
|
$("heading").replaceChildren(t.heading.split(" ").slice(0, -1).join(" ") + " ");
|
|
489
604
|
const mark = document.createElement("mark"); mark.textContent = t.heading.split(" ").slice(-1)[0]; $("heading").append(mark);
|
|
490
|
-
|
|
605
|
+
const intro = more ? (OPENED_BY ? t.introMoreApp : t.introMore) : OPENED_BY ? t.introApp : t.intro;
|
|
606
|
+
text("intro", OPENED_BY ? intro.split("{app}").join(OPENED_BY.app) : intro);
|
|
491
607
|
text("trust-local-title", t.trustLocalTitle); text("trust-local", t.trustLocal);
|
|
492
608
|
text("trust-wilma-title", t.trustWilmaTitle);
|
|
493
609
|
text("trust-read-title", t.trustReadTitle); text("trust-read", t.trustRead);
|
|
494
|
-
text("
|
|
495
|
-
|
|
496
|
-
text("school-help", t.schoolHelp);
|
|
497
|
-
text("username-label", t.username); text("password-label", t.password);
|
|
498
|
-
text("mfa-title", t.mfaTitle); text("mfa-body", t.mfaBody); text("mfa-label", t.mfaLabel); text("mfa-help", t.mfaHelp);
|
|
499
|
-
if (!$("submit").disabled) text("submit", t.submit);
|
|
500
|
-
text("done-title", t.doneTitle);
|
|
501
|
-
text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
|
|
610
|
+
text("who-label", t.who); text("who-guardian", t.parent); text("who-student", t.student);
|
|
611
|
+
text("add", t.addAnother); text("add-help", t.addAnotherHint);
|
|
502
612
|
text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
|
|
503
613
|
text("privacy", t.privacy.replace("{path}", CONFIG_PATH));
|
|
504
614
|
text("learn-more", t.learnMore); text("source-code", t.sourceCode);
|
|
505
615
|
text("disclaimer", t.disclaimer);
|
|
506
616
|
for (const code of ["fi", "en"]) $("lang-" + code).setAttribute("aria-pressed", String(code === lang));
|
|
617
|
+
render();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The rows: each saved login (or its login form while logging in again), then the new ones.
|
|
621
|
+
function render() {
|
|
622
|
+
const box = $("rows");
|
|
623
|
+
box.replaceChildren();
|
|
624
|
+
for (const account of accounts) {
|
|
625
|
+
const draft = drafts.find((d) => d.replace === account.id);
|
|
626
|
+
box.append(draft ? draftRow(draft, t.school) : savedRow(account));
|
|
627
|
+
}
|
|
628
|
+
drafts.filter((d) => !d.replace).forEach((d, i) => box.append(draftRow(d, !accounts.length && i === 0 ? t.school : t.otherSchool)));
|
|
629
|
+
for (const v of ["guardian", "student"]) $("who-" + v).setAttribute("aria-pressed", String(who === v));
|
|
630
|
+
text("who-help", who === "guardian" ? t.whoParent : t.whoStudent);
|
|
631
|
+
$("add-box").hidden = who !== "guardian";
|
|
632
|
+
$("note").textContent = note; $("note").hidden = !note;
|
|
507
633
|
showHost();
|
|
508
|
-
|
|
634
|
+
updateGo();
|
|
509
635
|
}
|
|
510
636
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
$("sent-to").hidden = !host;
|
|
516
|
-
if (host) text("sent-to", t.sentTo.replace("{host}", host));
|
|
637
|
+
function updateGo() {
|
|
638
|
+
const go = $("go");
|
|
639
|
+
go.disabled = busy;
|
|
640
|
+
go.textContent = busy ? t.working : drafts.some(filled) || !accounts.length ? t.submit : t.done;
|
|
517
641
|
}
|
|
518
642
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
$("chips").append(b);
|
|
643
|
+
// Where the password goes, once a Wilma is picked.
|
|
644
|
+
function showHost() {
|
|
645
|
+
const hosts = [...new Set(drafts.filter((d) => d.tenant).map((d) => hostOf(d.tenant.url)))];
|
|
646
|
+
text("trust-wilma", hosts.length === 1 ? t.trustWilmaHost.replace("{host}", hosts[0]) : t.trustWilma);
|
|
524
647
|
}
|
|
525
648
|
|
|
526
|
-
function
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
649
|
+
function savedRow(account) {
|
|
650
|
+
const row = el("div", { className: "wilma-row" });
|
|
651
|
+
const who_ = account.username + " · " + (account.role === "student" ? t.ownLogin : t.saved);
|
|
652
|
+
row.append(el("div", { className: "wilma-head" },
|
|
653
|
+
el("div", {}, el("b", { textContent: account.wilma }), el("small", { textContent: who_ })),
|
|
654
|
+
el("button", { type: "button", className: "quiet", textContent: t.again, onclick: () => {
|
|
655
|
+
const draft = newDraft(account.id, { url: account.url, name: account.wilma }, account.username);
|
|
656
|
+
drafts.push(draft); note = ""; render(); focus("pass-" + draft.id);
|
|
657
|
+
} })));
|
|
658
|
+
const kids = account.children || [];
|
|
659
|
+
if (!kids.length) row.append(el("p", { className: "help", textContent: t.noChildren }));
|
|
660
|
+
else if (who === "guardian") {
|
|
661
|
+
const group = el("div", { className: "kids" });
|
|
662
|
+
group.setAttribute("role", "group");
|
|
663
|
+
group.setAttribute("aria-label", t.kidsLabel.replace("{school}", account.wilma));
|
|
664
|
+
for (const kid of kids) {
|
|
665
|
+
const tick = el("input", { type: "checkbox", checked: !kid.hidden });
|
|
666
|
+
tick.addEventListener("change", () => setShown(account, kid.number, tick.checked));
|
|
667
|
+
group.append(el("label", { className: "kid" }, tick, el("span", { textContent: kid.name })));
|
|
533
668
|
}
|
|
534
|
-
const
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
537
|
-
|
|
538
|
-
box.append(ul);
|
|
669
|
+
const names = kids.map((k) => k.name);
|
|
670
|
+
const twice = names.find((name, i) => names.indexOf(name) !== i);
|
|
671
|
+
if (twice) group.append(el("p", { className: "help", textContent: t.twice.replace("{name}", twice.split(" ")[0]) }));
|
|
672
|
+
row.append(group);
|
|
539
673
|
}
|
|
674
|
+
if (accounts.length > 1) row.append(removeLink(account));
|
|
675
|
+
return row;
|
|
540
676
|
}
|
|
541
677
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
678
|
+
// Remove asks first: the login goes, and its Wilma session ends.
|
|
679
|
+
function removeLink(account) {
|
|
680
|
+
const box = el("div", { className: "remove" });
|
|
681
|
+
const link = el("button", { type: "button", className: "link", textContent: t.remove, onclick: () => {
|
|
682
|
+
box.replaceChildren(el("div", { className: "confirm" },
|
|
683
|
+
el("span", { textContent: t.removeAsk.replace("{school}", account.wilma) }),
|
|
684
|
+
el("button", { type: "button", className: "danger", textContent: t.remove, onclick: () => remove(account) }),
|
|
685
|
+
el("button", { type: "button", className: "quiet", textContent: t.cancel, onclick: () => box.replaceChildren(link) })));
|
|
686
|
+
} });
|
|
687
|
+
box.append(link);
|
|
688
|
+
return box;
|
|
689
|
+
}
|
|
550
690
|
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
691
|
+
function draftRow(d, label) {
|
|
692
|
+
const row = el("div", { className: "wilma-row new" });
|
|
693
|
+
const fresh = drafts.filter((x) => !x.replace);
|
|
694
|
+
// Cancel sits on the label's line, clear of the field below.
|
|
695
|
+
const cancel = d.replace || accounts.length || fresh.length > 1
|
|
696
|
+
? el("button", { type: "button", className: "quiet", textContent: t.cancel, onclick: () => {
|
|
697
|
+
drafts = drafts.filter((x) => x !== d);
|
|
698
|
+
if (!accounts.length && !drafts.length) drafts.push(newDraft());
|
|
699
|
+
note = ""; render();
|
|
700
|
+
} })
|
|
701
|
+
: null;
|
|
702
|
+
row.append(schoolField(d, label, cancel));
|
|
703
|
+
const user = el("input", { id: "user-" + d.id, value: d.username, autocomplete: "username", autocapitalize: "none", spellcheck: false });
|
|
704
|
+
user.addEventListener("input", () => { d.username = user.value; updateGo(); });
|
|
705
|
+
const pass = el("input", { id: "pass-" + d.id, type: "password", value: d.password, autocomplete: "current-password" });
|
|
706
|
+
pass.addEventListener("input", () => { d.password = pass.value; updateGo(); });
|
|
707
|
+
pass.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
|
708
|
+
row.append(el("label", { htmlFor: user.id, textContent: t.username }), user, el("label", { htmlFor: pass.id, textContent: t.password }), pass);
|
|
709
|
+
if (d.tenant) row.append(el("p", { className: "sent-to", textContent: t.sentTo.replace("{host}", hostOf(d.tenant.url)) }));
|
|
710
|
+
if (d.mfa) {
|
|
711
|
+
const key = el("input", { id: "totp-" + d.id, value: d.totp, autocomplete: "off", spellcheck: false });
|
|
712
|
+
key.addEventListener("input", () => { d.totp = key.value; });
|
|
713
|
+
key.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
|
714
|
+
row.append(el("div", { className: "mfa" },
|
|
715
|
+
el("span", { className: "label", textContent: t.mfaTitle }),
|
|
716
|
+
el("p", { className: "muted", textContent: t.mfaBody }),
|
|
717
|
+
el("label", { htmlFor: key.id, textContent: t.mfaLabel }), key,
|
|
718
|
+
el("p", { className: "muted", textContent: t.mfaHelp })));
|
|
563
719
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const showError = (msg) => { const el = $("error"); el.textContent = msg; el.hidden = !msg; };
|
|
567
|
-
|
|
568
|
-
function choose(t2) {
|
|
569
|
-
tenant = t2;
|
|
570
|
-
text("chosen-name", t2.name || t2.url); text("chosen-url", t2.url); showHost();
|
|
571
|
-
$("school-pick").hidden = true; $("school-chosen").hidden = false;
|
|
572
|
-
$("username").focus();
|
|
720
|
+
if (d.error) row.append(el("p", { className: "error", textContent: d.error }));
|
|
721
|
+
return row;
|
|
573
722
|
}
|
|
574
|
-
$("change").addEventListener("click", () => {
|
|
575
|
-
tenant = null; showHost(); $("school-chosen").hidden = true; $("school-pick").hidden = false; $("school").focus();
|
|
576
|
-
});
|
|
577
723
|
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
724
|
+
function schoolField(d, label, cancel) {
|
|
725
|
+
const box = el("div", { className: "school" });
|
|
726
|
+
const id = "school-" + d.id;
|
|
727
|
+
if (d.tenant) {
|
|
728
|
+
box.append(el("div", { className: "school-top" }, el("span", { className: "label", textContent: label }), cancel), el("div", { className: "chosen" },
|
|
729
|
+
el("div", {}, el("span", { textContent: d.tenant.name || d.tenant.url }), el("small", { textContent: d.tenant.url })),
|
|
730
|
+
d.mfa ? null : el("button", { type: "button", className: "link", textContent: t.change, onclick: () => { d.tenant = null; d.error = ""; render(); focus(id); } })));
|
|
731
|
+
return box;
|
|
584
732
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
733
|
+
const input = el("input", { id, autocomplete: "off", spellcheck: false, placeholder: t.schoolHint });
|
|
734
|
+
input.setAttribute("role", "combobox");
|
|
735
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
736
|
+
input.setAttribute("aria-expanded", "false");
|
|
737
|
+
const results = el("ul", { className: "results", id: "results-" + d.id });
|
|
738
|
+
results.setAttribute("role", "listbox");
|
|
739
|
+
input.setAttribute("aria-controls", results.id);
|
|
740
|
+
const help = el("p", { className: "help", textContent: t.schoolHelp });
|
|
741
|
+
const chips = el("div", { className: "chips" });
|
|
742
|
+
const none = el("p", { className: "muted", textContent: t.noResults, hidden: true });
|
|
743
|
+
let seq = 0;
|
|
744
|
+
let timer = null;
|
|
745
|
+
let highlighted = 0;
|
|
746
|
+
const pick = (tenant) => {
|
|
747
|
+
d.error = taken(tenant.url, d) ? t.taken : "";
|
|
748
|
+
if (!d.error) d.tenant = tenant;
|
|
749
|
+
render();
|
|
750
|
+
focus(d.tenant ? "user-" + d.id : id);
|
|
751
|
+
};
|
|
752
|
+
const highlight = (index) => {
|
|
753
|
+
const buttons = [...results.querySelectorAll("button")];
|
|
754
|
+
if (!buttons.length) return;
|
|
755
|
+
highlighted = (index + buttons.length) % buttons.length;
|
|
756
|
+
buttons.forEach((b, i) => { b.classList.toggle("hl", i === highlighted); b.setAttribute("aria-selected", String(i === highlighted)); });
|
|
757
|
+
buttons[highlighted].scrollIntoView({ block: "nearest" });
|
|
758
|
+
};
|
|
759
|
+
const show = (list) => {
|
|
760
|
+
results.replaceChildren();
|
|
761
|
+
input.setAttribute("aria-expanded", String(list.length > 0));
|
|
762
|
+
// While results are showing, the hint and city chips step aside so the list is easy to read.
|
|
763
|
+
help.hidden = list.length > 0;
|
|
764
|
+
chips.hidden = list.length > 0;
|
|
765
|
+
for (const item of list) {
|
|
766
|
+
const b = el("button", { type: "button", textContent: item.name || item.url }, el("small", { textContent: item.url.replace(/^https?:\\/\\//, "") }));
|
|
767
|
+
b.setAttribute("role", "option");
|
|
768
|
+
b.addEventListener("click", () => pick(item));
|
|
769
|
+
results.append(el("li", {}, b));
|
|
770
|
+
}
|
|
771
|
+
highlight(0);
|
|
772
|
+
};
|
|
773
|
+
const search = async (q) => {
|
|
774
|
+
const mine = ++seq;
|
|
775
|
+
const trimmed = q.trim();
|
|
776
|
+
if (/^https?:\\/\\//i.test(trimmed)) return show([{ url: trimmed.replace(/\\/$/, ""), name: trimmed.replace(/\\/$/, "") }]);
|
|
777
|
+
if (!trimmed) { show([]); none.hidden = true; return; }
|
|
778
|
+
const res = await fetch(API + "/tenants?q=" + encodeURIComponent(trimmed));
|
|
779
|
+
if (!res.ok) throw new Error("search failed");
|
|
780
|
+
const data = await res.json();
|
|
781
|
+
if (mine !== seq) return;
|
|
782
|
+
show(data.tenants || []);
|
|
783
|
+
none.hidden = (data.tenants || []).length > 0;
|
|
784
|
+
};
|
|
785
|
+
const failed = () => { note = t.expired; render(); };
|
|
786
|
+
input.addEventListener("input", () => {
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
timer = setTimeout(() => search(input.value).catch(failed), 150);
|
|
789
|
+
});
|
|
790
|
+
input.addEventListener("keydown", (e) => {
|
|
791
|
+
if (e.key === "ArrowDown") { e.preventDefault(); highlight(highlighted + 1); }
|
|
792
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); highlight(highlighted - 1); }
|
|
793
|
+
else if (e.key === "Enter") { const b = results.querySelectorAll("button")[highlighted]; if (b) b.click(); }
|
|
794
|
+
});
|
|
795
|
+
for (const city of ["Helsinki", "Espoo", "Tampere", "Vantaa", "Oulu", "Turku", "Jyväskylä"]) {
|
|
796
|
+
chips.append(el("button", { type: "button", textContent: city, onclick: () => { input.value = city; search(city).catch(failed); input.focus(); } }));
|
|
616
797
|
}
|
|
617
|
-
|
|
798
|
+
box.append(el("div", { className: "school-top" }, el("label", { htmlFor: id, textContent: label }), cancel), input, results, help, chips, none);
|
|
799
|
+
return box;
|
|
618
800
|
}
|
|
619
|
-
$("school").addEventListener("input", (e) => {
|
|
620
|
-
clearTimeout(timer);
|
|
621
|
-
const q = e.target.value;
|
|
622
|
-
timer = setTimeout(() => search(q).catch(() => showError(t.expired)), 150);
|
|
623
|
-
});
|
|
624
|
-
$("school").addEventListener("keydown", (e) => {
|
|
625
|
-
if (e.key === "ArrowDown") { e.preventDefault(); highlight(highlighted + 1); }
|
|
626
|
-
else if (e.key === "ArrowUp") { e.preventDefault(); highlight(highlighted - 1); }
|
|
627
|
-
else if (e.key === "Enter") { const b = $("results").querySelectorAll("button")[highlighted]; if (b) b.click(); }
|
|
628
|
-
});
|
|
629
|
-
$("password").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
|
630
|
-
$("totp").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
|
631
|
-
$("submit").addEventListener("click", () => submit());
|
|
632
801
|
|
|
802
|
+
// Log in every row filled in, one after another; with none, Done.
|
|
633
803
|
async function submit() {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
if (!
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return;
|
|
804
|
+
if (busy) return;
|
|
805
|
+
note = "";
|
|
806
|
+
const ready = drafts.filter(filled);
|
|
807
|
+
if (!ready.length) {
|
|
808
|
+
if (accounts.length) return finish();
|
|
809
|
+
note = t.missing; render(); return;
|
|
641
810
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
811
|
+
busy = true; updateGo();
|
|
812
|
+
let first = null;
|
|
813
|
+
for (const d of ready) {
|
|
814
|
+
d.error = "";
|
|
815
|
+
if (!d.tenant || !d.username.trim() || !d.password) { d.error = t.missing; first ??= d; continue; }
|
|
816
|
+
try {
|
|
817
|
+
const data = await post("/login", {
|
|
818
|
+
tenantUrl: d.tenant.url, tenantName: d.tenant.name, username: d.username.trim(), password: d.password,
|
|
819
|
+
totpSecret: d.totp.trim() || undefined, replace: d.replace || undefined,
|
|
820
|
+
});
|
|
821
|
+
if (data.status === "ok") { accounts = data.accounts; drafts = drafts.filter((x) => x !== d); continue; }
|
|
822
|
+
first ??= d;
|
|
823
|
+
if (data.status === "mfa_required") { d.mfa = true; continue; }
|
|
824
|
+
if (data.code === "bad_totp") { d.mfa = true; d.error = lang === "fi" ? t.badTotp : data.message || t.badTotp; }
|
|
825
|
+
else d.error = { bad_credentials: t.badCredentials, too_many_attempts: t.tooMany, missing_fields: t.missing, taken: t.taken }[data.code] || data.message || "Error";
|
|
826
|
+
} catch {
|
|
827
|
+
// The page stops after Done, a timeout, or too many attempts.
|
|
828
|
+
note = t.expired;
|
|
829
|
+
break;
|
|
655
830
|
}
|
|
656
|
-
if (data.status === "mfa_required") { $("mfa").hidden = false; $("totp").focus(); return; }
|
|
657
|
-
if (data.code === "bad_credentials") showError(t.badCredentials);
|
|
658
|
-
else if (data.code === "too_many_attempts") showError(t.tooMany);
|
|
659
|
-
else if (data.code === "missing_fields") showError(t.missing);
|
|
660
|
-
else if (data.code === "bad_totp") { $("mfa").hidden = false; showError(lang === "fi" ? t.badTotp : (data.message || t.badTotp)); }
|
|
661
|
-
else showError(data.message || "Error");
|
|
662
|
-
} catch (err) {
|
|
663
|
-
// The server stops after a login, a timeout, or too many attempts.
|
|
664
|
-
showError(t.expired);
|
|
665
|
-
} finally {
|
|
666
|
-
btn.disabled = false; btn.textContent = t.submit;
|
|
667
831
|
}
|
|
832
|
+
// Empty rows go once something is saved; a page with nothing saved keeps one to fill in.
|
|
833
|
+
if (accounts.length) drafts = drafts.filter(filled);
|
|
834
|
+
busy = false;
|
|
835
|
+
render();
|
|
836
|
+
if (first && drafts.includes(first)) focus(first.mfa ? "totp-" + first.id : "pass-" + first.id);
|
|
837
|
+
else focus("go");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function finish() {
|
|
841
|
+
busy = true; updateGo();
|
|
842
|
+
try {
|
|
843
|
+
const data = await post("/done", {});
|
|
844
|
+
if (data.status !== "ok") { note = data.message || t.expired; busy = false; render(); return; }
|
|
845
|
+
} catch {
|
|
846
|
+
// The page may already have closed; the logins are saved either way.
|
|
847
|
+
}
|
|
848
|
+
$("wilmas").hidden = true; $("finished").hidden = false;
|
|
668
849
|
}
|
|
850
|
+
|
|
851
|
+
async function change(path, body) {
|
|
852
|
+
try {
|
|
853
|
+
const data = await post(path, body);
|
|
854
|
+
if (data.status === "ok") accounts = data.accounts;
|
|
855
|
+
else note = data.message || t.expired;
|
|
856
|
+
} catch {
|
|
857
|
+
note = t.expired;
|
|
858
|
+
}
|
|
859
|
+
render();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// A child unticked isn't read (a profile Wilma lists twice); ticking it again reads it again.
|
|
863
|
+
const setShown = (account, number, shown) =>
|
|
864
|
+
change("/children", { id: account.id, hidden: account.children.filter((k) => (k.number === number ? !shown : k.hidden)).map((k) => k.number) });
|
|
865
|
+
|
|
866
|
+
async function remove(account) {
|
|
867
|
+
drafts = drafts.filter((d) => d.replace !== account.id);
|
|
868
|
+
await change("/remove", { id: account.id });
|
|
869
|
+
if (!accounts.length && !drafts.length) { drafts.push(newDraft()); render(); }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for (const v of ["guardian", "student"]) {
|
|
873
|
+
$("who-" + v).addEventListener("click", () => {
|
|
874
|
+
note = "";
|
|
875
|
+
if (v === "student" && accounts.length + drafts.filter((d) => !d.replace).length > 1) note = t.oneWilma;
|
|
876
|
+
else who = v;
|
|
877
|
+
render();
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
for (const code of ["fi", "en"]) $("lang-" + code).addEventListener("click", () => { lang = code; applyText(); });
|
|
881
|
+
$("add").addEventListener("click", () => {
|
|
882
|
+
const draft = newDraft();
|
|
883
|
+
drafts.push(draft); note = ""; render(); focus("school-" + draft.id);
|
|
884
|
+
});
|
|
885
|
+
$("go").addEventListener("click", () => submit());
|
|
669
886
|
applyText();
|
|
670
|
-
|
|
887
|
+
focus(drafts.length ? "school-" + drafts[0].id : "go");
|
|
671
888
|
})();
|
|
672
889
|
</script>
|
|
673
890
|
</body>
|