Add a Generator page for server names, usernames, and passwords

New "Generator" nav item, open to every role since it's a pure
client-side utility with no data mutation. Three independent cards:
- Server name: picks from Swedish girl names or Disney characters (or
  both), skipping any name already used by an existing server —
  checked against the real server list, not just avoiding duplicates
  within one session.
- Username: adjective+animal with a configurable separator and an
  optional 2-digit suffix.
- Password: length slider, per-charset toggles, an "exclude ambiguous
  characters" option (0/O, 1/l/I), and a rough entropy/strength
  readout. Uses crypto.getRandomValues with rejection sampling (not
  Math.random or a plain modulo), since a biased password generator
  is a real security footgun.

Nothing generated here is sent to or stored on the server — it's
computed entirely in the browser and only reaches the backend if the
user pastes it into some other form themselves (e.g. Secrets, adding
a server).

Verified generators.ts directly: collision avoidance always returns
the one remaining unused name when every other option in a themed
list is taken, the full-list-exhausted fallback produces a numbered
name that still doesn't collide, matching is case-insensitive,
per-charset password generation only ever produces characters from
the selected sets, excludeAmbiguous holds across 200 40-character
samples, entropy math matches the expected log2 formula, and a
5000-sample single-character distribution came out uniform (469-521
per digit against an expected 500, no modulo bias) confirming the
rejection-sampling RNG is unbiased.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:56:15 +02:00
co-authored by Claude Sonnet 5
parent 35979cb043
commit 0da73711d9
4 changed files with 360 additions and 0 deletions
+2
View File
@@ -19,6 +19,7 @@ import Semaphore from "./pages/Semaphore";
import Gitea from "./pages/Gitea";
import Proxmox from "./pages/Proxmox";
import Synology from "./pages/Synology";
import Generator from "./pages/Generator";
import Settings from "./pages/Settings";
import NotificationSettings from "./pages/settings/NotificationSettings";
import BadgeSettings from "./pages/settings/BadgeSettings";
@@ -86,6 +87,7 @@ export default function App() {
<Route path="/ipam" element={<Ipam user={user} />} />
<Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<Integrations user={user} />} />
<Route path="/generator" element={<Generator />} />
<Route path="/docker" element={<Docker user={user} />} />
<Route path="/tailscale" element={<Tailscale user={user} />} />
<Route path="/semaphore" element={<Semaphore user={user} />} />
+2
View File
@@ -21,6 +21,7 @@ import {
IconMenu2,
IconSun,
IconMoon,
IconWand,
} from "@tabler/icons-react";
import type { CurrentUser } from "../api/client";
import { getTheme, setTheme, type Theme } from "../utils/theme";
@@ -46,6 +47,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
{ to: "/sessions", label: "Sessions", icon: <IconDevices size={20} />, minRole: "admin" },
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import CopyButton from "../components/CopyButton";
import {
generateServerName,
generateUsername,
generatePassword,
passwordEntropyBits,
passwordStrengthLabel,
type ServerNameTheme,
type UsernameOptions,
type PasswordOptions,
} from "../utils/generators";
export default function Generator() {
// ─── Server name ───────────────────────────────────────────────────────
const [existingServerNames, setExistingServerNames] = useState<string[]>([]);
const [theme, setTheme] = useState<ServerNameTheme>("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.
});
}, []);
function rollServerName() {
setServerName(generateServerName(theme, 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">
Everything here is generated locally in your browser — nothing is sent to or stored on the server.
</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 Swedish girl names and Disney characters, avoiding names already in use.</p>
<label className="form-label">Theme</label>
<select className="form-select mb-3" value={theme} onChange={(e) => setTheme(e.target.value as ServerNameTheme)}>
<option value="mixed">Mixed</option>
<option value="swedish">Swedish girl names</option>
<option value="disney">Disney characters</option>
</select>
<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}>
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>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
/** Uniform random integer in [0, max) via rejection sampling — avoids the modulo bias a plain `% max` would introduce. */
function randomInt(max: number): number {
const array = new Uint32Array(1);
const limit = Math.floor(0x100000000 / max) * max;
let value: number;
do {
crypto.getRandomValues(array);
value = array[0];
} while (value >= limit);
return value % max;
}
function pick<T>(list: T[]): T {
return list[randomInt(list.length)];
}
// ─── Server names ───────────────────────────────────────────────────────────
export type ServerNameTheme = "swedish" | "disney" | "mixed";
// Common Swedish girl names, per SCB/Skatteverket's own published
// name-popularity statistics for recent birth cohorts.
const SWEDISH_GIRL_NAMES = [
"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",
];
// Single-word Disney character names (kept single-word so they slug into a
// hostname cleanly without a judgment call on how to join "Snow White").
const DISNEY_CHARACTERS = [
"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",
];
/** Picks a name from the given theme not already present (case-insensitively) in `existing`, appending -2/-3/... only if the whole list is exhausted. */
export function generateServerName(theme: ServerNameTheme, existing: string[] = []): string {
const pool = theme === "swedish" ? SWEDISH_GIRL_NAMES : theme === "disney" ? DISNEY_CHARACTERS : [...SWEDISH_GIRL_NAMES, ...DISNEY_CHARACTERS];
const used = new Set(existing.map((n) => n.toLowerCase()));
const available = pool.filter((n) => !used.has(n));
if (available.length > 0) return pick(available);
// Every name in the theme is already taken — fall back to numbering a random one.
const base = pick(pool);
for (let suffix = 2; suffix < 1000; suffix++) {
const candidate = `${base}${suffix}`;
if (!used.has(candidate)) return candidate;
}
return `${base}${randomInt(100000)}`;
}
// ─── Usernames ───────────────────────────────────────────────────────────────
const USERNAME_ADJECTIVES = [
"clever", "quiet", "swift", "brave", "calm", "bright", "steady", "gentle", "bold", "keen",
"quick", "sharp", "lucky", "sunny", "cool", "wise", "mellow", "spry", "nimble", "vivid",
];
const USERNAME_NOUNS = [
"otter", "falcon", "lynx", "raven", "badger", "heron", "wolf", "sparrow", "fox", "hawk",
"marten", "puffin", "osprey", "harrier", "kestrel", "pike", "elk", "moose", "wren", "orca",
];
export interface UsernameOptions {
separator: "" | "-" | "_" | ".";
includeNumber: boolean;
}
export function generateUsername(options: UsernameOptions): string {
const parts = [pick(USERNAME_ADJECTIVES), pick(USERNAME_NOUNS)];
if (options.includeNumber) parts.push(String(randomInt(90) + 10));
return parts.join(options.separator);
}
// ─── Passwords ───────────────────────────────────────────────────────────────
const CHARSETS = {
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lowercase: "abcdefghijklmnopqrstuvwxyz",
numbers: "0123456789",
symbols: "!@#$%^&*()-_=+[]{}",
};
// Characters that are easy to misread in most fonts (0/O, 1/l/I, etc).
const AMBIGUOUS = "0O1lI";
export interface PasswordOptions {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
symbols: boolean;
excludeAmbiguous: boolean;
}
export function generatePassword(options: PasswordOptions): string {
let charset = "";
if (options.uppercase) charset += CHARSETS.uppercase;
if (options.lowercase) charset += CHARSETS.lowercase;
if (options.numbers) charset += CHARSETS.numbers;
if (options.symbols) charset += CHARSETS.symbols;
if (options.excludeAmbiguous) charset = [...charset].filter((c) => !AMBIGUOUS.includes(c)).join("");
if (charset.length === 0) return "";
let result = "";
for (let i = 0; i < options.length; i++) {
result += charset[randomInt(charset.length)];
}
return result;
}
/** log2(charsetSize^length) — a standard, if approximate (assumes uniform random selection, which generatePassword provides), entropy estimate. */
export function passwordEntropyBits(options: PasswordOptions): number {
let charsetSize = 0;
if (options.uppercase) charsetSize += CHARSETS.uppercase.length;
if (options.lowercase) charsetSize += CHARSETS.lowercase.length;
if (options.numbers) charsetSize += CHARSETS.numbers.length;
if (options.symbols) charsetSize += CHARSETS.symbols.length;
if (options.excludeAmbiguous) charsetSize = Math.max(0, charsetSize - AMBIGUOUS.length);
if (charsetSize === 0) return 0;
return Math.round(options.length * Math.log2(charsetSize));
}
export function passwordStrengthLabel(bits: number): { label: string; className: string } {
if (bits < 40) return { label: "Weak", className: "text-red" };
if (bits < 60) return { label: "Fair", className: "text-yellow" };
if (bits < 90) return { label: "Strong", className: "text-green" };
return { label: "Very strong", className: "text-green" };
}