add organizer role and features
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
This commit is contained in:
@@ -4,9 +4,10 @@ A digital alternate reality game — find and scan hidden QR codes in real life
|
|||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. **Organizers** create a "hunt" and generate printable QR code cards
|
1. **Admins** create hunts, manage all content, assign roles, and reset passwords
|
||||||
2. **Players** scan hidden QR codes to earn points — first find gets the most points
|
2. **Organizers** create and manage their own hunts with printable QR code cards
|
||||||
3. **Leaderboards** track top players per hunt and globally
|
3. **Players** scan hidden QR codes to earn points — first find gets the most points
|
||||||
|
4. **Leaderboards** track top players per hunt and globally
|
||||||
|
|
||||||
### Point System
|
### Point System
|
||||||
|
|
||||||
@@ -19,10 +20,34 @@ A digital alternate reality game — find and scan hidden QR codes in real life
|
|||||||
|
|
||||||
Players earn points only once per package. Re-scanning lets you update the package hint.
|
Players earn points only once per package. Re-scanning lets you update the package hint.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Capability | Admin | Organizer | Player |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Create hunts | Yes | Yes | No |
|
||||||
|
| Manage/edit/delete own hunts | Yes | Yes | No |
|
||||||
|
| Manage other users' hunts | Yes | No | No |
|
||||||
|
| Download QR code PDFs | Yes | Own hunts | No |
|
||||||
|
| Password reset | Yes | No | No |
|
||||||
|
| Delete any image / clear hints | Yes | No | No |
|
||||||
|
| Assign organizer role | Yes | No | No |
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **QR Code Cards** — Printable Avery 5371 format PDFs with double-sided backs
|
||||||
|
- **First Finder Photos** — First scanner can upload/replace an image per package
|
||||||
|
- **Hints** — Most recent scanner can leave a message for the next finder
|
||||||
|
- **Player Profiles** — Stats, rank, hunt breakdown, and recent activity
|
||||||
|
- **Dark Mode** — Toggle with system preference detection and localStorage persistence
|
||||||
|
- **Relative Timestamps** — "3h ago" format with full date on hover
|
||||||
|
- **Flash Messages** — Feedback on actions (create, edit, delete, upload, etc.)
|
||||||
|
- **Paginated Leaderboards** — 25 per page with navigation controls
|
||||||
|
- **Admin Dashboard** — Hunt stats, top finders, discovery rate, recent scans, role management
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Node.js** + Express
|
- **Node.js** + Express
|
||||||
- **SQLite** via better-sqlite3
|
- **SQLite** via sql.js (WASM)
|
||||||
- **EJS** templates
|
- **EJS** templates
|
||||||
- **PDFKit** + qrcode for printable QR sheets
|
- **PDFKit** + qrcode for printable QR sheets
|
||||||
- **Docker** deployment via Portainer
|
- **Docker** deployment via Portainer
|
||||||
@@ -75,17 +100,22 @@ src/
|
|||||||
├── app.js # Express application entry point
|
├── app.js # Express application entry point
|
||||||
├── setup-admin.js # CLI tool to create/promote admin users
|
├── setup-admin.js # CLI tool to create/promote admin users
|
||||||
├── config/
|
├── config/
|
||||||
│ └── database.js # SQLite initialization & schema
|
│ └── database.js # SQLite (sql.js) initialization, schema & migrations
|
||||||
├── middleware/
|
├── middleware/
|
||||||
│ └── auth.js # Auth & admin middleware
|
│ └── auth.js # Auth, admin & organizer middleware
|
||||||
├── models/
|
├── models/
|
||||||
│ └── index.js # All database operations
|
│ └── index.js # All database operations
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── auth.js # Login/register/logout
|
│ ├── auth.js # Login/register/logout/password reset
|
||||||
│ ├── admin.js # Hunt management & PDF download
|
│ ├── admin.js # Hunt management, PDF download, roles, password reset
|
||||||
│ ├── loot.js # QR scan handling, image upload, hints
|
│ ├── loot.js # QR scan handling, image upload, hints
|
||||||
│ └── hunts.js # Public hunt profiles & leaderboards
|
│ └── hunts.js # Public hunt profiles, leaderboards, player profiles
|
||||||
├── utils/
|
├── utils/
|
||||||
│ └── pdf.js # QR code PDF generation
|
│ └── pdf.js # Avery 5371 QR code PDF generation
|
||||||
└── views/ # EJS templates
|
└── views/ # EJS templates
|
||||||
|
public/
|
||||||
|
├── css/
|
||||||
|
│ └── style.css # Styles with dark mode support
|
||||||
|
└── js/
|
||||||
|
└── timeago.js # Relative timestamp formatting
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
|
|||||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER DEFAULT 0,
|
is_admin INTEGER DEFAULT 0,
|
||||||
|
is_organizer INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -221,6 +222,14 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
|
|||||||
try { _db.run(idx); } catch (e) { /* index may already exist */ }
|
try { _db.run(idx); } catch (e) { /* index may already exist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrations — add columns to existing databases
|
||||||
|
const migrations = [
|
||||||
|
'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0'
|
||||||
|
];
|
||||||
|
for (const m of migrations) {
|
||||||
|
try { _db.run(m); } catch (e) { /* column already exists */ }
|
||||||
|
}
|
||||||
|
|
||||||
save(); // persist initial schema
|
save(); // persist initial schema
|
||||||
_readyResolve();
|
_readyResolve();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+14
-2
@@ -20,12 +20,24 @@ function requireAdmin(req, res, next) {
|
|||||||
res.redirect('/auth/login');
|
res.redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireOrganizerOrAdmin(req, res, next) {
|
||||||
|
if (req.session && req.session.userId && (req.session.isAdmin || req.session.isOrganizer)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (req.session && req.session.userId) {
|
||||||
|
return res.status(403).render('error', { title: 'Forbidden', message: 'You do not have access to this page.' });
|
||||||
|
}
|
||||||
|
req.session.returnTo = req.originalUrl;
|
||||||
|
res.redirect('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
function loadUser(req, res, next) {
|
function loadUser(req, res, next) {
|
||||||
if (req.session && req.session.userId) {
|
if (req.session && req.session.userId) {
|
||||||
res.locals.currentUser = {
|
res.locals.currentUser = {
|
||||||
id: req.session.userId,
|
id: req.session.userId,
|
||||||
username: req.session.username,
|
username: req.session.username,
|
||||||
isAdmin: req.session.isAdmin
|
isAdmin: req.session.isAdmin,
|
||||||
|
isOrganizer: req.session.isOrganizer
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
res.locals.currentUser = null;
|
res.locals.currentUser = null;
|
||||||
@@ -34,4 +46,4 @@ function loadUser(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { requireAuth, requireAdmin, loadUser };
|
module.exports = { requireAuth, requireAdmin, requireOrganizerOrAdmin, loadUser };
|
||||||
|
|||||||
+10
-2
@@ -34,7 +34,7 @@ const Users = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
findById(id) {
|
findById(id) {
|
||||||
return db.prepare('SELECT id, username, is_admin, created_at FROM users WHERE id = ?').get(id);
|
return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users WHERE id = ?').get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
verifyPassword(user, password) {
|
verifyPassword(user, password) {
|
||||||
@@ -45,6 +45,14 @@ const Users = {
|
|||||||
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
|
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
makeOrganizer(userId) {
|
||||||
|
db.prepare('UPDATE users SET is_organizer = 1 WHERE id = ?').run(userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOrganizer(userId) {
|
||||||
|
db.prepare('UPDATE users SET is_organizer = 0 WHERE id = ?').run(userId);
|
||||||
|
},
|
||||||
|
|
||||||
setPassword(userId, newPassword) {
|
setPassword(userId, newPassword) {
|
||||||
const hash = bcrypt.hashSync(newPassword, 12);
|
const hash = bcrypt.hashSync(newPassword, 12);
|
||||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId);
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId);
|
||||||
@@ -72,7 +80,7 @@ const Users = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getAllUsers() {
|
getAllUsers() {
|
||||||
return db.prepare('SELECT id, username, is_admin, created_at FROM users ORDER BY username ASC').all();
|
return db.prepare('SELECT id, username, is_admin, is_organizer, created_at FROM users ORDER BY username ASC').all();
|
||||||
},
|
},
|
||||||
|
|
||||||
getTotalPoints(userId) {
|
getTotalPoints(userId) {
|
||||||
|
|||||||
+59
-24
@@ -1,17 +1,32 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { requireAdmin } = require('../middleware/auth');
|
const { requireAdmin, requireOrganizerOrAdmin } = require('../middleware/auth');
|
||||||
const { Hunts, Packages, Users } = require('../models');
|
const { Hunts, Packages, Users } = require('../models');
|
||||||
const { generateHuntPDF } = require('../utils/pdf');
|
const { generateHuntPDF } = require('../utils/pdf');
|
||||||
|
|
||||||
// All admin routes require admin access
|
// Helper: check if user owns this hunt (or is admin)
|
||||||
router.use(requireAdmin);
|
function requireHuntAccess(req, res, next) {
|
||||||
|
const hunt = Hunts.findById(req.params.id);
|
||||||
|
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||||
|
// Admins can access any hunt; organizers only their own
|
||||||
|
if (req.session.isAdmin || hunt.created_by === req.session.userId) {
|
||||||
|
req.hunt = hunt;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).render('error', { title: 'Forbidden', message: 'You can only manage your own hunts.' });
|
||||||
|
}
|
||||||
|
|
||||||
// Admin dashboard
|
// All admin routes require at least organizer access
|
||||||
|
router.use(requireOrganizerOrAdmin);
|
||||||
|
|
||||||
|
// Admin/Organizer dashboard
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const hunts = Hunts.getByCreator(req.session.userId);
|
const hunts = Hunts.getByCreator(req.session.userId);
|
||||||
const users = Users.getAllUsers();
|
const isAdmin = !!req.session.isAdmin;
|
||||||
res.render('admin/dashboard', { title: 'Admin Dashboard', hunts, users, resetUrl: null, resetUsername: null });
|
|
||||||
|
// Only admins see the full user list and password reset
|
||||||
|
const users = isAdmin ? Users.getAllUsers() : [];
|
||||||
|
res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, resetUrl: null, resetUsername: null, isAdmin });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create hunt form
|
// Create hunt form
|
||||||
@@ -68,9 +83,8 @@ router.post('/hunts', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Manage hunt
|
// Manage hunt
|
||||||
router.get('/hunts/:id', (req, res) => {
|
router.get('/hunts/:id', requireHuntAccess, (req, res) => {
|
||||||
const hunt = Hunts.findById(req.params.id);
|
const hunt = req.hunt;
|
||||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
|
||||||
|
|
||||||
const packages = Packages.getByHunt(hunt.id);
|
const packages = Packages.getByHunt(hunt.id);
|
||||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
@@ -79,16 +93,14 @@ router.get('/hunts/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Edit hunt form
|
// Edit hunt form
|
||||||
router.get('/hunts/:id/edit', (req, res) => {
|
router.get('/hunts/:id/edit', requireHuntAccess, (req, res) => {
|
||||||
const hunt = Hunts.findById(req.params.id);
|
const hunt = req.hunt;
|
||||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
|
||||||
res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt, error: null });
|
res.render('admin/edit-hunt', { title: `Edit: ${hunt.name}`, hunt, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update hunt
|
// Update hunt
|
||||||
router.post('/hunts/:id/edit', (req, res) => {
|
router.post('/hunts/:id/edit', requireHuntAccess, (req, res) => {
|
||||||
const hunt = Hunts.findById(req.params.id);
|
const hunt = req.hunt;
|
||||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
|
||||||
|
|
||||||
const { name, description, expiry_date } = req.body;
|
const { name, description, expiry_date } = req.body;
|
||||||
if (!name || !name.trim()) {
|
if (!name || !name.trim()) {
|
||||||
@@ -101,9 +113,8 @@ router.post('/hunts/:id/edit', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete hunt
|
// Delete hunt
|
||||||
router.post('/hunts/:id/delete', (req, res) => {
|
router.post('/hunts/:id/delete', requireHuntAccess, (req, res) => {
|
||||||
const hunt = Hunts.findById(req.params.id);
|
const hunt = req.hunt;
|
||||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
|
||||||
|
|
||||||
Hunts.delete(hunt.id);
|
Hunts.delete(hunt.id);
|
||||||
req.session.flash = { type: 'success', message: `Hunt "${hunt.name}" deleted.` };
|
req.session.flash = { type: 'success', message: `Hunt "${hunt.name}" deleted.` };
|
||||||
@@ -111,9 +122,8 @@ router.post('/hunts/:id/delete', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download PDF of QR codes
|
// Download PDF of QR codes
|
||||||
router.get('/hunts/:id/pdf', async (req, res) => {
|
router.get('/hunts/:id/pdf', requireHuntAccess, async (req, res) => {
|
||||||
const hunt = Hunts.findById(req.params.id);
|
const hunt = req.hunt;
|
||||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
|
||||||
|
|
||||||
const packages = Packages.getByHunt(hunt.id);
|
const packages = Packages.getByHunt(hunt.id);
|
||||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
@@ -129,8 +139,32 @@ router.get('/hunts/:id/pdf', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Generate password reset URL ──────────────────────────
|
// ─── Manage user roles (admin only) ───────────────────────
|
||||||
router.post('/reset-password', (req, res) => {
|
router.post('/users/:id/role', requireAdmin, (req, res) => {
|
||||||
|
const userId = parseInt(req.params.id, 10);
|
||||||
|
const user = Users.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
req.session.flash = { type: 'danger', message: 'User not found.' };
|
||||||
|
return res.redirect('/admin');
|
||||||
|
}
|
||||||
|
if (user.is_admin) {
|
||||||
|
req.session.flash = { type: 'danger', message: 'Cannot change role of an admin.' };
|
||||||
|
return res.redirect('/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { role } = req.body;
|
||||||
|
if (role === 'organizer') {
|
||||||
|
Users.makeOrganizer(userId);
|
||||||
|
req.session.flash = { type: 'success', message: `${user.username} is now an Organizer.` };
|
||||||
|
} else {
|
||||||
|
Users.removeOrganizer(userId);
|
||||||
|
req.session.flash = { type: 'success', message: `${user.username} is no longer an Organizer.` };
|
||||||
|
}
|
||||||
|
res.redirect('/admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Generate password reset URL (admin only) ────────────
|
||||||
|
router.post('/reset-password', requireAdmin, (req, res) => {
|
||||||
const { username } = req.body;
|
const { username } = req.body;
|
||||||
const user = Users.findByUsername(username);
|
const user = Users.findByUsername(username);
|
||||||
|
|
||||||
@@ -152,7 +186,8 @@ router.post('/reset-password', (req, res) => {
|
|||||||
hunts,
|
hunts,
|
||||||
users,
|
users,
|
||||||
resetUrl,
|
resetUrl,
|
||||||
resetUsername: username
|
resetUsername: username,
|
||||||
|
isAdmin: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ router.post('/login', (req, res) => {
|
|||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
req.session.username = user.username;
|
req.session.username = user.username;
|
||||||
req.session.isAdmin = !!user.is_admin;
|
req.session.isAdmin = !!user.is_admin;
|
||||||
|
req.session.isOrganizer = !!user.is_organizer;
|
||||||
|
|
||||||
const returnTo = req.session.returnTo || '/';
|
const returnTo = req.session.returnTo || '/';
|
||||||
delete req.session.returnTo;
|
delete req.session.returnTo;
|
||||||
@@ -63,6 +64,7 @@ router.post('/register', (req, res) => {
|
|||||||
req.session.userId = userId;
|
req.session.userId = userId;
|
||||||
req.session.username = username;
|
req.session.username = username;
|
||||||
req.session.isAdmin = false;
|
req.session.isAdmin = false;
|
||||||
|
req.session.isOrganizer = false;
|
||||||
req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' };
|
req.session.flash = { type: 'success', message: 'Account created! Welcome to Loot Hunt.' };
|
||||||
|
|
||||||
const returnTo = req.session.returnTo || '/';
|
const returnTo = req.session.returnTo || '/';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
<h1>Admin Dashboard</h1>
|
<h1><%= typeof isAdmin !== 'undefined' && isAdmin ? 'Admin' : 'Organizer' %> Dashboard</h1>
|
||||||
<a href="/admin/hunts/new" class="btn btn-primary">+ New Hunt</a>
|
<a href="/admin/hunts/new" class="btn btn-primary">+ New Hunt</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<% }) %>
|
<% }) %>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof isAdmin !== 'undefined' && isAdmin) { %>
|
||||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Password Reset</h2>
|
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Password Reset</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Generate a one-time password reset link for a user. The link expires in 24 hours.</p>
|
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Generate a one-time password reset link for a user. The link expires in 24 hours.</p>
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<select name="username" class="form-control" required>
|
<select name="username" class="form-control" required>
|
||||||
<option value="">Select user...</option>
|
<option value="">Select user...</option>
|
||||||
<% if (typeof users !== 'undefined' && users) { users.forEach(u => { %>
|
<% if (typeof users !== 'undefined' && users) { users.forEach(u => { %>
|
||||||
<option value="<%= u.username %>"><%= u.username %><%= u.is_admin ? ' (admin)' : '' %></option>
|
<option value="<%= u.username %>"><%= u.username %><%= u.is_admin ? ' (admin)' : u.is_organizer ? ' (organizer)' : '' %></option>
|
||||||
<% }); } %>
|
<% }); } %>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +53,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Manage Roles</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Grant or revoke the <strong>Organizer</strong> role. Organizers can create hunts and manage their own hunts only.</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>User</th><th>Role</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (typeof users !== 'undefined' && users) { users.filter(u => !u.is_admin).forEach(u => { %>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/player/<%= u.username %>"><%= u.username %></a></td>
|
||||||
|
<td><%= u.is_organizer ? 'Organizer' : 'Player' %></td>
|
||||||
|
<td>
|
||||||
|
<% if (u.is_organizer) { %>
|
||||||
|
<form method="POST" action="/admin/users/<%= u.id %>/role" style="margin:0;">
|
||||||
|
<input type="hidden" name="role" value="player">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Remove organizer role from <%= u.username %>?')">Remove Organizer</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="POST" action="/admin/users/<%= u.id %>/role" style="margin:0;">
|
||||||
|
<input type="hidden" name="role" value="organizer">
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">Make Organizer</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- include('../partials/footer') %>
|
<%- include('../partials/footer') %>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
<li><a href="/player/<%= currentUser.username %>">My Profile</a></li>
|
<li><a href="/player/<%= currentUser.username %>">My Profile</a></li>
|
||||||
<% if (currentUser.isAdmin) { %>
|
<% if (currentUser.isAdmin) { %>
|
||||||
<li><a href="/admin">Admin</a></li>
|
<li><a href="/admin">Admin</a></li>
|
||||||
|
<% } else if (currentUser.isOrganizer) { %>
|
||||||
|
<li><a href="/admin">Organizer</a></li>
|
||||||
<% } %>
|
<% } %>
|
||||||
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
|
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|||||||
Reference in New Issue
Block a user