Files
Homelab-manager/web/src/pages/Generator.tsx
T
bobbanandClaude Sonnet 5.5 3035d7fc08 Make the Generator's server-name lists editable, add themes, import names from Skatteverket
Name lists now live in settings instead of the web bundle. Admins edit them under
Settings -> Names (add/remove names, create/rename/delete lists, reset a built-in
list). Names are folded to hostname-safe form (a-z, 0-9, hyphen) and validated on
the server; saves are audited.

Adds Swedish boy names, Pixar, Norse mythology and Astrid Lindgren lists, and
lengthens the Swedish girl and Disney lists. "Mixed" is now every list with each
name counted once.

Admins can preview and import the most common Swedish names from Skatteverket's
open "Namn pa nyfodda" data (girls or boys, latest 1-5 full years); nothing is
stored until the editor is saved. The old comment that credited SCB statistics is
gone: SCB stopped publishing name statistics after 2023.

Privacy page, README and ROLES updated for the new outbound call and page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:53:31 +02:00

257 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 UsernameOptions,
type PasswordOptions,
} from "../utils/generators";
const MIXED = "mixed";
export default function Generator({ user }: { user: CurrentUser }) {
// ─── Server name ───────────────────────────────────────────────────────
const [existingServerNames, setExistingServerNames] = useState<string[]>([]);
const [themes, setThemes] = useState<NameTheme[] | null>(null);
const [themesError, setThemesError] = useState<string | null>(null);
const [theme, setTheme] = useState<string>(MIXED);
const [serverName, setServerName] = useState("");
useEffect(() => {
api.servers
.list()
.then((res) => setExistingServerNames(res.servers.map((s) => s.name)))
.catch(() => {
// Best-effort — collision-avoidance just won't have anything to check against.
});
}, []);
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(pool, existingServerNames));
}
// ─── Username ────────────────────────────────────────────────────────
const [usernameOptions, setUsernameOptions] = useState<UsernameOptions>({ separator: "-", includeNumber: true });
const [username, setUsername] = useState("");
function rollUsername() {
setUsername(generateUsername(usernameOptions));
}
// ─── Password ────────────────────────────────────────────────────────
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>({
length: 20,
uppercase: true,
lowercase: true,
numbers: true,
symbols: true,
excludeAmbiguous: false,
});
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
function rollPassword() {
setPassword(generatePassword(passwordOptions));
}
const noCharsetSelected =
!passwordOptions.uppercase && !passwordOptions.lowercase && !passwordOptions.numbers && !passwordOptions.symbols;
const entropy = passwordEntropyBits(passwordOptions);
const strength = passwordStrengthLabel(entropy);
return (
<>
<h2 className="page-title mb-3">Generator</h2>
<div className="text-secondary mb-3">
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.
</div>
<div className="row row-cards">
<div className="col-md-6 col-lg-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Server name</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Picks from a list of names, avoiding names already in use.
{user.role === "admin" && (
<>
{" "}
<Link to="/settings/names">Edit the lists</Link>.
</>
)}
</p>
{themesError && <div className="alert alert-danger py-2">{themesError}</div>}
<label className="form-label">Theme</label>
<select className="form-select mb-1" value={theme} onChange={(e) => setTheme(e.target.value)} disabled={!themes}>
<option value={MIXED}>Mixed — all lists</option>
{(themes ?? []).map((t) => (
<option key={t.id} value={t.id}>
{t.label} ({t.names.length})
</option>
))}
</select>
<div className="form-hint mb-3">{themes ? `${pool.length} name${pool.length === 1 ? "" : "s"} to pick from.` : "Loading the lists…"}</div>
<div className="input-group">
<input type="text" className="form-control" readOnly value={serverName} placeholder="Click Generate…" />
{serverName && <CopyButton text={serverName} />}
</div>
</div>
<div className="card-footer">
<button className="btn btn-primary" onClick={rollServerName} disabled={pool.length === 0}>
Generate
</button>
</div>
</div>
</div>
<div className="col-md-6 col-lg-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Username</h3>
</div>
<div className="card-body">
<p className="text-secondary">An adjective + animal, optionally with a number — easy to say, hard to collide.</p>
<div className="row g-2 mb-3">
<div className="col-7">
<label className="form-label">Separator</label>
<select
className="form-select"
value={usernameOptions.separator}
onChange={(e) => setUsernameOptions((o) => ({ ...o, separator: e.target.value as UsernameOptions["separator"] }))}
>
<option value="-">Hyphen (clever-otter)</option>
<option value="_">Underscore (clever_otter)</option>
<option value=".">Dot (clever.otter)</option>
<option value="">None (cleverotter)</option>
</select>
</div>
<div className="col-5 d-flex align-items-end">
<label className="form-check mb-2">
<input
type="checkbox"
className="form-check-input"
checked={usernameOptions.includeNumber}
onChange={(e) => setUsernameOptions((o) => ({ ...o, includeNumber: e.target.checked }))}
/>
<span className="form-check-label">Add number</span>
</label>
</div>
</div>
<div className="input-group">
<input type="text" className="form-control" readOnly value={username} placeholder="Click Generate…" />
{username && <CopyButton text={username} />}
</div>
</div>
<div className="card-footer">
<button className="btn btn-primary" onClick={rollUsername}>
Generate
</button>
</div>
</div>
</div>
<div className="col-md-6 col-lg-4">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Password</h3>
</div>
<div className="card-body">
<label className="form-label">Length: {passwordOptions.length}</label>
<input
type="range"
className="form-range mb-3"
min={8}
max={64}
value={passwordOptions.length}
onChange={(e) => setPasswordOptions((o) => ({ ...o, length: Number(e.target.value) }))}
/>
<div className="row g-2 mb-3">
{(
[
["uppercase", "A–Z"],
["lowercase", "a–z"],
["numbers", "0–9"],
["symbols", "!@#$…"],
] as const
).map(([key, label]) => (
<div className="col-6" key={key}>
<label className="form-check mb-0">
<input
type="checkbox"
className="form-check-input"
checked={passwordOptions[key]}
onChange={(e) => setPasswordOptions((o) => ({ ...o, [key]: e.target.checked }))}
/>
<span className="form-check-label">{label}</span>
</label>
</div>
))}
</div>
<label className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
checked={passwordOptions.excludeAmbiguous}
onChange={(e) => setPasswordOptions((o) => ({ ...o, excludeAmbiguous: e.target.checked }))}
/>
<span className="form-check-label">Exclude ambiguous characters (0/O, 1/l/I…)</span>
</label>
{noCharsetSelected && <div className="alert alert-danger py-2">Pick at least one character type.</div>}
<div className="input-group mb-2">
<input
type={showPassword ? "text" : "password"}
className="form-control font-monospace"
readOnly
value={password}
placeholder="Click Generate…"
/>
{password && (
<>
<button type="button" className="btn btn-outline-secondary" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? "Hide" : "Show"}
</button>
<CopyButton text={password} />
</>
)}
</div>
{password && (
<div className={`small ${strength.className}`}>
{strength.label} (~{entropy} bits of entropy)
</div>
)}
</div>
<div className="card-footer">
<button className="btn btn-primary" onClick={rollPassword} disabled={noCharsetSelected}>
Generate
</button>
</div>
</div>
</div>
</div>
</>
);
}