Initial commit, working server
This commit is contained in:
commit
49e857f258
3
.env.template
Normal file
3
.env.template
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
OPENID_DISCOVERY_URI=
|
||||||
|
OPENID_CLIENT_ID=
|
||||||
|
SYKKELAKSJON_INITIAL_ADMIN=
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
secrets/**
|
||||||
|
db-data/**
|
||||||
|
.env
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
FROM node AS frontend-build
|
||||||
|
WORKDIR /sources/client
|
||||||
|
COPY client/package.json /sources/client/package.json
|
||||||
|
RUN npm install
|
||||||
|
COPY client/index.html client/tsconfig.json /sources/client/
|
||||||
|
COPY client/src/ /sources/client/src/
|
||||||
|
COPY client/public/ /sources/client/public/
|
||||||
|
COPY messages/ /sources/messages/
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM maven:3.9.14-eclipse-temurin-25-alpine AS java-build
|
||||||
|
WORKDIR /java/server/
|
||||||
|
COPY server/pom.xml /java/server
|
||||||
|
COPY server/src/ /java/server/src/
|
||||||
|
COPY messages/ /java/messages/
|
||||||
|
RUN mvn package
|
||||||
|
|
||||||
|
FROM eclipse-temurin:25-alpine-3.23
|
||||||
|
WORKDIR /server/
|
||||||
|
COPY --from=java-build /java/server/target/Sykkelaksjon-1.0-SNAPSHOT.jar /server/
|
||||||
|
COPY --from=frontend-build /sources/client/dist/ /server/static
|
||||||
|
ENTRYPOINT ["java", "-jar", "/server/Sykkelaksjon-1.0-SNAPSHOT.jar"]
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# How to run
|
||||||
|
Create a folder named secrets, and make a file in it called postgrespassword. Put a strong password in this file
|
||||||
|
Copy .env.template into a file called .env, and set the environment variables in it to something sensible.
|
||||||
|
Change the port number of the server you wish to expose in the docker compose file
|
||||||
|
Run docker compose up -d
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
203
client/index.html
Normal file
203
client/index.html
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sykkelaksjon</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading-div">
|
||||||
|
<p>Laster data...</p>
|
||||||
|
</div>
|
||||||
|
<div x-data="{ unitType: $store.state.data.activityTypes[0]?.unit ?? 'km' }" id="content-div" style="display: none;">
|
||||||
|
<h1>Velkommen, <span x-text="$store.state.data.name"></span></h1>
|
||||||
|
<div class="outer-vertical-flex">
|
||||||
|
<div class="flex-container">
|
||||||
|
<div class="vertical-flex">
|
||||||
|
<div class="register-activity-div">
|
||||||
|
<h3>Registrer ny aktivitet:</h3>
|
||||||
|
<form id="activity-form">
|
||||||
|
<div class="activity-type-div">
|
||||||
|
<label for="activity-type">Type aktivitet: </label>
|
||||||
|
<template x-if="$store.state.data.activityTypes.length > 1">
|
||||||
|
<select name="activity-type" id="activity-type" x-on:change="unitType = event.target[event.target.selectedIndex].dataset.unit;">
|
||||||
|
<template x-for="activityType in $store.state.data.activityTypes" :key="activityType.id">
|
||||||
|
<option x-text="activityType.name" x-bind:value="activityType.id" x-bind:data-unit="activityType.unit"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<template x-if="$store.state.data.activityTypes.length == 1">
|
||||||
|
<span><span x-text="$store.state.data.activityTypes[0].name"></span><input type="hidden" name="activity-type" x-bind:value="$store.state.data.activityTypes[0].id" /></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distance-div">
|
||||||
|
<label for="activity-distance">Avstand: </label>
|
||||||
|
<input id="activity-distance" name="activity-distance" type="number" min="0" step="0.1" />
|
||||||
|
<span x-text="unitType"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="description-div">
|
||||||
|
<label for="activity-description">Beskrivelse:</label>
|
||||||
|
<input type="text" id="activity-description" name="activity-description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-div">
|
||||||
|
<label for="activity-date">Dato:</label>
|
||||||
|
<input type="date" id="activity-date" name="activity-date" />
|
||||||
|
</div>
|
||||||
|
<div class="submit-buttons-container">
|
||||||
|
<button id="submit-activity-button">Registrer aktivitet</button>
|
||||||
|
<button id="submit-activity-template-button">Legg til som standardaktivitet</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="registered-activities-div">
|
||||||
|
<h3>Registrerte aktiviteter:</h3>
|
||||||
|
<p>Til sammen: <span x-text="$store.state.data.activities.reduce((p, a) => p + a.numberOfUnits * a.activityType.conversionFactor, 0).toFixed(1)"></span> km</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Avstand</th>
|
||||||
|
<template x-if="$store.state.data.unitConversionNecessary">
|
||||||
|
<th>Ekvivalent</th>
|
||||||
|
</template>
|
||||||
|
<th>Fjern registrert aktivitet</th>
|
||||||
|
</tr>
|
||||||
|
<template x-for="activity in $store.state.data.activities">
|
||||||
|
<tr>
|
||||||
|
<td x-text="activity.date"></td>
|
||||||
|
<td x-text="activity.description"></td>
|
||||||
|
<td x-text="`${activity.numberOfUnits} ${activity.activityType.unit}`"></td>
|
||||||
|
<template x-if="$store.state.data.unitConversionNecessary">
|
||||||
|
<td x-text="`${activity.numberOfUnits * activity.activityType.conversionFactor} km`"></td>
|
||||||
|
</template>
|
||||||
|
<td><button x-on:click="$store.state.deleteActivity(activity)">Fjern</button></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="standard-activities-div">
|
||||||
|
<h3>Standardaktiviteter:</h3>
|
||||||
|
<template x-for="activityTemplate in $store.state.data.activityTemplates" :key="activityTemplate.id">
|
||||||
|
<p>
|
||||||
|
<button x-on:click="$store.state.registerTemplateActivity(activityTemplate)">Registrer</button>
|
||||||
|
<span x-text="`${activityTemplate.name}, ${activityTemplate.activityType.name.toLowerCase()}, ${activityTemplate.numberOfUnits} ${activityTemplate.activityType.unit}`"></span>
|
||||||
|
<button x-on:click="$store.state.deleteTemplateActivity(activityTemplate)">Fjern</button>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="other-users-div">
|
||||||
|
<h3>Andre aktive:</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th><th>Total avstand</th><th>Første aktivitet</th><th>Siste aktivitet</th>
|
||||||
|
</tr>
|
||||||
|
<template x-if="$store.state.data.otherUsers.length == 0">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">Det er ingen andre deltakere</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="$store.state.data.otherUsers.length > 0">
|
||||||
|
<template x-for="otherUser in $store.state.data.otherUsers">
|
||||||
|
<tr>
|
||||||
|
<td x-text="otherUser.name"></td>
|
||||||
|
<td x-text="otherUser.totalKilometers"></td>
|
||||||
|
<td x-text="otherUser.earliestActivity"></td>
|
||||||
|
<td x-text="otherUser.latestActivity"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.state.data.isAdmin">
|
||||||
|
<div class="admin-div" >
|
||||||
|
<h3>Administratorinnstillinger:</h3>
|
||||||
|
<details>
|
||||||
|
<summary>Aktivitetstyper</summary>
|
||||||
|
<h4>Opprett ny aktivitetstype:</h4>
|
||||||
|
<form id="activity-type-form" onsubmit="return false;">
|
||||||
|
<div id="activity-type-name-div">
|
||||||
|
<label for="activity-type-name">Navn:</label>
|
||||||
|
<input type="text" name="activity-type-name" id="activity-type-name" />
|
||||||
|
</div>
|
||||||
|
<div id="activity-type-unit-div">
|
||||||
|
<label for="activity-type-unit">Enhet:</label>
|
||||||
|
<input type="text" name="activity-type-unit" id="activity-type-unit" />
|
||||||
|
</div>
|
||||||
|
<div id="activity-type-conversion-factor-div">
|
||||||
|
<label for="activity-type-conversion-factor">Konverteringsfaktor:</label>
|
||||||
|
<input type="number" name="activity-type-conversion-factor" id="activity-type-conversion-factor" min="0" value="1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button onclick="postActivityType();">Legg til</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<h4>Eksisterende aktivitetstyper:</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Aktivitetstype</th>
|
||||||
|
<th>Enhet</th>
|
||||||
|
<th>Konverteringsfaktor til kilometer</th>
|
||||||
|
<th>Fjern</th>
|
||||||
|
</tr>
|
||||||
|
<template x-for="activityType in $store.state.data.activityTypes">
|
||||||
|
<tr>
|
||||||
|
<td x-text="activityType.name"></td>
|
||||||
|
<td x-text="activityType.unit"></td>
|
||||||
|
<td x-text="activityType.conversionFactor.toString()"></td>
|
||||||
|
<td><button x-on:click="$store.state.deleteActivityType(activityType)">Fjern</button></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Brukere</summary>
|
||||||
|
<template x-for="user in $store.state.data.otherUsers">
|
||||||
|
<details>
|
||||||
|
<summary x-text="`${user.name} (${user.userName})${user.isAdmin ? ' - Administrator' : ''}`"></summary>
|
||||||
|
<template x-if="user.isAdmin">
|
||||||
|
<button x-on:click="$store.state.removeAdministrator(user.userId)">Fjern administrator</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="!user.isAdmin">
|
||||||
|
<button x-on:click="$store.state.makeAdministrator(user.userId)">Gjør til administrator</button>
|
||||||
|
</template>
|
||||||
|
<button x-on:click="$store.state.deleteUser(user.userId)">Slett bruker</button>
|
||||||
|
<h4>Aktiviteter:</h4>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Beskrivelse</th>
|
||||||
|
<th>Avstand</th>
|
||||||
|
<template x-if="$store.state.data.unitConversionNecessary">
|
||||||
|
<th>Ekvivalent</th>
|
||||||
|
</template>
|
||||||
|
<th>Fjern registrert aktivitet</th>
|
||||||
|
</tr>
|
||||||
|
<template x-for="activity in user.activities">
|
||||||
|
<tr>
|
||||||
|
<td x-text="activity.date"></td>
|
||||||
|
<td x-text="activity.description"></td>
|
||||||
|
<td x-text="`${activity.numberOfUnits} ${activity.activityType.unit}`"></td>
|
||||||
|
<template x-if="$store.state.data.unitConversionNecessary">
|
||||||
|
<td x-text="`${activity.numberOfUnits * activity.activityType.conversionFactor} km`"></td>
|
||||||
|
</template>
|
||||||
|
<td><button x-on:click="$store.state.deleteActivity(activity)">Fjern</button></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1124
client/package-lock.json
generated
Normal file
1124
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"transpile": "json2ts --input ../messages/server-message.schema.json --output src/message.d.ts",
|
||||||
|
"transpile-2": "json2ts --input ../messages/openid.schema.json --output src/openid.d.ts",
|
||||||
|
"dev": "npm run transpile && npm run transpile-2 && vite",
|
||||||
|
"build": "npm run transpile && npm run transpile-2 && tsc && vite build",
|
||||||
|
"preview": "npm run transpile && npm run transpile-2 && vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/alpinejs": "^3.13.11",
|
||||||
|
"json-schema-to-typescript": "^15.0.4",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"alpinejs": "^3.15.11",
|
||||||
|
"openid-client": "^6.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
client/public/favicon.svg
Normal file
7
client/public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="256px" height="256px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<g id="SVGRepo_iconCarrier"> <path d="M11.5 3C12.3284 3 13 2.32843 13 1.5C13 0.671573 12.3284 0 11.5 0C10.6716 0 10 0.671573 10 1.5C10 2.32843 10.6716 3 11.5 3Z" fill="#000000"/> <path d="M0 12C0 10.4697 1.14578 9.20702 2.62626 9.02304L5.00001 10.6055V14.2361C4.46925 14.7111 3.76836 15 3 15C1.34315 15 0 13.6568 0 12Z" fill="#000000"/> <path d="M11 14.2361C11.5308 14.7111 12.2316 15 13 15C14.6569 15 16 13.6569 16 12C16 10.3431 14.6569 9 13 9C12.2316 9 11.5308 9.28885 11 9.76389V14.2361Z" fill="#000000"/> <path d="M8.28111 5.01444C8.29524 5.00503 8.31184 5 8.32881 5C8.36139 5 8.39117 5.0184 8.40574 5.04754L9.38197 7H13V5H10.618L10.1946 4.15311C9.84124 3.44641 9.11893 3 8.32881 3C7.91699 3 7.51437 3.1219 7.17171 3.35034L4.86132 4.8906C4.32322 5.24934 4 5.85327 4 6.5C4 7.14673 4.32322 7.75066 4.86132 8.1094L7 9.53518V13H9V8.46482L6.05278 6.5L8.28111 5.01444Z" fill="#000000"/> </g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
5
client/src/global.d.ts
vendored
Normal file
5
client/src/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Alpine as AlpineType } from 'alpinejs'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var Alpine: AlpineType
|
||||||
|
}
|
||||||
293
client/src/main.ts
Normal file
293
client/src/main.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import type { Activity, ActivityTemplate, ActivityType, Sykkelaksjon } from './message';
|
||||||
|
import type { SykkelaksjonOpenid } from './openid';
|
||||||
|
import './style.css'
|
||||||
|
import Alpine from 'alpinejs'
|
||||||
|
import * as openidClient from 'openid-client';
|
||||||
|
|
||||||
|
// ---------------- HTML ELEMENT SETUP ----------------
|
||||||
|
// Set the default date to today
|
||||||
|
let datepicker = document.getElementById("activity-date") as HTMLInputElement;
|
||||||
|
datepicker.valueAsDate = new Date();
|
||||||
|
|
||||||
|
|
||||||
|
// Override what the activity form does
|
||||||
|
let activityForm = document.getElementById("activity-form") as HTMLFormElement;
|
||||||
|
activityForm.onsubmit = () => false;
|
||||||
|
|
||||||
|
// Override what the activity button does
|
||||||
|
document.getElementById("submit-activity-button")?.addEventListener("click", () => submitFormToUrl(apiUrl + "/submitActivity"));
|
||||||
|
document.getElementById("submit-activity-template-button")?.addEventListener("click", () => submitFormToUrl(apiUrl + "/submitActivityTemplate"));
|
||||||
|
|
||||||
|
let loadingDiv = document.getElementById("loading-div");
|
||||||
|
let contentDiv = document.getElementById("content-div");
|
||||||
|
|
||||||
|
// ---------------- ALPINE STATE -----------------------
|
||||||
|
interface AlpineState {
|
||||||
|
data: Sykkelaksjon,
|
||||||
|
registerTemplateActivity: (template: ActivityTemplate) => void,
|
||||||
|
deleteTemplateActivity: (template: ActivityTemplate) => void,
|
||||||
|
deleteActivity: (activity: Activity) => void,
|
||||||
|
deleteActivityType: (activityType: ActivityType) => void,
|
||||||
|
|
||||||
|
makeAdministrator: (userId: number) => void,
|
||||||
|
removeAdministrator: (userId: number) => void,
|
||||||
|
deleteUser: (userId: number) => void,
|
||||||
|
|
||||||
|
init: () => void,
|
||||||
|
setServerMessage: (newMessage: Sykkelaksjon) => void,
|
||||||
|
getServerMessage: () => Sykkelaksjon,
|
||||||
|
};
|
||||||
|
|
||||||
|
let defaultData: Sykkelaksjon = {
|
||||||
|
name: "ukjent bruker",
|
||||||
|
isAdmin: false,
|
||||||
|
unitConversionNecessary: false,
|
||||||
|
activityTypes: [],
|
||||||
|
activityTemplates: [],
|
||||||
|
activities: [],
|
||||||
|
otherUsers: []
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpineState: AlpineState = {
|
||||||
|
data: defaultData,
|
||||||
|
registerTemplateActivity: registerTemplateActivity,
|
||||||
|
deleteTemplateActivity: deleteTemplateActivity,
|
||||||
|
deleteActivity: deleteActivity,
|
||||||
|
deleteActivityType: deleteActivityType,
|
||||||
|
|
||||||
|
makeAdministrator: makeAdministrator,
|
||||||
|
removeAdministrator: removeAdministrator,
|
||||||
|
deleteUser: deleteUser,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.data = defaultData;
|
||||||
|
},
|
||||||
|
setServerMessage(newMessage: Sykkelaksjon) {
|
||||||
|
this.data = newMessage;
|
||||||
|
},
|
||||||
|
getServerMessage(): Sykkelaksjon {
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Alpine = Alpine
|
||||||
|
Alpine.store('state', alpineState);
|
||||||
|
Alpine.start();
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------- FUNCTIONS TO BE CALLED FROM THE HTML -----------------------
|
||||||
|
function registerTemplateActivity(template: ActivityTemplate): void {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("activity-type", template.activityType.id?.toString() ?? "");
|
||||||
|
form.set("activity-description", template.name);
|
||||||
|
form.set("activity-distance", template.numberOfUnits.toString());
|
||||||
|
var currentDate = new Date();
|
||||||
|
form.set("activity-date", `${currentDate.getFullYear().toString().padStart(4, '0')}-${(currentDate.getMonth()+1).toString().padStart(2, '0')}-${currentDate.getDate().toString().padStart(2, '0')}`);
|
||||||
|
submitFormToUrl(apiUrl + "/submitActivity", form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTemplateActivity(template: ActivityTemplate): void {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("activity-template-id", template.id?.toString() ?? "");
|
||||||
|
submitFormToUrl(apiUrl + "/deleteActivityTemplate", form, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteActivity(activity: Activity): void {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("activity-id", activity.id?.toString() ?? "");
|
||||||
|
submitFormToUrl(apiUrl + "/deleteActivity", form, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function postActivityType(): void {
|
||||||
|
let activityTypeForm = document.getElementById("activity-type-form") as HTMLFormElement;
|
||||||
|
let activtyTypeData = new FormData(activityTypeForm);
|
||||||
|
|
||||||
|
submitFormToUrl(apiUrl + "/addActivityType", activtyTypeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window { postActivityType: () => void }
|
||||||
|
}
|
||||||
|
window.postActivityType = postActivityType;
|
||||||
|
|
||||||
|
function deleteActivityType(activityType: ActivityType) {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("activity-type-id", activityType.id?.toString() ?? "");
|
||||||
|
submitFormToUrl(apiUrl + "/deleteActivityType", form, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdministrator(userId: number) {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("user-id", userId.toString());
|
||||||
|
submitFormToUrl(apiUrl + "/makeAdmin", form, "PUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAdministrator(userId: number) {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("user-id", userId.toString());
|
||||||
|
submitFormToUrl(apiUrl + "/removeAdmin", form, "PUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUser(userId: number) {
|
||||||
|
let form = new FormData();
|
||||||
|
form.set("user-id", userId.toString());
|
||||||
|
submitFormToUrl(apiUrl + "/deleteUser", form, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- DECIDE WHERE TO CONNECT -------------
|
||||||
|
let apiUrl: string;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
apiUrl = "http://localhost:8080/api";
|
||||||
|
} else {
|
||||||
|
apiUrl = document.location.protocol + "//" + document.location.host + "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- OPENID CONNECT STUFF ----------------
|
||||||
|
|
||||||
|
let discoveryUri = localStorage.getItem("sykkelaksjon-discovery-uri");
|
||||||
|
let clientId = localStorage.getItem("sykkelaksjon-client-id");
|
||||||
|
|
||||||
|
if (!discoveryUri || !clientId) {
|
||||||
|
await fetch(apiUrl + "/openid")
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(json => {
|
||||||
|
let received = json as SykkelaksjonOpenid;
|
||||||
|
discoveryUri = received.openid_discovery_uri;
|
||||||
|
clientId = received.client_id;
|
||||||
|
|
||||||
|
localStorage.setItem("sykkelaksjon-discovery-uri", discoveryUri);
|
||||||
|
localStorage.setItem("sykkelaksjon-client-id", clientId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshToken: string | null = null;
|
||||||
|
let config: openidClient.Configuration | null = null;
|
||||||
|
if (discoveryUri && clientId) {
|
||||||
|
let server: URL = new URL(discoveryUri);
|
||||||
|
config = await openidClient.discovery(server, clientId);
|
||||||
|
|
||||||
|
let getCurrentUrl: ((...args: any) => URL) = () => {
|
||||||
|
return new URL(window.location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUrl = getCurrentUrl();
|
||||||
|
let codeVerifier = localStorage.getItem("sykkelaksjon-codeverifier");
|
||||||
|
|
||||||
|
if (!currentUrl.searchParams.has("session_state") || !currentUrl.searchParams.has("iss") || !currentUrl.searchParams.has("code") || !codeVerifier) {
|
||||||
|
let redirectUri = window.location.href;
|
||||||
|
let scope = "openid profile";
|
||||||
|
|
||||||
|
codeVerifier = openidClient.randomPKCECodeVerifier();
|
||||||
|
localStorage.setItem("sykkelaksjon-codeverifier", codeVerifier);
|
||||||
|
let codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
let state: string;
|
||||||
|
|
||||||
|
let parameters: Record<string, string> = {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.serverMetadata().supportsPKCE()) {
|
||||||
|
state = openidClient.randomState();
|
||||||
|
parameters.state = state;
|
||||||
|
localStorage.setItem("sykkelaksjon-state", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let redirectTo: URL = openidClient.buildAuthorizationUrl(config, parameters);
|
||||||
|
window.location.href = redirectTo.toString();
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, "", window.location.protocol + "//" + window.location.host);
|
||||||
|
let state = localStorage.getItem("sykkelaksjon-state");
|
||||||
|
|
||||||
|
let authorizationData: Record<string, string> = {
|
||||||
|
pkceCodeVerifier: codeVerifier
|
||||||
|
};
|
||||||
|
if (state) {
|
||||||
|
authorizationData['state'] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = await openidClient.authorizationCodeGrant(
|
||||||
|
config,
|
||||||
|
currentUrl,
|
||||||
|
authorizationData
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshToken = tokens.refresh_token ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- REQUESTING DATA FROM THE API -------------------------------
|
||||||
|
let receivedData = false;
|
||||||
|
const getAccessToken: () => Promise<string | null> = async () => {
|
||||||
|
if (!refreshToken || !config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let receivedTokens = await openidClient.refreshTokenGrant(config, refreshToken);
|
||||||
|
refreshToken = receivedTokens.refresh_token ?? null;
|
||||||
|
return receivedTokens.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFormToUrl = (url: string, formData?: FormData, method?: string) => {
|
||||||
|
if (!refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData) {
|
||||||
|
formData = new FormData(activityForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
method = 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
getAccessToken().then(token => {
|
||||||
|
if (token) {
|
||||||
|
fetch(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: method,
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).then(() => refreshData(token));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshData = async (accessToken?: string) => {
|
||||||
|
if (!refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
let possibleAccessToken = await getAccessToken();
|
||||||
|
if (!possibleAccessToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken = possibleAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(apiUrl, {headers: {"Authorization": `Bearer ${accessToken}`}})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(message => {
|
||||||
|
(Alpine.store('state') as AlpineState).setServerMessage(message)
|
||||||
|
receivedData = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, refresh data
|
||||||
|
await refreshData();
|
||||||
|
// After data is refreshed, display it
|
||||||
|
if (receivedData) {
|
||||||
|
loadingDiv?.setAttribute("style", "display: none");
|
||||||
|
contentDiv?.setAttribute("style", "display: block");
|
||||||
|
}
|
||||||
147
client/src/message.d.ts
vendored
Normal file
147
client/src/message.d.ts
vendored
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by json-schema-to-typescript.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||||
|
* and run json-schema-to-typescript to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server message for users of the Sykkelaksjon web site
|
||||||
|
*/
|
||||||
|
export interface Sykkelaksjon {
|
||||||
|
/**
|
||||||
|
* The full name of the connected user
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Whether the connected user is an administrator
|
||||||
|
*/
|
||||||
|
isAdmin: boolean;
|
||||||
|
/**
|
||||||
|
* Available types of activities
|
||||||
|
*/
|
||||||
|
activityTypes: ActivityType[];
|
||||||
|
/**
|
||||||
|
* Whether there exists activity types that don't have a conversion factor of 1
|
||||||
|
*/
|
||||||
|
unitConversionNecessary: boolean;
|
||||||
|
/**
|
||||||
|
* Templates that the user might apply to quickly add a new activity
|
||||||
|
*/
|
||||||
|
activityTemplates: ActivityTemplate[];
|
||||||
|
/**
|
||||||
|
* The activities the user has performed
|
||||||
|
*/
|
||||||
|
activities: Activity[];
|
||||||
|
/**
|
||||||
|
* Other users of the web site to create a leaderboard, and more detailed information for administrators
|
||||||
|
*/
|
||||||
|
otherUsers: {
|
||||||
|
/**
|
||||||
|
* The display name of the user
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The earliest date the user has performed an activity on
|
||||||
|
*/
|
||||||
|
earliestActivity?: string;
|
||||||
|
/**
|
||||||
|
* The latest date the user has performed an activity on
|
||||||
|
*/
|
||||||
|
latestActivity?: string;
|
||||||
|
/**
|
||||||
|
* The total amount of kilometers (including converted activities) the user has covered
|
||||||
|
*/
|
||||||
|
totalKilometers?: number;
|
||||||
|
/**
|
||||||
|
* Administrator info: ther user id of the user
|
||||||
|
*/
|
||||||
|
userId?: number;
|
||||||
|
/**
|
||||||
|
* Administrator info: the username of the user
|
||||||
|
*/
|
||||||
|
userName?: string;
|
||||||
|
/**
|
||||||
|
* Administrator info: whether the user listed is still active
|
||||||
|
*/
|
||||||
|
isActive?: boolean;
|
||||||
|
/**
|
||||||
|
* Administrator info: whether the user listed is an administrator
|
||||||
|
*/
|
||||||
|
isAdmin?: boolean;
|
||||||
|
/**
|
||||||
|
* Administrator info: the activity templates this user has defined
|
||||||
|
*/
|
||||||
|
activityTemplates?: ActivityTemplate[];
|
||||||
|
/**
|
||||||
|
* Administrator info: a detailed list of activities the user has performed
|
||||||
|
*/
|
||||||
|
activities?: Activity[];
|
||||||
|
[k: string]: unknown;
|
||||||
|
}[];
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* An available activity the user may log
|
||||||
|
*/
|
||||||
|
export interface ActivityType {
|
||||||
|
/**
|
||||||
|
* The database ID of the activity type
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
|
/**
|
||||||
|
* The name of the activity type
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The unit this activity type is measured in
|
||||||
|
*/
|
||||||
|
unit: string;
|
||||||
|
/**
|
||||||
|
* The conversion factor from the unit the activity is measured in into kilometers
|
||||||
|
*/
|
||||||
|
conversionFactor: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Templates that the user has stored in order to quickly add common activities
|
||||||
|
*/
|
||||||
|
export interface ActivityTemplate {
|
||||||
|
/**
|
||||||
|
* The database ID of the activity template
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
|
activityType: ActivityType;
|
||||||
|
/**
|
||||||
|
* The name the user has given to this activity
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The length of the activity (in units defined in the activity type)
|
||||||
|
*/
|
||||||
|
numberOfUnits: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* An activity a user has performed at some date
|
||||||
|
*/
|
||||||
|
export interface Activity {
|
||||||
|
/**
|
||||||
|
* The database ID of the activity
|
||||||
|
*/
|
||||||
|
id?: number;
|
||||||
|
activityType: ActivityType;
|
||||||
|
/**
|
||||||
|
* The length of the activity (in units defined in the activity type)
|
||||||
|
*/
|
||||||
|
numberOfUnits: number;
|
||||||
|
/**
|
||||||
|
* The user's description of the activity
|
||||||
|
*/
|
||||||
|
description: string;
|
||||||
|
/**
|
||||||
|
* The date the user performed the activity
|
||||||
|
*/
|
||||||
|
date: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
21
client/src/openid.d.ts
vendored
Normal file
21
client/src/openid.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/**
|
||||||
|
* This file was automatically generated by json-schema-to-typescript.
|
||||||
|
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||||
|
* and run json-schema-to-typescript to regenerate this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server message telling where the openId server is
|
||||||
|
*/
|
||||||
|
export interface SykkelaksjonOpenid {
|
||||||
|
/**
|
||||||
|
* The discovery URI of the OpenID provider
|
||||||
|
*/
|
||||||
|
openid_discovery_uri: string;
|
||||||
|
/**
|
||||||
|
* The client ID of the sykkelaksjon client
|
||||||
|
*/
|
||||||
|
client_id: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
103
client/src/style.css
Normal file
103
client/src/style.css
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
body {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outer-vertical-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-activity-div {
|
||||||
|
border: 1px gray solid;
|
||||||
|
padding: 10px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-activity-div input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-buttons-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard-activities-div {
|
||||||
|
border: 1px gray solid;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registered-activities-div {
|
||||||
|
border: 1px gray solid;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,td,th {
|
||||||
|
border: 1px solid lightgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registered-activities-div td {
|
||||||
|
padding-right:10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registered-activities-div td button {
|
||||||
|
margin-left:50px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-users-div {
|
||||||
|
border: 1px gray solid;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-div {
|
||||||
|
border: 1px gray solid;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
border: 1px solid #aaaaaa;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5em 0.5em 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: -0.5em -0.5em 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
23
client/tsconfig.json
Normal file
23
client/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
34
docker-compose.yaml
Normal file
34
docker-compose.yaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:18.3
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./db-data:/db-data
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: "sykkelaksjon"
|
||||||
|
POSTGRES_PASSWORD_FILE: "/run/secrets/postgrespassword"
|
||||||
|
POSTGRES_DB: "sykkelaksjon"
|
||||||
|
PGDATA: "/db-data"
|
||||||
|
secrets:
|
||||||
|
- postgrespassword
|
||||||
|
# For local debugging and development
|
||||||
|
# ports:
|
||||||
|
# - 9876:5432
|
||||||
|
|
||||||
|
server:
|
||||||
|
image: sykkelaksjon:latest
|
||||||
|
build:
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: "jdbc:postgresql://database/sykkelaksjon"
|
||||||
|
OPENID_DISCOVERY_URI: "${OPENID_DISCOVERY_URI}"
|
||||||
|
OPENID_CLIENT_ID: "${OPENID_CLIENT_ID}"
|
||||||
|
SYKKELAKSJON_INITIAL_ADMIN: "${SYKKELAKSJON_INITIAL_ADMIN}"
|
||||||
|
secrets:
|
||||||
|
- postgrespassword
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
postgrespassword:
|
||||||
|
file: secrets/postgrespassword
|
||||||
18
messages/openid.schema.json
Normal file
18
messages/openid.schema.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "sykkelaksjon-openid",
|
||||||
|
"title": "Sykkelaksjon openid",
|
||||||
|
"description": "Server message telling where the openId server is",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"openid_discovery_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The discovery URI of the OpenID provider"
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The client ID of the sykkelaksjon client"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["openid_discovery_uri", "client_id"]
|
||||||
|
}
|
||||||
180
messages/server-message.schema.json
Normal file
180
messages/server-message.schema.json
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "sykkelaksjon-api",
|
||||||
|
"title": "Sykkelaksjon",
|
||||||
|
"description": "Server message for users of the Sykkelaksjon web site",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The full name of the connected user"
|
||||||
|
},
|
||||||
|
"isAdmin": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the connected user is an administrator"
|
||||||
|
},
|
||||||
|
"activityTypes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Available types of activities",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/activityType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unitConversionNecessary" : {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether there exists activity types that don't have a conversion factor of 1"
|
||||||
|
},
|
||||||
|
"activityTemplates": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Templates that the user might apply to quickly add a new activity",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/activityTemplate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "The activities the user has performed",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/activity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"otherUsers": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Other users of the web site to create a leaderboard, and more detailed information for administrators",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Information about other users in the system",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The display name of the user"
|
||||||
|
},
|
||||||
|
"earliestActivity": {
|
||||||
|
"$ref": "#/$defs/date",
|
||||||
|
"description": "The earliest date the user has performed an activity on"
|
||||||
|
},
|
||||||
|
"latestActivity": {
|
||||||
|
"$ref": "#/$defs/date",
|
||||||
|
"description": "The latest date the user has performed an activity on"
|
||||||
|
},
|
||||||
|
"totalKilometers": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The total amount of kilometers (including converted activities) the user has covered"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Administrator info: ther user id of the user"
|
||||||
|
},
|
||||||
|
"userName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Administrator info: the username of the user"
|
||||||
|
},
|
||||||
|
"isActive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Administrator info: whether the user listed is still active"
|
||||||
|
},
|
||||||
|
"isAdmin": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Administrator info: whether the user listed is an administrator"
|
||||||
|
},
|
||||||
|
"activityTemplates": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Administrator info: the activity templates this user has defined",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/activityTemplate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Administrator info: a detailed list of activities the user has performed",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/activity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "isAdmin", "activityTypes", "unitConversionNecessary", "activityTemplates", "activities", "otherUsers"],
|
||||||
|
|
||||||
|
"$defs": {
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A date in local time",
|
||||||
|
"pattern": "[0-9]{4}-[0-9]{2}-[0-9]{4}"
|
||||||
|
},
|
||||||
|
"activityType": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "An available activity the user may log",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The database ID of the activity type"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the activity type"
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The unit this activity type is measured in"
|
||||||
|
},
|
||||||
|
"conversionFactor": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The conversion factor from the unit the activity is measured in into kilometers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "unit", "conversionFactor"]
|
||||||
|
},
|
||||||
|
"activityTemplate": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Templates that the user has stored in order to quickly add common activities",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The database ID of the activity template"
|
||||||
|
},
|
||||||
|
"activityType": {
|
||||||
|
"$ref": "#/$defs/activityType"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name the user has given to this activity"
|
||||||
|
},
|
||||||
|
"numberOfUnits": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The length of the activity (in units defined in the activity type)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["activityType", "name", "numberOfUnits"]
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "An activity a user has performed at some date",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The database ID of the activity"
|
||||||
|
},
|
||||||
|
"activityType": {
|
||||||
|
"$ref": "#/$defs/activityType"
|
||||||
|
},
|
||||||
|
"numberOfUnits": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The length of the activity (in units defined in the activity type)"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The user's description of the activity"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"$ref": "#/$defs/date",
|
||||||
|
"description": "The date the user performed the activity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["activityType", "numberOfUnits", "description", "date"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
server/.gitignore
vendored
Normal file
90
server/.gitignore
vendored
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# SonarLint plugin
|
||||||
|
.idea/sonarlint/
|
||||||
|
.idea/sonarlint.xml # see https://community.sonarsource.com/t/is-the-file-idea-idea-idea-sonarlint-xml-intended-to-be-under-source-control/121119
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based HTTP Client
|
||||||
|
.idea/httpRequests
|
||||||
|
http-client.private.env.json
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
# Apifox Helper cache
|
||||||
|
.idea/.cache/.Apifox_Helper
|
||||||
|
.idea/ApifoxUploaderProjectSetting.xml
|
||||||
|
|
||||||
|
# Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215
|
||||||
|
.idea/**/copilot.data.migration.*.xml
|
||||||
|
|
||||||
|
target/
|
||||||
0
server/.mvn/jvm.config
Normal file
0
server/.mvn/jvm.config
Normal file
0
server/.mvn/maven.config
Normal file
0
server/.mvn/maven.config
Normal file
102
server/pom.xml
Normal file
102
server/pom.xml
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>no.asprusten</groupId>
|
||||||
|
<artifactId>Sykkelaksjon</artifactId>
|
||||||
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
<name>Sykkelaksjon</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.persistence</groupId>
|
||||||
|
<artifactId>jakarta.persistence-api</artifactId>
|
||||||
|
<version>3.2.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-core</artifactId>
|
||||||
|
<version>7.1.0.Final</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
<version>4.0.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
<version>4.0.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
<version>4.0.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.pac4j</groupId>
|
||||||
|
<artifactId>spring-webmvc-pac4j</artifactId>
|
||||||
|
<version>8.0.2</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.pac4j</groupId>
|
||||||
|
<artifactId>pac4j-oidc</artifactId>
|
||||||
|
<version>6.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.pac4j</groupId>
|
||||||
|
<artifactId>pac4j-http</artifactId>
|
||||||
|
<version>6.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>42.7.4</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jsonschema2pojo</groupId>
|
||||||
|
<artifactId>jsonschema2pojo-maven-plugin</artifactId>
|
||||||
|
<version>1.3.3</version>
|
||||||
|
<configuration>
|
||||||
|
<sourceDirectory>${basedir}/../messages/</sourceDirectory>
|
||||||
|
<targetPackage>no.asprusten.sykkelaksjon.messages</targetPackage>
|
||||||
|
<useLongIntegers>true</useLongIntegers>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>generate</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>4.0.5</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>no.asprusten.sykkelaksjon.Server</mainClass>
|
||||||
|
<layout>ZIP</layout>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package no.asprusten.sykkelaksjon;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
public class PropertiesListener implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
|
||||||
|
// Make this actually find the postgres password and run when not in a Docker container
|
||||||
|
// Also make it find the static files
|
||||||
|
ConfigurableEnvironment environment = event.getEnvironment();
|
||||||
|
String passwordSetting = environment.getProperty("postgrespassword");
|
||||||
|
if (passwordSetting == null) {
|
||||||
|
File passwordFile = new File("../secrets/postgrespassword");
|
||||||
|
if (passwordFile.canRead()) {
|
||||||
|
try {
|
||||||
|
Scanner reader = new Scanner(passwordFile);
|
||||||
|
if (reader.hasNextLine()) {
|
||||||
|
String password = reader.nextLine();
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("postgrespassword", password);
|
||||||
|
environment.getPropertySources().addFirst(new PropertiesPropertySource("passwordProps", props));
|
||||||
|
}
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
// Do nothing, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String staticResourcesPath = environment.getProperty("spring.web.resources.static-locations");
|
||||||
|
File staticResources = new File(staticResourcesPath.replaceFirst("file:", ""));
|
||||||
|
if (!staticResources.exists()) {
|
||||||
|
// Look for static resources in the default location
|
||||||
|
File nonDockerStatic = new File("../client/dist");
|
||||||
|
if (nonDockerStatic.exists()) {
|
||||||
|
Properties staticProps = new Properties();
|
||||||
|
try {
|
||||||
|
staticProps.put("spring.web.resources.static-locations", "file:" + nonDockerStatic.getCanonicalPath());
|
||||||
|
environment.getPropertySources().addFirst(new PropertiesPropertySource("staticProps", staticProps));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAsyncExecution() {
|
||||||
|
return ApplicationListener.super.supportsAsyncExecution();
|
||||||
|
}
|
||||||
|
}
|
||||||
354
server/src/main/java/no/asprusten/sykkelaksjon/Server.java
Normal file
354
server/src/main/java/no/asprusten/sykkelaksjon/Server.java
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
package no.asprusten.sykkelaksjon;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.services.ActivityService;
|
||||||
|
import no.asprusten.sykkelaksjon.db.services.ActivityTemplateService;
|
||||||
|
import no.asprusten.sykkelaksjon.db.services.ActivityTypeService;
|
||||||
|
import no.asprusten.sykkelaksjon.db.services.UserService;
|
||||||
|
import no.asprusten.sykkelaksjon.messages.*;
|
||||||
|
import no.asprusten.sykkelaksjon.security.ServerExceptionHandler;
|
||||||
|
import org.pac4j.core.profile.ProfileManager;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hello world!
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@SpringBootApplication
|
||||||
|
public class Server {
|
||||||
|
@Autowired
|
||||||
|
private ProfileManager profileManager;
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
@Autowired
|
||||||
|
private ActivityTypeService activityTypeService;
|
||||||
|
@Autowired
|
||||||
|
private ActivityService activityService;
|
||||||
|
@Autowired
|
||||||
|
private ActivityTemplateService activityTemplateService;
|
||||||
|
|
||||||
|
@Value("${sykkelaksjon.openid.discoveryURI}")
|
||||||
|
private String discoveryURI;
|
||||||
|
|
||||||
|
@Value("${sykkelaksjon.openid.clientId}")
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
private ActivityType getActivityTypeMessage(no.asprusten.sykkelaksjon.db.datatypes.ActivityType activityType) {
|
||||||
|
ActivityType activityTypeMessage = new ActivityType();
|
||||||
|
activityTypeMessage.setId(activityType.getId());
|
||||||
|
activityTypeMessage.setName(activityType.getActivityType());
|
||||||
|
activityTypeMessage.setConversionFactor(activityType.getConversion());
|
||||||
|
activityTypeMessage.setUnit(activityType.getUnit());
|
||||||
|
return activityTypeMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityTemplate getActivityTemplateMessage(no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate activityTemplate) {
|
||||||
|
ActivityTemplate templateMessage = new ActivityTemplate();
|
||||||
|
templateMessage.setId(activityTemplate.getId());
|
||||||
|
templateMessage.setActivityType(getActivityTypeMessage(activityTemplate.getActivityType()));
|
||||||
|
templateMessage.setName(activityTemplate.getName());
|
||||||
|
templateMessage.setNumberOfUnits(activityTemplate.getNumberOfUnits());
|
||||||
|
return templateMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Activity getActivityMessage(no.asprusten.sykkelaksjon.db.datatypes.Activity activity) {
|
||||||
|
Activity activityMessage = new Activity();
|
||||||
|
activityMessage.setId(activity.getId());
|
||||||
|
activityMessage.setActivityType(getActivityTypeMessage(activity.getActivityType()));
|
||||||
|
activityMessage.setDescription(activity.getDescription());
|
||||||
|
activityMessage.setNumberOfUnits(activity.getNumberOfUnits());
|
||||||
|
activityMessage.setDate(activity.getDate().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
return activityMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@GetMapping("/api")
|
||||||
|
public ServerMessageSchema respondToRequest() throws ServerExceptionHandler.InvalidUserException {
|
||||||
|
var optionalUserProfile = profileManager.getProfile();
|
||||||
|
if (optionalUserProfile.isEmpty()) {
|
||||||
|
throw new ServerExceptionHandler.InvalidUserException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var userProfile = optionalUserProfile.get();
|
||||||
|
|
||||||
|
var optionalUser = userService.getUser(userProfile.getUsername());
|
||||||
|
var user = optionalUser
|
||||||
|
.orElseGet(
|
||||||
|
() -> userService.createUser(
|
||||||
|
userProfile.getUsername(),
|
||||||
|
userProfile.getAttribute("name").toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var allUsers = userService.list();
|
||||||
|
ServerMessageSchema serverMessage = new ServerMessageSchema();
|
||||||
|
serverMessage.setName(user.getFullName());
|
||||||
|
serverMessage.setIsAdmin(user.isAdmin());
|
||||||
|
|
||||||
|
boolean unitConversionNecessary = false;
|
||||||
|
for (var activityType : activityTypeService.list()) {
|
||||||
|
serverMessage.getActivityTypes().add(getActivityTypeMessage(activityType));
|
||||||
|
unitConversionNecessary |= activityType.getConversion() != 1.0;
|
||||||
|
}
|
||||||
|
serverMessage.setUnitConversionNecessary(unitConversionNecessary);
|
||||||
|
|
||||||
|
for (var activityTemplate : user.getTemplates()) {
|
||||||
|
serverMessage.getActivityTemplates().add(getActivityTemplateMessage(activityTemplate));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Activity> unsortedActivities = new ArrayList<>();
|
||||||
|
|
||||||
|
for (var activity : user.getActivities()) {
|
||||||
|
unsortedActivities.add(getActivityMessage(activity));
|
||||||
|
}
|
||||||
|
unsortedActivities.sort(Comparator.comparing(Activity::getDate));
|
||||||
|
serverMessage.getActivities().addAll(unsortedActivities.reversed());
|
||||||
|
|
||||||
|
List<OtherUser> unsortedOtherUsers = new ArrayList<>();
|
||||||
|
for (var otherUser : allUsers) {
|
||||||
|
// Don't describe the current user
|
||||||
|
if (otherUser.getId().equals(user.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// If this is not an active user, and the requesting user is not an admin, skip
|
||||||
|
if (!otherUser.isActive() && !user.isAdmin()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user has not registered anything yet, also skip it (unless admin)
|
||||||
|
if (otherUser.getActivities().isEmpty() && !user.isAdmin()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
OtherUser otherUserMessage = new OtherUser();
|
||||||
|
otherUserMessage.setName(otherUser.getFullName());
|
||||||
|
|
||||||
|
double totalKilometers = otherUser.getActivities()
|
||||||
|
.stream()
|
||||||
|
.mapToDouble(activity -> activity.getActivityType().getConversion() * activity.getNumberOfUnits())
|
||||||
|
.sum();
|
||||||
|
otherUserMessage.setTotalKilometers(totalKilometers);
|
||||||
|
|
||||||
|
otherUser.getActivities().stream()
|
||||||
|
.map(no.asprusten.sykkelaksjon.db.datatypes.Activity::getDate)
|
||||||
|
.min(LocalDate::compareTo)
|
||||||
|
.ifPresent(earliestDate ->
|
||||||
|
otherUserMessage.setEarliestActivity(earliestDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
|
||||||
|
);
|
||||||
|
|
||||||
|
otherUser.getActivities().stream()
|
||||||
|
.map(no.asprusten.sykkelaksjon.db.datatypes.Activity::getDate)
|
||||||
|
.max(LocalDate::compareTo)
|
||||||
|
.ifPresent(latestDate ->
|
||||||
|
otherUserMessage.setLatestActivity(latestDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Additional info is only for administrators
|
||||||
|
if (user.isAdmin()) {
|
||||||
|
otherUserMessage.setUserId(otherUser.getId());
|
||||||
|
otherUserMessage.setUserName(otherUser.getUsername());
|
||||||
|
otherUserMessage.setIsActive(otherUser.isActive());
|
||||||
|
otherUserMessage.setIsAdmin(otherUser.isAdmin());
|
||||||
|
List<Activity> userUnsortedActivities = new ArrayList<>();
|
||||||
|
for (var activity : otherUser.getActivities()) {
|
||||||
|
userUnsortedActivities.add(getActivityMessage(activity));
|
||||||
|
}
|
||||||
|
userUnsortedActivities.sort(Comparator.comparing(Activity::getDate));
|
||||||
|
otherUserMessage.getActivities().addAll(userUnsortedActivities.reversed());
|
||||||
|
|
||||||
|
for (var activityTemplate : otherUser.getTemplates()) {
|
||||||
|
otherUserMessage.getActivityTemplates().add(getActivityTemplateMessage(activityTemplate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsortedOtherUsers.add(otherUserMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsortedOtherUsers.sort(Comparator.comparing(OtherUser::getTotalKilometers));
|
||||||
|
serverMessage.getOtherUsers().addAll(unsortedOtherUsers.reversed());
|
||||||
|
return serverMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@PostMapping(path = "/api/submitActivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void submitActivity(
|
||||||
|
@RequestParam("activity-type") Long activityTypeId,
|
||||||
|
@RequestParam("activity-distance") double distance,
|
||||||
|
@RequestParam("activity-description") String description,
|
||||||
|
@RequestParam("activity-date") String date
|
||||||
|
) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
activityTypeService.getById(activityTypeId).ifPresent(activityType -> {
|
||||||
|
no.asprusten.sykkelaksjon.db.datatypes.Activity activity = new no.asprusten.sykkelaksjon.db.datatypes.Activity(
|
||||||
|
activityType,
|
||||||
|
dbUser,
|
||||||
|
distance,
|
||||||
|
description,
|
||||||
|
LocalDate.parse(date)
|
||||||
|
);
|
||||||
|
activityService.saveActivity(activity);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@PostMapping(path = "/api/submitActivityTemplate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void submitActivityTemplate(
|
||||||
|
@RequestParam("activity-type") Long activityTypeId,
|
||||||
|
@RequestParam("activity-distance") double distance,
|
||||||
|
@RequestParam("activity-description") String description,
|
||||||
|
@RequestParam("activity-date") String date
|
||||||
|
) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
activityTypeService.getById(activityTypeId).ifPresent(activityType -> {
|
||||||
|
no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate activityTemplate = new no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate(
|
||||||
|
dbUser,
|
||||||
|
activityType,
|
||||||
|
description,
|
||||||
|
distance
|
||||||
|
);
|
||||||
|
|
||||||
|
activityTemplateService.saveActivityTemplate(activityTemplate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@DeleteMapping(path = "/api/deleteActivityTemplate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void deleteActivityTemplate(@RequestParam("activity-template-id") Long activityTemplateId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
activityTemplateService.findById(activityTemplateId).ifPresent(activityTemplate -> {
|
||||||
|
if (activityTemplate.getOwner().equals(dbUser)) {
|
||||||
|
activityTemplateService.deleteActivityTemplate(activityTemplate.getId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@DeleteMapping(path = "/api/deleteActivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void deleteActivity(@RequestParam("activity-id") Long activityId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
activityService.findById(activityId).ifPresent(activity -> {
|
||||||
|
if (activity.getActivityOwner().equals(dbUser) || dbUser.isAdmin()) {
|
||||||
|
activityService.deleteActivity(activityId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@PostMapping(path = "/api/addActivityType", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void createActivityType(
|
||||||
|
@RequestParam("activity-type-name") String name,
|
||||||
|
@RequestParam("activity-type-unit") String unit,
|
||||||
|
@RequestParam("activity-type-conversion-factor") double conversionFactor
|
||||||
|
) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
if (dbUser.isAdmin()) {
|
||||||
|
no.asprusten.sykkelaksjon.db.datatypes.ActivityType activityType = new no.asprusten.sykkelaksjon.db.datatypes.ActivityType(
|
||||||
|
name, unit, conversionFactor
|
||||||
|
);
|
||||||
|
activityTypeService.saveActivityType(activityType);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@DeleteMapping(path = "/api/deleteActivityType", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void deleteActivityType(@RequestParam("activity-type-id") Long activityTypeId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
if (dbUser.isAdmin()) {
|
||||||
|
activityTypeService.deleteActivityType(activityTypeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@PutMapping(path = "/api/makeAdmin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void makeAdmin(@RequestParam("user-id") Long userId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
if (dbUser.isAdmin()) {
|
||||||
|
userService.getUserById(userId).ifPresent(elevatedUser -> {
|
||||||
|
elevatedUser.setAdmin(true);
|
||||||
|
userService.saveUser(elevatedUser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@PutMapping(path = "/api/removeAdmin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void removeAdmin(@RequestParam("user-id") Long userId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
if (dbUser.isAdmin()) {
|
||||||
|
userService.getUserById(userId).ifPresent(elevatedUser -> {
|
||||||
|
elevatedUser.setAdmin(false);
|
||||||
|
userService.saveUser(elevatedUser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@DeleteMapping(path = "/api/deleteUser", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public void deleteUser(@RequestParam("user-id") Long userId) {
|
||||||
|
profileManager.getProfile().ifPresent(userProfile -> {
|
||||||
|
String username = userProfile.getUsername();
|
||||||
|
userService.getUser(username).ifPresent(dbUser -> {
|
||||||
|
if (dbUser.isAdmin()) {
|
||||||
|
userService.deleteUserById(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@CrossOrigin(allowCredentials = "true", origins = {"http://localhost:5173"})
|
||||||
|
@GetMapping(path = "/api/openid")
|
||||||
|
public OpenidSchema provideOpenidConfig() {
|
||||||
|
OpenidSchema openidSchema = new OpenidSchema();
|
||||||
|
openidSchema.setClientId(clientId);
|
||||||
|
openidSchema.setOpenidDiscoveryUri(discoveryURI);
|
||||||
|
return openidSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication application = new SpringApplication(Server.class);
|
||||||
|
application.addListeners(new PropertiesListener());
|
||||||
|
application.run(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.datatypes;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Activity {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private long id;
|
||||||
|
|
||||||
|
@ManyToOne( optional = false)
|
||||||
|
private ActivityType activityType;
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
private WebUser activityOwner;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Double numberOfUnits;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String description;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
public Activity() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public Activity(ActivityType activityType, WebUser activityOwner, Double numberOfUnits, String description, LocalDate date) {
|
||||||
|
this.activityType = activityType;
|
||||||
|
this.activityOwner = activityOwner;
|
||||||
|
this.numberOfUnits = numberOfUnits;
|
||||||
|
this.description = description;
|
||||||
|
this.date = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityType getActivityType() {
|
||||||
|
return activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActivityType(ActivityType activityType) {
|
||||||
|
this.activityType = activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getNumberOfUnits() {
|
||||||
|
return numberOfUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumberOfUnits(Double numberOfUnits) {
|
||||||
|
this.numberOfUnits = numberOfUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUser getActivityOwner() {
|
||||||
|
return activityOwner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getDate() {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.datatypes;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "UniqueNamesPerUser", columnNames = { "owner", "name" })
|
||||||
|
})
|
||||||
|
public class ActivityTemplate {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private long id;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "owner", nullable = false)
|
||||||
|
private WebUser owner;
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
private ActivityType activityType;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Double numberOfUnits;
|
||||||
|
|
||||||
|
public ActivityTemplate() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityTemplate(WebUser owner, ActivityType activityType, String name, Double numberOfUnits) {
|
||||||
|
this.owner = owner;
|
||||||
|
this.activityType = activityType;
|
||||||
|
this.name = name;
|
||||||
|
this.numberOfUnits = numberOfUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUser getOwner() {
|
||||||
|
return owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwner(WebUser owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityType getActivityType() {
|
||||||
|
return activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActivityType(ActivityType activityType) {
|
||||||
|
this.activityType = activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getNumberOfUnits() {
|
||||||
|
return numberOfUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNumberOfUnits(Double numberOfUnits) {
|
||||||
|
this.numberOfUnits = numberOfUnits;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.datatypes;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class ActivityType {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String activityType;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String unit;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Double conversion;
|
||||||
|
|
||||||
|
public ActivityType() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityType(String activityType, String unit, Double conversion) {
|
||||||
|
this.activityType = activityType;
|
||||||
|
this.unit = unit;
|
||||||
|
this.conversion = conversion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getActivityType() {
|
||||||
|
return activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUnit() {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getConversion() {
|
||||||
|
return conversion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActivityType(String activityType) {
|
||||||
|
this.activityType = activityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnit(String unit) {
|
||||||
|
this.unit = unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversion(Double conversion) {
|
||||||
|
this.conversion = conversion;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.datatypes;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "OnlyOneActiveWithUsername", columnNames = { "username", "zeroIfActive" })
|
||||||
|
})
|
||||||
|
public class WebUser {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private Long id;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String username;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String fullName;
|
||||||
|
private boolean isAdmin;
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long zeroIfActive;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "activityOwner", fetch = FetchType.EAGER)
|
||||||
|
private List<Activity> activities = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
|
||||||
|
private List<ActivityTemplate> templates = new ArrayList<>();
|
||||||
|
|
||||||
|
public WebUser() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUser(String username, String fullName, boolean isAdmin) {
|
||||||
|
this.username = username;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.isAdmin = isAdmin;
|
||||||
|
this.zeroIfActive = 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAdmin() {
|
||||||
|
return isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return zeroIfActive == 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAdmin(boolean admin) {
|
||||||
|
isAdmin = admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActive(boolean active) {
|
||||||
|
if (active) {
|
||||||
|
zeroIfActive = 0L;
|
||||||
|
} else {
|
||||||
|
zeroIfActive = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Activity> getActivities() {
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ActivityTemplate> getTemplates() {
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.repositories;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.Activity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ActivityRepository extends JpaRepository<Activity, Long> {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.repositories;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ActivityTemplateRepository extends JpaRepository<ActivityTemplate, Long> {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.repositories;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.ActivityType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ActivityTypeRepository extends JpaRepository<ActivityType, Long> {
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.repositories;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.WebUser;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WebUserRepository extends JpaRepository<WebUser, Long> {
|
||||||
|
List<WebUser> findByUsername(String username);
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.services;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.Activity;
|
||||||
|
import no.asprusten.sykkelaksjon.db.repositories.ActivityRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ActivityService {
|
||||||
|
@Autowired
|
||||||
|
ActivityRepository activityRepository;
|
||||||
|
|
||||||
|
public void saveActivity(Activity activity) {
|
||||||
|
activityRepository.save(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteActivity(Long id) {
|
||||||
|
activityRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Activity> findById(long id) {
|
||||||
|
return activityRepository.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.services;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.ActivityTemplate;
|
||||||
|
import no.asprusten.sykkelaksjon.db.repositories.ActivityTemplateRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ActivityTemplateService {
|
||||||
|
@Autowired
|
||||||
|
private ActivityTemplateRepository activityTemplateRepository;
|
||||||
|
|
||||||
|
public Optional<ActivityTemplate> findById(Long id) {
|
||||||
|
return activityTemplateRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveActivityTemplate(ActivityTemplate template) {
|
||||||
|
activityTemplateRepository.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteActivityTemplate(Long id) {
|
||||||
|
activityTemplateRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.services;
|
||||||
|
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.ActivityType;
|
||||||
|
import no.asprusten.sykkelaksjon.db.repositories.ActivityTypeRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ActivityTypeService {
|
||||||
|
@Autowired
|
||||||
|
private ActivityTypeRepository activityTypeRepository;
|
||||||
|
|
||||||
|
public List<ActivityType> list() {
|
||||||
|
return activityTypeRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<ActivityType> getById(Long id) {
|
||||||
|
return activityTypeRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityType saveActivityType(ActivityType activityType) {
|
||||||
|
return activityTypeRepository.save(activityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteActivityType(Long id) {
|
||||||
|
activityTypeRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.db.services;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import no.asprusten.sykkelaksjon.db.datatypes.WebUser;
|
||||||
|
import no.asprusten.sykkelaksjon.db.repositories.WebUserRepository;
|
||||||
|
import org.apache.catalina.User;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
@Autowired
|
||||||
|
private WebUserRepository webUserRepository;
|
||||||
|
@Autowired
|
||||||
|
@Value("${sykkelaksjon.initial-admin}")
|
||||||
|
private String initialAdmin;
|
||||||
|
|
||||||
|
public List<WebUser> list() {
|
||||||
|
return webUserRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUserCount() {
|
||||||
|
return webUserRepository.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<WebUser> getUser(String username) {
|
||||||
|
List<WebUser> users = webUserRepository.findByUsername(username);
|
||||||
|
for (var user : users) {
|
||||||
|
if (user.isActive()) {
|
||||||
|
return Optional.of(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<WebUser> getUserById(Long id) {
|
||||||
|
return webUserRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUser createUser(String username, String fullname) {
|
||||||
|
boolean isAdmin = username.equals(initialAdmin);
|
||||||
|
WebUser newUser = new WebUser(username, fullname, isAdmin);
|
||||||
|
newUser = webUserRepository.save(newUser);
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebUser saveUser(WebUser user) {
|
||||||
|
return webUserRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteUserById(Long id) {
|
||||||
|
webUserRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.security;
|
||||||
|
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
|
||||||
|
import org.pac4j.core.authorization.authorizer.DefaultAuthorizers;
|
||||||
|
import org.pac4j.core.config.Config;
|
||||||
|
import org.pac4j.http.client.direct.HeaderClient;
|
||||||
|
import org.pac4j.oidc.client.OidcClient;
|
||||||
|
import org.pac4j.oidc.config.OidcConfiguration;
|
||||||
|
import org.pac4j.springframework.config.Pac4jSecurityConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfig extends Pac4jSecurityConfig {
|
||||||
|
|
||||||
|
@Value("${sykkelaksjon.openid.discoveryURI}")
|
||||||
|
private String discoveryURI;
|
||||||
|
|
||||||
|
@Value("${sykkelaksjon.openid.clientId}")
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Config config() {
|
||||||
|
final var config = new OidcConfiguration()
|
||||||
|
.setDiscoveryURI(discoveryURI)
|
||||||
|
.setClientId(clientId);
|
||||||
|
|
||||||
|
OidcClient client = new OidcClient(config);
|
||||||
|
client.setCallbackUrl("notused");
|
||||||
|
client.init();
|
||||||
|
|
||||||
|
HeaderClient headerClient = new HeaderClient("Authorization", "Bearer ", client.getProfileCreator());
|
||||||
|
return new Config(headerClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(final InterceptorRegistry registry) {
|
||||||
|
addSecurity(registry, "HeaderClient").addPathPatterns("/api/**").excludePathPatterns("/api/openid").excludeHttpMethods(List.of(HttpMethod.OPTIONS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package no.asprusten.sykkelaksjon.security;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
@ControllerAdvice
|
||||||
|
public class ServerExceptionHandler {
|
||||||
|
public static class InvalidUserException extends Exception {}
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||||
|
@ExceptionHandler(InvalidUserException.class)
|
||||||
|
public void handleInvalidUser() {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
11
server/src/main/resources/application.properties
Normal file
11
server/src/main/resources/application.properties
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:9876/sykkelaksjon}
|
||||||
|
spring.config.import=optional:configtree:/run/secrets/
|
||||||
|
spring.datasource.username=sykkelaksjon
|
||||||
|
spring.datasource.password=${postgrespassword}
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
|
sykkelaksjon.initial-admin=${SYKKELAKSJON_INITIAL_ADMIN:martin}
|
||||||
|
spring.web.resources.static-locations=./static
|
||||||
|
|
||||||
|
sykkelaksjon.openid.discoveryURI = ${OPENID_DISCOVERY_URI}
|
||||||
|
sykkelaksjon.openid.clientId = ${OPENID_CLIENT_ID}
|
||||||
Loading…
x
Reference in New Issue
Block a user