@wilm-ai/wilma-cli 1.6.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ import { type StoredProfile } from "./config.js";
2
+ export interface LoginServer {
3
+ url: string;
4
+ /** Resolves with the first saved login, or null if the page closed without one. */
5
+ result: Promise<StoredProfile | null>;
6
+ /** Resolves with every login saved on this page once it closes (Done, idle timeout, or close()). */
7
+ closed: Promise<StoredProfile[]>;
8
+ close(): void;
9
+ }
10
+ /**
11
+ * Serve a one-time login page on 127.0.0.1. The parent picks their school's
12
+ * Wilma, logs in, and the verified login is saved to the local config file —
13
+ * the password never passes through an agent's chat. Families with children
14
+ * on different Wilmas can add another login on the same page; it closes when
15
+ * the parent presses Done or after 15 idle minutes.
16
+ */
17
+ export declare function startLoginServer(opts?: {
18
+ timeoutMs?: number;
19
+ onSaved?: (profile: StoredProfile) => void;
20
+ }): Promise<LoginServer>;
21
+ export type BrowserOpenResult = "opened" | "skipped" | "headless";
22
+ /** Best-effort: open a URL in the user's default browser. */
23
+ export declare function openBrowser(url: string, opts?: {
24
+ dryRun?: boolean;
25
+ }): BrowserOpenResult;
26
+ export interface LoginPageOptions {
27
+ nonce: string;
28
+ apiBase: string;
29
+ /** Shown under the form; "{path}" is replaced with the config path. */
30
+ privacy?: {
31
+ en: string;
32
+ fi: string;
33
+ };
34
+ configPath?: string;
35
+ /** Extra fields posted with the login (e.g. a sealed OAuth request). */
36
+ extra?: Record<string, string>;
37
+ /** Label for the finishing button, for hosts that continue elsewhere after the login. */
38
+ finishLabel?: {
39
+ en: string;
40
+ fi: string;
41
+ };
42
+ /** Shown above the form, e.g. which app is connecting. */
43
+ notice?: {
44
+ en: string;
45
+ fi: string;
46
+ };
47
+ }
48
+ /**
49
+ * The login page. The login API answers {status: "ok", students, redirect?};
50
+ * with a redirect the page navigates there (e.g. an OAuth callback).
51
+ */
52
+ export declare function renderLoginPage(opts: LoginPageOptions): string;
@@ -0,0 +1,595 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { spawn } from "node:child_process";
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";
8
+ import { normalizeTenantUrl, searchTenants } from "./tenant-search.js";
9
+ const MAX_ATTEMPTS = 10;
10
+ const MAX_BODY_BYTES = 16 * 1024;
11
+ const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
12
+ /** The children per connected Wilma, for the page's "You're connected" list. */
13
+ function accountsSummary(profiles) {
14
+ return profiles.map((p) => ({
15
+ wilma: p.tenantName ?? p.tenantUrl,
16
+ students: (p.students ?? []).map((s) => s.name),
17
+ }));
18
+ }
19
+ /**
20
+ * Serve a one-time login page on 127.0.0.1. The parent picks their school's
21
+ * Wilma, logs in, and the verified login is saved to the local config file —
22
+ * the password never passes through an agent's chat. Families with children
23
+ * on different Wilmas can add another login on the same page; it closes when
24
+ * the parent presses Done or after 15 idle minutes.
25
+ */
26
+ export async function startLoginServer(opts = {}) {
27
+ const token = randomBytes(18).toString("base64url");
28
+ const nonce = randomBytes(16).toString("base64");
29
+ const base = `/${token}`;
30
+ const saved = [];
31
+ let attempts = 0;
32
+ let settleFirst = () => { };
33
+ const result = new Promise((resolve) => {
34
+ settleFirst = resolve;
35
+ });
36
+ let settleClosed = () => { };
37
+ const closed = new Promise((resolve) => {
38
+ settleClosed = resolve;
39
+ });
40
+ let origin = "";
41
+ const server = createServer((req, res) => {
42
+ handle(req, res).catch((err) => {
43
+ sendJson(res, 500, { status: "error", message: err instanceof Error ? err.message : String(err) });
44
+ });
45
+ });
46
+ // Only our own page may post here: browsers always send Origin on fetch POSTs.
47
+ const fromOwnPage = (req) => req.headers.origin === origin && (req.headers["content-type"] ?? "").includes("application/json");
48
+ async function handle(req, res) {
49
+ const url = new URL(req.url ?? "/", origin);
50
+ if (url.pathname === base || url.pathname === `${base}/`) {
51
+ res.writeHead(200, {
52
+ "Content-Type": "text/html; charset=utf-8",
53
+ "Cache-Control": "no-store",
54
+ "Referrer-Policy": "no-referrer",
55
+ "X-Frame-Options": "DENY",
56
+ "Content-Security-Policy": [
57
+ "default-src 'none'",
58
+ `script-src 'nonce-${nonce}'`,
59
+ `style-src 'nonce-${nonce}'`,
60
+ "connect-src 'self'",
61
+ "img-src 'self' data:",
62
+ "form-action 'none'",
63
+ "frame-ancestors 'none'",
64
+ "base-uri 'none'",
65
+ ].join("; "),
66
+ });
67
+ res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath() }));
68
+ return;
69
+ }
70
+ if (url.pathname === `${base}/api/tenants` && req.method === "GET") {
71
+ const tenants = await searchTenants(url.searchParams.get("q") ?? "", 12);
72
+ sendJson(res, 200, { tenants: tenants.map((t) => ({ url: t.url, name: t.name })) });
73
+ return;
74
+ }
75
+ if (url.pathname === `${base}/api/done` && req.method === "POST") {
76
+ if (!fromOwnPage(req)) {
77
+ sendJson(res, 403, { status: "error", message: "Forbidden" });
78
+ return;
79
+ }
80
+ sendJson(res, 200, { status: "ok" });
81
+ setTimeout(() => close(), 300).unref();
82
+ return;
83
+ }
84
+ if (url.pathname === `${base}/api/login` && req.method === "POST") {
85
+ if (!fromOwnPage(req)) {
86
+ sendJson(res, 403, { status: "error", message: "Forbidden" });
87
+ return;
88
+ }
89
+ attempts += 1;
90
+ if (attempts > MAX_ATTEMPTS) {
91
+ sendJson(res, 429, { status: "error", code: "too_many_attempts" });
92
+ // Shut this page down so the next login attempt starts a fresh one.
93
+ setTimeout(() => close(), 500).unref();
94
+ return;
95
+ }
96
+ const body = (await readJson(req));
97
+ const tenantUrl = (body.tenantUrl ?? "").trim();
98
+ const username = (body.username ?? "").trim();
99
+ const password = body.password ?? "";
100
+ const totpSecret = (body.totpSecret ?? "").trim() || null;
101
+ if (!/^https?:\/\//i.test(tenantUrl) || !username || !password) {
102
+ sendJson(res, 400, { status: "error", code: "missing_fields" });
103
+ return;
104
+ }
105
+ try {
106
+ const { client, students } = await verifyLoginSession({ tenantUrl, username, password, totpSecret });
107
+ // Keep this verified session for the assistant's first questions (no second login or code).
108
+ adoptSession({ baseUrl: normalizeTenantUrl(tenantUrl), username, password }, client);
109
+ const config = await loadConfig();
110
+ const stored = await saveLogin(config, {
111
+ tenantUrl: normalizeTenantUrl(tenantUrl),
112
+ tenantName: body.tenantName ?? null,
113
+ username,
114
+ password,
115
+ totpSecret,
116
+ students,
117
+ });
118
+ // Every Wilma login saved so far, so the page can show the whole family.
119
+ sendJson(res, 200, {
120
+ status: "ok",
121
+ students: students.map((s) => s.name),
122
+ accounts: accountsSummary(config.profiles),
123
+ });
124
+ saved.push(stored);
125
+ attempts = 0;
126
+ resetIdle();
127
+ settleFirst(stored);
128
+ opts.onSaved?.(stored);
129
+ }
130
+ catch (err) {
131
+ if (err instanceof TotpSecretRequiredError) {
132
+ sendJson(res, 200, { status: "mfa_required" });
133
+ }
134
+ else if (err instanceof TotpSecretInvalidError) {
135
+ sendJson(res, 200, { status: "error", code: "bad_totp", message: err.message });
136
+ }
137
+ else if (err instanceof AuthenticationError) {
138
+ sendJson(res, 200, { status: "error", code: "bad_credentials" });
139
+ }
140
+ else {
141
+ sendJson(res, 200, { status: "error", message: err instanceof Error ? err.message : String(err) });
142
+ }
143
+ }
144
+ return;
145
+ }
146
+ res.writeHead(404, { "Content-Type": "text/plain" });
147
+ res.end("Not found");
148
+ }
149
+ await new Promise((resolve, reject) => {
150
+ server.once("error", reject);
151
+ server.listen(0, "127.0.0.1", () => resolve());
152
+ });
153
+ const address = server.address();
154
+ if (!address || typeof address !== "object")
155
+ throw new Error("Login server failed to start");
156
+ origin = `http://127.0.0.1:${address.port}`;
157
+ let timer;
158
+ function resetIdle() {
159
+ if (timer)
160
+ clearTimeout(timer);
161
+ timer = setTimeout(() => close(), opts.timeoutMs ?? IDLE_TIMEOUT_MS);
162
+ timer.unref();
163
+ }
164
+ resetIdle();
165
+ let isClosed = false;
166
+ function close() {
167
+ if (isClosed)
168
+ return;
169
+ isClosed = true;
170
+ if (timer)
171
+ clearTimeout(timer);
172
+ server.close();
173
+ server.closeAllConnections?.();
174
+ settleFirst(null);
175
+ settleClosed(saved);
176
+ }
177
+ return { url: `${origin}${base}/`, result, closed, close };
178
+ }
179
+ /** Best-effort: open a URL in the user's default browser. */
180
+ export function openBrowser(url, opts = {}) {
181
+ if (process.env.WILMAI_NO_BROWSER)
182
+ return "skipped";
183
+ const platform = process.platform;
184
+ if (platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
185
+ return "headless";
186
+ if (opts.dryRun)
187
+ return "skipped";
188
+ const [command, args] = platform === "darwin"
189
+ ? ["open", [url]]
190
+ : platform === "win32"
191
+ ? ["cmd", ["/c", "start", "", url]]
192
+ : ["xdg-open", [url]];
193
+ try {
194
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
195
+ child.on("error", () => undefined);
196
+ child.unref();
197
+ return "opened";
198
+ }
199
+ catch {
200
+ return "skipped";
201
+ }
202
+ }
203
+ function sendJson(res, status, body) {
204
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
205
+ res.end(JSON.stringify(body));
206
+ }
207
+ async function readJson(req) {
208
+ const chunks = [];
209
+ let size = 0;
210
+ for await (const chunk of req) {
211
+ size += chunk.length;
212
+ if (size > MAX_BODY_BYTES)
213
+ throw new Error("Request too large");
214
+ chunks.push(chunk);
215
+ }
216
+ try {
217
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
218
+ }
219
+ catch {
220
+ return {};
221
+ }
222
+ }
223
+ /** JSON for embedding inside <script>: "<" escaped so a value can never close the script tag. */
224
+ function jsonForScript(value) {
225
+ return JSON.stringify(value).replace(/</g, "\\u003c");
226
+ }
227
+ function escapeHtml(value) {
228
+ return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
229
+ }
230
+ /**
231
+ * The login page. The login API answers {status: "ok", students, redirect?};
232
+ * with a redirect the page navigates there (e.g. an OAuth callback).
233
+ */
234
+ export function renderLoginPage(opts) {
235
+ const strings = {
236
+ en: {
237
+ title: "Log in to Wilma — WilmAI",
238
+ heading: "Log in to Wilma",
239
+ intro: "Connect your Wilma account once. Your assistant can then check schedules, homework, exams, messages and news for you.",
240
+ school: "Your school's Wilma",
241
+ schoolHint: "Type your city or school name",
242
+ schoolHelp: "Start typing, then pick your Wilma from the list. Or tap your city:",
243
+ change: "Change",
244
+ 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",
245
+ username: "Wilma username",
246
+ password: "Password",
247
+ mfaTitle: "Two-step verification",
248
+ 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).",
249
+ mfaLabel: "Authenticator setup key",
250
+ 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.",
251
+ submit: "Log in",
252
+ working: "Checking with Wilma…",
253
+ doneTitle: "You're connected",
254
+ doneIntro: "Children found:",
255
+ addAnother: "Add another Wilma",
256
+ addAnotherHint: "If one of your children goes to a school with a different Wilma, add that login too.",
257
+ finish: "Done",
258
+ finishedTitle: "All set",
259
+ finishedBody: "You can close this tab and go back to your assistant.",
260
+ noChildren: "No children found on this login.",
261
+ badCredentials: "Wilma didn't accept that username and password.",
262
+ 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.",
263
+ tooMany: "Too many attempts. Close this tab and start again from your assistant.",
264
+ missing: "Pick your school's Wilma and fill in both fields.",
265
+ notAllowed: "This Wilma account isn't on the test list for the hosted connection yet.",
266
+ expired: "This login page is no longer active. Start the login again from your assistant, or run wilma login.",
267
+ privacy: "Your login is saved only on this computer, in {path}. WilmAI has no server; it talks only to your school's Wilma.",
268
+ disclaimer: "WilmAI is an independent open-source project, not affiliated with Visma or the Wilma service.",
269
+ },
270
+ fi: {
271
+ title: "Kirjaudu Wilmaan — WilmAI",
272
+ heading: "Kirjaudu Wilmaan",
273
+ intro: "Yhdistä Wilma-tunnuksesi kerran. Sen jälkeen avustajasi voi tarkistaa lukujärjestykset, läksyt, kokeet, viestit ja tiedotteet puolestasi.",
274
+ school: "Koulusi Wilma",
275
+ schoolHint: "Kirjoita kaupunki tai koulun nimi",
276
+ schoolHelp: "Ala kirjoittaa ja valitse Wilmasi listalta. Tai napauta kaupunkiasi:",
277
+ change: "Vaihda",
278
+ 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",
279
+ username: "Wilma-käyttäjätunnus",
280
+ password: "Salasana",
281
+ mfaTitle: "Kaksivaiheinen tunnistautuminen",
282
+ mfaBody: "Tunnuksessasi on kaksivaiheinen tunnistautuminen. Jotta avustajasi voi tarkistaa Wilman kysymättä sinulta joka kerta koodia, liitä todennussovelluksesi asetusavain (base32-avain tai otpauth://-linkki).",
283
+ mfaLabel: "Todennussovelluksen asetusavain",
284
+ 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.",
285
+ submit: "Kirjaudu",
286
+ working: "Tarkistetaan Wilmasta…",
287
+ doneTitle: "Yhteys on valmis",
288
+ doneIntro: "Löytyneet lapset:",
289
+ addAnother: "Lisää toinen Wilma",
290
+ addAnotherHint: "Jos joku lapsistasi käy koulua, jolla on eri Wilma, lisää myös sen tunnukset.",
291
+ finish: "Valmis",
292
+ finishedTitle: "Kaikki valmista",
293
+ finishedBody: "Voit sulkea tämän välilehden ja palata avustajaasi.",
294
+ noChildren: "Näillä tunnuksilla ei löytynyt lapsia.",
295
+ badCredentials: "Wilma ei hyväksynyt käyttäjätunnusta ja salasanaa.",
296
+ badTotp: "Wilma ei hyväksynyt kaksivaiheista tunnistautumista. Liitä todennussovelluksen asetusavain (pitkä base32-avain tai otpauth://-linkki), ei 6-numeroista koodia.",
297
+ tooMany: "Liian monta yritystä. Sulje välilehti ja aloita uudelleen avustajasi kautta.",
298
+ missing: "Valitse koulusi Wilma ja täytä molemmat kentät.",
299
+ notAllowed: "Tämä Wilma-tunnus ei ole vielä verkkoyhteyden testilistalla.",
300
+ expired: "Tämä kirjautumissivu ei ole enää käytössä. Aloita kirjautuminen uudelleen avustajasi kautta tai komennolla wilma login.",
301
+ privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}. WilmAI:lla ei ole palvelinta; se on yhteydessä vain koulusi Wilmaan.",
302
+ disclaimer: "WilmAI on itsenäinen avoimen lähdekoodin projekti, eikä se liity Vismaan tai Wilma-palveluun.",
303
+ },
304
+ };
305
+ if (opts.finishLabel) {
306
+ strings.en.finish = opts.finishLabel.en;
307
+ strings.fi.finish = opts.finishLabel.fi;
308
+ }
309
+ if (opts.privacy) {
310
+ strings.en.privacy = opts.privacy.en;
311
+ strings.fi.privacy = opts.privacy.fi;
312
+ }
313
+ const nonce = escapeHtml(opts.nonce);
314
+ return `<!doctype html>
315
+ <html lang="en">
316
+ <head>
317
+ <meta charset="utf-8">
318
+ <meta name="viewport" content="width=device-width, initial-scale=1">
319
+ <meta name="robots" content="noindex">
320
+ <title>WilmAI</title>
321
+ <style nonce="${nonce}">
322
+ :root { --ink:#1b2b34; --muted:#5b6b73; --paper:#fbf8f1; --line:#e6dfcf; --teal:#2e9e93; --yellow:#ffd84d; --card:#fff; --err:#b3261e; }
323
+ @media (prefers-color-scheme: dark) { :root { --ink:#eef2f3; --muted:#a8b5bb; --paper:#141c21; --line:#2c3a42; --card:#1b252b; --err:#ff8a80; } }
324
+ * { box-sizing:border-box; }
325
+ body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
326
+ main { max-width:440px; margin:0 auto; padding:40px 16px 48px; }
327
+ h1 { font-size:28px; line-height:1.2; margin:0 0 8px; }
328
+ h1 mark { background:linear-gradient(transparent 55%, var(--yellow) 55%); color:inherit; padding:0 2px; }
329
+ p { margin:0 0 16px; }
330
+ .muted { color:var(--muted); font-size:14px; }
331
+ .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin:20px 0; }
332
+ label { display:block; font-weight:600; margin:14px 0 6px; }
333
+ label:first-child { margin-top:0; }
334
+ input { width:100%; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--paper); color:var(--ink); }
335
+ input:focus { outline:2px solid var(--teal); outline-offset:1px; }
336
+ 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); }
337
+ ul.results:empty { display:none; }
338
+ 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; }
339
+ ul.results li:last-child button { border-bottom:0; }
340
+ ul.results button:hover, ul.results button:focus, ul.results button.hl { background:var(--paper); }
341
+ ul.results button.hl { box-shadow: inset 3px 0 0 var(--teal); }
342
+ .help { font-size:14px; color:var(--muted); margin:8px 0 6px; }
343
+ .chips { display:flex; flex-wrap:wrap; gap:6px; }
344
+ .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; }
345
+ .chips button:hover, .chips button:focus { border-color:var(--teal); }
346
+ ul.results small { display:block; margin-top:2px; font-weight:400; font-size:13px; color:var(--muted); }
347
+ .chosen { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px 12px; border:1px solid var(--teal); border-radius:8px; }
348
+ .chosen small { display:block; color:var(--muted); word-break:break-all; }
349
+ .link { background:none; border:0; color:var(--teal); font:inherit; cursor:pointer; padding:0; text-decoration:underline; }
350
+ .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; }
351
+ .primary:disabled { opacity:.6; cursor:wait; }
352
+ .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; }
353
+ .wilma-name { font-weight:700; margin:14px 0 4px; }
354
+ ul.children { margin:6px 0 0; padding-left:22px; }
355
+ ul.children li { padding:3px 0; }
356
+ .add-hint { margin-top:8px; }
357
+ .notice { margin:0 0 16px; padding:10px 12px; border-radius:8px; background:var(--paper); border:1px solid var(--line); font-size:14px; }
358
+ .error { color:var(--err); margin-top:12px; }
359
+ .done h2 { margin:0 0 8px; }
360
+ [hidden] { display:none !important; }
361
+ </style>
362
+ </head>
363
+ <body>
364
+ <main>
365
+ <h1 id="heading"></h1>
366
+ <p id="intro"></p>
367
+ <section class="card" id="form-card">
368
+ <p class="notice" id="notice" hidden></p>
369
+ <div id="school-pick">
370
+ <label for="school" id="school-label"></label>
371
+ <input id="school" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-controls="results" aria-expanded="false">
372
+ <ul class="results" id="results" role="listbox"></ul>
373
+ <p class="help" id="school-help"></p>
374
+ <div class="chips" id="chips"></div>
375
+ <p class="muted" id="no-results" hidden></p>
376
+ </div>
377
+ <div id="school-chosen" hidden>
378
+ <label id="school-label-2"></label>
379
+ <div class="chosen"><div><span id="chosen-name"></span><small id="chosen-url"></small></div><button class="link" id="change" type="button"></button></div>
380
+ </div>
381
+ <label for="username" id="username-label"></label>
382
+ <input id="username" autocomplete="username" autocapitalize="none" spellcheck="false">
383
+ <label for="password" id="password-label"></label>
384
+ <input id="password" type="password" autocomplete="current-password">
385
+ <div id="mfa" hidden>
386
+ <label id="mfa-title"></label>
387
+ <p class="muted" id="mfa-body"></p>
388
+ <label for="totp" id="mfa-label"></label>
389
+ <input id="totp" autocomplete="off" spellcheck="false">
390
+ <p class="muted" id="mfa-help"></p>
391
+ </div>
392
+ <p class="error" id="error" role="alert" hidden></p>
393
+ <button class="primary" id="submit" type="button"></button>
394
+ </section>
395
+ <section class="card done" id="done" hidden>
396
+ <h2 id="done-title"></h2>
397
+ <div id="accounts"></div>
398
+ <button class="primary" id="finish" type="button"></button>
399
+ <button class="secondary" id="add-another" type="button"></button>
400
+ <p class="muted add-hint" id="add-hint"></p>
401
+ </section>
402
+ <section class="card done" id="finished" hidden>
403
+ <h2 id="finished-title"></h2>
404
+ <p id="finished-body"></p>
405
+ </section>
406
+ <p class="muted" id="privacy"></p>
407
+ <p class="muted" id="disclaimer"></p>
408
+ </main>
409
+ <script nonce="${nonce}">
410
+ (() => {
411
+ const STRINGS = ${jsonForScript(strings)};
412
+ const API = ${jsonForScript(opts.apiBase)};
413
+ const CONFIG_PATH = ${jsonForScript(opts.configPath ?? "")};
414
+ const EXTRA = ${jsonForScript(opts.extra ?? {})};
415
+ const NOTICE = ${jsonForScript(opts.notice ?? null)};
416
+ const lang = (navigator.language || "en").toLowerCase().startsWith("fi") ? "fi" : "en";
417
+ const t = STRINGS[lang];
418
+ document.documentElement.lang = lang;
419
+ document.title = t.title;
420
+ const $ = (id) => document.getElementById(id);
421
+ const text = (id, value) => { $(id).textContent = value; };
422
+ text("heading", ""); $("heading").append(t.heading.split(" ").slice(0, -1).join(" ") + " ");
423
+ const mark = document.createElement("mark"); mark.textContent = t.heading.split(" ").slice(-1)[0]; $("heading").append(mark);
424
+ text("intro", t.intro); text("school-label", t.school); text("school-label-2", t.school);
425
+ $("school").placeholder = t.schoolHint; text("no-results", t.noResults); text("change", t.change);
426
+ text("school-help", t.schoolHelp);
427
+ for (const city of ["Helsinki", "Espoo", "Tampere", "Vantaa", "Oulu", "Turku", "Jyväskylä"]) {
428
+ const b = document.createElement("button"); b.type = "button"; b.textContent = city;
429
+ b.addEventListener("click", () => { $("school").value = city; search(city).catch(() => showError(t.expired)); $("school").focus(); });
430
+ $("chips").append(b);
431
+ }
432
+ text("username-label", t.username); text("password-label", t.password);
433
+ text("mfa-title", t.mfaTitle); text("mfa-body", t.mfaBody); text("mfa-label", t.mfaLabel); text("mfa-help", t.mfaHelp);
434
+ text("submit", t.submit); text("done-title", t.doneTitle);
435
+ text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
436
+ text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
437
+ if (NOTICE) { text("notice", NOTICE[lang]); $("notice").hidden = false; }
438
+ let pending = null;
439
+
440
+ function renderAccounts(accounts) {
441
+ const box = $("accounts"); box.replaceChildren();
442
+ const intro = document.createElement("p"); intro.textContent = t.doneIntro; box.append(intro);
443
+ for (const account of accounts) {
444
+ if (accounts.length > 1) {
445
+ const name = document.createElement("p"); name.className = "wilma-name"; name.textContent = account.wilma; box.append(name);
446
+ }
447
+ const ul = document.createElement("ul"); ul.className = "children";
448
+ const kids = (account.students || []).filter(Boolean);
449
+ if (!kids.length) { const li = document.createElement("li"); li.textContent = t.noChildren; ul.append(li); }
450
+ for (const kid of kids) { const li = document.createElement("li"); li.textContent = kid; ul.append(li); }
451
+ box.append(ul);
452
+ }
453
+ }
454
+
455
+ $("add-another").addEventListener("click", () => {
456
+ tenant = null;
457
+ for (const id of ["school", "username", "password", "totp"]) $(id).value = "";
458
+ render([]); showError("");
459
+ $("mfa").hidden = true; $("school-chosen").hidden = true; $("school-pick").hidden = false;
460
+ $("done").hidden = true; $("form-card").hidden = false;
461
+ $("school").focus();
462
+ });
463
+
464
+ $("finish").addEventListener("click", async () => {
465
+ const btn = $("finish"); btn.disabled = true;
466
+ try {
467
+ const res = await fetch(API + "/done", {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify({ ...EXTRA, pending: pending || undefined }),
471
+ });
472
+ const data = await res.json();
473
+ if (data.redirect) { window.location.assign(data.redirect); return; }
474
+ if (data.status !== "ok") { showError(data.message || t.expired); btn.disabled = false; return; }
475
+ } catch (err) {
476
+ // The page may already have closed; the login is saved either way.
477
+ }
478
+ $("done").hidden = true; $("finished").hidden = false;
479
+ });
480
+ text("privacy", t.privacy.replace("{path}", CONFIG_PATH)); text("disclaimer", t.disclaimer);
481
+
482
+ let tenant = null;
483
+ let timer = null;
484
+ let seq = 0;
485
+ const showError = (msg) => { const el = $("error"); el.textContent = msg; el.hidden = !msg; };
486
+
487
+ function choose(t2) {
488
+ tenant = t2;
489
+ text("chosen-name", t2.name || t2.url); text("chosen-url", t2.url);
490
+ $("school-pick").hidden = true; $("school-chosen").hidden = false;
491
+ $("username").focus();
492
+ }
493
+ $("change").addEventListener("click", () => {
494
+ tenant = null; $("school-chosen").hidden = true; $("school-pick").hidden = false; $("school").focus();
495
+ });
496
+
497
+ async function search(q) {
498
+ const mine = ++seq;
499
+ const trimmed = q.trim();
500
+ if (/^https?:\\/\\//i.test(trimmed)) {
501
+ render([{ url: trimmed.replace(/\\/$/, ""), name: trimmed.replace(/\\/$/, "") }]);
502
+ return;
503
+ }
504
+ if (!trimmed) { render([]); $("no-results").hidden = true; return; }
505
+ const res = await fetch(API + "/tenants?q=" + encodeURIComponent(trimmed));
506
+ if (!res.ok) throw new Error("search failed");
507
+ const data = await res.json();
508
+ if (mine !== seq) return;
509
+ showError("");
510
+ render(data.tenants || []);
511
+ $("no-results").hidden = (data.tenants || []).length > 0;
512
+ }
513
+ let highlighted = 0;
514
+ function highlight(index) {
515
+ const buttons = [...$("results").querySelectorAll("button")];
516
+ if (!buttons.length) return;
517
+ highlighted = (index + buttons.length) % buttons.length;
518
+ buttons.forEach((b, i) => { b.classList.toggle("hl", i === highlighted); b.setAttribute("aria-selected", String(i === highlighted)); });
519
+ buttons[highlighted].scrollIntoView({ block: "nearest" });
520
+ }
521
+ function render(list) {
522
+ const ul = $("results"); ul.replaceChildren();
523
+ $("school").setAttribute("aria-expanded", String(list.length > 0));
524
+ // While results are showing, hide the hint and city chips so the list is easy to read.
525
+ $("school-help").hidden = list.length > 0;
526
+ $("chips").hidden = list.length > 0;
527
+ for (const item of list) {
528
+ const li = document.createElement("li");
529
+ const b = document.createElement("button"); b.type = "button";
530
+ b.textContent = item.name || item.url;
531
+ const small = document.createElement("small"); small.textContent = item.url.replace(/^https?:\\/\\//, ""); b.append(small);
532
+ b.addEventListener("click", () => choose(item));
533
+ b.setAttribute("role", "option");
534
+ li.append(b); ul.append(li);
535
+ }
536
+ highlight(0);
537
+ }
538
+ $("school").addEventListener("input", (e) => {
539
+ clearTimeout(timer);
540
+ const q = e.target.value;
541
+ timer = setTimeout(() => search(q).catch(() => showError(t.expired)), 150);
542
+ });
543
+ $("school").addEventListener("keydown", (e) => {
544
+ if (e.key === "ArrowDown") { e.preventDefault(); highlight(highlighted + 1); }
545
+ else if (e.key === "ArrowUp") { e.preventDefault(); highlight(highlighted - 1); }
546
+ else if (e.key === "Enter") { const b = $("results").querySelectorAll("button")[highlighted]; if (b) b.click(); }
547
+ });
548
+ $("password").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
549
+ $("totp").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
550
+ $("submit").addEventListener("click", () => submit());
551
+
552
+ async function submit() {
553
+ showError("");
554
+ const username = $("username").value.trim();
555
+ const password = $("password").value;
556
+ if (!tenant || !username || !password) {
557
+ showError(t.missing);
558
+ if (!tenant) $("school").focus();
559
+ return;
560
+ }
561
+ const btn = $("submit"); btn.disabled = true; btn.textContent = t.working;
562
+ try {
563
+ const res = await fetch(API + "/login", {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({ ...EXTRA, pending: pending || undefined, tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret: $("totp").value.trim() || undefined }),
567
+ });
568
+ const data = await res.json();
569
+ if (data.status === "ok") {
570
+ if (data.pending) pending = data.pending;
571
+ renderAccounts(data.accounts || [{ wilma: tenant.name || tenant.url, students: data.students || [] }]);
572
+ $("form-card").hidden = true; $("done").hidden = false;
573
+ $("finish").focus();
574
+ return;
575
+ }
576
+ if (data.status === "mfa_required") { $("mfa").hidden = false; $("totp").focus(); return; }
577
+ if (data.code === "bad_credentials") showError(t.badCredentials);
578
+ else if (data.code === "too_many_attempts") showError(t.tooMany);
579
+ else if (data.code === "missing_fields") showError(t.missing);
580
+ else if (data.code === "not_allowed") showError(t.notAllowed);
581
+ else if (data.code === "bad_totp") { $("mfa").hidden = false; showError(lang === "fi" ? t.badTotp : (data.message || t.badTotp)); }
582
+ else showError(data.message || "Error");
583
+ } catch (err) {
584
+ // The server stops after a login, a timeout, or too many attempts.
585
+ showError(t.expired);
586
+ } finally {
587
+ btn.disabled = false; btn.textContent = t.submit;
588
+ }
589
+ }
590
+ $("school").focus();
591
+ })();
592
+ </script>
593
+ </body>
594
+ </html>`;
595
+ }