From 5c46a9f1eb89d02c082eb22773a4d858a003f969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Runar=20Skjong=20R=C3=B8ssevold?= Date: Fri, 8 May 2026 12:05:55 +0200 Subject: [PATCH] added complete authentication layer with registration, login and persistence of users across sessions --- .vscode/settings.json | 3 +- bin/main/application.properties | 7 ++ build.gradle.kts | 7 +- docker-compose.yml | 26 ++++ frontend/src/App.jsx | 30 ++++- frontend/src/api/axios.js | 13 +- frontend/src/components/NavBar.jsx | 49 ++++++++ frontend/src/context/AuthContext.jsx | 43 +++++++ frontend/src/pages/AboutPage.jsx | 9 +- frontend/src/pages/ChallengeListPage.jsx | 86 +++++++------ frontend/src/pages/ChallengePage.jsx | 6 +- frontend/src/pages/LoginPage.jsx | 108 +++++++++++++++++ frontend/src/pages/RegisterPage.jsx | 113 ++++++++++++++++++ .../java/no/hvl/schemalab/model/AppUser.java | 2 + src/main/resources/application.properties | 10 +- src/test/resources/application.properties | 3 + 16 files changed, 447 insertions(+), 68 deletions(-) create mode 100644 bin/main/application.properties create mode 100644 frontend/src/components/NavBar.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 src/test/resources/application.properties diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b016a8..d53ecaf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.compile.nullAnalysis.mode": "automatic" + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" } \ No newline at end of file diff --git a/bin/main/application.properties b/bin/main/application.properties new file mode 100644 index 0000000..c0cbefd --- /dev/null +++ b/bin/main/application.properties @@ -0,0 +1,7 @@ +spring.application.name=schemalab +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/schemalab} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:schemalab} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:schemalab} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=update +server.port=8080 diff --git a/build.gradle.kts b/build.gradle.kts index e46893f..6d29a5e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,19 +18,18 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-h2console") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webmvc") + runtimeOnly("org.postgresql:postgresql") compileOnly("org.projectlombok:lombok:1.18.46") annotationProcessor("org.projectlombok:lombok:1.18.46") - + testCompileOnly("org.projectlombok:lombok:1.18.46") testAnnotationProcessor("org.projectlombok:lombok:1.18.46") - - runtimeOnly("com.h2database:h2") + testRuntimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") diff --git a/docker-compose.yml b/docker-compose.yml index 0db4bc3..13a4ac0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,31 @@ services: + postgres: + image: postgres:17 + environment: + POSTGRES_DB: schemalab + POSTGRES_USER: schemalab + POSTGRES_PASSWORD: schemalab + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U schemalab"] + interval: 5s + timeout: 5s + retries: 5 + backend: build: . ports: - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/schemalab + SPRING_DATASOURCE_USERNAME: schemalab + SPRING_DATASOURCE_PASSWORD: schemalab + depends_on: + postgres: + condition: service_healthy runner: build: ./runner @@ -14,3 +37,6 @@ services: depends_on: - backend - runner + +volumes: + postgres_data: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a20f439..eaf7c18 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,17 +1,35 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './context/AuthContext' import ChallengeListPage from './pages/ChallengeListPage' import ChallengePage from './pages/ChallengePage' import AboutPage from './pages/AboutPage' +import LoginPage from './pages/LoginPage' +import RegisterPage from './pages/RegisterPage' + +function ProtectedRoute({ children }) { + const { user } = useAuth() + return user ? children : +} + +function AppRoutes() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} export default function App() { return ( - - } /> - } /> - } /> - } /> - + + + ) } diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index 2bd83d1..9ff5e8a 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,10 +1,13 @@ import axios from 'axios' -const api = axios.create({ - baseURL: '/api', - headers: { - Authorization: 'Basic ' + btoa('dev:dev'), - }, +const api = axios.create({ baseURL: '/api' }) + +api.interceptors.request.use(config => { + const credentials = localStorage.getItem('auth_credentials') + if (credentials) { + config.headers.Authorization = `Basic ${credentials}` + } + return config }) export default api diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx new file mode 100644 index 0000000..0e5ff30 --- /dev/null +++ b/frontend/src/components/NavBar.jsx @@ -0,0 +1,49 @@ +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function NavBar() { + const { user, logout } = useAuth() + const navigate = useNavigate() + + function handleLogout() { + logout() + navigate('/login') + } + + return ( + + ) +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..1df766c --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -0,0 +1,43 @@ +import { createContext, useContext, useState } from 'react' +import api from '../api/axios' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(() => { + const stored = localStorage.getItem('auth_user') + return stored ? JSON.parse(stored) : null + }) + + async function login(username, password) { + const credentials = btoa(`${username}:${password}`) + const res = await api.post('/auth/login', null, { + headers: { Authorization: `Basic ${credentials}` }, + }) + localStorage.setItem('auth_credentials', credentials) + localStorage.setItem('auth_user', JSON.stringify(res.data)) + setUser(res.data) + return res.data + } + + async function register(username, password) { + await api.post('/auth/register', { username, password }) + return login(username, password) + } + + function logout() { + localStorage.removeItem('auth_credentials') + localStorage.removeItem('auth_user') + setUser(null) + } + + return ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} diff --git a/frontend/src/pages/AboutPage.jsx b/frontend/src/pages/AboutPage.jsx index d177870..1dfa447 100644 --- a/frontend/src/pages/AboutPage.jsx +++ b/frontend/src/pages/AboutPage.jsx @@ -1,6 +1,10 @@ +import NavBar from '../components/NavBar' + export default function AboutPage() { return ( -
+ <> + +

About SchemaLab

@@ -85,6 +89,7 @@ export default function AboutPage() { ))}
-
+ + ) } diff --git a/frontend/src/pages/ChallengeListPage.jsx b/frontend/src/pages/ChallengeListPage.jsx index e0e41f5..ece5f5b 100644 --- a/frontend/src/pages/ChallengeListPage.jsx +++ b/frontend/src/pages/ChallengeListPage.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import api from '../api/axios' +import NavBar from '../components/NavBar' const DIFFICULTY_COLOR = { EASY: '#68d391', @@ -24,52 +25,49 @@ export default function ChallengeListPage() { }, []) return ( -
-
-

- SchemaLab -

- About -
-

- Practice schema matching and schema versioning challenges. -

+ <> + +
+

+ Practice schema matching and schema versioning challenges. +

- {error &&

{error}

} + {error &&

{error}

} - - - - - - - - - - - {challenges.map((c, i) => ( - - - - - +
#TitleTypeDifficulty
{i + 1} - - {c.title} - - - {TYPE_LABEL[c.type] ?? c.type} - - - {c.difficulty} - -
+ + + + + + - ))} - -
#TitleTypeDifficulty
-
+ + + {challenges.map((c, i) => ( + + {i + 1} + + + {c.title} + + + + {TYPE_LABEL[c.type] ?? c.type} + + + + {c.difficulty} + + + + ))} + + +
+ ) } diff --git a/frontend/src/pages/ChallengePage.jsx b/frontend/src/pages/ChallengePage.jsx index 846edbc..4e4d825 100644 --- a/frontend/src/pages/ChallengePage.jsx +++ b/frontend/src/pages/ChallengePage.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useParams, Link } from 'react-router-dom' import Editor from '@monaco-editor/react' import api from '../api/axios' +import NavBar from '../components/NavBar' function sortKeys(val) { if (Array.isArray(val)) return val.map(sortKeys) @@ -144,7 +145,9 @@ export default function ChallengePage() { if (!challenge) return
Loading…
return ( -
+
+ +
{/* Left column */}
+
) } diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..8b4ea8f --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,108 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function LoginPage() { + const { login } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + setError(null) + setLoading(true) + try { + await login(username, password) + navigate('/challenges') + } catch { + setError('Invalid username or password.') + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Sign in +

+

+ Don't have an account?{' '} + Register +

+ +
+
+ + setUsername(e.target.value)} + required + autoFocus + style={inputStyle} + /> +
+
+ + setPassword(e.target.value)} + required + style={inputStyle} + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} + +const inputStyle = { + width: '100%', + padding: '8px 12px', + background: '#0f1117', + border: '1px solid #2d3748', + borderRadius: 6, + color: '#f7fafc', + fontSize: 14, + outline: 'none', +} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..2c671d6 --- /dev/null +++ b/frontend/src/pages/RegisterPage.jsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +export default function RegisterPage() { + const { register } = useAuth() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e) { + e.preventDefault() + setError(null) + setLoading(true) + try { + await register(username, password) + navigate('/challenges') + } catch (err) { + const status = err?.response?.status + if (status === 500 || status === 409) { + setError('Username already taken.') + } else { + setError('Registration failed. Please try again.') + } + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Create account +

+

+ Already have an account?{' '} + Sign in +

+ +
+
+ + setUsername(e.target.value)} + required + autoFocus + style={inputStyle} + /> +
+
+ + setPassword(e.target.value)} + required + style={inputStyle} + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} + +const inputStyle = { + width: '100%', + padding: '8px 12px', + background: '#0f1117', + border: '1px solid #2d3748', + borderRadius: 6, + color: '#f7fafc', + fontSize: 14, + outline: 'none', +} diff --git a/src/main/java/no/hvl/schemalab/model/AppUser.java b/src/main/java/no/hvl/schemalab/model/AppUser.java index 3e177af..b95f5b7 100644 --- a/src/main/java/no/hvl/schemalab/model/AppUser.java +++ b/src/main/java/no/hvl/schemalab/model/AppUser.java @@ -1,5 +1,6 @@ package no.hvl.schemalab.model; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; @Entity @@ -14,6 +15,7 @@ public class AppUser { private String username; @Column(nullable = false) + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String passwordHash; private String role; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5a0c508..c0cbefd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,7 @@ spring.application.name=schemalab -# ci test -spring.datasource.url=jdbc:h2:mem:schemalab -spring.datasource.driver-class-name=org.h2.Driver -spring.jpa.hibernate.ddl-auto=create-drop -spring.h2.console.enabled=true +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/schemalab} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:schemalab} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:schemalab} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=update server.port=8080 diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..ef91bba --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,3 @@ +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=create-drop