add session table

This commit is contained in:
Maxime Duchêne-Savard 2026-07-28 16:06:20 -04:00
parent 25ab7a4f58
commit ca62140e6f
14 changed files with 307 additions and 49 deletions

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1,13 +1,38 @@
<script lang="ts" setup>
//
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { auth, clearSession } from '@/auth'
const router = useRouter()
const signedIn = computed(() => Boolean(auth.token))
async function signOut () {
clearSession()
await router.push('/')
}
</script>
<template>
<div aria-hidden="true" class="page-glow" />
<main class="min-h-screen p-5 flex items-center">
<router-view />
</main>
<div v-if="signedIn" class="app-shell">
<aside class="drawer">
<div class="brand"><span class="brand-mark">B</span><span>Bolts</span></div>
<nav aria-label="Main navigation">
<router-link v-if="auth.role === 'admin'" to="/users">User Management</router-link>
</nav>
<div class="account">
<div><strong>{{ auth.username }}</strong><span>{{ auth.role }}</span></div>
<button type="button" @click="signOut">Sign out</button>
</div>
</aside>
<main class="content"><router-view /></main>
</div>
<main v-else class="login-main"><router-view /></main>
</template>
<style>
@ -25,6 +50,24 @@
margin: 0;
}
.app-shell { display: flex; min-height: 100vh; }
.drawer { position: fixed; inset: 0 auto 0 0; display: flex; width: 16rem; padding: 1.5rem; flex-direction: column; border-right: 1px solid var(--v0-divider); background: var(--v0-surface); }
.brand { display: flex; align-items: center; gap: 0.7rem; margin-bottom: 2.5rem; font-size: 1.15rem; font-weight: 700; }
.brand-mark { display: grid; width: 2.25rem; height: 2.25rem; place-items: center; border-radius: 0.7rem; color: var(--v0-on-primary); background: var(--v0-primary); }
nav a { display: block; padding: 0.8rem 0.9rem; border-radius: 0.65rem; color: var(--v0-on-surface-variant); text-decoration: none; }
nav a.router-link-active { color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 12%, transparent); font-weight: 700; }
.account { display: flex; margin-top: auto; padding-top: 1rem; align-items: center; justify-content: space-between; border-top: 1px solid var(--v0-divider); }
.account div { display: flex; flex-direction: column; }
.account span { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: capitalize; }
.account button { border: 0; color: var(--v0-primary); background: transparent; cursor: pointer; }
.content { width: 100%; min-height: 100vh; margin-left: 16rem; padding: 3rem; }
.login-main { display: flex; min-height: 100vh; padding: 1.25rem; align-items: center; }
@media (max-width: 700px) {
.drawer { width: 12rem; }
.content { margin-left: 12rem; padding: 2rem 1.25rem; }
}
button,
input {
font: inherit;

25
frontend/src/auth.ts Normal file
View File

@ -0,0 +1,25 @@
import { reactive } from 'vue'
interface StoredSession {
username: string
role: string
token: string
}
const savedSession = sessionStorage.getItem('bolts-session')
export const auth = reactive<StoredSession>(
savedSession
? JSON.parse(savedSession) as StoredSession
: { username: '', role: '', token: '' },
)
export function saveSession (session: StoredSession) {
Object.assign(auth, session)
sessionStorage.setItem('bolts-session', JSON.stringify(session))
}
export function clearSession () {
Object.assign(auth, { username: '', role: '', token: '' })
sessionStorage.removeItem('bolts-session')
}

View File

@ -1,16 +1,20 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { saveSession } from '@/auth'
interface LoginResponse {
username: string
role: string
token: string
}
const router = useRouter()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const authenticatedUser = ref('')
async function login () {
loading.value = true
@ -33,8 +37,9 @@
}
const result = await response.json() as LoginResponse
authenticatedUser.value = result.username
saveSession(result)
password.value = ''
await router.push(result.role === 'admin' ? '/users' : '/')
} catch {
errorMessage.value = 'Unable to reach the server. Please try again.'
} finally {
@ -42,12 +47,6 @@
}
}
function reset () {
authenticatedUser.value = ''
username.value = ''
password.value = ''
errorMessage.value = ''
}
</script>
<template>
@ -60,27 +59,7 @@
<span>Bolts</span>
</div>
<div v-if="authenticatedUser" class="success-panel" role="status">
<div aria-hidden="true" class="success-icon">
</div>
<p class="eyebrow">
Signed in
</p>
<h1>Welcome, {{ authenticatedUser }}</h1>
<p class="supporting">
Youre securely connected to your Bolts workspace.
</p>
<button class="secondary-button" type="button" @click="reset">
Sign out
</button>
</div>
<form v-else class="login-card" @submit.prevent="login">
<form class="login-card" @submit.prevent="login">
<div class="card-heading">
<p class="eyebrow">
Welcome back

View File

@ -0,0 +1,78 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { auth } from '@/auth'
interface User {
id: number
username: string
role: string
}
const router = useRouter()
const users = ref<User[]>([])
const loading = ref(true)
const errorMessage = ref('')
onMounted(async () => {
if (!auth.token || auth.role !== 'admin') {
await router.replace('/')
return
}
try {
const response = await fetch('/api/users', {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (!response.ok) throw new Error('User request failed')
users.value = await response.json() as User[]
} catch {
errorMessage.value = 'Unable to load users.'
} finally {
loading.value = false
}
})
</script>
<template>
<section class="users-page">
<header>
<p class="eyebrow">Administration</p>
<h1>User Management</h1>
<p>View the people who can access this workspace.</p>
</header>
<div class="user-card">
<p v-if="loading">Loading users</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
<table v-else>
<thead>
<tr><th>Username</th><th>Role</th></tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td><span class="role-badge">{{ user.role }}</span></td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.users-page { width: 100%; max-width: 70rem; }
header { margin-bottom: 2rem; }
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
header > p:last-child { color: var(--v0-on-surface-variant); }
.user-card { overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.user-card > p { padding: 1.5rem; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
</style>

View File

@ -7,6 +7,7 @@
// Composables
import { createRouter, createWebHistory } from 'vue-router'
import Index from '@/pages/index.vue'
import Users from '@/pages/users.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -15,6 +16,10 @@ const router = createRouter({
path: '/',
component: Index,
},
{
path: '/users',
component: Users,
},
],
})

View File

@ -17,6 +17,9 @@ test('logs in with the initialized admin user', async ({ page }) => {
await page.getByRole('textbox', { name: 'Password', exact: true }).fill('admin')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Welcome, admin' })).toBeVisible()
await expect(page).toHaveURL('/users')
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible()
const adminRow = page.getByRole('row').filter({ hasText: 'admin' })
await expect(adminRow).toContainText('admin')
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
})

View File

@ -25,7 +25,7 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:7070',
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:7070',
},
},
})

View File

@ -1,6 +1,7 @@
import dev.mduchene.bolts.persistence.Database
import dev.mduchene.bolts.persistence.DatabaseConfig
import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.SessionRepository
import dev.mduchene.bolts.user.UserRepository
import io.javalin.Javalin
import io.javalin.http.HttpStatus
@ -8,7 +9,8 @@ import io.javalin.http.HttpStatus
fun main() {
val database = Database(DatabaseConfig.fromEnvironment())
database.initialize()
val loginService = LoginService(UserRepository(database))
val users = UserRepository(database)
val loginService = LoginService(users, SessionRepository(database))
loginService.initializeAdmin()
Javalin.create { config ->
@ -18,17 +20,35 @@ fun main() {
val username = ctx.formParam("username").orEmpty()
val password = ctx.formParam("password").orEmpty()
if (loginService.authenticate(username, password)) {
val session = loginService.authenticate(username, password)
if (session != null) {
ctx.contentType("application/json")
.result("""{"username":"${username.toJsonString()}"}""")
.result(
"""{"username":"${session.user.username.toJsonString()}","role":"${session.user.role.toJsonString()}","token":"${session.token}"}""",
)
} else {
ctx.status(HttpStatus.UNAUTHORIZED)
.contentType("application/json")
.result("""{"message":"Invalid username or password"}""")
}
}
get("/api/users") { ctx ->
val token = ctx.header("Authorization")?.removePrefix("Bearer ")
val currentUser = loginService.userForToken(token)
if (currentUser?.role != "admin") {
ctx.status(HttpStatus.FORBIDDEN)
.contentType("application/json")
.result("""{"message":"Admin access required"}""")
return@get
}
}.start(7070)
val result = users.findAll().joinToString(prefix = "[", postfix = "]") { user ->
"""{"id":${user.id},"username":"${user.username.toJsonString()}","role":"${user.role.toJsonString()}"}"""
}
ctx.contentType("application/json").result(result)
}
}
}.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070)
}
private fun String.toJsonString() = buildString {

View File

@ -11,6 +11,8 @@ class Database(private val config: DatabaseConfig) {
getConnection().use { connection ->
connection.createStatement().use { statement ->
statement.execute(CREATE_USERS_TABLE)
statement.execute(ADD_USER_ROLE)
statement.execute(CREATE_SESSIONS_TABLE)
}
}
}
@ -23,5 +25,18 @@ class Database(private val config: DatabaseConfig) {
password_hash VARCHAR(255) NOT NULL
)
"""
const val ADD_USER_ROLE = """
ALTER TABLE users
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user'
"""
const val CREATE_SESSIONS_TABLE = """
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
}
}

View File

@ -1,24 +1,37 @@
package dev.mduchene.bolts.user
import org.mindrot.jbcrypt.BCrypt
import java.util.UUID
class LoginService(
private val users: UserRepository,
private val sessions: SessionRepository,
private val environment: Map<String, String> = System.getenv(),
) {
data class Session(val token: String, val user: User)
fun initializeAdmin() {
val username = environment.valueOrDefault("ADMIN_USERNAME", "admin")
if (users.findByUsername(username) == null) {
val existingUser = users.findByUsername(username)
if (existingUser == null) {
val password = environment.valueOrDefault("ADMIN_PASSWORD", "admin")
users.create(username, BCrypt.hashpw(password, BCrypt.gensalt()))
users.create(username, BCrypt.hashpw(password, BCrypt.gensalt()), "admin")
} else if (existingUser.role != "admin") {
users.updateRole(existingUser.id, "admin")
}
}
fun authenticate(username: String, password: String): Boolean {
val user = users.findByUsername(username) ?: return false
return BCrypt.checkpw(password, user.passwordHash)
fun authenticate(username: String, password: String): Session? {
val user = users.findByUsername(username) ?: return null
if (!BCrypt.checkpw(password, user.passwordHash)) return null
val token = UUID.randomUUID().toString()
sessions.create(token, user.id)
return Session(token, user)
}
fun userForToken(token: String?): User? = token?.let(sessions::findUserByToken)
private fun Map<String, String>.valueOrDefault(name: String, default: String) =
this[name]?.takeIf { it.isNotBlank() } ?: default
}

View File

@ -0,0 +1,43 @@
package dev.mduchene.bolts.user
import dev.mduchene.bolts.persistence.Database
import java.sql.ResultSet
class SessionRepository(private val database: Database) {
fun create(token: String, userId: Long) {
val sql = "INSERT INTO sessions (token, user_id) VALUES (?, ?)"
database.getConnection().use { connection ->
connection.prepareStatement(sql).use { statement ->
statement.setString(1, token)
statement.setLong(2, userId)
statement.executeUpdate()
}
}
}
fun findUserByToken(token: String): User? {
val sql = """
SELECT users.id, users.username, users.password_hash, users.role
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token = ?
""".trimIndent()
database.getConnection().use { connection ->
connection.prepareStatement(sql).use { statement ->
statement.setString(1, token)
statement.executeQuery().use { result ->
return if (result.next()) result.toUser() else null
}
}
}
}
private fun ResultSet.toUser() = User(
id = getLong("id"),
username = getString("username"),
passwordHash = getString("password_hash"),
role = getString("role"),
)
}

View File

@ -4,4 +4,5 @@ data class User(
val id: Long,
val username: String,
val passwordHash: String,
val role: String,
)

View File

@ -4,17 +4,18 @@ import dev.mduchene.bolts.persistence.Database
import java.sql.ResultSet
class UserRepository(private val database: Database) {
fun create(username: String, passwordHash: String): User {
fun create(username: String, passwordHash: String, role: String): User {
val sql = """
INSERT INTO users (username, password_hash)
VALUES (?, ?)
RETURNING id, username, password_hash
INSERT INTO users (username, password_hash, role)
VALUES (?, ?, ?)
RETURNING id, username, password_hash, role
""".trimIndent()
database.getConnection().use { connection ->
connection.prepareStatement(sql).use { statement ->
statement.setString(1, username)
statement.setString(2, passwordHash)
statement.setString(3, role)
statement.executeQuery().use { result ->
check(result.next()) { "User insert returned no row" }
return result.toUser()
@ -25,7 +26,7 @@ class UserRepository(private val database: Database) {
fun findByUsername(username: String): User? {
val sql = """
SELECT id, username, password_hash
SELECT id, username, password_hash, role
FROM users
WHERE username = ?
""".trimIndent()
@ -40,9 +41,35 @@ class UserRepository(private val database: Database) {
}
}
fun findAll(): List<User> {
val sql = "SELECT id, username, password_hash, role FROM users ORDER BY username"
database.getConnection().use { connection ->
connection.prepareStatement(sql).use { statement ->
statement.executeQuery().use { result ->
return buildList {
while (result.next()) add(result.toUser())
}
}
}
}
}
fun updateRole(id: Long, role: String) {
val sql = "UPDATE users SET role = ? WHERE id = ?"
database.getConnection().use { connection ->
connection.prepareStatement(sql).use { statement ->
statement.setString(1, role)
statement.setLong(2, id)
statement.executeUpdate()
}
}
}
private fun ResultSet.toUser() = User(
id = getLong("id"),
username = getString("username"),
passwordHash = getString("password_hash"),
role = getString("role"),
)
}