Initial Commit

This commit is contained in:
Rokojori 2026-07-13 05:48:45 +02:00
commit d7eeaace01
22 changed files with 2580 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
build/
dist/
.env
data/

1375
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -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"
}
}

15
scripts/copy-pages.js Normal file
View File

@ -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}`);
}
}

View 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>

60
source/pages/login.html Normal file
View File

@ -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>

221
source/pages/profile.html Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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>

142
source/server/db.ts Normal file
View File

@ -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 ) );
}
};

View File

@ -0,0 +1,4 @@
export interface EmailSender
{
sendEmail( to: string, subject: string, body: string ): Promise<void>;
}

View File

@ -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 );
}
}

View File

@ -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
} );
}
}

23
source/server/index.ts Normal file
View File

@ -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}` ) );

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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 ) );
}

30
source/server/roles.ts Normal file
View File

@ -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' );
}

View File

@ -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;

View File

@ -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;

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["source/server/**/*"]
}

7
tsconfig.ts-node.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strictNullChecks": false
},
"include": ["source/server/**/*"]
}