@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.
- package/README.md +20 -3
- package/dist/access.d.ts +40 -1
- package/dist/access.js +105 -6
- package/dist/agent-data.d.ts +200 -21
- package/dist/agent-data.js +261 -56
- package/dist/cli-args.js +12 -3
- package/dist/cli-help.js +29 -13
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +36 -6
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +214 -108
- package/dist/login-server.d.ts +28 -6
- package/dist/login-server.js +468 -239
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +26 -15
- package/dist/secret-moves.d.ts +44 -0
- package/dist/secret-moves.js +297 -0
- package/dist/secret-store.d.ts +45 -0
- package/dist/secret-store.js +487 -0
- package/dist/session-keys.d.ts +19 -0
- package/dist/session-keys.js +26 -0
- package/dist/session-store.d.ts +16 -0
- package/dist/session-store.js +69 -0
- package/package.json +8 -3
package/dist/login-server.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
241
305
|
}
|
|
242
|
-
/**
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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 {
|
|
379
|
-
.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
.
|
|
383
|
-
.
|
|
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="
|
|
421
|
-
<div
|
|
422
|
-
<
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
<p class="
|
|
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
|
-
<
|
|
434
|
-
<
|
|
435
|
-
<
|
|
436
|
-
<
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
476
|
-
let
|
|
477
|
-
let
|
|
478
|
-
|
|
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
|
-
|
|
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("
|
|
495
|
-
|
|
496
|
-
text("school-help", t.schoolHelp);
|
|
497
|
-
text("username-label", t.username); text("password-label", t.password);
|
|
498
|
-
text("mfa-title", t.mfaTitle); text("mfa-body", t.mfaBody); text("mfa-label", t.mfaLabel); text("mfa-help", t.mfaHelp);
|
|
499
|
-
if (!$("submit").disabled) text("submit", t.submit);
|
|
500
|
-
text("done-title", t.doneTitle);
|
|
501
|
-
text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
|
|
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
|
-
|
|
646
|
+
updateGo();
|
|
509
647
|
}
|
|
510
648
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
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
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
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
|
|
535
|
-
const
|
|
536
|
-
if (
|
|
537
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
if (!
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
899
|
+
focus(drafts.length ? "school-" + drafts[0].id : "go");
|
|
671
900
|
})();
|
|
672
901
|
</script>
|
|
673
902
|
</body>
|