Initial Commit
This commit is contained in:
commit
d7eeaace01
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "rokojori-auth",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "source/server/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npm run build && ts-node --project tsconfig.ts-node.json source/server/index.ts",
|
||||||
|
"build": "node scripts/copy-pages.js",
|
||||||
|
"dev": "ts-node --project tsconfig.ts-node.json source/server/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"nodemailer": "^9.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const PAGES = path.join(ROOT, 'source', 'pages');
|
||||||
|
const DEST = path.join(ROOT, 'build', 'app');
|
||||||
|
|
||||||
|
fs.mkdirSync(DEST, { recursive: true });
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(PAGES)) {
|
||||||
|
if (file.endsWith('.html')) {
|
||||||
|
fs.copyFileSync(path.join(PAGES, file), path.join(DEST, file));
|
||||||
|
console.log(`copied ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Forgot password — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 360px; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
||||||
|
p.subtitle { font-size: 0.85rem; color: #888; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; }
|
||||||
|
input { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||||
|
input:focus { outline: none; border-color: #555; }
|
||||||
|
button { width: 100%; padding: 0.65rem; background: #2563eb; border: none; border-radius: 4px; color: #fff; font-size: 0.95rem; cursor: pointer; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
.links { display: flex; justify-content: center; margin-top: 1.25rem; font-size: 0.8rem; }
|
||||||
|
.links a { color: #888; text-decoration: none; }
|
||||||
|
.links a:hover { color: #ccc; }
|
||||||
|
.success { background: #14532d; border: 1px solid #166534; border-radius: 4px; padding: 0.75rem; font-size: 0.9rem; color: #4ade80; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Forgot password</h1>
|
||||||
|
<p class="subtitle">Enter your email and we'll send a reset link.</p>
|
||||||
|
<div id="sent" class="success" hidden>Check your inbox — a reset link is on its way.</div>
|
||||||
|
<form id="form">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
|
<button type="submit">Send reset link</button>
|
||||||
|
</form>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/login.html">Back to log in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
await fetch('/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
document.getElementById('form').hidden = true;
|
||||||
|
document.getElementById('sent').hidden = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Log in — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 360px; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; }
|
||||||
|
input { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||||
|
input:focus { outline: none; border-color: #555; }
|
||||||
|
button { width: 100%; padding: 0.65rem; background: #2563eb; border: none; border-radius: 4px; color: #fff; font-size: 0.95rem; cursor: pointer; margin-top: 0.25rem; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
.links { display: flex; justify-content: space-between; margin-top: 1.25rem; font-size: 0.8rem; }
|
||||||
|
.links a { color: #888; text-decoration: none; }
|
||||||
|
.links a:hover { color: #ccc; }
|
||||||
|
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Log in</h1>
|
||||||
|
<form id="form">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
<p class="error" id="error" hidden></p>
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/register.html">Create account</a>
|
||||||
|
<a href="/forgot-password.html">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const redirect = new URLSearchParams(location.search).get('redirect') || '/profile.html';
|
||||||
|
document.getElementById('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = { email: e.target.email.value, password: e.target.password.value };
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = redirect;
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
const p = document.getElementById('error');
|
||||||
|
p.textContent = err.error;
|
||||||
|
p.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Profile — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; padding: 2rem; }
|
||||||
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 600px; margin: 0 auto 1.5rem; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
|
||||||
|
h2 { font-size: 1rem; margin-bottom: 1rem; color: #bbb; }
|
||||||
|
.field { margin-bottom: 0.75rem; }
|
||||||
|
.field-label { font-size: 0.8rem; color: #888; }
|
||||||
|
.field-value { font-size: 0.95rem; margin-top: 0.15rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; margin-top: 0.75rem; }
|
||||||
|
input[type="password"] { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 0.25rem; }
|
||||||
|
input[type="password"]:focus { outline: none; border-color: #555; }
|
||||||
|
button { padding: 0.5rem 1.1rem; border: none; border-radius: 4px; color: #fff; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
button.primary { background: #2563eb; }
|
||||||
|
button.primary:hover { background: #1d4ed8; }
|
||||||
|
button.secondary { background: #333; }
|
||||||
|
button.secondary:hover { background: #444; }
|
||||||
|
button.danger { background: #7f1d1d; }
|
||||||
|
button.danger:hover { background: #991b1b; }
|
||||||
|
button.grant { background: #14532d; }
|
||||||
|
button.grant:hover { background: #166534; }
|
||||||
|
.success { color: #4ade80; font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
|
.error { color: #f87171; font-size: 0.85rem; margin-top: 0.5rem; }
|
||||||
|
nav { max-width: 600px; margin: 0 auto 1.5rem; display: flex; justify-content: flex-end; }
|
||||||
|
/* User table */
|
||||||
|
.user-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||||
|
.user-table th { text-align: left; color: #888; font-weight: normal; padding: 0.4rem 0.5rem; border-bottom: 1px solid #2a2a2a; }
|
||||||
|
.user-table td { padding: 0.5rem 0.5rem; border-bottom: 1px solid #1e1e1e; vertical-align: middle; }
|
||||||
|
.role-tag { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.75rem; margin-right: 0.25rem; }
|
||||||
|
.role-tag.superadmin { background: #451a03; color: #fb923c; }
|
||||||
|
.role-tag.admin { background: #1e1b4b; color: #a5b4fc; }
|
||||||
|
.role-tag.user { background: #1c1c1c; color: #888; border: 1px solid #333; }
|
||||||
|
.actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<button class="secondary" id="logout-btn">Log out</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Profile info -->
|
||||||
|
<div class="card">
|
||||||
|
<h1>Profile</h1>
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-label">Email</div>
|
||||||
|
<div class="field-value" id="email-display">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-label">Roles</div>
|
||||||
|
<div class="field-value" id="roles-display">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="products-row" hidden>
|
||||||
|
<div class="field-label">Products</div>
|
||||||
|
<div class="field-value" id="products-display">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change password -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Change password</h2>
|
||||||
|
<form id="pw-form" autocomplete="on">
|
||||||
|
<input type="email" id="email-field" name="email" autocomplete="username" hidden readonly>
|
||||||
|
<label for="current-pw">Current password</label>
|
||||||
|
<input type="password" id="current-pw" name="currentPassword" required autocomplete="current-password">
|
||||||
|
<label for="new-pw">New password</label>
|
||||||
|
<input type="password" id="new-pw" name="newPassword" required minlength="8" autocomplete="new-password">
|
||||||
|
<button type="submit" class="primary" style="margin-top:0.75rem">Update password</button>
|
||||||
|
<p class="success" id="pw-success" hidden>Password updated.</p>
|
||||||
|
<p class="error" id="pw-error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin: user list (admin + superadmin) -->
|
||||||
|
<div class="card" id="admin-card" hidden>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<table class="user-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th id="actions-header" hidden>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="user-list"></tbody>
|
||||||
|
</table>
|
||||||
|
<p class="error" id="admin-error" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
function roleTag(role) {
|
||||||
|
return `<span class="role-tag ${role}">${role}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoles(roles) {
|
||||||
|
return roles.map(roleTag).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminUsers() {
|
||||||
|
const res = await fetch('/api/admin/users');
|
||||||
|
if (!res.ok) {
|
||||||
|
document.getElementById('admin-error').textContent = 'Could not load users.';
|
||||||
|
document.getElementById('admin-error').hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userList = await res.json();
|
||||||
|
const isSuperAdmin = currentUser.roles.includes('superadmin');
|
||||||
|
if (isSuperAdmin) document.getElementById('actions-header').hidden = false;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('user-list');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
for (const u of userList) {
|
||||||
|
const isSelf = u.id === currentUser.id;
|
||||||
|
const targetIsSuperAdmin = u.roles.includes('superadmin');
|
||||||
|
const targetIsAdmin = u.roles.includes('admin');
|
||||||
|
const joined = new Date(u.createdAt).toLocaleDateString();
|
||||||
|
|
||||||
|
let actions = '';
|
||||||
|
if (isSuperAdmin && !isSelf) {
|
||||||
|
if (targetIsSuperAdmin) {
|
||||||
|
actions = '<span style="color:#666;font-size:0.8rem">superadmin</span>';
|
||||||
|
} else if (targetIsAdmin) {
|
||||||
|
actions = `<div class="actions">
|
||||||
|
<button class="danger" onclick="setRoles('${u.id}', ['user'])">Revoke admin</button>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
actions = `<div class="actions">
|
||||||
|
<button class="grant" onclick="setRoles('${u.id}', ['admin', 'user'])">Grant admin</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.insertAdjacentHTML('beforeend', `
|
||||||
|
<tr>
|
||||||
|
<td>${u.email}${isSelf ? ' <span style="color:#888;font-size:0.75rem">(you)</span>' : ''}</td>
|
||||||
|
<td>${renderRoles(u.roles)}</td>
|
||||||
|
<td style="color:#666">${joined}</td>
|
||||||
|
${isSuperAdmin ? `<td>${actions}</td>` : ''}
|
||||||
|
</tr>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRoles(userId, roles) {
|
||||||
|
const res = await fetch(`/api/admin/users/${userId}/roles`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roles })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
loadAdminUsers();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
document.getElementById('admin-error').textContent = err.error;
|
||||||
|
document.getElementById('admin-error').hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
const res = await fetch('/api/auth/me');
|
||||||
|
if (!res.ok) { location.href = '/login.html'; return; }
|
||||||
|
currentUser = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('email-display').textContent = currentUser.email;
|
||||||
|
document.getElementById('roles-display').innerHTML = renderRoles(currentUser.roles);
|
||||||
|
document.getElementById('email-field').value = currentUser.email;
|
||||||
|
|
||||||
|
if (currentUser.products?.length) {
|
||||||
|
document.getElementById('products-display').textContent = currentUser.products.map(p => p.id).join(', ');
|
||||||
|
document.getElementById('products-row').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = currentUser.roles.includes('admin') || currentUser.roles.includes('superadmin');
|
||||||
|
if (isAdmin) {
|
||||||
|
document.getElementById('admin-card').hidden = false;
|
||||||
|
loadAdminUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pw-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('pw-success').hidden = true;
|
||||||
|
document.getElementById('pw-error').hidden = true;
|
||||||
|
const res = await fetch('/api/auth/me/password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: document.getElementById('current-pw').value,
|
||||||
|
newPassword: document.getElementById('new-pw').value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('pw-success').hidden = false;
|
||||||
|
document.getElementById('pw-form').reset();
|
||||||
|
document.getElementById('email-field').value = currentUser.email;
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
const p = document.getElementById('pw-error');
|
||||||
|
p.textContent = err.error;
|
||||||
|
p.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create account — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 360px; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; }
|
||||||
|
input { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||||
|
input:focus { outline: none; border-color: #555; }
|
||||||
|
button { width: 100%; padding: 0.65rem; background: #2563eb; border: none; border-radius: 4px; color: #fff; font-size: 0.95rem; cursor: pointer; margin-top: 0.25rem; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
.links { display: flex; justify-content: center; margin-top: 1.25rem; font-size: 0.8rem; }
|
||||||
|
.links a { color: #888; text-decoration: none; }
|
||||||
|
.links a:hover { color: #ccc; }
|
||||||
|
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Create account</h1>
|
||||||
|
<form id="form">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="new-password" minlength="8">
|
||||||
|
<p class="error" id="error" hidden></p>
|
||||||
|
<button type="submit">Create account</button>
|
||||||
|
</form>
|
||||||
|
<div class="links">
|
||||||
|
<a href="/login.html">Already have an account? Log in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const redirect = new URLSearchParams(location.search).get('redirect') || '/profile.html';
|
||||||
|
document.getElementById('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = { email: e.target.email.value, password: e.target.password.value };
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = redirect;
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
const p = document.getElementById('error');
|
||||||
|
p.textContent = err.error;
|
||||||
|
p.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reset password — rokojori</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #111; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
.card { background: #1c1c1c; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 360px; }
|
||||||
|
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.25rem; }
|
||||||
|
input[type="password"] { display: block; width: 100%; padding: 0.6rem 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||||
|
input[type="password"]:focus { outline: none; border-color: #555; }
|
||||||
|
button { width: 100%; padding: 0.65rem; background: #2563eb; border: none; border-radius: 4px; color: #fff; font-size: 0.95rem; cursor: pointer; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 0.75rem; }
|
||||||
|
.invalid { color: #f87171; font-size: 0.95rem; }
|
||||||
|
.invalid a { color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
<p class="invalid" id="invalid" hidden>
|
||||||
|
This reset link is invalid or has expired.
|
||||||
|
<a href="/forgot-password.html">Request a new one.</a>
|
||||||
|
</p>
|
||||||
|
<form id="form" autocomplete="on">
|
||||||
|
<!-- Hidden email field so browsers can associate the new password with the account -->
|
||||||
|
<input type="email" id="email-field" name="email" autocomplete="username" hidden readonly>
|
||||||
|
<label for="new-pw">New password</label>
|
||||||
|
<input type="password" id="new-pw" name="password" required minlength="8" autocomplete="new-password">
|
||||||
|
<p class="error" id="error" hidden></p>
|
||||||
|
<button type="submit">Set new password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const token = new URLSearchParams(location.search).get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('invalid').hidden = false;
|
||||||
|
document.getElementById('form').hidden = true;
|
||||||
|
} else {
|
||||||
|
// Fetch the email for this token so the browser can save the updated password
|
||||||
|
fetch('/api/auth/reset-token-email?token=' + encodeURIComponent(token))
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data) {
|
||||||
|
document.getElementById('invalid').hidden = false;
|
||||||
|
document.getElementById('form').hidden = true;
|
||||||
|
} else {
|
||||||
|
document.getElementById('email-field').value = data.email;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('new-pw').value;
|
||||||
|
const res = await fetch('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, password })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = '/login.html';
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
const p = document.getElementById('error');
|
||||||
|
p.textContent = err.error;
|
||||||
|
p.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const DATA_DIR = path.join( __dirname, '..', '..', 'build', 'data' );
|
||||||
|
if ( !fs.existsSync( DATA_DIR ) ) fs.mkdirSync( DATA_DIR, { recursive: true } );
|
||||||
|
|
||||||
|
export interface UserProduct
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
acquiredAt: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
passwordHash: string;
|
||||||
|
roles: string[];
|
||||||
|
products: UserProduct[];
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshToken
|
||||||
|
{
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetToken
|
||||||
|
{
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read<T>( table: string ): T[]
|
||||||
|
{
|
||||||
|
const file = path.join( DATA_DIR, `${table}.json` );
|
||||||
|
if ( !fs.existsSync( file ) ) return [];
|
||||||
|
return JSON.parse( fs.readFileSync( file, 'utf8' ) ) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function write<T>( table: string, rows: T[] ): void
|
||||||
|
{
|
||||||
|
fs.writeFileSync( path.join( DATA_DIR, `${table}.json` ), JSON.stringify( rows, null, 2 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
export const users =
|
||||||
|
{
|
||||||
|
all: (): User[] => read<User>( 'users' ),
|
||||||
|
|
||||||
|
findById: ( id: string ): User | undefined =>
|
||||||
|
read<User>( 'users' ).find( u => u.id === id ),
|
||||||
|
|
||||||
|
findByEmail: ( email: string ): User | undefined =>
|
||||||
|
read<User>( 'users' ).find( u => u.email.toLowerCase() === email.toLowerCase() ),
|
||||||
|
|
||||||
|
create( email: string, passwordHash: string ): User
|
||||||
|
{
|
||||||
|
const rows = read<User>( 'users' );
|
||||||
|
if ( rows.find( u => u.email.toLowerCase() === email.toLowerCase() ) ) throw new Error( 'Taken' );
|
||||||
|
const user: User =
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
roles: [ 'user' ],
|
||||||
|
products: [],
|
||||||
|
settings: {},
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
write( 'users', [ ...rows, user ] );
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
|
||||||
|
update( id: string, patch: Partial<Omit<User, 'id' | 'createdAt'>> ): void
|
||||||
|
{
|
||||||
|
const rows = read<User>( 'users' ).map( u => u.id === id ? { ...u, ...patch } : u );
|
||||||
|
write( 'users', rows );
|
||||||
|
},
|
||||||
|
|
||||||
|
delete( id: string ): void
|
||||||
|
{
|
||||||
|
write( 'users', read<User>( 'users' ).filter( u => u.id !== id ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshTokens =
|
||||||
|
{
|
||||||
|
find: ( token: string ): RefreshToken | undefined =>
|
||||||
|
read<RefreshToken>( 'refreshTokens' ).find( t => t.token === token ),
|
||||||
|
|
||||||
|
create( userId: string ): RefreshToken
|
||||||
|
{
|
||||||
|
const token: RefreshToken =
|
||||||
|
{
|
||||||
|
token: randomUUID(),
|
||||||
|
userId,
|
||||||
|
expiresAt: new Date( Date.now() + 30 * 24 * 60 * 60 * 1000 ).toISOString()
|
||||||
|
};
|
||||||
|
write( 'refreshTokens', [ ...read<RefreshToken>( 'refreshTokens' ), token ] );
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete( token: string ): void
|
||||||
|
{
|
||||||
|
write( 'refreshTokens', read<RefreshToken>( 'refreshTokens' ).filter( t => t.token !== token ) );
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteForUser( userId: string ): void
|
||||||
|
{
|
||||||
|
write( 'refreshTokens', read<RefreshToken>( 'refreshTokens' ).filter( t => t.userId !== userId ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetTokens =
|
||||||
|
{
|
||||||
|
find: ( token: string ): ResetToken | undefined =>
|
||||||
|
read<ResetToken>( 'resetTokens' ).find( t => t.token === token ),
|
||||||
|
|
||||||
|
create( userId: string ): ResetToken
|
||||||
|
{
|
||||||
|
const token: ResetToken =
|
||||||
|
{
|
||||||
|
token: randomUUID(),
|
||||||
|
userId,
|
||||||
|
expiresAt: new Date( Date.now() + 60 * 60 * 1000 ).toISOString()
|
||||||
|
};
|
||||||
|
const existing = read<ResetToken>( 'resetTokens' ).filter( t => t.userId !== userId );
|
||||||
|
write( 'resetTokens', [ ...existing, token ] );
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete( token: string ): void
|
||||||
|
{
|
||||||
|
write( 'resetTokens', read<ResetToken>( 'resetTokens' ).filter( t => t.token !== token ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface EmailSender
|
||||||
|
{
|
||||||
|
sendEmail( to: string, subject: string, body: string ): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { EmailSender } from './EmailSender';
|
||||||
|
import { SMTPEmailSender } from './SMTPEmailSender';
|
||||||
|
|
||||||
|
export class EmailService
|
||||||
|
{
|
||||||
|
private static sender: EmailSender = new SMTPEmailSender(
|
||||||
|
{
|
||||||
|
host: process.env.SMTP_HOST ?? '',
|
||||||
|
port: Number( process.env.SMTP_PORT ?? 587 ),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
user: process.env.SMTP_USER ?? '',
|
||||||
|
pass: process.env.SMTP_PASS ?? '',
|
||||||
|
from: process.env.SMTP_FROM ?? ''
|
||||||
|
} );
|
||||||
|
|
||||||
|
static async sendEmail( to: string, subject: string, body: string ): Promise<void>
|
||||||
|
{
|
||||||
|
return EmailService.sender.sendEmail( to, subject, body );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { EmailSender } from './EmailSender';
|
||||||
|
|
||||||
|
export interface SMTPConfig
|
||||||
|
{
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SMTPEmailSender implements EmailSender
|
||||||
|
{
|
||||||
|
private transporter: nodemailer.Transporter;
|
||||||
|
private from: string;
|
||||||
|
|
||||||
|
constructor( config: SMTPConfig )
|
||||||
|
{
|
||||||
|
this.from = config.from;
|
||||||
|
this.transporter = nodemailer.createTransport(
|
||||||
|
{
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.secure,
|
||||||
|
auth:
|
||||||
|
{
|
||||||
|
user: config.user,
|
||||||
|
pass: config.pass
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail( to: string, subject: string, body: string ): Promise<void>
|
||||||
|
{
|
||||||
|
await this.transporter.sendMail(
|
||||||
|
{
|
||||||
|
from: this.from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text: body
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import path from 'path';
|
||||||
|
import authRouter from './routes/auth';
|
||||||
|
import adminRouter from './routes/admin';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use( express.json() );
|
||||||
|
app.use( cookieParser() );
|
||||||
|
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
||||||
|
|
||||||
|
app.use( '/api/auth', authRouter );
|
||||||
|
app.use( '/api/admin', adminRouter );
|
||||||
|
|
||||||
|
app.get( '/', ( _req, res ) =>
|
||||||
|
{
|
||||||
|
res.redirect( '/login.html' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3001;
|
||||||
|
app.listen( PORT, () => console.log( `rokojori-auth running on http://localhost:${PORT}` ) );
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { isAdmin, isSuperAdmin } from '../roles';
|
||||||
|
|
||||||
|
export function requireAdmin( req: Request, res: Response, next: NextFunction ): void
|
||||||
|
{
|
||||||
|
if ( !req.auth || !isAdmin( req.auth.roles ) )
|
||||||
|
{
|
||||||
|
res.status( 403 ).json( { error: 'Admin access required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireSuperAdmin( req: Request, res: Response, next: NextFunction ): void
|
||||||
|
{
|
||||||
|
if ( !req.auth || !isSuperAdmin( req.auth.roles ) )
|
||||||
|
{
|
||||||
|
res.status( 403 ).json( { error: 'Superadmin access required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface AuthPayload
|
||||||
|
{
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
products: string[];
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global
|
||||||
|
{
|
||||||
|
namespace Express
|
||||||
|
{
|
||||||
|
interface Request
|
||||||
|
{
|
||||||
|
auth?: AuthPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth( req: Request, res: Response, next: NextFunction ): void
|
||||||
|
{
|
||||||
|
const token = req.cookies?.accessToken ?? extractBearer( req );
|
||||||
|
if ( !token )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Not authenticated' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
req.auth = jwt.verify( token, process.env.JWT_SECRET ?? '' ) as AuthPayload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Invalid token' } );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBearer( req: Request ): string | undefined
|
||||||
|
{
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if ( auth?.startsWith( 'Bearer ' ) ) return auth.slice( 7 );
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
const WINDOW_MS = 20 * 60 * 1000; // 20 minutes
|
||||||
|
const MAX_ATTEMPTS = 20;
|
||||||
|
|
||||||
|
interface Entry
|
||||||
|
{
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Entry>();
|
||||||
|
|
||||||
|
function delayMs( count: number ): number
|
||||||
|
{
|
||||||
|
if ( count <= 5 ) return 5_000;
|
||||||
|
if ( count <= 10 ) return 15_000;
|
||||||
|
return 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkForgotPasswordRate( ip: string ): { blocked: boolean; delay: number }
|
||||||
|
{
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = store.get( ip );
|
||||||
|
|
||||||
|
if ( !entry || now - entry.windowStart > WINDOW_MS )
|
||||||
|
entry = { count: 0, windowStart: now };
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
store.set( ip, entry );
|
||||||
|
|
||||||
|
if ( entry.count > MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
|
||||||
|
return { blocked: false, delay: delayMs( entry.count ) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep( ms: number ): Promise<void>
|
||||||
|
{
|
||||||
|
return new Promise( resolve => setTimeout( resolve, ms ) );
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
export const rolePermissions: Record<string, string[]> =
|
||||||
|
{
|
||||||
|
superadmin: [ 'manage-users', 'manage-roles', 'manage-products', 'access-all' ],
|
||||||
|
admin: [ 'manage-users', 'manage-products', 'access-all' ],
|
||||||
|
user: [ 'access-own' ]
|
||||||
|
};
|
||||||
|
|
||||||
|
export function permissionsForRoles( roles: string[] ): string[]
|
||||||
|
{
|
||||||
|
const perms = new Set<string>();
|
||||||
|
for ( const role of roles )
|
||||||
|
for ( const p of rolePermissions[ role ] ?? [] )
|
||||||
|
perms.add( p );
|
||||||
|
return [ ...perms ];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRole( roles: string[], role: string ): boolean
|
||||||
|
{
|
||||||
|
return roles.includes( role );
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin( roles: string[] ): boolean
|
||||||
|
{
|
||||||
|
return roles.includes( 'admin' ) || roles.includes( 'superadmin' );
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdmin( roles: string[] ): boolean
|
||||||
|
{
|
||||||
|
return roles.includes( 'superadmin' );
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { users } from '../db';
|
||||||
|
import { requireAuth } from '../middleware/requireAuth';
|
||||||
|
import { requireAdmin, requireSuperAdmin } from '../middleware/requireAdmin';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All admin routes require a valid JWT first
|
||||||
|
router.use( requireAuth );
|
||||||
|
|
||||||
|
// GET /api/admin/users — list all users (admin + superadmin)
|
||||||
|
router.get( '/users', requireAdmin, ( _req, res ) =>
|
||||||
|
{
|
||||||
|
const all = users.all().map( u => (
|
||||||
|
{
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
roles: u.roles,
|
||||||
|
createdAt: u.createdAt
|
||||||
|
} ) );
|
||||||
|
res.json( all );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// PATCH /api/admin/users/:id/roles — set roles on a user (superadmin only)
|
||||||
|
router.patch( '/users/:id/roles', requireSuperAdmin, ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if ( id === req.auth!.userId )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Cannot change your own roles' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = users.findById( id );
|
||||||
|
if ( !target )
|
||||||
|
{
|
||||||
|
res.status( 404 ).json( { error: 'User not found' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roles } = req.body as { roles?: string[] };
|
||||||
|
if ( !Array.isArray( roles ) )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'roles must be an array' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always keep 'user' as base role
|
||||||
|
const next = roles.includes( 'user' ) ? roles : [ ...roles, 'user' ];
|
||||||
|
users.update( id, { roles: next } );
|
||||||
|
res.json( { id, roles: next } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { Router, Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { users, refreshTokens, resetTokens } from '../db';
|
||||||
|
import { requireAuth } from '../middleware/requireAuth';
|
||||||
|
import { EmailService } from '../email/EmailService';
|
||||||
|
import { checkForgotPasswordRate, sleep } from '../rateLimiter';
|
||||||
|
import { isSuperAdmin } from '../roles';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_TTL = '1h';
|
||||||
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
|
||||||
|
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
|
||||||
|
|
||||||
|
function issueAccessToken( userId: string ): string
|
||||||
|
{
|
||||||
|
const user = users.findById( userId )!;
|
||||||
|
const payload =
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.roles,
|
||||||
|
products: user.products.map( p => p.id ),
|
||||||
|
settings: user.settings
|
||||||
|
};
|
||||||
|
return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTokenCookie( res: Response, accessToken: string ): void
|
||||||
|
{
|
||||||
|
res.cookie( 'accessToken', accessToken,
|
||||||
|
{
|
||||||
|
domain: COOKIE_DOMAIN,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 1000,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/'
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/register
|
||||||
|
router.post( '/register', async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { email, password } = req.body as { email?: string; password?: string };
|
||||||
|
if ( !email || !password )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Email and password required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const passwordHash = await bcrypt.hash( password, 10 );
|
||||||
|
const user = users.create( email, passwordHash );
|
||||||
|
|
||||||
|
// Bootstrap: if this email matches INITIAL_SUPERADMIN_EMAIL and no superadmin exists yet
|
||||||
|
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
|
||||||
|
if ( superadminEmail && email.toLowerCase() === superadminEmail )
|
||||||
|
{
|
||||||
|
const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) );
|
||||||
|
if ( !alreadyHasSuperAdmin )
|
||||||
|
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = issueAccessToken( user.id );
|
||||||
|
const refresh = refreshTokens.create( user.id );
|
||||||
|
setTokenCookie( res, accessToken );
|
||||||
|
res.json( { accessToken, refreshToken: refresh.token } );
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
res.status( 409 ).json( { error: 'Email already registered' } );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post( '/login', async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { email, password } = req.body as { email?: string; password?: string };
|
||||||
|
const user = email ? users.findByEmail( email ) : undefined;
|
||||||
|
if ( !user || !password || !( await bcrypt.compare( password, user.passwordHash ) ) )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Invalid credentials' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accessToken = issueAccessToken( user.id );
|
||||||
|
const refresh = refreshTokens.create( user.id );
|
||||||
|
setTokenCookie( res, accessToken );
|
||||||
|
res.json( { accessToken, refreshToken: refresh.token } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
router.post( '/logout', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { refreshToken } = req.body as { refreshToken?: string };
|
||||||
|
if ( refreshToken ) refreshTokens.delete( refreshToken );
|
||||||
|
res.clearCookie( 'accessToken', { domain: COOKIE_DOMAIN, path: '/' } );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/refresh
|
||||||
|
router.post( '/refresh', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { refreshToken } = req.body as { refreshToken?: string };
|
||||||
|
if ( !refreshToken )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Refresh token required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = refreshTokens.find( refreshToken );
|
||||||
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Invalid or expired refresh token' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = users.findById( record.userId );
|
||||||
|
if ( !user )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'User not found' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accessToken = issueAccessToken( user.id );
|
||||||
|
setTokenCookie( res, accessToken );
|
||||||
|
res.json( { accessToken } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/forgot-password — rate-limited with escalating delay
|
||||||
|
router.post( '/forgot-password', async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const ip = req.ip ?? 'unknown';
|
||||||
|
const { blocked, delay } = checkForgotPasswordRate( ip );
|
||||||
|
|
||||||
|
if ( blocked )
|
||||||
|
{
|
||||||
|
res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep( delay );
|
||||||
|
|
||||||
|
const { email } = req.body as { email?: string };
|
||||||
|
if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; }
|
||||||
|
|
||||||
|
const user = users.findByEmail( email );
|
||||||
|
if ( user )
|
||||||
|
{
|
||||||
|
const token = resetTokens.create( user.id );
|
||||||
|
const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`;
|
||||||
|
await EmailService.sendEmail(
|
||||||
|
email,
|
||||||
|
'Reset your password — rokojori',
|
||||||
|
`Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always respond OK — never reveal whether the email exists
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/reset-password
|
||||||
|
router.post( '/reset-password', async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { token, password } = req.body as { token?: string; password?: string };
|
||||||
|
if ( !token || !password )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Token and password required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = resetTokens.find( token );
|
||||||
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash( password, 10 );
|
||||||
|
users.update( record.userId, { passwordHash } );
|
||||||
|
resetTokens.delete( token );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// GET /api/auth/reset-token-email — returns email for a valid reset token (used by reset-password page)
|
||||||
|
router.get( '/reset-token-email', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const token = req.query.token as string | undefined;
|
||||||
|
const record = token ? resetTokens.find( token ) : undefined;
|
||||||
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = users.findById( record.userId );
|
||||||
|
if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; }
|
||||||
|
res.json( { email: user.email } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get( '/me', requireAuth, ( req, res ) =>
|
||||||
|
{
|
||||||
|
const user = users.findById( req.auth!.userId );
|
||||||
|
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||||
|
res.json(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.roles,
|
||||||
|
products: user.products,
|
||||||
|
settings: user.settings
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// PATCH /api/auth/me/settings
|
||||||
|
router.patch( '/me/settings', requireAuth, ( req, res ) =>
|
||||||
|
{
|
||||||
|
const user = users.findById( req.auth!.userId );
|
||||||
|
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||||
|
const settings = { ...user.settings, ...( req.body as Record<string, unknown> ) };
|
||||||
|
users.update( req.auth!.userId, { settings } );
|
||||||
|
res.json( { settings } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// POST /api/auth/me/password — authenticated password change
|
||||||
|
router.post( '/me/password', requireAuth, async ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
|
||||||
|
if ( !currentPassword || !newPassword )
|
||||||
|
{
|
||||||
|
res.status( 400 ).json( { error: 'Current and new password required' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = users.findById( req.auth!.userId );
|
||||||
|
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||||
|
if ( !( await bcrypt.compare( currentPassword, user.passwordHash ) ) )
|
||||||
|
{
|
||||||
|
res.status( 401 ).json( { error: 'Current password is incorrect' } );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash( newPassword, 10 );
|
||||||
|
users.update( user.id, { passwordHash } );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["source/server/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strictNullChecks": false
|
||||||
|
},
|
||||||
|
"include": ["source/server/**/*"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue