From 24f14da9b23b96f89a12d2f3bde883665510a6d5 Mon Sep 17 00:00:00 2001 From: walamana Date: Mon, 18 Sep 2023 11:51:41 +0200 Subject: [PATCH] save changed marks --- package.json | 5 +- private/minis-backend/gradle.properties | 2 +- .../main/kotlin/de/walamana/models/Marks.kt | 2 +- .../kotlin/de/walamana/models/Ministranten.kt | 44 ++++++--- .../kotlin/de/walamana/plugins/Security.kt | 24 ++--- .../de/walamana/views/AuthenticationView.kt | 3 +- .../kotlin/de/walamana/views/MarksView.kt | 29 +++--- public/src/App.vue | 23 ++++- public/src/components/LoginPanel.vue | 7 +- public/src/components/MobilePlan.vue | 15 ++- public/src/components/Plan.vue | 2 +- public/src/components/TablePlan.vue | 42 +++++--- public/src/models/models.ts | 4 +- public/src/{views => services}/api.ts | 33 +++---- public/src/services/auth.ts | 85 ++++++++++++++++ public/src/views/PlanView.vue | 99 +++++++++++++------ 16 files changed, 300 insertions(+), 119 deletions(-) rename public/src/{views => services}/api.ts (75%) create mode 100644 public/src/services/auth.ts diff --git a/package.json b/package.json index 19e0207..43be007 100644 --- a/package.json +++ b/package.json @@ -7,5 +7,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", - "license": "ISC" + "license": "ISC", + "dependencies": { + "rxjs": "^7.8.1" + } } diff --git a/private/minis-backend/gradle.properties b/private/minis-backend/gradle.properties index a8509f3..5c77def 100644 --- a/private/minis-backend/gradle.properties +++ b/private/minis-backend/gradle.properties @@ -2,5 +2,5 @@ ktor_version=2.3.3 kotlin_version=1.9.0 logback_version=1.2.11 kotlin.code.style=official -exposed_version=0.41.1 +exposed_version=0.43.0 h2_version=2.1.214 diff --git a/private/minis-backend/src/main/kotlin/de/walamana/models/Marks.kt b/private/minis-backend/src/main/kotlin/de/walamana/models/Marks.kt index d5d40f8..000e6b5 100644 --- a/private/minis-backend/src/main/kotlin/de/walamana/models/Marks.kt +++ b/private/minis-backend/src/main/kotlin/de/walamana/models/Marks.kt @@ -37,7 +37,7 @@ object MarksDao { } suspend fun setMark(ministrantId: Int, gottesdienstId: Int, value: Int): Boolean = dbQuery { - Marks.insert { + Marks.upsert { it[Marks.mid] = ministrantId it[Marks.gid] = gottesdienstId it[Marks.value] = value diff --git a/private/minis-backend/src/main/kotlin/de/walamana/models/Ministranten.kt b/private/minis-backend/src/main/kotlin/de/walamana/models/Ministranten.kt index cbaa1eb..64410c4 100644 --- a/private/minis-backend/src/main/kotlin/de/walamana/models/Ministranten.kt +++ b/private/minis-backend/src/main/kotlin/de/walamana/models/Ministranten.kt @@ -27,6 +27,7 @@ data class Ministrant( @Serializable data class SimplifiedMinistrant( val id: Int, + val username: String, val firstname: String, val lastname: String ) @@ -44,10 +45,10 @@ object Ministranten : Table() { } object MinistrantenDao { - private fun resultRowToMinistrant(row: ResultRow, showPasswordHash: Boolean = false) = Ministrant ( + private fun resultRowToMinistrant(row: ResultRow, showPasswordHash: Boolean = false) = Ministrant( row[Ministranten.id], row[Ministranten.username], - if(showPasswordHash) row[Ministranten.passwordHash] else "", + if (showPasswordHash) row[Ministranten.passwordHash] else "", row[Ministranten.firstname], row[Ministranten.lastname], Date(row[Ministranten.birthday]), @@ -59,8 +60,18 @@ object MinistrantenDao { } suspend fun simplifiedMinistranten(): List = dbQuery { - Ministranten.selectAll().map { row -> - SimplifiedMinistrant(row[Ministranten.id], row[Ministranten.firstname], row[Ministranten.lastname]) + Ministranten.slice( + Ministranten.id, + Ministranten.username, + Ministranten.firstname, + Ministranten.lastname + ).selectAll().map { row -> + SimplifiedMinistrant( + row[Ministranten.id], + row[Ministranten.username], + row[Ministranten.firstname], + row[Ministranten.lastname] + ) } } @@ -70,7 +81,14 @@ object MinistrantenDao { }.firstOrNull() } - suspend fun createMinistrant(username: String, passwordHash: String, firstname: String, lastname: String, birthday: Date, privileges: List) = dbQuery { + suspend fun createMinistrant( + username: String, + passwordHash: String, + firstname: String, + lastname: String, + birthday: Date, + privileges: List + ) = dbQuery { val statement = Ministranten.insert { it[Ministranten.username] = username it[Ministranten.passwordHash] = "" @@ -95,14 +113,14 @@ object MinistrantenDao { lastname: String? = null, birthday: Date? = null, privileges: List? = null - ) = dbQuery{ - Ministranten.update({Ministranten.id eq id}) { - if(username != null) it[Ministranten.username] = username - if(passwordHash != null) it[Ministranten.passwordHash] = passwordHash - if(firstname != null) it[Ministranten.firstname] = firstname - if(lastname != null) it[Ministranten.lastname] = lastname - if(birthday != null) it[Ministranten.birthday] = birthday.time - if(privileges != null) it[Ministranten.privileges] = privileges.joinToString(",") + ) = dbQuery { + Ministranten.update({ Ministranten.id eq id }) { + if (username != null) it[Ministranten.username] = username + if (passwordHash != null) it[Ministranten.passwordHash] = passwordHash + if (firstname != null) it[Ministranten.firstname] = firstname + if (lastname != null) it[Ministranten.lastname] = lastname + if (birthday != null) it[Ministranten.birthday] = birthday.time + if (privileges != null) it[Ministranten.privileges] = privileges.joinToString(",") } } } \ No newline at end of file diff --git a/private/minis-backend/src/main/kotlin/de/walamana/plugins/Security.kt b/private/minis-backend/src/main/kotlin/de/walamana/plugins/Security.kt index 7c53ca6..0954572 100644 --- a/private/minis-backend/src/main/kotlin/de/walamana/plugins/Security.kt +++ b/private/minis-backend/src/main/kotlin/de/walamana/plugins/Security.kt @@ -17,6 +17,7 @@ import java.util.* const val SALT_ROUNDS = 10; + data class JWTEnvironment( val secret: String, val issuer: String, @@ -45,7 +46,7 @@ fun Application.configureSecurity() { } -fun Application.getJWTEnvironment(): JWTEnvironment{ +fun Application.getJWTEnvironment(): JWTEnvironment { val secret = environment.config.property("jwt.secret").getString() val issuer = environment.config.property("jwt.issuer").getString() @@ -58,12 +59,12 @@ fun Payload.mid() = getClaim("id").asInt() object Security { - fun DEFAULT_EXPIRY() = Date(System.currentTimeMillis() + 1000*60*60); + fun DEFAULT_EXPIRY() = Date(System.currentTimeMillis() + 1000 * 60 * 60); suspend fun authenticateUser(application: Application, username: String, password: String): Ministrant? { - if(username == "admin") { + if (username == "admin") { val adminPw = application.environment.config.property("admin.password").getString() - if(adminPw == password) { + if (adminPw == password) { val allMinis = MinistrantenDao.allMinistranten().map { it.username } return Ministrant( 0, "admin", "", "admin", "admin", Date(), allMinis @@ -75,7 +76,7 @@ object Security { val ministrant = MinistrantenDao.getMinistrant(username, true) ?: return null - if(!BCrypt.verifyer().verify(password.toCharArray(), ministrant.passwordHash).verified) { + if (!BCrypt.verifyer().verify(password.toCharArray(), ministrant.passwordHash).verified) { return null } @@ -95,10 +96,11 @@ object Security { } fun createToken(jwtEnv: JWTEnvironment, ministrant: Ministrant) = JWT.create() - .withAudience(jwtEnv.audience) - .withIssuer(jwtEnv.issuer) - .withClaim("username", ministrant.username) - .withClaim("id", ministrant.id) - .withExpiresAt(DEFAULT_EXPIRY()) - .sign(Algorithm.HMAC256(jwtEnv.secret)) + .withAudience(jwtEnv.audience) + .withIssuer(jwtEnv.issuer) + .withClaim("username", ministrant.username) + .withClaim("id", ministrant.id) + .withClaim("privileges", ministrant.privileges) + .withExpiresAt(DEFAULT_EXPIRY()) + .sign(Algorithm.HMAC256(jwtEnv.secret)) } \ No newline at end of file diff --git a/private/minis-backend/src/main/kotlin/de/walamana/views/AuthenticationView.kt b/private/minis-backend/src/main/kotlin/de/walamana/views/AuthenticationView.kt index 9c6bc91..de66f65 100644 --- a/private/minis-backend/src/main/kotlin/de/walamana/views/AuthenticationView.kt +++ b/private/minis-backend/src/main/kotlin/de/walamana/views/AuthenticationView.kt @@ -28,6 +28,7 @@ data class AuthenticationRequest( @Serializable data class AuthenticationResult( val success: Boolean, + val token: String? = null, val privileges: List? = null, ) @@ -61,7 +62,7 @@ fun Route.configureAuthenticationRoutes() { "token=$token; HttpOnly; Expires=$expiry" ) - call.respond(AuthenticationResult(true, ministrant.privileges)) + call.respond(AuthenticationResult(true, token, ministrant.privileges)) } authenticate { diff --git a/private/minis-backend/src/main/kotlin/de/walamana/views/MarksView.kt b/private/minis-backend/src/main/kotlin/de/walamana/views/MarksView.kt index 5735003..b14e0fb 100644 --- a/private/minis-backend/src/main/kotlin/de/walamana/views/MarksView.kt +++ b/private/minis-backend/src/main/kotlin/de/walamana/views/MarksView.kt @@ -8,6 +8,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.util.* + fun Route.configureMarksView() { route("/marks") { get { @@ -15,22 +16,26 @@ fun Route.configureMarksView() { call.respond(data) } put { - val data = call.receive() - val mark = MarksDao.setMark( - data.mid, - data.gid, - data.value - ) + val changedMarks = call.receive>() + for(changedMark in changedMarks) { + val mark = MarksDao.setMark( + changedMark.mid, + changedMark.gid, + changedMark.value + ) + } call.respond(HttpStatusCode.OK) } patch { // TODO: Access only by admin - val data = call.receive() - val changed = MarksDao.setMark( - data.mid, - data.gid, - data.value - ) + val changedMarks = call.receive>() + for(changedMark in changedMarks) { + val mark = MarksDao.setMark( + changedMark.mid, + changedMark.gid, + changedMark.value + ) + } call.respond(HttpStatusCode.OK) } delete { diff --git a/public/src/App.vue b/public/src/App.vue index dabf9b3..444af47 100644 --- a/public/src/App.vue +++ b/public/src/App.vue @@ -2,9 +2,23 @@ import {RouterLink, RouterView} from 'vue-router' import HelloWorld from './components/HelloWorld.vue' import LoginPanel from "@/components/LoginPanel.vue"; -import {ref} from "vue"; +import {onMounted, ref} from "vue"; +import {Auth} from "@/services/auth"; -const showPopup = ref(false) +let showPopup = ref(false) +let loggedIn = ref(false) + +Auth.loggedInSubject.subscribe((isLoggedIn) => { + loggedIn.value = isLoggedIn +}) + +function logout(){ + Auth.logout() +} + +onMounted(() => { + Auth.checkForToken() +}) @@ -14,13 +28,14 @@ const showPopup = ref(false) Miniplan
- + +
diff --git a/public/src/components/LoginPanel.vue b/public/src/components/LoginPanel.vue index 2afde02..905dcb5 100644 --- a/public/src/components/LoginPanel.vue +++ b/public/src/components/LoginPanel.vue @@ -2,7 +2,8 @@ @@ -158,7 +156,7 @@ function getMinistrantClasses(mini: SimplifiedMinistrant) { - {{ mini.id }} {{ mini.firstname }} {{ mini.lastname }} + lock_reset{{ mini.id }} {{ mini.firstname }} {{ mini.lastname }} data.status == 200) } - export async function login(username: string, password: string): Promise<{ - success: boolean, - token?: string - }> { - return api("/auth", "POST", { - username, password - }).then(res => res.json() as Promise<{ - success: boolean, - token?: string - }>) - .then(res => { - if(res.success) { - console.log("test") - } - return Promise.resolve(res) - }) + export async function setMarks(marks: Mark[]): Promise { + return api("/marks", "PATCH", marks) + .then(res => Promise.resolve(res.status == 200)) } + export async function resetPassword(username: String): Promise { + return api("/auth/reset", "POST", { username }) + .then(res => res.json()) + } } + + diff --git a/public/src/services/auth.ts b/public/src/services/auth.ts new file mode 100644 index 0000000..58abe21 --- /dev/null +++ b/public/src/services/auth.ts @@ -0,0 +1,85 @@ +import {api} from "@/services/api"; +import {Subject} from "rxjs"; + +export namespace Auth { + + let loggedIn = false + let user = "" + let privileges: string[] = [] + export const loggedInSubject: Subject = new Subject() + + export function isLoggedIn() { + return loggedIn + } + + export function getUser() { + return user; + } + + export function getPrivileges() { + return privileges + } + + function setToken(token: string) { + window.localStorage.setItem("token", token) + } + + export function getToken(): string { + return window.localStorage.getItem("token") + } + + export async function login(username: string, password: string): Promise<{ + success: boolean, + token?: string + }> { + return api("/auth", "POST", { + username, password + }).then(res => res.json() as Promise<{ + success: boolean, + token?: string + privileges?: string[] + }>).then(res => { + if(res.success) { + loggedIn = true + user = username; + privileges = res.privileges ?? [] + privileges.push(username) + setToken(res.token ?? "") + loggedInSubject.next(true) + } + return Promise.resolve(res) + }) + } + + export function logout(){ + setToken("") + loggedIn = false; + user = ""; + privileges = [] + loggedInSubject.next(false) + } + + export function checkForToken() { + const token = getToken() + if(token && token != ""){ + const payload = parseJwt(token) + loggedIn = true + user = payload.username + privileges = payload.privileges + privileges.push(user) + loggedInSubject.next(true) + } + } + + function parseJwt (token) { + var base64Url = token.split('.')[1]; + var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + + return JSON.parse(jsonPayload); + } + +} + diff --git a/public/src/views/PlanView.vue b/public/src/views/PlanView.vue index 99f172d..8465a9c 100644 --- a/public/src/views/PlanView.vue +++ b/public/src/views/PlanView.vue @@ -2,23 +2,26 @@ import {onMounted, reactive, ref, toRaw, computed} from "vue"; import TablePlan from "@/components/TablePlan.vue"; -import {API} from "@/views/api"; +import {API} from "@/services/api"; import type {Gottesdienst, Mark, PlanModel, SimplifiedMinistrant} from "@/models/models"; import MobilePlan from "@/components/MobilePlan.vue"; import PlanActionBar from "@/components/PlanActionBar.vue"; +import {Auth} from "@/services/auth"; + +const MAX_WIDTH_MOBILE = 600; const plan = reactive<{ gottesdienste: Gottesdienst[], ministranten: SimplifiedMinistrant[], marks: Mark[], - editable: number[] + editable: string[] }>({ gottesdienste: [], ministranten: [], marks: [], editable: [] }) -const mobile = ref(false) +const mobile = ref(window.innerWidth <= MAX_WIDTH_MOBILE) const editedMarks = reactive([]); const editPlanAdmin = ref(false) @@ -35,7 +38,7 @@ async function addGodi(data, validate) { let date = Date.parse(data.date + "T" + data.time); let attendance = data.attendance && data.attendance != "" ? Date.parse(data.date + "T" + data.attendance) - : (date - 1000*60*30) + : (date - 1000 * 60 * 30) console.log(date, attendance, data.date + "T" + data.attendance) let newGodi = await API.addGottesdienst( data.name, @@ -50,7 +53,7 @@ async function addGodi(data, validate) { async function deleteGottedienst(id) { let deleted = await API.deleteGottesdienst(id) - if(deleted) { + if (deleted) { let index = plan.gottesdienste.findIndex(godi => godi.id == id) plan.gottesdienste.splice(index, 1) } @@ -62,64 +65,98 @@ onMounted(async () => { plan.gottesdienste = fetchedPlan.gottesdienste plan.ministranten = fetchedPlan.ministranten plan.marks = fetchedPlan.marks + Auth.checkForToken() }) -function getMarks(): Mark[]{ - return plan.marks.filter((mark: Mark) => { - let difMark = editedMarks.find((m: Mark) => m.gid == mark.gid && m.mid == mark.mid) - return !difMark - }).concat(editedMarks); +Auth.loggedInSubject.subscribe(loggedIn => { + if (loggedIn) { + plan.editable = Auth.getPrivileges() + if(Auth.getUser() == "admin"){ + editPlanAdmin.value = true + } + }else { + editPlanAdmin.value = false + plan.editable = [] + } +}) + +window.addEventListener("resize", (ev) => { + mobile.value = window.innerWidth <= MAX_WIDTH_MOBILE +}) + +function getMarks(): Mark[] { + return plan.marks.filter((mark: Mark) => { + let difMark = editedMarks.find((m: Mark) => m.gid == mark.gid && m.mid == mark.mid) + return !difMark + }).concat(editedMarks); } -function getDif(): Mark[]{ - return editedMarks.filter((mark: Mark) => { - let sameMark = plan.marks.find((m: Mark) => m.gid == mark.gid && m.mid == mark.mid) - return (!sameMark && mark.value != 0) || (sameMark && mark.value != sameMark.value) - }) +function getDif(): Mark[] { + return editedMarks.filter((mark: Mark) => { + let sameMark = plan.marks.find((m: Mark) => m.gid == mark.gid && m.mid == mark.mid) + return (!sameMark && mark.value != 0) || (sameMark && mark.value != sameMark.value) + }) } -function canEdit(mid) { - return plan.editable.includes(mid) + +async function saveChanges() { + const saved = await API.setMarks(getDif()) + if(saved) { + plan.marks = getMarks() + editedMarks.length = 0 + } +} + +async function resetPassword(username: string) { + const result = await API.resetPassword(username) + alert("Neues Passwort für " + username + "\n\n" + result.password) + console.log(result) +} + +function canEdit(username: string) { + return plan.editable.includes(username) } function toggleMark(gid, mid) { - // TODO: track changes - if(!canEdit(mid)) return; + // TODO: track changes + const username = plan.ministranten.find(m => m.id == mid)?.username + if (!canEdit(username)) return; - let mark = editedMarks.find(m => m.mid == mid && m.gid == gid); - if (mark) { - mark.value = ((mark.value + 2) % 3) - 1 - } else { - mark = { - gid, mid, value: 1 + let mark = editedMarks.find(m => m.mid == mid && m.gid == gid); + if (mark) { + mark.value = ((mark.value + 2) % 3) - 1 + } else { + mark = { + gid, mid, value: 1 + } + editedMarks.push(mark) } - editedMarks.push(mark) - } } -