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