585 lines
22 KiB
HTML
585 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Python 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(560px, 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.75rem 0; font-size: 1.8rem; 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.65rem 1rem;
|
|
font-weight: 600;
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
}
|
|
.btn-primary { background: #3b82f6; color: #ffffff; }
|
|
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
|
|
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
|
|
input[type="password"] {
|
|
width: 100%;
|
|
padding: 0.5rem 0.65rem;
|
|
border-radius: 8px;
|
|
border: 1px solid #64748b;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
|
|
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
|
.nav span { color: #94a3b8; font-size: 0.9rem; }
|
|
.hidden { display: none !important; }
|
|
.invite-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;
|
|
width: auto;
|
|
margin: 0;
|
|
}
|
|
.invite-row button {
|
|
margin: 0;
|
|
}
|
|
.invite-result {
|
|
margin-top: 0.55rem;
|
|
font-size: 0.85rem;
|
|
color: #cbd5e1;
|
|
word-break: break-all;
|
|
}
|
|
.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);
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
}
|
|
#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;
|
|
box-sizing: border-box;
|
|
}
|
|
.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>Python Editor</h1>
|
|
<div id="auth-nav" class="nav">
|
|
<span id="auth-greeting" class="hidden"></span>
|
|
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
|
|
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
|
|
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
|
|
</div>
|
|
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>. Choose Editor or the interactive Tutorial below.</p>
|
|
<div id="optional-api-key">
|
|
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
|
|
<label for="api-key">API key (optional)</label>
|
|
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
|
|
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
|
|
</div>
|
|
<section id="users-panel" class="users-panel hidden">
|
|
<strong>User management</strong>
|
|
<p class="note" style="margin-top:0.35rem">
|
|
New accounts sign up via an <strong>invite link</strong> below. Remove accounts here or open their workspace.
|
|
</p>
|
|
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
|
|
<p style="margin: 1rem 0 0.35rem 0; font-size: 0.9rem; color: #94a3b8">Accounts</p>
|
|
<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>
|
|
<div class="nav">
|
|
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
|
<a class="btn btn-ghost" href="/tutorial" id="open-tutorial">Open Tutorial</a>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
const storageKey = 'python-editor.api_key';
|
|
const input = document.getElementById('api-key');
|
|
const openLink = document.getElementById('open-editor');
|
|
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 refreshAuthNav() {
|
|
const st = await fetch('/api/auth/status');
|
|
const status = await st.json();
|
|
const loginEl = document.getElementById('link-login');
|
|
const regEl = document.getElementById('link-register');
|
|
const outEl = document.getElementById('btn-logout');
|
|
const greet = document.getElementById('auth-greeting');
|
|
const optionalKey = document.getElementById('optional-api-key');
|
|
const invitePanel = document.getElementById('invite-panel');
|
|
const usersPanel = document.getElementById('users-panel');
|
|
if (!status.auth_enabled) {
|
|
currentAdminUserId = null;
|
|
loginEl.classList.add('hidden');
|
|
regEl.classList.add('hidden');
|
|
outEl.classList.add('hidden');
|
|
greet.classList.add('hidden');
|
|
if (invitePanel) invitePanel.classList.add('hidden');
|
|
if (usersPanel) usersPanel.classList.add('hidden');
|
|
return;
|
|
}
|
|
loginEl.classList.remove('hidden');
|
|
if (status.register_open) {
|
|
regEl.classList.remove('hidden');
|
|
}
|
|
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
|
if (me.ok) {
|
|
const j = await me.json();
|
|
greet.textContent = `Signed in as ${j.user.username}`;
|
|
greet.classList.remove('hidden');
|
|
loginEl.classList.add('hidden');
|
|
regEl.classList.add('hidden');
|
|
outEl.classList.remove('hidden');
|
|
if (optionalKey) optionalKey.classList.add('hidden');
|
|
if (invitePanel) {
|
|
if (j.user && j.user.is_superuser) {
|
|
currentAdminUserId = j.user.id;
|
|
invitePanel.classList.remove('hidden');
|
|
if (usersPanel) usersPanel.classList.remove('hidden');
|
|
await refreshUsersList(j.user.id);
|
|
} else {
|
|
currentAdminUserId = null;
|
|
invitePanel.classList.add('hidden');
|
|
if (usersPanel) usersPanel.classList.add('hidden');
|
|
}
|
|
}
|
|
} else {
|
|
currentAdminUserId = null;
|
|
outEl.classList.add('hidden');
|
|
greet.classList.add('hidden');
|
|
if (invitePanel) invitePanel.classList.add('hidden');
|
|
if (usersPanel) usersPanel.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
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.reload();
|
|
});
|
|
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const fromQuery = params.get('api_key');
|
|
if (fromQuery) {
|
|
sessionStorage.setItem(storageKey, fromQuery);
|
|
}
|
|
const existing = sessionStorage.getItem(storageKey);
|
|
if (existing) {
|
|
input.value = existing;
|
|
}
|
|
} catch (_e) {}
|
|
openLink.addEventListener('click', () => {
|
|
try {
|
|
const v = input.value.trim();
|
|
if (v) {
|
|
sessionStorage.setItem(storageKey, v);
|
|
} else {
|
|
sessionStorage.removeItem(storageKey);
|
|
}
|
|
} catch (_e) {}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
refreshAuthNav();
|
|
</script>
|
|
</body>
|
|
</html>
|