mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
- Backend: Rust/Axum with PostgreSQL, plugin architecture - Frontend: Astro with polished UI - Voting methods: Approval, Ranked Choice, Schulze, STAR, Quadratic - Features: Liquid delegation, transparent moderation, structured deliberation - Documentation: User and admin guides in /docs - Deployment: Docker/Podman compose files for production and demo - Demo: Seeded data with 3 communities, 13 users, 7 proposals License: AGPLv3
384 lines
9.6 KiB
Text
384 lines
9.6 KiB
Text
---
|
|
export const prerender = false;
|
|
import Layout from '../../layouts/Layout.astro';
|
|
import AdminNav from '../../components/AdminNav.astro';
|
|
import { API_BASE as apiBase } from '../../lib/api';
|
|
---
|
|
|
|
<Layout title="Invitations - Admin">
|
|
<div class="admin-container">
|
|
<AdminNav currentPage="/admin/invitations" />
|
|
|
|
<main class="admin-main">
|
|
<header class="admin-header">
|
|
<h1>Invitation Management</h1>
|
|
<p>Create and manage invitation codes for user registration</p>
|
|
</header>
|
|
|
|
<section class="create-section">
|
|
<h2>Create Invitation</h2>
|
|
<form id="create-form" class="create-form">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="email">Email (optional)</label>
|
|
<input type="email" id="email" placeholder="Restrict to specific email" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="max_uses">Max Uses</label>
|
|
<input type="number" id="max_uses" value="1" min="1" max="100" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="expires_hours">Expires In (hours)</label>
|
|
<input type="number" id="expires_hours" value="168" min="1" max="8760" />
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn-create">Create Invitation</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="list-section">
|
|
<div class="section-header">
|
|
<h2>Active Invitations</h2>
|
|
<label class="filter-label">
|
|
<input type="checkbox" id="show-all" />
|
|
Show inactive
|
|
</label>
|
|
</div>
|
|
<div id="invitations-list" class="invitations-list">
|
|
<p class="loading">Loading invitations...</p>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</Layout>
|
|
|
|
<script define:vars={{ apiBase }}>
|
|
const token = localStorage.getItem('token');
|
|
|
|
if (!token) {
|
|
window.location.href = '/login?redirect=/admin/invitations';
|
|
}
|
|
|
|
let showInactive = false;
|
|
|
|
document.getElementById('show-all')?.addEventListener('change', (e) => {
|
|
showInactive = (e.target).checked;
|
|
loadInvitations();
|
|
});
|
|
|
|
document.getElementById('create-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const email = (document.getElementById('email')).value || null;
|
|
const max_uses = parseInt((document.getElementById('max_uses')).value) || 1;
|
|
const expires_in_hours = parseInt((document.getElementById('expires_hours')).value) || 168;
|
|
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/invitations`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ email, max_uses, expires_in_hours })
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
alert(`Invitation created!\nCode: ${data.code}`);
|
|
(document.getElementById('email')).value = '';
|
|
loadInvitations();
|
|
} else {
|
|
const err = await res.text();
|
|
alert('Error: ' + err);
|
|
}
|
|
} catch (error) {
|
|
alert('Error creating invitation');
|
|
}
|
|
});
|
|
|
|
async function loadInvitations() {
|
|
const container = document.getElementById('invitations-list');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const url = showInactive
|
|
? `${apiBase}/api/invitations`
|
|
: `${apiBase}/api/invitations?active_only=true`;
|
|
|
|
const res = await fetch(url, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Failed to load');
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.length === 0) {
|
|
container.innerHTML = '<p class="empty">No invitations found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = data.map(inv => `
|
|
<div class="invitation-item ${inv.is_active ? '' : 'inactive'}" data-id="${inv.id}">
|
|
<div class="inv-main">
|
|
<code class="inv-code">${inv.code}</code>
|
|
<div class="inv-meta">
|
|
${inv.email ? `<span class="tag email-tag">📧 ${inv.email}</span>` : ''}
|
|
${inv.community_name ? `<span class="tag community-tag">🏘️ ${inv.community_name}</span>` : ''}
|
|
<span class="tag uses-tag">${inv.uses_count}/${inv.max_uses || '∞'} uses</span>
|
|
${inv.expires_at ? `<span class="tag expires-tag">⏰ ${new Date(inv.expires_at).toLocaleDateString()}</span>` : ''}
|
|
</div>
|
|
<div class="inv-creator">
|
|
Created by ${inv.created_by_username || 'Unknown'} on ${new Date(inv.created_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div class="inv-actions">
|
|
<button class="btn-copy" data-code="${inv.code}" title="Copy code">📋</button>
|
|
${inv.is_active ? `<button class="btn-revoke" data-id="${inv.id}" title="Revoke">🚫</button>` : '<span class="status-inactive">Inactive</span>'}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Setup action buttons
|
|
document.querySelectorAll('.btn-copy').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const code = (btn).dataset.code;
|
|
navigator.clipboard.writeText(code || '');
|
|
alert('Code copied to clipboard!');
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-revoke').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (!confirm('Revoke this invitation?')) return;
|
|
const id = (btn).dataset.id;
|
|
try {
|
|
const res = await fetch(`${apiBase}/api/invitations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (res.ok) {
|
|
loadInvitations();
|
|
} else {
|
|
alert('Error revoking invitation');
|
|
}
|
|
} catch (error) {
|
|
alert('Error revoking invitation');
|
|
}
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
container.innerHTML = '<p class="error">Error loading invitations</p>';
|
|
}
|
|
}
|
|
|
|
loadInvitations();
|
|
</script>
|
|
|
|
<style>
|
|
.admin-container {
|
|
display: flex;
|
|
min-height: calc(100vh - 60px);
|
|
}
|
|
|
|
.admin-main {
|
|
flex: 1;
|
|
padding: 2rem;
|
|
max-width: 1000px;
|
|
}
|
|
|
|
.admin-header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.admin-header h1 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.75rem;
|
|
}
|
|
|
|
.admin-header p {
|
|
color: var(--color-text-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
.create-section, .list-section {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.create-section h2, .list-section h2 {
|
|
font-size: 1.25rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.section-header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.filter-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.create-form {
|
|
background: var(--color-surface);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr 1fr;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 8px;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.btn-create {
|
|
padding: 0.75rem 1.5rem;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.btn-create:hover {
|
|
background: var(--color-primary-hover);
|
|
}
|
|
|
|
.invitations-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.invitation-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem 1.5rem;
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.invitation-item.inactive {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.inv-main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.inv-code {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
background: var(--color-bg);
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.inv-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.tag {
|
|
font-size: 0.75rem;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.inv-creator {
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.inv-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-copy, .btn-revoke {
|
|
padding: 0.5rem;
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.btn-copy:hover {
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.btn-revoke:hover {
|
|
background: var(--color-error);
|
|
border-color: var(--color-error);
|
|
}
|
|
|
|
.status-inactive {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--color-bg);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.loading, .empty, .error {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.admin-container {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.form-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.invitation-item {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
}
|
|
}
|
|
</style>
|