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