Files
Homelab-manager/web/src/pages/Generator.tsx
T
bobbanandClaude Sonnet 5 0da73711d9 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>
2026-09-24 20:56:15 +02:00

224 lines
9.2 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, 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>
</>
);
}