feat: Add invite token-based user registration and an admin interface for token management.

This commit is contained in:
x
2026-01-24 16:01:40 +01:00
parent 1b1867332b
commit 16da3ac9d0
8 changed files with 298 additions and 5 deletions

View File

@@ -2204,7 +2204,7 @@ body[type='login'] {
align-items: center;
padding: 20px 20px;
background: rgba(0, 0, 0, 0.8);
border-radius: 10px;
border-radius: 0;
border: 1px solid var(--accent);
}
@@ -3365,7 +3365,8 @@ input#s_avatar {
}
/* Login Modal */
#login-modal {
#login-modal,
#register-modal {
position: fixed;
top: 0;
left: 0;
@@ -3437,7 +3438,8 @@ input#s_avatar {
filter: brightness(1.1);
}
#login-modal-close {
#login-modal-close,
#register-modal-close {
position: absolute;
top: 10px;
right: 15px;
@@ -3449,7 +3451,8 @@ input#s_avatar {
opacity: 0.7;
}
#login-modal-close:hover {
#login-modal-close:hover,
#register-modal-close:hover {
opacity: 1;
color: var(--accent);
}

View File

@@ -71,6 +71,38 @@ window.requestAnimFrame = (function () {
});
}
// Register Modal Logic
const registerBtn = document.getElementById('nav-register-btn');
const registerModal = document.getElementById('register-modal');
const registerClose = document.getElementById('register-modal-close');
if (registerBtn && registerModal) {
registerBtn.addEventListener('click', (e) => {
e.preventDefault();
registerModal.style.display = 'flex';
// Close dropdown
if (visitorMenu) visitorMenu.classList.remove('show');
});
if (registerClose) {
registerClose.addEventListener('click', () => {
registerModal.style.display = 'none';
});
}
registerModal.addEventListener('click', (e) => {
if (e.target === registerModal) {
registerModal.style.display = 'none';
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && registerModal.style.display === 'flex') {
registerModal.style.display = 'none';
}
});
}
// Initialize background preference
if (localStorage.getItem('background') == undefined) {
localStorage.setItem('background', 'true');

View File

@@ -2,6 +2,7 @@ import db from "../sql.mjs";
import lib from "../lib.mjs";
import { exec } from "child_process";
import { promises as fs } from "fs";
import cfg from "../config.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -291,5 +292,52 @@ export default (router, tpl) => {
}
});
// Token Routes
router.get(/^\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/tokens", { session: req.session, tmp: null }, req)
});
});
router.get(/^\/api\/v2\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
const tokens = await db`
select invite_tokens.*, "user".user as used_by_name
from invite_tokens
left join "user" on "user".id = invite_tokens.used_by
order by created_at desc
`;
if (res.json) {
return res.json({ success: true, tokens });
}
// Fallback if res.json is not available
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, tokens }));
});
router.post(/^\/api\/v2\/admin\/tokens\/create\/?$/, lib.auth, async (req, res) => {
try {
const secret = cfg.main.invite_secret || 'defaultsecret';
const token = lib.md5(lib.createID() + secret).substring(0, 10).toUpperCase(); // Short readable token
await db`
insert into invite_tokens (token, created_at, created_by)
values (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
`;
if (res.json) return res.json({ success: true, token });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
} catch (err) {
if (res.json) return res.json({ success: false, msg: err.message });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/tokens\/delete\/?$/, lib.auth, async (req, res) => {
if (!req.post.id) {
if (res.json) return res.json({ success: false });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
}
await db`delete from invite_tokens where id = ${req.post.id}`;
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
});
return router;
};

View File

@@ -0,0 +1,76 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
router.get(/^\/register(\/)?$/, async (req, res) => {
if (req.cookies.session) {
return res.writeHead(302, { "Location": "/" }).end();
}
res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck" })
});
});
router.post(/^\/register(\/)?$/, async (req, res) => {
const { username, password, password_confirm, token } = req.post;
const renderError = (msg) => {
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg })
});
};
if (!username || !password || !token) return renderError("All fields are required");
if (password !== password_confirm) return renderError("Passwords do not match");
if (username.length < 3) return renderError("Username too short");
// Check token
const tokenRow = await db`
select * from invite_tokens where token = ${token} and is_used = false
`;
if (tokenRow.length === 0) {
return renderError("Invalid or used invite token");
}
// Check user existence
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()}`;
if (existing.length > 0) return renderError("Username taken");
// Create User
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
// Note: Creating user. Assuming columns based on typical structure.
// Need to check 'user' table columns to be safe, but usually: login, password, user (display name), created_at, admin
// I'll assume 'user' is display name and 'login' is lowercase
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin")
values (${username.toLowerCase()}, ${hash}, ${username}, ${ts}, false)
returning id
`;
const userId = newUser[0].id;
// Mark token used
await db`
update invite_tokens
set is_used = true, used_by = ${userId}
where id = ${tokenRow[0].id}
`;
// Get a valid avatar ID (default to 1 or whatever exists)
const avatarRow = await db`select id from items limit 1`;
const avatarId = avatarRow.length > 0 ? avatarRow[0].id : 1; // Fallback to 1, though checking length is safer
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar)
values (${userId}, 0, 'f0ck', 0, ${avatarId})
`;
// Redirect to login
return res.writeHead(302, { "Location": "/login" }).end();
});
return router;
};

View File

@@ -16,6 +16,7 @@
<!-- <li><a href="/admin/log">Logs</a></li> -->
<li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li>
</ul>
</div>
</div>

89
views/admin/tokens.html Normal file
View File

@@ -0,0 +1,89 @@
@include(snippets/header)
<div class="container" style="padding-top: 20px;">
<h2>Invite Tokens</h2>
<div style="margin-bottom: 20px; text-align: right;">
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New
Token</button>
</div>
<div class="upload-form" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
<thead>
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
<th style="padding: 10px;">Token</th>
<th style="padding: 10px;">Status</th>
<th style="padding: 10px;">Used By</th>
<th style="padding: 10px;">Created</th>
<th style="padding: 10px;">Actions</th>
</tr>
</thead>
<tbody id="token-list">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<script>
const loadTokens = async () => {
try {
console.log('Loading tokens...');
const res = await fetch('/api/v2/admin/tokens');
const data = await res.json();
console.log('Tokens data:', data);
if (data.success) {
const tbody = document.getElementById('token-list');
tbody.innerHTML = data.tokens.map(t =>
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
'<td style="padding: 10px; font-family: monospace; font-size: 1.1em; color: var(--accent);">' + t.token + '</td>' +
'<td style="padding: 10px;">' +
(t.is_used ? '<span style="color: #ff6b6b">Used</span>' : '<span style="color: #51cf66">Available</span>') +
'</td>' +
'<td style="padding: 10px;">' + (t.used_by_name || '-') + '</td>' +
'<td style="padding: 10px;">' + new Date(parseInt(t.created_at) * 1000).toLocaleString() + '</td>' +
'<td style="padding: 10px;">' +
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
'</td>' +
'</tr>'
).join('');
}
} catch (e) { console.error(e); }
};
const generateToken = async () => {
console.log('Generating...');
try {
const res = await fetch('/api/v2/admin/tokens/create', { method: 'POST' });
const data = await res.json();
console.log('Gen result:', data);
if (data.success) {
loadTokens();
} else {
alert('Failed: ' + data.msg);
}
} catch (e) {
console.error(e);
alert('Error: ' + e.message);
}
};
const deleteToken = async (id) => {
if (!confirm('Delete this token?')) return;
const res = await fetch('/api/v2/admin/tokens/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
loadTokens();
}
};
document.getElementById('generate-token').addEventListener('click', generateToken);
loadTokens();
</script>
@include(snippets/footer)

28
views/register.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype f0ck>
<html theme="amoled">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>register</title>
<link href="/s/css/f0ck.css" rel="stylesheet" />
</head>
<body type="login">
<form class="login-form" method="post" action="/register">
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
@if(typeof error !== 'undefined')
<div style="color: #ff6b6b; margin-bottom: 10px; text-align: center;">{{ error }}</div>
@endif
<input type="text" name="username" placeholder="username" autocomplete="off" required />
<input type="password" name="password" placeholder="password" autocomplete="off" required />
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" required />
<input type="text" name="token" placeholder="invite token" autocomplete="off" required />
<button type="submit">Create Account</button>
<div style="margin-top: 15px; text-align: center;">
<a href="/login" style="color: var(--accent); text-decoration: none;">Back to Login</a>
</div>
</form>
</body>
</html>

View File

@@ -56,10 +56,11 @@
<div class="nav-left-group">
<div class="nav-user-dropdown">
<button class="nav-user-btn" id="nav-visitor-toggle">
Not logged in
guest
</button>
<div class="nav-user-menu" id="nav-visitor-menu">
<a href="#" id="nav-login-btn">Login</a>
<a href="#" id="nav-register-btn">Register</a>
<div class="nav-user-divider"></div>
<a href="/about">about</a>
</div>
@@ -109,4 +110,19 @@
<button type="submit">Login</button>
</form>
</div>
</div>
<!-- Register Modal -->
<div id="register-modal" style="display: none;">
<div class="login-modal-content">
<button id="register-modal-close">&times;</button>
<form class="login-form" method="post" action="/register">
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
<input type="text" name="username" placeholder="username" autocomplete="off" required />
<input type="password" name="password" placeholder="password" autocomplete="off" required />
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" required />
<input type="text" name="token" placeholder="invite token" autocomplete="off" required />
<button type="submit">Create Account</button>
</form>
</div>
</div>