implement authentication and register correctly

This commit is contained in:
2026-07-09 08:43:39 +02:00
parent 054437b829
commit 8b07b17e5f
7 changed files with 414 additions and 9 deletions
+5
View File
@@ -67,6 +67,11 @@
<artifactId>rest-assured</artifactId> <artifactId>rest-assured</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -79,10 +79,9 @@ public class DatabaseService {
private void seed(Connection connection) throws SQLException { private void seed(Connection connection) throws SQLException {
List<String> platforms = List.of("Windows", "Linux", "Mobile (Android)"); List<String> platforms = List.of("Windows", "Linux", "Mobile (Android)");
try (PreparedStatement insert = connection.prepareStatement("INSERT INTO platforms (name) VALUES (?) ON CONFLICT (name) DO NOTHING")) { for (String platform : platforms) {
for (String platform : platforms) { if (findOne("SELECT id FROM platforms WHERE name = ?", platform) == null) {
insert.setString(1, platform); execute(connection, "INSERT INTO platforms (name) VALUES (?)", platform);
insert.executeUpdate();
} }
} }
@@ -175,9 +174,12 @@ public class DatabaseService {
} }
private static long insertAndReturnId(Connection connection, String sql, Object... params) throws SQLException { private static long insertAndReturnId(Connection connection, String sql, Object... params) throws SQLException {
try (PreparedStatement statement = prepare(connection, sql + " RETURNING id", params); try (PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ResultSet resultSet = statement.executeQuery()) { bind(statement, params);
return resultSet.next() ? resultSet.getLong(1) : 0; statement.executeUpdate();
try (ResultSet resultSet = statement.getGeneratedKeys()) {
return resultSet.next() ? resultSet.getLong(1) : 0;
}
} }
} }
@@ -189,9 +191,13 @@ public class DatabaseService {
private static PreparedStatement prepare(Connection connection, String sql, Object... params) throws SQLException { private static PreparedStatement prepare(Connection connection, String sql, Object... params) throws SQLException {
PreparedStatement statement = connection.prepareStatement(sql); PreparedStatement statement = connection.prepareStatement(sql);
bind(statement, params);
return statement;
}
private static void bind(PreparedStatement statement, Object... params) throws SQLException {
for (int i = 0; i < params.length; i++) { for (int i = 0; i < params.length; i++) {
statement.setObject(i + 1, params[i]); statement.setObject(i + 1, params[i]);
} }
return statement;
} }
} }
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kernel Panic - Auth</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/" aria-label="Kernel Panic home">
<span class="brand-mark">KP</span>
<span>Kernel Panic</span>
</a>
<nav class="nav" aria-label="Primary navigation">
<a href="/">Main</a>
<a href="/register.html">Register</a>
<a href="/auth.html">Auth</a>
</nav>
</header>
<main>
<section class="auth-page" aria-labelledby="auth-title">
<div class="section-heading">
<p class="eyebrow">registered player auth</p>
<h1 id="auth-title">Login to the private boot log</h1>
<p class="hero-text">
Use the same username and email from pre-registration. Authenticated
players can inspect extra game intel before launch.
</p>
</div>
<div class="auth-grid">
<form class="auth-form" id="auth-form">
<label>
Username
<input id="auth-username" name="username" autocomplete="username" required>
</label>
<label>
Email
<input id="auth-email" name="email" type="email" autocomplete="email" required>
</label>
<div class="auth-actions">
<button class="button primary" type="submit">jwt login</button>
<button class="button secondary" id="auth-logout" type="button">logout</button>
</div>
<p class="form-message" id="auth-message" role="status" aria-live="polite"></p>
</form>
<div class="auth-session" id="auth-session">
<span class="terminal-label">session</span>
<strong>not authenticated</strong>
<span>role: none</span>
</div>
</div>
</section>
<section class="private-intel-section" aria-labelledby="private-intel-title">
<div class="section-heading">
<p class="eyebrow">authenticated intel</p>
<h2 id="private-intel-title">Game details unlocked after login</h2>
</div>
<div class="private-intel-grid" id="private-intel">
<article>
<h3>Access required</h3>
<p>Log in with your registered shell account to reveal private build notes.</p>
</article>
</div>
</section>
</main>
<footer class="site-footer">
<span>auth mode: bearer jwt</span>
<span>registered players only</span>
</footer>
<script src="/auth.js"></script>
</body>
</html>
@@ -0,0 +1,132 @@
const authForm = document.querySelector("#auth-form");
const authMessage = document.querySelector("#auth-message");
const authSession = document.querySelector("#auth-session");
const authLogout = document.querySelector("#auth-logout");
const privateIntel = document.querySelector("#private-intel");
const tokenStorageKey = "kernel-panic-player-token";
function setAuthMessage(message, isError = false) {
authMessage.textContent = message;
authMessage.classList.toggle("error", isError);
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...options
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Request failed.");
}
return data;
}
function authHeaders() {
const token = localStorage.getItem(tokenStorageKey);
return token ? { Authorization: `Bearer ${token}` } : {};
}
function renderAuthSession(username, groups = []) {
const groupText = groups.length > 0 ? groups.join(", ") : "none";
authSession.innerHTML = `
<span class="terminal-label">session</span>
<strong>${username || "not authenticated"}</strong>
<span>role: ${groupText}</span>
`;
}
function renderLockedIntel() {
privateIntel.innerHTML = `
<article>
<h3>Access required</h3>
<p>Log in with your registered shell account to reveal private build notes.</p>
</article>
`;
}
function renderGameDetails(details) {
privateIntel.innerHTML = `
<article>
<h3>Starting distros</h3>
<p>${details.available_starting_distros.join(", ")}</p>
</article>
<article>
<h3>Language pool</h3>
<p>${details.starting_languages.join(", ")}</p>
</article>
<article>
<h3>Hidden enemies</h3>
<p>${details.hidden_enemies.join(", ")}</p>
</article>
<article>
<h3>Economy</h3>
<p>${Object.entries(details.economy).map(([name, text]) => `${name}: ${text}`).join(" / ")}</p>
</article>
<article class="wide">
<h3>${details.title}</h3>
<p>${details.developer_note}</p>
</article>
`;
}
async function refreshAuthSession() {
const token = localStorage.getItem(tokenStorageKey);
if (!token) {
renderAuthSession(null);
renderLockedIntel();
return;
}
try {
const [session, details] = await Promise.all([
requestJson("/auth/me", { headers: authHeaders() }),
requestJson("/auth/game-details", { headers: authHeaders() })
]);
renderAuthSession(session.username, session.groups || []);
renderGameDetails(details);
} catch (error) {
localStorage.removeItem(tokenStorageKey);
renderAuthSession(null);
renderLockedIntel();
}
}
authForm.addEventListener("submit", async (event) => {
event.preventDefault();
setAuthMessage("Checking registered shell account...");
const formData = new FormData(authForm);
try {
const result = await requestJson("/auth/login", {
method: "POST",
body: JSON.stringify({
username: formData.get("username"),
email: formData.get("email")
})
});
localStorage.setItem(tokenStorageKey, result.access_token);
authForm.reset();
setAuthMessage("Authenticated. Private game intel unlocked.");
await refreshAuthSession();
} catch (error) {
localStorage.removeItem(tokenStorageKey);
renderAuthSession(null);
renderLockedIntel();
setAuthMessage(error.message, true);
}
});
authLogout.addEventListener("click", () => {
localStorage.removeItem(tokenStorageKey);
renderAuthSession(null);
renderLockedIntel();
setAuthMessage("Session cleared.");
});
refreshAuthSession();
@@ -182,7 +182,7 @@
</div> </div>
<div class="intel-grid"> <div class="intel-grid">
<article> <article>
<h3>Rott Credits</h3> <h3>Root Credits</h3>
<p>Premium currency converts to pull currency for banners and cosmetics, not direct stat purchases.</p> <p>Premium currency converts to pull currency for banners and cosmetics, not direct stat purchases.</p>
</article> </article>
<article> <article>
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kernel Panic - Register</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/" aria-label="Kernel Panic home">
<span class="brand-mark">KP</span>
<span>Kernel Panic</span>
</a>
<nav class="nav" aria-label="Primary navigation">
<a href="/">Main</a>
<a href="/register.html">Register</a>
<a href="/auth.html">Auth</a>
</nav>
</header>
<main>
<section class="register-page" id="register" aria-labelledby="register-title">
<div class="section-heading">
<p class="eyebrow">pre-register</p>
<h1 id="register-title">Reserve your shell account</h1>
<p class="hero-text">
Choose your launch platform, create a shell account, then use the same
username and email on the auth page to reveal private game details.
</p>
</div>
<form class="register-form" id="register-form">
<label>
Username
<input id="username" name="username" autocomplete="nickname" required maxlength="40">
</label>
<label>
Email
<input id="email" name="email" type="email" autocomplete="email" required>
</label>
<fieldset>
<legend>Platforms</legend>
<div class="platform-list" id="platform-list"></div>
</fieldset>
<label class="checkbox-row">
<input id="newsletter" name="newsletter_optin" type="checkbox">
<span>Send me patch notes, pull banners, and community unlocks</span>
</label>
<button class="button primary form-submit" type="submit">sudo pre-register</button>
<p class="form-message" id="form-message" role="status" aria-live="polite"></p>
</form>
</section>
<section class="stats-band" aria-label="Community status">
<div>
<span class="stat-value" id="registration-count">0</span>
<span class="stat-label">registered platform installs</span>
</div>
<div>
<span class="stat-value" id="platform-count">3</span>
<span class="stat-label">launch platforms</span>
</div>
<div>
<span class="stat-value" id="reward-count">2</span>
<span class="stat-label">known rewards</span>
</div>
</section>
</main>
<footer class="site-footer">
<span>supported targets: windows / linux / android</span>
<span>auth unlocks after registration</span>
</footer>
<script src="/register.js"></script>
</body>
</html>
@@ -0,0 +1,105 @@
const platformList = document.querySelector("#platform-list");
const registerForm = document.querySelector("#register-form");
const formMessage = document.querySelector("#form-message");
const registrationCount = document.querySelector("#registration-count");
const platformCount = document.querySelector("#platform-count");
const rewardCount = document.querySelector("#reward-count");
function setMessage(message, isError = false) {
formMessage.textContent = message;
formMessage.classList.toggle("error", isError);
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...options
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Request failed.");
}
return data;
}
function renderPlatforms(platforms) {
platformList.innerHTML = "";
platforms.forEach((platform) => {
const label = document.createElement("label");
label.className = "platform-option";
label.innerHTML = `
<input type="checkbox" name="platforms" value="${platform.id}">
<span>${platform.name}</span>
`;
platformList.appendChild(label);
});
platformCount.textContent = platforms.length;
}
async function refreshStats() {
const [count, rewards] = await Promise.all([
requestJson("/registrations/count"),
requestJson("/rewards")
]);
registrationCount.textContent = count.count;
rewardCount.textContent = rewards.length;
}
async function loadPageData() {
try {
const platforms = await requestJson("/platforms");
renderPlatforms(platforms);
await refreshStats();
} catch (error) {
setMessage(error.message, true);
}
}
registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
setMessage("Opening shell account...");
const formData = new FormData(registerForm);
const selectedPlatforms = formData.getAll("platforms").map(Number);
if (selectedPlatforms.length === 0) {
setMessage("Choose at least one platform.", true);
return;
}
try {
const user = await requestJson("/users", {
method: "POST",
body: JSON.stringify({
username: formData.get("username"),
email: formData.get("email"),
newsletter_optin: formData.get("newsletter_optin") === "on"
})
});
await Promise.all(
selectedPlatforms.map((platformId) =>
requestJson("/registrations", {
method: "POST",
body: JSON.stringify({
user_id: user.id,
platform_id: platformId
})
})
)
);
registerForm.reset();
setMessage("Pre-registration complete. Use Auth to unlock private game intel.");
await refreshStats();
} catch (error) {
setMessage(error.message, true);
}
});
loadPageData();