feat: Add invite token-based user registration and an admin interface for token management.
This commit is contained in:
@@ -2204,7 +2204,7 @@ body[type='login'] {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20px 20px;
|
padding: 20px 20px;
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3365,7 +3365,8 @@ input#s_avatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Login Modal */
|
/* Login Modal */
|
||||||
#login-modal {
|
#login-modal,
|
||||||
|
#register-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -3437,7 +3438,8 @@ input#s_avatar {
|
|||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#login-modal-close {
|
#login-modal-close,
|
||||||
|
#register-modal-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
@@ -3449,7 +3451,8 @@ input#s_avatar {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
#login-modal-close:hover {
|
#login-modal-close:hover,
|
||||||
|
#register-modal-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -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
|
// Initialize background preference
|
||||||
if (localStorage.getItem('background') == undefined) {
|
if (localStorage.getItem('background') == undefined) {
|
||||||
localStorage.setItem('background', 'true');
|
localStorage.setItem('background', 'true');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import db from "../sql.mjs";
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
import cfg from "../config.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
76
src/inc/routes/register.mjs
Normal file
76
src/inc/routes/register.mjs
Normal 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;
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<!-- <li><a href="/admin/log">Logs</a></li> -->
|
<!-- <li><a href="/admin/log">Logs</a></li> -->
|
||||||
<li><a href="/admin/approve">Approval Queue</a></li>
|
<li><a href="/admin/approve">Approval Queue</a></li>
|
||||||
<li><a href="/admin/sessions">Sessions</a></li>
|
<li><a href="/admin/sessions">Sessions</a></li>
|
||||||
|
<li><a href="/admin/tokens">Invite Tokens</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
89
views/admin/tokens.html
Normal file
89
views/admin/tokens.html
Normal 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
28
views/register.html
Normal 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>
|
||||||
@@ -56,10 +56,11 @@
|
|||||||
<div class="nav-left-group">
|
<div class="nav-left-group">
|
||||||
<div class="nav-user-dropdown">
|
<div class="nav-user-dropdown">
|
||||||
<button class="nav-user-btn" id="nav-visitor-toggle">
|
<button class="nav-user-btn" id="nav-visitor-toggle">
|
||||||
Not logged in ▾
|
guest ▾
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-user-menu" id="nav-visitor-menu">
|
<div class="nav-user-menu" id="nav-visitor-menu">
|
||||||
<a href="#" id="nav-login-btn">Login</a>
|
<a href="#" id="nav-login-btn">Login</a>
|
||||||
|
<a href="#" id="nav-register-btn">Register</a>
|
||||||
<div class="nav-user-divider"></div>
|
<div class="nav-user-divider"></div>
|
||||||
<a href="/about">about</a>
|
<a href="/about">about</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,3 +111,18 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Modal -->
|
||||||
|
<div id="register-modal" style="display: none;">
|
||||||
|
<div class="login-modal-content">
|
||||||
|
<button id="register-modal-close">×</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>
|
||||||
Reference in New Issue
Block a user