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:
bobbanandClaude Sonnet 5 committed 2026-09-24 20:56:15 +02:00
1 parent 35979cb043
commit 0da73711d9
4 files changed
+360

No files matched your search

+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>
</>
);
}