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:
1 parent
35979cb043
commit
0da73711d9
4 files changed
+360
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user