implement authentication and register correctly
This commit is contained in:
@@ -67,6 +67,11 @@
|
||||
<artifactId>rest-assured</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -79,10 +79,9 @@ public class DatabaseService {
|
||||
|
||||
private void seed(Connection connection) throws SQLException {
|
||||
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) {
|
||||
insert.setString(1, platform);
|
||||
insert.executeUpdate();
|
||||
if (findOne("SELECT id FROM platforms WHERE name = ?", platform) == null) {
|
||||
execute(connection, "INSERT INTO platforms (name) VALUES (?)", platform);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +174,14 @@ public class DatabaseService {
|
||||
}
|
||||
|
||||
private static long insertAndReturnId(Connection connection, String sql, Object... params) throws SQLException {
|
||||
try (PreparedStatement statement = prepare(connection, sql + " RETURNING id", params);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
bind(statement, params);
|
||||
statement.executeUpdate();
|
||||
try (ResultSet resultSet = statement.getGeneratedKeys()) {
|
||||
return resultSet.next() ? resultSet.getLong(1) : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int execute(Connection connection, String sql, Object... params) throws SQLException {
|
||||
try (PreparedStatement statement = prepare(connection, sql, params)) {
|
||||
@@ -189,9 +191,13 @@ public class DatabaseService {
|
||||
|
||||
private static PreparedStatement prepare(Connection connection, String sql, Object... params) throws SQLException {
|
||||
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++) {
|
||||
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 class="intel-grid">
|
||||
<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>
|
||||
</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();
|
||||
Reference in New Issue
Block a user