diff --git a/README.md b/README.md index 73aa214..17e298b 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,18 @@ offered as one-click suggestions when tagging), give any tag a colour of your ch that already exists merges the two, and both rename and delete rewrite every server that carries the tag. +**Name generator** — Operations → Generator suggests server names (and usernames and +passwords, which are made in your browser and never stored). Server names are picked from +name lists: Swedish girl and boy names, Disney and Pixar characters, Norse mythology and +Astrid Lindgren to start with, or "Mixed" for all of them together. A name already used +by a server isn't suggested. Admins edit the lists under Settings → Names — add and remove +names, rename, delete or create lists, put a built-in list back as it was — and can +import the most common Swedish names from Skatteverket's open name statistics for +girls or boys over the latest one to five years. The import is shown to you first and only +changes a list once you add it and save. (Statistics Sweden used to publish this but +stopped after 2023.) Names are kept to letters, digits and hyphens so they work as +hostnames; å, ä and ö become a, a and o. + **Privacy** — a page every signed-in user can open that says what this installation stores (accounts, sign-in sessions with their IP and browser, the audit and diagnostic logs, server reports, the secrets tracker, credentials), where data diff --git a/ROLES.md b/ROLES.md index 1bd2051..784e623 100644 --- a/ROLES.md +++ b/ROLES.md @@ -79,6 +79,9 @@ even further, to admin only: - **Admin Links**: everyone can see and open every server's admin bookmarks, from that server's own page or the summary page; adding, editing, or removing one needs operator, from either place. +- **Generator**: everyone can use it; the name lists it picks server names from + are edited under Settings → Names, which is admin-only (and so is importing + names from Skatteverket). - Everything under **Settings** (notification channels, badge colors, display prefs, log retention, backup/restore) is admin-only, matching the page itself being admin-only. diff --git a/server/src/index.ts b/server/src/index.ts index 5c6991f..000c40a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { privacyRouter } from "./routes/privacy.js"; import { tagsRouter } from "./routes/tags.js"; import { portsRouter } from "./routes/ports.js"; import { alertsRouter } from "./routes/alerts.js"; +import { generatorRouter } from "./routes/generator.js"; import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js"; import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js"; import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js"; @@ -106,6 +107,7 @@ app.use("/api/privacy", privacyRouter); app.use("/api/tags", tagsRouter); app.use("/api/ports", portsRouter); app.use("/api/alerts", alertsRouter); +app.use("/api/generator", generatorRouter); if (existsSync(webDist)) { app.use(express.static(webDist)); diff --git a/server/src/routes/generator.ts b/server/src/routes/generator.ts new file mode 100644 index 0000000..de5fcf4 --- /dev/null +++ b/server/src/routes/generator.ts @@ -0,0 +1,128 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth, requireRole } from "../auth/middleware.js"; +import { recordAudit } from "../services/audit.js"; +import { getSettings, updateSettings } from "../services/settingsStore.js"; +import { + DEFAULT_THEME_IDS, + InvalidThemesError, + MAX_NAME_LENGTH, + cleanThemes, + defaultThemes, + importSkatteverketNames, + readStoredThemes, + type NameTheme, + type NameThemeSource, +} from "../services/nameThemes.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +export const generatorRouter = Router(); +generatorRouter.use(requireAuth); + +async function currentThemes(): Promise { + return readStoredThemes((await getSettings()).nameGenerator.themes); +} + +// Read by everyone signed in — the Generator page needs the lists to pick from. Editing them is a Settings matter. +generatorRouter.get("/themes", asyncHandler(async (_req, res) => { + res.json({ themes: await currentThemes(), builtinIds: DEFAULT_THEME_IDS }); +})); + +generatorRouter.get("/themes/defaults", requireRole("admin"), (_req, res) => { + res.json({ themes: defaultThemes() }); +}); + +const sourceSchema = z.object({ + kind: z.literal("skatteverket"), + sex: z.enum(["girls", "boys"]), + years: z.array(z.number().int()).max(10), + count: z.number().int(), + importedAt: z.string().max(40), +}); + +const saveSchema = z.object({ + themes: z + .array( + z.object({ + id: z.string().max(40).optional(), + label: z.string().max(200), + names: z.array(z.string().max(MAX_NAME_LENGTH * 3)).max(10000), + lastImport: sourceSchema.optional(), + }), + ) + .max(100), +}); + +/** A short, readable account of what an edit changed, for the audit log. */ +function describeThemeChanges(before: NameTheme[], after: NameTheme[]) { + const beforeById = new Map(before.map((t) => [t.id, t])); + const afterIds = new Set(after.map((t) => t.id)); + const created = after.filter((t) => !beforeById.has(t.id)).map((t) => `${t.label} (${t.names.length} names)`); + const deleted = before.filter((t) => !afterIds.has(t.id)).map((t) => t.label); + const edited: { list: string; renamedFrom?: string; added: number; removed: number }[] = []; + for (const t of after) { + const old = beforeById.get(t.id); + if (!old) continue; + const had = new Set(old.names); + const has = new Set(t.names); + const added = t.names.filter((n) => !had.has(n)).length; + const removed = old.names.filter((n) => !has.has(n)).length; + if (added || removed || old.label !== t.label) edited.push({ list: t.label, ...(old.label !== t.label ? { renamedFrom: old.label } : {}), added, removed }); + } + return { created, deleted, edited }; +} + +generatorRouter.put("/themes", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = saveSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "That doesn't look like a set of name lists.", details: parsed.error.flatten() }); + + let themes: NameTheme[]; + try { + themes = cleanThemes(parsed.data.themes); + } catch (err) { + if (err instanceof InvalidThemesError) return res.status(400).json({ error: "invalid_names", message: err.message, invalid: err.invalid }); + throw err; + } + + const before = await currentThemes(); + const changes = describeThemeChanges(before, themes); + await updateSettings({ nameGenerator: { themes } }); + + if (changes.created.length || changes.deleted.length || changes.edited.length) { + await recordAudit({ + actor: req.currentUser!, + category: "settings", + action: "update_name_lists", + targetType: "name_generator", + detail: changes, + }); + } + res.json({ themes }); +})); + +const importSchema = z.object({ + sex: z.enum(["girls", "boys"]), + years: z.number().int().min(1).max(5), + count: z.number().int().min(10).max(500), +}); + +// Only fetches and returns a preview — nothing is stored until the editor's own Save, so an import can be looked at (and +// thrown away) first. The address is fixed; none of the request's values go into it unchecked. +generatorRouter.post("/themes/import", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = importSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Pick girls or boys, 1–5 years and 10–500 names.", details: parsed.error.flatten() }); + + try { + const result = await importSkatteverketNames(parsed.data.sex, parsed.data.years, parsed.data.count); + const source: NameThemeSource = { + kind: "skatteverket", + sex: parsed.data.sex, + years: result.years, + count: parsed.data.count, + importedAt: new Date().toISOString(), + }; + res.json({ names: result.names, source, missingYears: result.missingYears, skipped: result.skipped }); + } catch (err) { + res.status(502).json({ error: "import_failed", message: err instanceof Error ? err.message : String(err) }); + } +})); diff --git a/server/src/services/nameThemes.ts b/server/src/services/nameThemes.ts new file mode 100644 index 0000000..224f7a6 --- /dev/null +++ b/server/src/services/nameThemes.ts @@ -0,0 +1,294 @@ +/** + * The name lists behind Operations → Generator's server-name picker. + * + * The built-in lists below are hand-picked, not taken from any official source — they're a starting point. Admins can + * edit every list under Settings → Names, and can replace a Swedish list with real statistics from Skatteverket. + */ + +export interface NameThemeSource { + kind: "skatteverket"; + sex: "girls" | "boys"; + /** Birth years that were combined. */ + years: number[]; + /** How many names the import asked for. */ + count: number; + importedAt: string; +} + +export interface NameTheme { + id: string; + label: string; + names: string[]; + /** The last import into this list, if there's been one. Editing the list by hand doesn't clear it. */ + lastImport?: NameThemeSource; +} + +export const MAX_THEMES = 20; +export const MAX_NAMES_PER_THEME = 2000; +export const MAX_LABEL_LENGTH = 40; +export const MAX_NAME_LENGTH = 30; + +/** + * Names end up as server names and hostnames, so they're kept to lowercase a–z, digits and inner hyphens. Accents are + * folded away first (Åsa → asa, José → jose) so a pasted Swedish name isn't rejected over its å, ä or ö. + */ +export function normalizeName(raw: string): string | null { + const folded = raw + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .trim() + .toLowerCase(); + return /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(folded) ? folded : null; +} + +export function slugifyId(label: string): string { + return ( + label + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "list" + ); +} + +function words(text: string): string[] { + return text.split(/\s+/).filter(Boolean); +} + +const SWEDISH_GIRLS = words(` + freja elsa alice maja wilma alma ebba lilly ella saga agnes stella selma vera ingrid astrid linnea nova sara emma + julia olivia isabelle klara nellie elin signe tuva moa tyra hedda nora amanda anna elvira iris matilda molly sofia + thea vilma cornelia filippa livia meja ronja sigrid tilde greta hilda leia lovisa siri jasmine felicia ida lina + mira mimmi lykke ellen ellie emelie hanna jenny josefin kajsa karin lisa lotta maria märta nathalie pia + rebecka sanna sally stina svea tindra ylva yvonne +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +const SWEDISH_BOYS = words(` + noah liam hugo lucas oliver elias oscar william adam alfred nils axel arvid vincent theo leo ludvig filip viktor + albin gustav melvin sixten love ivar edvin otto wilmer malte valter folke sigge algot ebbe noel benjamin felix isak + jonathan anton erik emil johan karl lars anders mattias henrik jakob sebastian daniel samuel alex max rasmus + tage olle tobias simon kasper julius ture vidar viggo vilgot ville lennart gunnar bertil sten stig bo björn + rolf ulf kurt mats magnus mikael peter per pontus +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +const DISNEY = words(` + moana elsa anna belle ariel jasmine aurora cinderella rapunzel mulan tiana merida pocahontas mickey minnie simba + nala stitch lilo olaf baymax dory nemo woody buzz genie aladdin eric flynn kristoff sven pumbaa timon mufasa scar + ursula maleficent gaston hercules meg tinkerbell wendy pinocchio bambi dumbo thumper flounder sebastian iago abu + pascal maximus heihei goofy donald daisy pluto chip dale bagheera baloo mowgli rafiki zazu piglet tigger eeyore + pooh hades phil jafar rajah tarzan jane kida milo pacha kuzco yzma bolt judy nick gazelle mirabel bruno luisa + isabela cruella dodger tramp lady jiminy figaro cleo shenzi banzai kronk vanellope ralph esmeralda quasimodo + megara belle gus jaq +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +const PIXAR = words(` + jessie rex hamm slinky bo lotso flik heimlich mike sulley boo randall marlin crush bruce gill remy linguini colette + walle eve carl russell dug kevin lightning mater sally doc luigi guido fillmore joy sadness anger fear disgust bing + arlo spot miguel hector dante ian barley luca alberto giulia mei ming elastigirl dash violet jack edna syndrome + forky gabby ducky bunny duke ember wade bing-bong riley +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +const NORSE = words(` + odin thor loki freya frigg baldur tyr heimdall idun bragi njord freyr sif hel ymir fenrir sleipnir ran aegir skadi + vidar vali ullr forseti hermod sigyn nanna jord eir fulla gefjon mimir yggdrasil asgard midgard valhalla bifrost + ragnarok jormungandr hugin munin audhumla surtr +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +const LINDGREN = words(` + pippi emil ida lotta madicken mio ronja birk mattis borka karlsson lillebror rasmus tengil katla skorpan jonatan + nangijala bullerbyn vimmerby lonneberga junibacken villekulla kalle +`) + .map((n) => normalizeName(n)) + .filter((n): n is string => n !== null); + +function dedupe(list: string[]): string[] { + return [...new Set(list)]; +} + +/** What a fresh install starts with, and what "restore" puts back. */ +export function defaultThemes(): NameTheme[] { + return [ + { id: "swedish-girls", label: "Swedish girl names", names: dedupe(SWEDISH_GIRLS) }, + { id: "swedish-boys", label: "Swedish boy names", names: dedupe(SWEDISH_BOYS) }, + { id: "disney", label: "Disney characters", names: dedupe(DISNEY) }, + { id: "pixar", label: "Pixar characters", names: dedupe(PIXAR) }, + { id: "norse", label: "Norse mythology", names: dedupe(NORSE) }, + { id: "lindgren", label: "Astrid Lindgren", names: dedupe(LINDGREN) }, + ]; +} + +export const DEFAULT_THEME_IDS = defaultThemes().map((t) => t.id); + +// ─── Validating an edit ────────────────────────────────────────────────────── + +export interface InvalidName { + theme: string; + name: string; +} + +export class InvalidThemesError extends Error { + constructor( + message: string, + public readonly invalid: InvalidName[] = [], + ) { + super(message); + } +} + +/** + * Turns whatever the editor sent into clean themes, or throws with a message that says what to fix. Names are folded + * and de-duplicated; a name that can't be made into a hostname-safe one is reported, never silently dropped. + */ +export function cleanThemes(input: { id?: string; label: string; names: string[]; lastImport?: NameThemeSource }[]): NameTheme[] { + if (input.length > MAX_THEMES) throw new InvalidThemesError(`At most ${MAX_THEMES} lists.`); + const usedIds = new Set(); + const invalid: InvalidName[] = []; + const out: NameTheme[] = []; + + for (const raw of input) { + const label = raw.label.trim(); + if (!label) throw new InvalidThemesError("Every list needs a name."); + if (label.length > MAX_LABEL_LENGTH) throw new InvalidThemesError(`“${label}” — list names can be at most ${MAX_LABEL_LENGTH} characters.`); + + let id = raw.id && /^[a-z0-9-]{1,40}$/.test(raw.id) ? raw.id : slugifyId(label); + for (let n = 2; usedIds.has(id); n++) id = `${slugifyId(label)}-${n}`; + usedIds.add(id); + + const names: string[] = []; + for (const entry of raw.names) { + if (!entry.trim()) continue; + const clean = normalizeName(entry); + if (clean === null) invalid.push({ theme: label, name: entry.trim() }); + else names.push(clean); + } + const unique = dedupe(names); + if (unique.length > MAX_NAMES_PER_THEME) throw new InvalidThemesError(`“${label}” has ${unique.length} names — at most ${MAX_NAMES_PER_THEME} per list.`); + out.push({ id, label, names: unique, ...(raw.lastImport ? { lastImport: raw.lastImport } : {}) }); + } + + if (invalid.length > 0) { + const shown = invalid.slice(0, 5).map((i) => `“${i.name}”`).join(", "); + throw new InvalidThemesError( + `${invalid.length} name${invalid.length === 1 ? " isn't" : "s aren't"} usable as a server name (${shown}${invalid.length > 5 ? ", …" : ""}). Use letters, digits and hyphens, no spaces.`, + invalid, + ); + } + return out; +} + +/** Reads themes back out of settings defensively — a backup restore or hand-edited row must not be able to break the Generator. */ +export function readStoredThemes(stored: unknown): NameTheme[] { + if (!Array.isArray(stored)) return defaultThemes(); + const themes: NameTheme[] = []; + for (const t of stored) { + if (!t || typeof t !== "object") continue; + const { id, label, names, lastImport } = t as Partial; + if (typeof id !== "string" || typeof label !== "string" || !Array.isArray(names)) continue; + themes.push({ + id, + label, + names: names.filter((n): n is string => typeof n === "string"), + ...(lastImport && typeof lastImport === "object" ? { lastImport } : {}), + }); + } + return themes; +} + +// ─── Importing from Skatteverket ──────────────────────────────────────────── + +/** + * Skatteverket's open-data API for "Namn på nyfödda": the most common given names of babies, by sex and birth year, + * for the whole country ("Total"). It needs no key. Statistics Sweden (SCB), which used to publish this, stopped + * after 2023 and points to Skatteverket. + */ +const SKATTEVERKET_DATASET = "https://skatteverket.entryscape.net/rowstore/dataset/da2556d0-c717-45e8-a1d8-3320161d3a7d"; +const PAGE_SIZE = 500; +const MAX_PAGES = 4; +const FETCH_TIMEOUT_MS = 20_000; + +export interface NameImport { + names: string[]; + years: number[]; + /** Years that had no data (yet) and were left out. */ + missingYears: number[]; + /** Names Skatteverket lists that can't be used as a server name (a space, an unusual letter) and were left out. */ + skipped: string[]; +} + +interface Row { + namn?: string; + antal?: string; + rangordning?: string; +} + +async function fetchYear(sex: "girls" | "boys", year: number): Promise { + const rows: Row[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const url = `${SKATTEVERKET_DATASET}?gruppering=Total&fodelsear=${year}&k%C3%B6n=${sex === "girls" ? "Kvinna" : "Man"}&_limit=${PAGE_SIZE}&_offset=${page * PAGE_SIZE}`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Skatteverket's name service answered ${res.status}.`); + const body = (await res.json()) as { results?: Row[] }; + const results = Array.isArray(body.results) ? body.results : []; + rows.push(...results); + if (results.length < PAGE_SIZE) break; + } + return rows; +} + +/** + * The most common names over the last `yearCount` full calendar years (the running year is only partly counted, so it's + * skipped). Counts are added up across years, so one unusually popular year doesn't decide the list. + */ +export async function importSkatteverketNames(sex: "girls" | "boys", yearCount: number, count: number, now = new Date()): Promise { + const wanted = Array.from({ length: yearCount }, (_, i) => now.getUTCFullYear() - 1 - i); + const totals = new Map(); + const skipped = new Set(); + const years: number[] = []; + const missingYears: number[] = []; + + for (const year of wanted) { + let rows: Row[]; + try { + rows = await fetchYear(sex, year); + } catch (err) { + if (err instanceof Error && err.name === "TimeoutError") throw new Error("Skatteverket's name service didn't answer in time."); + throw err; + } + if (rows.length === 0) { + missingYears.push(year); + continue; + } + years.push(year); + for (const row of rows) { + const raw = (row.namn ?? "").trim(); + const amount = Number(row.antal); + if (!raw || !Number.isFinite(amount)) continue; + const clean = normalizeName(raw); + if (clean === null) { + skipped.add(raw); + continue; + } + totals.set(clean, (totals.get(clean) ?? 0) + amount); + } + } + + if (years.length === 0) throw new Error("Skatteverket has no name statistics for those years."); + + const names = [...totals.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "sv")) + .slice(0, count) + .map(([name]) => name); + return { names, years: years.sort((a, b) => a - b), missingYears, skipped: [...skipped].sort((a, b) => a.localeCompare(b, "sv")) }; +} diff --git a/server/src/services/settingsStore.ts b/server/src/services/settingsStore.ts index 7c62049..8cb5abb 100644 --- a/server/src/services/settingsStore.ts +++ b/server/src/services/settingsStore.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { settings } from "../db/schema.js"; +import { defaultThemes, type NameTheme } from "./nameThemes.js"; export interface GotifySettings { enabled: boolean; @@ -99,6 +100,11 @@ export interface ConsistencySettings { excludedRanges: string[]; } +export interface NameGeneratorSettings { + /** The lists the Generator picks server names from — edited under Settings → Names. */ + themes: NameTheme[]; +} + export interface AppSettings { gotify: GotifySettings; ntfy: NtfySettings; @@ -112,6 +118,7 @@ export interface AppSettings { quietHours: QuietHoursSettings; healthChecks: HealthCheckSettings; consistency: ConsistencySettings; + nameGenerator: NameGeneratorSettings; } const DEFAULTS: AppSettings = { @@ -145,6 +152,7 @@ const DEFAULTS: AppSettings = { // Docker's default bridge (172.17.0.0/16) and the pool it hands custom networks from (172.18–31) live here, and every // Docker host repeats them. Shown on the Consistency page, where it can be removed if 172.16/12 is used as a real LAN. consistency: { excludedRanges: ["172.16.0.0/12"] }, + nameGenerator: { themes: defaultThemes() }, }; const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[]; diff --git a/web/src/App.tsx b/web/src/App.tsx index e1787d9..983f350 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,6 +38,7 @@ import TagSettings from "./pages/settings/TagSettings"; import CacheSettings from "./pages/settings/CacheSettings"; import LogSettings from "./pages/settings/LogSettings"; import BackupSettings from "./pages/settings/BackupSettings"; +import NameSettings from "./pages/settings/NameSettings"; import AppShell from "./layout/AppShell"; import DialogHost from "./components/DialogHost"; import { setDateTimeSettings } from "./utils/date"; @@ -100,7 +101,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -161,6 +162,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index afe70c5..dadf364 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -651,6 +651,28 @@ export interface PrivacyOverview { }; } +export interface NameThemeSource { + kind: "skatteverket"; + sex: "girls" | "boys"; + years: number[]; + count: number; + importedAt: string; +} + +export interface NameTheme { + id: string; + label: string; + names: string[]; + lastImport?: NameThemeSource; +} + +export interface NameImportPreview { + names: string[]; + source: NameThemeSource; + missingYears: number[]; + skipped: string[]; +} + export interface TagEntry { name: string; /** "#rrggbb", or null for the automatic colour. */ @@ -1030,6 +1052,13 @@ export const api = { syncProxmox: () => request("/api/ipam/sync-proxmox", { method: "POST" }), syncPhpIpam: () => request("/api/ipam/sync-phpipam", { method: "POST" }), }, + generator: { + themes: () => request<{ themes: NameTheme[]; builtinIds: string[] }>("/api/generator/themes"), + defaults: () => request<{ themes: NameTheme[] }>("/api/generator/themes/defaults"), + save: (themes: (Omit & { id?: string })[]) => request<{ themes: NameTheme[] }>("/api/generator/themes", { method: "PUT", body: JSON.stringify({ themes }) }), + importNames: (sex: "girls" | "boys", years: number, count: number) => + request("/api/generator/themes/import", { method: "POST", body: JSON.stringify({ sex, years, count }) }), + }, alerts: { list: (refresh = false) => request(`/api/alerts${refresh ? "?refresh=1" : ""}`), }, diff --git a/web/src/pages/Generator.tsx b/web/src/pages/Generator.tsx index 0b29e0a..e69ae8d 100644 --- a/web/src/pages/Generator.tsx +++ b/web/src/pages/Generator.tsx @@ -1,21 +1,26 @@ -import { useEffect, useState } from "react"; -import { api } from "../api/client"; +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { api, type CurrentUser, type NameTheme } from "../api/client"; import CopyButton from "../components/CopyButton"; +import { readableError } from "../utils/errors"; import { generateServerName, generateUsername, generatePassword, passwordEntropyBits, passwordStrengthLabel, - type ServerNameTheme, type UsernameOptions, type PasswordOptions, } from "../utils/generators"; -export default function Generator() { +const MIXED = "mixed"; + +export default function Generator({ user }: { user: CurrentUser }) { // ─── Server name ─────────────────────────────────────────────────────── const [existingServerNames, setExistingServerNames] = useState([]); - const [theme, setTheme] = useState("mixed"); + const [themes, setThemes] = useState(null); + const [themesError, setThemesError] = useState(null); + const [theme, setTheme] = useState(MIXED); const [serverName, setServerName] = useState(""); useEffect(() => { @@ -27,8 +32,22 @@ export default function Generator() { }); }, []); + useEffect(() => { + api.generator + .themes() + .then((res) => setThemes(res.themes)) + .catch((err) => setThemesError(readableError(err))); + }, []); + + // "Mixed" is every list together, each name once even if it appears in several. + const pool = useMemo(() => { + if (!themes) return []; + if (theme === MIXED) return [...new Set(themes.flatMap((t) => t.names))]; + return themes.find((t) => t.id === theme)?.names ?? []; + }, [themes, theme]); + function rollServerName() { - setServerName(generateServerName(theme, existingServerNames)); + setServerName(generateServerName(pool, existingServerNames)); } // ─── Username ──────────────────────────────────────────────────────── @@ -64,7 +83,8 @@ export default function Generator() { <>

Generator

- Everything here is generated locally in your browser — nothing is sent to or stored on the server. + Usernames and passwords are generated locally in your browser — nothing is sent to or stored on the server. Server names are + picked in your browser too, from name lists the server hands out.
@@ -74,20 +94,33 @@ export default function Generator() {

Server name

-

Picks from Swedish girl names and Disney characters, avoiding names already in use.

+

+ Picks from a list of names, avoiding names already in use. + {user.role === "admin" && ( + <> + {" "} + Edit the lists. + + )} +

+ {themesError &&
{themesError}
} - setTheme(e.target.value)} disabled={!themes}> + + {(themes ?? []).map((t) => ( + + ))} +
{themes ? `${pool.length} name${pool.length === 1 ? "" : "s"} to pick from.` : "Loading the lists…"}
{serverName && }
-
diff --git a/web/src/pages/Privacy.tsx b/web/src/pages/Privacy.tsx index c0dd9f1..73ee8d0 100644 --- a/web/src/pages/Privacy.tsx +++ b/web/src/pages/Privacy.tsx @@ -251,6 +251,11 @@ export default function Privacy() { Certificate checks — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with a host to check, the app connects to that host to read its certificate. +
  • + Skatteverket — only when an admin clicks "Import from Skatteverket" under Settings → Names, the app asks + Skatteverket's open name statistics for the most common given names. The request carries a sex and a birth year, nothing about you + or your servers. +
  • Servers and agents — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index ab58601..a15aba8 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -6,6 +6,7 @@ const SUB_NAV = [ { to: "/settings/badges", label: "Badges" }, { to: "/settings/display", label: "Display" }, { to: "/settings/tags", label: "Tags" }, + { to: "/settings/names", label: "Names" }, { to: "/settings/cache", label: "Cache" }, { to: "/settings/logs", label: "Logs" }, { to: "/settings/backup", label: "Backup" }, diff --git a/web/src/pages/settings/NameSettings.tsx b/web/src/pages/settings/NameSettings.tsx new file mode 100644 index 0000000..40b50d9 --- /dev/null +++ b/web/src/pages/settings/NameSettings.tsx @@ -0,0 +1,408 @@ +import { useEffect, useMemo, useState } from "react"; +import { api, type NameImportPreview, type NameTheme, type NameThemeSource } from "../../api/client"; +import { confirmDialog } from "../../utils/dialogs"; +import { formatDateTime } from "../../utils/date"; +import { readableError } from "../../utils/errors"; +import { parseNames } from "../../utils/nameLists"; + +/** One list as it's being edited. The names are kept as the text in the box, so typing stays natural. */ +interface Draft { + /** Stable for the life of the page; a list that hasn't been saved yet has no `id`. */ + key: string; + id?: string; + label: string; + text: string; + lastImport?: NameThemeSource; +} + +const MAX_LABEL = 40; + +function toDraft(t: NameTheme): Draft { + return { key: t.id, id: t.id, label: t.label, text: t.names.join("\n"), lastImport: t.lastImport }; +} + +/** What identifies the content of a list, for telling whether anything changed. */ +function snapshot(id: string | undefined, label: string, names: string[], lastImport: NameThemeSource | undefined): string { + return JSON.stringify({ id, label, names, lastImport }); +} + +function describeImport(s: NameThemeSource): string { + const years = s.years.length === 0 ? "" : s.years.length === 1 ? String(s.years[0]) : `${s.years[0]}–${s.years[s.years.length - 1]}`; + return `${s.sex === "girls" ? "girls" : "boys"}, ${years}, top ${s.count} — ${formatDateTime(new Date(s.importedAt))}`; +} + +export default function NameSettings() { + const [saved, setSaved] = useState(null); + const [builtinIds, setBuiltinIds] = useState([]); + const [drafts, setDrafts] = useState([]); + const [selectedKey, setSelectedKey] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + const [newCount, setNewCount] = useState(0); + + // Skatteverket import panel + const [importOpen, setImportOpen] = useState(false); + const [sex, setSex] = useState<"girls" | "boys">("girls"); + const [years, setYears] = useState(3); + const [count, setCount] = useState(100); + const [fetching, setFetching] = useState(false); + const [importError, setImportError] = useState(null); + const [preview, setPreview] = useState(null); + + useEffect(() => { + api.generator + .themes() + .then((res) => { + setSaved(res.themes); + setBuiltinIds(res.builtinIds); + const next = res.themes.map(toDraft); + setDrafts(next); + setSelectedKey(next[0]?.key ?? null); + }) + .catch((err) => setError(readableError(err))); + }, []); + + const parsed = useMemo(() => drafts.map((d) => parseNames(d.text)), [drafts]); + const selectedIndex = Math.max(0, drafts.findIndex((d) => d.key === selectedKey)); + const selected = drafts[selectedIndex]; + const selectedParsed = parsed[selectedIndex]; + + const dirty = useMemo(() => { + if (!saved) return false; + const now = drafts.map((d, i) => snapshot(d.id, d.label.trim(), parsed[i].names, d.lastImport)); + const before = saved.map((t) => snapshot(t.id, t.label, t.names, t.lastImport)); + return JSON.stringify(now) !== JSON.stringify(before); + }, [saved, drafts, parsed]); + + const problem = useMemo(() => { + for (let i = 0; i < drafts.length; i++) { + if (!drafts[i].label.trim()) return "Every list needs a name."; + if (parsed[i].invalid.length > 0) return `“${drafts[i].label.trim() || "A list"}” has names that can't be used — see the list.`; + } + return null; + }, [drafts, parsed]); + + function update(key: string, patch: Partial) { + setDrafts((all) => all.map((d) => (d.key === key ? { ...d, ...patch } : d))); + setNotice(null); + } + + function select(key: string) { + setSelectedKey(key); + setImportOpen(false); + setPreview(null); + setImportError(null); + } + + function addList() { + const key = `new-${newCount}`; + setNewCount((n) => n + 1); + setDrafts((all) => [...all, { key, label: "New list", text: "" }]); + select(key); + setNotice(null); + } + + async function removeList(d: Draft) { + const ok = await confirmDialog({ + title: "Delete list", + message: `Delete “${d.label.trim() || "this list"}”? It's removed when you save${d.id && builtinIds.includes(d.id) ? ", and can be brought back with “Restore built-in lists”" : ""}.`, + confirmLabel: "Delete", + danger: true, + }); + if (!ok) return; + const index = drafts.findIndex((x) => x.key === d.key); + const rest = drafts.filter((x) => x.key !== d.key); + setDrafts(rest); + select(rest[Math.min(index, rest.length - 1)]?.key ?? ""); + setNotice(null); + } + + async function resetToBuiltIn(d: Draft) { + if (!d.id) return; + const ok = await confirmDialog({ + title: "Reset list", + message: `Put “${d.label.trim() || d.id}” back to its built-in names? Your edits to this list are lost (once you save).`, + confirmLabel: "Reset", + danger: true, + }); + if (!ok) return; + try { + const { themes } = await api.generator.defaults(); + const original = themes.find((t) => t.id === d.id); + if (!original) throw new Error("There's no built-in version of this list."); + update(d.key, { label: original.label, text: original.names.join("\n"), lastImport: undefined }); + } catch (err) { + setError(readableError(err)); + } + } + + const missingBuiltIns = builtinIds.filter((id) => !drafts.some((d) => d.id === id)); + + async function restoreBuiltIns() { + try { + const { themes } = await api.generator.defaults(); + const missing = themes.filter((t) => missingBuiltIns.includes(t.id)); + setDrafts((all) => [...all, ...missing.map(toDraft)]); + setNotice(`Brought back ${missing.length} built-in list${missing.length === 1 ? "" : "s"} — save to keep ${missing.length === 1 ? "it" : "them"}.`); + } catch (err) { + setError(readableError(err)); + } + } + + async function save() { + setBusy(true); + setError(null); + setNotice(null); + try { + const res = await api.generator.save( + drafts.map((d, i) => ({ id: d.id, label: d.label.trim(), names: parsed[i].names, lastImport: d.lastImport })), + ); + const keepAt = selectedIndex; + setSaved(res.themes); + const next = res.themes.map(toDraft); + setDrafts(next); + setSelectedKey(next[Math.min(keepAt, next.length - 1)]?.key ?? null); + setNotice("Saved. The Generator uses these lists from now on."); + } catch (err) { + setError(readableError(err)); + } finally { + setBusy(false); + } + } + + function discard() { + if (!saved) return; + const next = saved.map(toDraft); + setDrafts(next); + setSelectedKey(next[0]?.key ?? null); + setNotice(null); + setError(null); + setImportOpen(false); + setPreview(null); + } + + async function fetchPreview() { + setFetching(true); + setImportError(null); + setPreview(null); + try { + setPreview(await api.generator.importNames(sex, years, count)); + } catch (err) { + setImportError(readableError(err)); + } finally { + setFetching(false); + } + } + + function applyPreview(mode: "add" | "replace") { + if (!preview || !selected) return; + const existing = mode === "add" ? selectedParsed.names : []; + const have = new Set(existing); + const fresh = preview.names.filter((n) => !have.has(n)); + update(selected.key, { text: [...existing, ...fresh].join("\n"), lastImport: preview.source }); + setNotice( + mode === "add" + ? `Added ${fresh.length} new name${fresh.length === 1 ? "" : "s"} (${preview.names.length - fresh.length} were already there) — save to keep ${fresh.length === 1 ? "it" : "them"}.` + : `Replaced the list with ${preview.names.length} names — save to keep them.`, + ); + setPreview(null); + setImportOpen(false); + } + + function sortList() { + if (!selected) return; + update(selected.key, { text: [...selectedParsed.names].sort((a, b) => a.localeCompare(b, "sv")).join("\n") }); + } + + if (!saved) { + return error ?
    {error}
    :
    Loading…
    ; + } + + return ( + <> + {error &&
    {error}
    } + {notice &&
    {notice}
    } + +
    +
    +

    Name lists

    +
    + {missingBuiltIns.length > 0 && ( + + )} + +
    +
    +
    +
    + Operations → Generator picks server names from these lists. A name is letters, digits and hyphens (å, ä and ö become a, a and o), so + it works as a hostname. Changes apply when you save. +
    + {drafts.length === 0 ? ( +
    No lists. Add one, or restore the built-in ones.
    + ) : ( +
      + {drafts.map((d, i) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + + {selected && selectedParsed && ( +
    +
    +
    +
    + + update(selected.key, { label: e.target.value })} + /> +
    +
    +
    + + + {selected.id && builtinIds.includes(selected.id) && ( + + )} + +
    +
    +
    + + {importOpen && ( +
    +
    Import the most common names from Skatteverket
    +
    + Skatteverket publishes the given names of newborns in Sweden, by sex and birth year, as open data. This fetches the most common ones + across the years you pick (the running year isn't counted — it isn't complete). You see them first; nothing changes until you add + them to this list and save. +
    +
    +
    + + +
    +
    + + +
    +
    + + setCount(Math.min(500, Math.max(10, Number(e.target.value) || 10)))} + /> +
    + +
    + {importError &&
    {importError}
    } + {preview && ( +
    +
    + Found {preview.names.length} names for {preview.source.sex === "girls" ? "girls" : "boys"}, born{" "} + {preview.source.years.join(", ")}. + {preview.missingYears.length > 0 && No data yet for {preview.missingYears.join(", ")}.} + {preview.skipped.length > 0 && ( + + {" "} + {preview.skipped.length} left out because they can't be used as a server name ({preview.skipped.slice(0, 5).join(", ")} + {preview.skipped.length > 5 ? ", …" : ""}). + + )} +
    +
    + {preview.names.join(", ")} +
    +
    + + + +
    +
    + )} +
    + )} + + +