add registration

This commit is contained in:
KingKDot
2026-04-19 18:24:50 -05:00
parent 86c1897406
commit d12dc04709
15 changed files with 1735 additions and 5 deletions
+11
View File
@@ -3,6 +3,17 @@ const user = document.getElementById("user");
const pass = document.getElementById("pass");
const errEl = document.getElementById("error");
(async () => {
try {
const res = await fetch("/api/registration/status");
const data = await res.json();
if (data.enabled) {
const el = document.getElementById("register-link");
if (el) el.style.display = "";
}
} catch {}
})();
if (typeof anime !== "undefined") {
const particles = document.querySelectorAll(".particle");
particles.forEach((particle, i) => {
+123
View File
@@ -0,0 +1,123 @@
const form = document.getElementById("register-form");
const errorEl = document.getElementById("error");
const successEl = document.getElementById("success");
const keyGroup = document.getElementById("key-group");
const keyInput = document.getElementById("reg-key");
async function checkStatus() {
try {
const res = await fetch("/api/registration/status");
const data = await res.json();
if (!data.enabled) {
form.style.display = "none";
errorEl.textContent = "Registration is currently disabled.";
return;
}
if (data.mode === "key") {
keyGroup.style.display = "";
keyInput.required = true;
}
} catch {
errorEl.textContent = "Unable to check registration status.";
}
}
checkStatus();
if (window.anime) {
document.querySelectorAll(".particle").forEach((p, i) => {
anime({
targets: p,
translateX: () => anime.random(-300, 300),
translateY: () => anime.random(-300, 300),
scale: () => anime.random(5, 15) / 10,
opacity: [{ value: 0.15, duration: 600 }, { value: 0, duration: 600 }],
easing: "easeInOutQuad",
duration: 3000,
delay: i * 400,
loop: true,
direction: "alternate",
});
});
anime.timeline()
.add({
targets: ".login-card",
opacity: [0, 1],
translateY: [30, 0],
duration: 800,
easing: "easeOutExpo",
})
.add({
targets: ".login-crown-wrapper",
scale: [0, 1],
rotate: [180, 0],
duration: 600,
easing: "easeOutBack",
}, "-=400")
.add({
targets: ".login-brand, .login-title, .login-subtitle",
opacity: [0, 1],
translateY: [10, 0],
duration: 500,
delay: anime.stagger(100),
easing: "easeOutQuad",
}, "-=300")
.add({
targets: ".form-group",
opacity: [0, 1],
translateY: [15, 0],
duration: 500,
delay: anime.stagger(100),
easing: "easeOutQuad",
}, "-=300")
.add({
targets: ".login-btn",
opacity: [0, 1],
scale: [0.95, 1],
duration: 400,
easing: "easeOutQuad",
}, "-=200");
} else {
document.querySelector(".login-card").style.opacity = "1";
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.textContent = "";
successEl.style.display = "none";
const username = document.getElementById("reg-user").value.trim();
const password = document.getElementById("reg-pass").value;
const confirmPassword = document.getElementById("reg-pass-confirm").value;
const key = keyInput.value.trim();
if (password !== confirmPassword) {
errorEl.textContent = "Passwords do not match.";
return;
}
if (password.length < 6) {
errorEl.textContent = "Password must be at least 6 characters.";
return;
}
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, key: key || undefined }),
});
const data = await res.json();
if (!res.ok) {
errorEl.textContent = data.error || "Registration failed.";
return;
}
form.style.display = "none";
successEl.style.display = "";
successEl.textContent = data.message || "Account created!";
if (!data.pending) {
setTimeout(() => { window.location.href = "/"; }, 2000);
}
} catch {
errorEl.textContent = "Network error. Please try again.";
}
});
+282
View File
@@ -1057,6 +1057,276 @@ async function handleRemoveInactiveSessions() {
}
}
// ── Registration Settings ──────────────────────────────────────────────────
async function loadRegistrationSettings() {
try {
const res = await fetch("/api/settings/registration", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const reg = data.registration || {};
const modeEl = document.getElementById("reg-mode");
const roleEl = document.getElementById("reg-default-role");
const maxEl = document.getElementById("reg-max-users");
if (modeEl) modeEl.value = reg.mode || "off";
if (roleEl) roleEl.value = reg.defaultRole || "operator";
if (maxEl) maxEl.value = reg.maxUsersTotal ?? 0;
updateRegSubsections(reg.mode || "off");
if (reg.mode === "key") loadRegistrationKeys();
if (reg.mode === "approval") loadPendingRegistrations();
} catch (e) {
console.error("Failed to load registration settings", e);
}
}
function updateRegSubsections(mode) {
const keySection = document.getElementById("reg-key-section");
const pendingSection = document.getElementById("reg-pending-section");
if (keySection) keySection.classList.toggle("hidden", mode !== "key");
if (pendingSection) pendingSection.classList.toggle("hidden", mode !== "approval");
}
function showRegMsg(text, type) {
const el = document.getElementById("reg-settings-msg");
if (!el) return;
el.textContent = text;
el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`;
el.classList.remove("hidden");
setTimeout(() => el.classList.add("hidden"), 5000);
}
async function saveRegistrationSettings(e) {
e.preventDefault();
const mode = document.getElementById("reg-mode")?.value;
const defaultRole = document.getElementById("reg-default-role")?.value;
const maxUsersTotal = Number(document.getElementById("reg-max-users")?.value) || 0;
try {
const res = await fetch("/api/settings/registration", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, defaultRole, maxUsersTotal }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showRegMsg(data.error || "Failed to save", "error");
return;
}
showRegMsg("Registration settings saved.", "success");
updateRegSubsections(mode);
if (mode === "key") loadRegistrationKeys();
if (mode === "approval") loadPendingRegistrations();
} catch {
showRegMsg("Network error.", "error");
}
}
async function loadRegistrationKeys() {
const tbody = document.getElementById("reg-keys-tbody");
if (!tbody) return;
try {
const res = await fetch("/api/registration/keys", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const keys = data.keys || [];
if (keys.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="px-3 py-4 text-center text-slate-500">No keys generated yet.</td></tr>';
return;
}
tbody.innerHTML = keys.map(k => {
const status = k.used_by ? '<span class="text-amber-400">Used</span>'
: (k.expires_at && k.expires_at < Date.now()) ? '<span class="text-slate-500">Expired</span>'
: '<span class="text-emerald-400">Available</span>';
const created = new Date(k.created_at).toLocaleDateString();
const expires = k.expires_at ? new Date(k.expires_at).toLocaleDateString() : "Never";
return `<tr class="border-b border-slate-800">
<td class="px-3 py-2 font-mono text-xs">${escapeHtml(k.key.substring(0, 12))}...</td>
<td class="px-3 py-2">${escapeHtml(k.label || "—")}</td>
<td class="px-3 py-2">${status}</td>
<td class="px-3 py-2">${created}</td>
<td class="px-3 py-2">${expires}</td>
<td class="px-3 py-2">${!k.used_by ? `<button data-delete-key="${k.id}" class="text-rose-400 hover:text-rose-300 text-xs"><i class="fa-solid fa-trash"></i></button>` : ""}</td>
</tr>`;
}).join("");
} catch (e) {
console.error("Failed to load keys", e);
}
}
async function generateRegistrationKeys() {
const count = Number(document.getElementById("reg-key-count")?.value) || 1;
const label = document.getElementById("reg-key-label")?.value || undefined;
const expiresInHours = Number(document.getElementById("reg-key-expires")?.value) || undefined;
try {
const res = await fetch("/api/registration/keys", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count, label, expiresInHours }),
});
if (!res.ok) {
showRegMsg("Failed to generate keys", "error");
return;
}
showRegMsg(`Generated ${count} key(s).`, "success");
loadRegistrationKeys();
} catch {
showRegMsg("Network error.", "error");
}
}
async function deleteRegistrationKey(keyId) {
try {
const res = await fetch(`/api/registration/keys/${keyId}`, {
method: "DELETE",
credentials: "include",
});
if (res.ok) loadRegistrationKeys();
} catch {}
}
async function loadPendingRegistrations() {
const tbody = document.getElementById("reg-pending-tbody");
const emptyEl = document.getElementById("reg-pending-empty");
if (!tbody) return;
try {
const res = await fetch("/api/registration/pending", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const pending = (data.pending || []).filter(p => p.status === "pending");
if (pending.length === 0) {
tbody.innerHTML = "";
if (emptyEl) emptyEl.classList.remove("hidden");
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
tbody.innerHTML = pending.map(p => {
const requested = new Date(p.requested_at).toLocaleString();
return `<tr class="border-b border-slate-800">
<td class="px-3 py-2">${escapeHtml(p.username)}</td>
<td class="px-3 py-2">${requested}</td>
<td class="px-3 py-2"><span class="text-amber-400">Pending</span></td>
<td class="px-3 py-2 space-x-2">
<button data-approve-pending="${p.id}" class="text-emerald-400 hover:text-emerald-300 text-xs"><i class="fa-solid fa-check"></i> Approve</button>
<button data-deny-pending="${p.id}" class="text-rose-400 hover:text-rose-300 text-xs"><i class="fa-solid fa-xmark"></i> Deny</button>
</td>
</tr>`;
}).join("");
} catch (e) {
console.error("Failed to load pending registrations", e);
}
}
async function handlePendingAction(id, action) {
try {
const res = await fetch(`/api/registration/pending/${id}/${action}`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showRegMsg(data.error || `Failed to ${action}`, "error");
return;
}
showRegMsg(`Registration ${action}d.`, "success");
loadPendingRegistrations();
} catch {
showRegMsg("Network error.", "error");
}
}
function initRegistrationHandlers() {
const regForm = document.getElementById("registration-form");
if (regForm) regForm.addEventListener("submit", saveRegistrationSettings);
const modeEl = document.getElementById("reg-mode");
if (modeEl) modeEl.addEventListener("change", () => {
updateRegSubsections(modeEl.value);
if (modeEl.value === "key") loadRegistrationKeys();
if (modeEl.value === "approval") loadPendingRegistrations();
});
const genBtn = document.getElementById("reg-key-generate-btn");
if (genBtn) genBtn.addEventListener("click", generateRegistrationKeys);
// Delegate key delete and pending approve/deny
document.addEventListener("click", (e) => {
const deleteBtn = e.target.closest("[data-delete-key]");
if (deleteBtn) {
deleteRegistrationKey(Number(deleteBtn.dataset.deleteKey));
return;
}
const approveBtn = e.target.closest("[data-approve-pending]");
if (approveBtn) {
handlePendingAction(Number(approveBtn.dataset.approvePending), "approve");
return;
}
const denyBtn = e.target.closest("[data-deny-pending]");
if (denyBtn) {
handlePendingAction(Number(denyBtn.dataset.denyPending), "deny");
}
});
}
// ── Build Rate Limit Settings ──────────────────────────────────────────────
async function loadBuildRateLimitSettings() {
try {
const res = await fetch("/api/settings/build-rate-limit", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const brl = data.buildRateLimit || {};
const perHourEl = document.getElementById("brl-max-per-hour");
const concUserEl = document.getElementById("brl-max-concurrent-user");
const globalConcEl = document.getElementById("brl-global-concurrent");
if (perHourEl) perHourEl.value = brl.maxBuildsPerHour ?? 5;
if (concUserEl) concUserEl.value = brl.maxConcurrentPerUser ?? 1;
if (globalConcEl) globalConcEl.value = brl.globalMaxConcurrent ?? 3;
} catch (e) {
console.error("Failed to load build rate limit settings", e);
}
}
function showBrlMsg(text, type) {
const el = document.getElementById("brl-settings-msg");
if (!el) return;
el.textContent = text;
el.className = `text-sm rounded-lg px-3 py-2 border ${type === "error" ? "border-rose-800 bg-rose-900/20 text-rose-200" : "border-emerald-800 bg-emerald-900/20 text-emerald-200"}`;
el.classList.remove("hidden");
setTimeout(() => el.classList.add("hidden"), 5000);
}
async function saveBuildRateLimitSettings(e) {
e.preventDefault();
const maxBuildsPerHour = Number(document.getElementById("brl-max-per-hour")?.value) || 5;
const maxConcurrentPerUser = Number(document.getElementById("brl-max-concurrent-user")?.value) || 1;
const globalMaxConcurrent = Number(document.getElementById("brl-global-concurrent")?.value) || 3;
try {
const res = await fetch("/api/settings/build-rate-limit", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxBuildsPerHour, maxConcurrentPerUser, globalMaxConcurrent }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showBrlMsg(data.error || "Failed to save", "error");
return;
}
showBrlMsg("Build rate limit settings saved.", "success");
} catch {
showBrlMsg("Network error.", "error");
}
}
function initBuildRateLimitHandlers() {
const brlForm = document.getElementById("build-rate-limit-form");
if (brlForm) brlForm.addEventListener("submit", saveBuildRateLimitSettings);
}
async function init() {
try {
await loadCurrentUser();
@@ -1076,6 +1346,18 @@ async function init() {
await loadChatSettings();
await loadBannedIps();
// Registration + Build Rate Limit (admin only)
if (isAdmin(currentUser?.role)) {
const regSection = document.getElementById("registration-section");
const brlSection = document.getElementById("build-rate-limit-section");
if (regSection) regSection.classList.remove("hidden");
if (brlSection) brlSection.classList.remove("hidden");
await loadRegistrationSettings();
await loadBuildRateLimitSettings();
initRegistrationHandlers();
initBuildRateLimitHandlers();
}
passwordForm.addEventListener("submit", updatePassword);
prefsForm.addEventListener("submit", savePrefs);
+6 -1
View File
@@ -65,7 +65,12 @@
</button>
<div id="error" class="error" aria-live="polite"></div>
</form>
<div id="register-link" style="text-align: center; margin-top: 1rem; display: none">
<a href="/register.html" class="muted" style="font-size: 0.85rem; text-decoration: underline"
>Don't have an account? Register</a
>
</div>
</div>
<script type="module" src="/assets/login.js"></script>
<script src="/assets/login.js"></script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overlord - Register</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/vendor/inter/400.css" />
<link rel="stylesheet" href="/vendor/inter/600.css" />
<link rel="stylesheet" href="/vendor/inter/700.css" />
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
<link rel="stylesheet" href="/assets/main.css" />
<link rel="stylesheet" href="/assets/custom.css" />
<script src="/vendor/animejs/anime.min.js"></script>
</head>
<body class="login-body">
<div class="login-particles">
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
</div>
<div class="login-card">
<div class="login-crown-wrapper">
<i class="fa-solid fa-crown login-crown"></i>
</div>
<div class="brand login-brand">Overlord</div>
<h2 class="login-title">Create Account</h2>
<p class="muted login-subtitle">Register for access</p>
<form id="register-form">
<div class="form-group">
<label class="label">Username</label>
<div class="input-row input-animated">
<i class="fa-solid fa-user"></i
><input
id="reg-user"
name="user"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="32"
pattern="[a-zA-Z0-9_\-]+"
placeholder="Choose a username"
/>
</div>
</div>
<div class="form-group">
<label class="label">Password</label>
<div class="input-row input-animated">
<i class="fa-solid fa-lock"></i
><input
id="reg-pass"
name="pass"
type="password"
autocomplete="new-password"
required
minlength="6"
placeholder="Choose a password"
/>
</div>
</div>
<div class="form-group">
<label class="label">Confirm Password</label>
<div class="input-row input-animated">
<i class="fa-solid fa-lock"></i
><input
id="reg-pass-confirm"
name="pass-confirm"
type="password"
autocomplete="new-password"
required
placeholder="Confirm password"
/>
</div>
</div>
<div class="form-group" id="key-group" style="display: none">
<label class="label">Registration Key</label>
<div class="input-row input-animated">
<i class="fa-solid fa-key"></i
><input
id="reg-key"
name="key"
type="text"
autocomplete="off"
placeholder="Enter your registration key"
/>
</div>
</div>
<button type="submit" class="button primary full login-btn">
<span class="btn-text">Create Account</span>
<i class="fa-solid fa-user-plus btn-icon"></i>
</button>
<div id="error" class="error" aria-live="polite"></div>
<div id="success" class="success" aria-live="polite" style="display: none"></div>
</form>
<div style="text-align: center; margin-top: 1rem">
<a href="/" class="muted" style="font-size: 0.85rem; text-decoration: underline"
>Already have an account? Sign in</a
>
</div>
</div>
<script src="/assets/register.js"></script>
</body>
</html>
+135
View File
@@ -664,6 +664,141 @@
</form>
</section>
<!-- Registration Settings (admin only) -->
<section id="registration-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<i class="fa-solid fa-user-plus text-emerald-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Registration</h2>
<p class="text-sm text-slate-400">Admin-only. Control how new users can register.</p>
</div>
</div>
<form id="registration-form" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Registration Mode</span>
<select id="reg-mode" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500">
<option value="off">Off</option>
<option value="open">Open</option>
<option value="key">Key Required</option>
<option value="approval">Approval Required</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-300">Default Role</span>
<select id="reg-default-role" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500">
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<label class="block">
<span class="text-sm text-slate-300">Max Users (0 = unlimited)</span>
<input id="reg-max-users" type="number" min="0" max="10000" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-floppy-disk"></i>Save Registration Settings
</button>
</form>
<p id="reg-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
<!-- Key Management (visible when mode=key) -->
<div id="reg-key-section" class="hidden space-y-3 border-t border-slate-700 pt-4">
<h3 class="text-md font-semibold text-slate-200">Registration Keys</h3>
<div class="flex flex-wrap items-end gap-3">
<label class="block">
<span class="text-sm text-slate-300">Count</span>
<input id="reg-key-count" type="number" value="1" min="1" max="100" class="mt-1 w-24 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Label (optional)</span>
<input id="reg-key-label" type="text" maxlength="64" placeholder="e.g. Batch 1" class="mt-1 w-40 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Expires (hours, 0=never)</span>
<input id="reg-key-expires" type="number" value="0" min="0" max="8760" class="mt-1 w-28 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
<button id="reg-key-generate-btn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-700 hover:bg-emerald-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-plus"></i>Generate
</button>
<a id="reg-key-export-btn" href="/api/registration/keys/export" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-file-export"></i>Export
</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-slate-300">
<thead class="text-xs uppercase text-slate-500 border-b border-slate-700">
<tr>
<th class="px-3 py-2">Key</th>
<th class="px-3 py-2">Label</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">Created</th>
<th class="px-3 py-2">Expires</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody id="reg-keys-tbody"></tbody>
</table>
</div>
</div>
<!-- Pending Registrations (visible when mode=approval) -->
<div id="reg-pending-section" class="hidden space-y-3 border-t border-slate-700 pt-4">
<h3 class="text-md font-semibold text-slate-200">Pending Registrations</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-slate-300">
<thead class="text-xs uppercase text-slate-500 border-b border-slate-700">
<tr>
<th class="px-3 py-2">Username</th>
<th class="px-3 py-2">Requested</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody id="reg-pending-tbody"></tbody>
</table>
</div>
<p id="reg-pending-empty" class="text-sm text-slate-500">No pending registrations.</p>
</div>
</section>
<!-- Build Rate Limit (admin only) -->
<section id="build-rate-limit-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-orange-500/10 border border-orange-500/30">
<i class="fa-solid fa-gauge-high text-orange-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Build Rate Limit</h2>
<p class="text-sm text-slate-400">Admin-only. Prevent abuse by limiting build frequency.</p>
</div>
</div>
<form id="build-rate-limit-form" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Max Builds per Hour (per user)</span>
<input id="brl-max-per-hour" type="number" min="1" max="100" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Max Concurrent (per user)</span>
<input id="brl-max-concurrent-user" type="number" min="1" max="10" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
<label class="block">
<span class="text-sm text-slate-300">Global Max Concurrent</span>
<input id="brl-global-concurrent" type="number" min="1" max="50" class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500" />
</label>
</div>
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-orange-700 hover:bg-orange-600 transition-colors text-white text-sm font-medium">
<i class="fa-solid fa-floppy-disk"></i>Save Rate Limit Settings
</button>
</form>
<p id="brl-settings-msg" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<footer class="text-center text-sm text-slate-500 py-4 border-t border-slate-800 mt-4">
<p>Created by KDot &mdash; <a href="https://t.me/kunta" target="_blank" rel="noopener noreferrer" class="text-sky-400 hover:text-sky-300 transition-colors"><i class="fa-brands fa-telegram"></i> t.me/kunta</a></p>
</footer>