Initial commit, working server

This commit is contained in:
Martin Asprusten
2026-04-12 01:28:25 +02:00
commit 49e857f258
39 changed files with 3375 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { Alpine as AlpineType } from 'alpinejs'
declare global {
var Alpine: AlpineType
}
+293
View 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
View 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
View 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
View 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;
}