diff --git a/web/src/App.tsx b/web/src/App.tsx index b037360..c5ee0a6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index bafb938..e3698a3 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -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: }, { to: "/gitea", label: "Gitea", icon: }, { to: "/integrations", label: "Integrations", icon: }, + { to: "/generator", label: "Generator", icon: }, { to: "/users", label: "Users", icon: , minRole: "admin" }, { to: "/sessions", label: "Sessions", icon: , minRole: "admin" }, { to: "/audit-log", label: "Audit Log", icon: , minRole: "operator" }, diff --git a/web/src/pages/Generator.tsx b/web/src/pages/Generator.tsx new file mode 100644 index 0000000..0b29e0a --- /dev/null +++ b/web/src/pages/Generator.tsx @@ -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([]); + const [theme, setTheme] = useState("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({ separator: "-", includeNumber: true }); + const [username, setUsername] = useState(""); + + function rollUsername() { + setUsername(generateUsername(usernameOptions)); + } + + // ─── Password ──────────────────────────────────────────────────────── + const [passwordOptions, setPasswordOptions] = useState({ + 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 ( + <> +

Generator

+
+ Everything here is generated locally in your browser — nothing is sent to or stored on the server. +
+ +
+
+
+
+

Server name

+
+
+

Picks from Swedish girl names and Disney characters, avoiding names already in use.

+ + +
+ + {serverName && } +
+
+
+ +
+
+
+ +
+
+
+

Username

+
+
+

An adjective + animal, optionally with a number — easy to say, hard to collide.

+
+
+ + +
+
+ +
+
+
+ + {username && } +
+
+
+ +
+
+
+ +
+
+
+

Password

+
+
+ + setPasswordOptions((o) => ({ ...o, length: Number(e.target.value) }))} + /> +
+ {( + [ + ["uppercase", "A–Z"], + ["lowercase", "a–z"], + ["numbers", "0–9"], + ["symbols", "!@#$…"], + ] as const + ).map(([key, label]) => ( +
+ +
+ ))} +
+ + {noCharsetSelected &&
Pick at least one character type.
} +
+ + {password && ( + <> + + + + )} +
+ {password && ( +
+ {strength.label} (~{entropy} bits of entropy) +
+ )} +
+
+ +
+
+
+
+ + ); +} diff --git a/web/src/utils/generators.ts b/web/src/utils/generators.ts new file mode 100644 index 0000000..727e826 --- /dev/null +++ b/web/src/utils/generators.ts @@ -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(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" }; +}