@wilm-ai/wilma-cli 2.3.1 → 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.
@@ -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
- /** The children per connected Wilma, for the page's "You're connected" list. */
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
- wilma: p.tenantName ?? p.tenantUrl,
16
- students: (p.students ?? []).map((s) => s.name),
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
- res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath(), openedBy: opts.openedBy }));
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: normalizeTenantUrl(tenantUrl), username, password }, client);
164
+ adoptSession({ baseUrl: wilmaUrl, username, password }, client);
121
165
  const config = await loadConfig();
122
166
  const stored = await saveLogin(config, {
123
- tenantUrl: normalizeTenantUrl(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
- status: "ok",
133
- students: students.map((s) => s.name),
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
241
296
  }
242
- /** The login page. The login API answers {status: "ok", students, accounts}. */
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
- doneTitle: "You're connected",
263
- doneIntro: "Children found:",
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: "Pick your school's Wilma and fill in both fields.",
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
- doneTitle: "Yhteys on valmis",
307
- doneIntro: "Löytyneet lapset:",
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 koulusi Wilma ja täytä molemmat kentät.",
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
- label:first-child { margin-top:0; }
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
- .primary { width:100%; margin-top:20px; padding:12px; font:inherit; font-weight:700; border:0; border-radius:8px; background:var(--teal); color:#fff; cursor:pointer; }
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 { width:100%; margin-top:10px; padding:11px; font:inherit; font-weight:700; border:1px solid var(--teal); border-radius:8px; background:transparent; color:var(--teal); cursor:pointer; }
379
- .wilma-name { font-weight:700; margin:14px 0 4px; }
380
- ul.children { margin:6px 0 0; padding-left:22px; }
381
- ul.children li { padding:3px 0; }
382
- .add-hint { margin-top:8px; }
383
- .error { color:var(--err); margin-top:12px; }
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="form-card">
421
- <div id="school-pick">
422
- <label for="school" id="school-label"></label>
423
- <input id="school" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-controls="results" aria-expanded="false">
424
- <ul class="results" id="results" role="listbox"></ul>
425
- <p class="help" id="school-help"></p>
426
- <div class="chips" id="chips"></div>
427
- <p class="muted" id="no-results" hidden></p>
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="school-chosen" hidden>
430
- <label id="school-label-2"></label>
431
- <div class="chosen"><div><span id="chosen-name"></span><small id="chosen-url"></small></div><button class="link" id="change" type="button"></button></div>
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
- let tenant = null;
476
- let timer = null;
477
- let seq = 0;
478
- let shownAccounts = null;
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
- text("intro", OPENED_BY ? t.introApp.split("{app}").join(OPENED_BY.app) : t.intro);
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("school-label", t.school); text("school-label-2", t.school);
495
- $("school").placeholder = t.schoolHint; text("no-results", t.noResults); text("change", t.change);
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
- if (shownAccounts) renderAccounts(shownAccounts);
634
+ updateGo();
509
635
  }
510
636
 
511
- // Where the password goes, once the parent has picked their Wilma.
512
- function showHost() {
513
- const host = tenant ? hostOf(tenant.url) : "";
514
- text("trust-wilma", host ? t.trustWilmaHost.replace("{host}", host) : t.trustWilma);
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
- for (const code of ["fi", "en"]) $("lang-" + code).addEventListener("click", () => { lang = code; applyText(); });
520
- for (const city of ["Helsinki", "Espoo", "Tampere", "Vantaa", "Oulu", "Turku", "Jyväskylä"]) {
521
- const b = document.createElement("button"); b.type = "button"; b.textContent = city;
522
- b.addEventListener("click", () => { $("school").value = city; search(city).catch(() => showError(t.expired)); $("school").focus(); });
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 renderAccounts(accounts) {
527
- shownAccounts = accounts;
528
- const box = $("accounts"); box.replaceChildren();
529
- const intro = document.createElement("p"); intro.textContent = t.doneIntro; box.append(intro);
530
- for (const account of accounts) {
531
- if (accounts.length > 1) {
532
- const name = document.createElement("p"); name.className = "wilma-name"; name.textContent = account.wilma; box.append(name);
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 ul = document.createElement("ul"); ul.className = "children";
535
- const kids = (account.students || []).filter(Boolean);
536
- if (!kids.length) { const li = document.createElement("li"); li.textContent = t.noChildren; ul.append(li); }
537
- for (const kid of kids) { const li = document.createElement("li"); li.textContent = kid; ul.append(li); }
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
- $("add-another").addEventListener("click", () => {
543
- tenant = null; shownAccounts = null; showHost();
544
- for (const id of ["school", "username", "password", "totp"]) $(id).value = "";
545
- render([]); showError("");
546
- $("mfa").hidden = true; $("school-chosen").hidden = true; $("school-pick").hidden = false;
547
- $("done").hidden = true; $("form-card").hidden = false;
548
- $("school").focus();
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
- $("finish").addEventListener("click", async () => {
552
- const btn = $("finish"); btn.disabled = true;
553
- try {
554
- const res = await fetch(API + "/done", {
555
- method: "POST",
556
- headers: { "Content-Type": "application/json" },
557
- body: "{}",
558
- });
559
- const data = await res.json();
560
- if (data.status !== "ok") { showError(data.message || t.expired); btn.disabled = false; return; }
561
- } catch (err) {
562
- // The page may already have closed; the login is saved either way.
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
- $("done").hidden = true; $("finished").hidden = false;
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
- async function search(q) {
579
- const mine = ++seq;
580
- const trimmed = q.trim();
581
- if (/^https?:\\/\\//i.test(trimmed)) {
582
- render([{ url: trimmed.replace(/\\/$/, ""), name: trimmed.replace(/\\/$/, "") }]);
583
- return;
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
- if (!trimmed) { render([]); $("no-results").hidden = true; return; }
586
- const res = await fetch(API + "/tenants?q=" + encodeURIComponent(trimmed));
587
- if (!res.ok) throw new Error("search failed");
588
- const data = await res.json();
589
- if (mine !== seq) return;
590
- showError("");
591
- render(data.tenants || []);
592
- $("no-results").hidden = (data.tenants || []).length > 0;
593
- }
594
- let highlighted = 0;
595
- function highlight(index) {
596
- const buttons = [...$("results").querySelectorAll("button")];
597
- if (!buttons.length) return;
598
- highlighted = (index + buttons.length) % buttons.length;
599
- buttons.forEach((b, i) => { b.classList.toggle("hl", i === highlighted); b.setAttribute("aria-selected", String(i === highlighted)); });
600
- buttons[highlighted].scrollIntoView({ block: "nearest" });
601
- }
602
- function render(list) {
603
- const ul = $("results"); ul.replaceChildren();
604
- $("school").setAttribute("aria-expanded", String(list.length > 0));
605
- // While results are showing, hide the hint and city chips so the list is easy to read.
606
- $("school-help").hidden = list.length > 0;
607
- $("chips").hidden = list.length > 0;
608
- for (const item of list) {
609
- const li = document.createElement("li");
610
- const b = document.createElement("button"); b.type = "button";
611
- b.textContent = item.name || item.url;
612
- const small = document.createElement("small"); small.textContent = item.url.replace(/^https?:\\/\\//, ""); b.append(small);
613
- b.addEventListener("click", () => choose(item));
614
- b.setAttribute("role", "option");
615
- li.append(b); ul.append(li);
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
- highlight(0);
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
- showError("");
635
- const username = $("username").value.trim();
636
- const password = $("password").value;
637
- if (!tenant || !username || !password) {
638
- showError(t.missing);
639
- if (!tenant) $("school").focus();
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
- const btn = $("submit"); btn.disabled = true; btn.textContent = t.working;
643
- try {
644
- const res = await fetch(API + "/login", {
645
- method: "POST",
646
- headers: { "Content-Type": "application/json" },
647
- body: JSON.stringify({ tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret: $("totp").value.trim() || undefined }),
648
- });
649
- const data = await res.json();
650
- if (data.status === "ok") {
651
- renderAccounts(data.accounts || [{ wilma: tenant.name || tenant.url, students: data.students || [] }]);
652
- $("form-card").hidden = true; $("done").hidden = false;
653
- $("finish").focus();
654
- return;
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
- $("school").focus();
887
+ focus(drafts.length ? "school-" + drafts[0].id : "go");
671
888
  })();
672
889
  </script>
673
890
  </body>