Add local-mode workspace, ZIP import/export, and richer pin/ADC/serial sims
Boot: - Editor now picks local vs server mode based on URL flag, sign-in state, and a stale local-mode flag. Signed-in users are no longer bounced to IndexedDB if they had previously clicked "Use locally". Local mode: - New LocalWorkspaceClient (src/static/local-workspace.js) with pluggable IndexedDB and File System Access backends. Picked folder handles persist across reloads with a Reconnect button when the permission lapses. - Static-only host: scripts/serve_static_editor.py serves src/static/ with COOP/COEP so SharedArrayBuffer-backed sims keep working. - Bundled MicroPython stubs ship under src/static/bundled-lib/ for static hosting; FastAPI also exposes them at /api/public/lib-bundle. Workspace import / export: - Zero-dep ZIP encoder + reader (STORE + DEFLATE via DecompressionStream). Export/Import buttons in the workspace badge work in both local and server modes; imports are confined to code/. Pin / ADC / Serial simulation: - machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven by SharedArrayBuffer when cross-origin isolated and falling back to postMessage + [pin-out] stdout markers otherwise — pins, ADC slider, and serial input now keep working over plain HTTP / LAN-IP origins. - NeoPixel pins are claimed via a [pin-claim] marker and dropped from the Pins panel so the data line doesn't flicker per write(). - New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py. Lib layout: - Single source of truth at repo lib/; workspace/lib/ caching layer removed and the directory deleted. Filesystem service reads stubs directly from PROJECT_ROOT/lib. UI: - Home page slimmed to "Sign in" + "Use locally" with optional editor / manage-users links. Admin user/invite UI moved to /users. - Workspace badge gains storage indicator, Folder…/Reconnect, Export, Import, and Exit controls. - Mobile-friendly tweaks: safer-area padding, larger touch targets, iOS-zoom-proof serial input, file-tree highlight fix. Tests: - test_auth.py patches PROJECT_ROOT for the lib-shared test so the repo-root lib refactor stays green. test_api.py asserts the new "LED Editor" branding. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
484
src/static/users.html
Normal file
484
src/static/users.html
Normal file
@@ -0,0 +1,484 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage users — LED Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.home-card {
|
||||
width: min(640px, 92vw);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
h1 { margin: 0 0 0.5rem 0; font-size: 1.6rem; color: #f8fafc; }
|
||||
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: #ffffff; }
|
||||
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
|
||||
.btn-danger { background: transparent; border-color: #f87171; color: #fecaca; }
|
||||
.btn-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
.note { font-size: 0.85rem; color: #94a3b8; }
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.hidden { display: none !important; }
|
||||
.invite-panel,
|
||||
.users-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.invite-row input[type="email"] {
|
||||
flex: 1 1 260px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #64748b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.invite-result {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.users-list { margin-top: 0.5rem; display: grid; gap: 0.45rem; }
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.user-row a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(147, 197, 253, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#admin-users-feedback:not(:empty) {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
#admin-users-feedback.ok {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
#admin-users-feedback.err {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
color: #fecaca;
|
||||
}
|
||||
.user-edit-form {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.user-edit-form label { display: block; margin-bottom: 0.65rem; }
|
||||
.user-edit-form input[type='text'],
|
||||
.user-edit-form input[type='password'] {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #64748b;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.user-edit-form .edit-actions {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.65rem;
|
||||
}
|
||||
.user-edit-form .super-line {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.85rem; color: #cbd5e1; margin-bottom: 0.65rem;
|
||||
}
|
||||
.user-edit-form .super-line input { accent-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="home-card">
|
||||
<h1>Manage users</h1>
|
||||
<p class="note">Superuser-only. Add accounts via invite link, edit roles, or remove users.</p>
|
||||
<div class="nav">
|
||||
<a class="btn btn-ghost" href="/">← Home</a>
|
||||
<a class="btn btn-ghost" href="/editor">Open editor</a>
|
||||
<span id="auth-greeting"></span>
|
||||
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
|
||||
</div>
|
||||
|
||||
<div id="not-allowed" class="hidden">
|
||||
<p style="color:#fca5a5">You need to be signed in as a superuser to manage users.
|
||||
<a href="/login?next=/users" style="color:#93c5fd">Sign in</a></p>
|
||||
</div>
|
||||
|
||||
<section id="users-panel" class="users-panel hidden">
|
||||
<strong>Accounts</strong>
|
||||
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
<div id="user-edit-form" class="user-edit-form hidden">
|
||||
<strong id="user-edit-heading">Edit account</strong>
|
||||
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
|
||||
<input type="hidden" id="edit-user-id" autocomplete="off" />
|
||||
<label for="edit-user-username">Username
|
||||
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
|
||||
</label>
|
||||
<label for="edit-user-password">New password
|
||||
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
|
||||
</label>
|
||||
<label class="super-line">
|
||||
<input type="checkbox" id="edit-user-super" />
|
||||
Superuser (can manage accounts and invites)
|
||||
</label>
|
||||
<div class="edit-actions">
|
||||
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
|
||||
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="invite-panel" class="invite-panel hidden">
|
||||
<strong>Add users via invite link</strong>
|
||||
<p class="note" style="margin-top:0.4rem">
|
||||
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
|
||||
</p>
|
||||
<div class="invite-row">
|
||||
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
|
||||
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
|
||||
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
|
||||
</div>
|
||||
<div id="invite-result" class="invite-result"></div>
|
||||
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let currentAdminUserId = null;
|
||||
|
||||
function formatApiDetail(body) {
|
||||
if (!body || body.detail === undefined || body.detail === null) return '';
|
||||
const d = body.detail;
|
||||
if (typeof d === 'string') return d;
|
||||
if (Array.isArray(d))
|
||||
return d.map((item) => (typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item))).join(' ');
|
||||
return String(d);
|
||||
}
|
||||
|
||||
function setAdminUsersFeedback(kind, msg) {
|
||||
const el = document.getElementById('admin-users-feedback');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
el.classList.remove('ok', 'err');
|
||||
if (kind === 'ok') el.classList.add('ok');
|
||||
if (kind === 'err') el.classList.add('err');
|
||||
}
|
||||
|
||||
async function refreshUsersList(viewerId) {
|
||||
const usersList = document.getElementById('users-list');
|
||||
if (!usersList) return;
|
||||
usersList.textContent = 'Loading users...';
|
||||
try {
|
||||
const res = await fetch('/api/users', { credentials: 'include' });
|
||||
const users = await res.json().catch(() => []);
|
||||
if (!res.ok) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
return;
|
||||
}
|
||||
usersList.innerHTML = '';
|
||||
for (const user of users) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'user-row';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'user-actions';
|
||||
const link = document.createElement('a');
|
||||
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
|
||||
link.textContent = 'Open workspace';
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'btn btn-ghost';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.title = `Edit ${user.username}`;
|
||||
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-ghost btn-danger';
|
||||
delBtn.textContent = 'Remove';
|
||||
const isSelf = Number(user.id) === Number(viewerId);
|
||||
delBtn.disabled = isSelf;
|
||||
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
const ok = confirm(`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`);
|
||||
if (!ok) return;
|
||||
delBtn.disabled = true;
|
||||
setAdminUsersFeedback('', '');
|
||||
try {
|
||||
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
});
|
||||
const body = await dres.json().catch(() => ({}));
|
||||
if (!dres.ok) {
|
||||
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
|
||||
delBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
|
||||
await refreshUsersList(viewerId);
|
||||
} catch (err) {
|
||||
setAdminUsersFeedback('err', String(err.message || err));
|
||||
delBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(link);
|
||||
actions.appendChild(delBtn);
|
||||
row.appendChild(name);
|
||||
row.appendChild(actions);
|
||||
usersList.appendChild(row);
|
||||
}
|
||||
} catch (_err) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
}
|
||||
}
|
||||
|
||||
function closeUserEdit() {
|
||||
const sheet = document.getElementById('user-edit-form');
|
||||
if (sheet) sheet.classList.add('hidden');
|
||||
}
|
||||
|
||||
function openUserEdit(userId, username, isSuperuser) {
|
||||
document.getElementById('edit-user-id').value = String(userId);
|
||||
document.getElementById('edit-user-username').value = username;
|
||||
document.getElementById('edit-user-password').value = '';
|
||||
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
|
||||
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
|
||||
document.getElementById('user-edit-form').classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
|
||||
|
||||
document.getElementById('edit-user-save').addEventListener('click', async () => {
|
||||
const id = document.getElementById('edit-user-id').value;
|
||||
const u = document.getElementById('edit-user-username').value.trim();
|
||||
const pw = document.getElementById('edit-user-password').value;
|
||||
const superU = document.getElementById('edit-user-super').checked;
|
||||
const saveBtn = document.getElementById('edit-user-save');
|
||||
setAdminUsersFeedback('', '');
|
||||
if (!u || u.length < 3) {
|
||||
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
|
||||
return;
|
||||
}
|
||||
const payload = { username: u, is_superuser: superU };
|
||||
const tpw = pw.trim();
|
||||
if (tpw.length > 0) {
|
||||
if (tpw.length < 8) {
|
||||
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
|
||||
return;
|
||||
}
|
||||
payload.password = tpw;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
|
||||
closeUserEdit();
|
||||
await refreshUsersList(currentAdminUserId);
|
||||
} catch (err) {
|
||||
setAdminUsersFeedback('err', String(err.message || err));
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
function showInviteOutcome(inviteUrl, headline) {
|
||||
const result = document.getElementById('invite-result');
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
result.textContent = '';
|
||||
const line = document.createElement('p');
|
||||
line.style.margin = '0 0 0.35rem 0';
|
||||
line.textContent = headline;
|
||||
const a = document.createElement('a');
|
||||
a.href = inviteUrl;
|
||||
a.textContent = inviteUrl;
|
||||
a.style.color = '#93c5fd';
|
||||
a.style.wordBreak = 'break-all';
|
||||
result.appendChild(line);
|
||||
result.appendChild(a);
|
||||
copyBtn.classList.remove('hidden');
|
||||
copyBtn.dataset.inviteUrl = inviteUrl;
|
||||
}
|
||||
|
||||
function clearInviteOutcome() {
|
||||
const result = document.getElementById('invite-result');
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
result.textContent = '';
|
||||
copyBtn.classList.add('hidden');
|
||||
copyBtn.textContent = 'Copy invite link';
|
||||
delete copyBtn.dataset.inviteUrl;
|
||||
}
|
||||
|
||||
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
|
||||
const copyBtn = document.getElementById('invite-copy-btn');
|
||||
const url = copyBtn.dataset.inviteUrl;
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2000);
|
||||
} catch (_e) {
|
||||
copyBtn.textContent = 'Copy failed — select the link above';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('invite-create-btn').addEventListener('click', async () => {
|
||||
const emailInput = document.getElementById('invite-email');
|
||||
const result = document.getElementById('invite-result');
|
||||
const email = (emailInput.value || '').trim();
|
||||
clearInviteOutcome();
|
||||
if (!email) {
|
||||
result.textContent = 'Enter an email address first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, expires_days: 7 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
|
||||
return;
|
||||
}
|
||||
const headline = body.delivered
|
||||
? 'Email sent. They can also use this link to register:'
|
||||
: 'Email not sent (SMTP not configured). Share this registration link:';
|
||||
showInviteOutcome(body.invite_url, headline);
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('invite-link-btn').addEventListener('click', async () => {
|
||||
const result = document.getElementById('invite-result');
|
||||
clearInviteOutcome();
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: null, expires_days: 7 }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
|
||||
return;
|
||||
}
|
||||
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const greet = document.getElementById('auth-greeting');
|
||||
const logout = document.getElementById('btn-logout');
|
||||
const usersPanel = document.getElementById('users-panel');
|
||||
const invitePanel = document.getElementById('invite-panel');
|
||||
const blocked = document.getElementById('not-allowed');
|
||||
|
||||
const st = await fetch('/api/auth/status').catch(() => null);
|
||||
if (!st || !st.ok) {
|
||||
blocked.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const status = await st.json();
|
||||
if (!status.auth_enabled) {
|
||||
// Auth disabled — anyone can poke /api/users; show panels regardless.
|
||||
usersPanel.classList.remove('hidden');
|
||||
invitePanel.classList.remove('hidden');
|
||||
await refreshUsersList(null);
|
||||
return;
|
||||
}
|
||||
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (!me.ok) {
|
||||
blocked.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const j = await me.json();
|
||||
if (!j.user || !j.user.is_superuser) {
|
||||
blocked.classList.remove('hidden');
|
||||
greet.textContent = `Signed in as ${j.user.username}`;
|
||||
logout.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
currentAdminUserId = j.user.id;
|
||||
greet.textContent = `Signed in as ${j.user.username} (admin)`;
|
||||
logout.classList.remove('hidden');
|
||||
usersPanel.classList.remove('hidden');
|
||||
invitePanel.classList.remove('hidden');
|
||||
await refreshUsersList(j.user.id);
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user