From a396c772526697e76e343392a7bd59123235a590 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Sat, 11 Jan 2025 13:09:26 -0500 Subject: [PATCH 01/18] List Users Finaaalllyy, list users again in a new branch --- frontend/src/admin/use-users.ts | 61 +++++++++ frontend/src/admin/user-form.tsx | 127 ++++++++++++++++++ frontend/src/admin/users.tsx | 58 ++++++++ frontend/src/components/zero-header.tsx | 4 + frontend/src/router.tsx | 5 + .../orm/companysurvey/SurveyRepository.kt | 2 +- .../main/kotlin/com/zenmo/orm/user/User.kt | 11 -- .../com/zenmo/orm/user/UserRepository.kt | 19 ++- .../com/zenmo/ztor/plugins/Databases.kt | 65 ++++++++- zummon/src/commonMain/kotlin/User.kt | 15 ++- 10 files changed, 351 insertions(+), 16 deletions(-) create mode 100644 frontend/src/admin/use-users.ts create mode 100644 frontend/src/admin/user-form.tsx create mode 100644 frontend/src/admin/users.tsx delete mode 100644 zorm/src/main/kotlin/com/zenmo/orm/user/User.kt diff --git a/frontend/src/admin/use-users.ts b/frontend/src/admin/use-users.ts new file mode 100644 index 00000000..5f610843 --- /dev/null +++ b/frontend/src/admin/use-users.ts @@ -0,0 +1,61 @@ +import {useState} from "react"; +import {useOnce} from "../hooks/use-once"; +import {User, usersFromJson } from "zero-zummon" +import {useNavigate} from "react-router-dom"; + +type UseUserReturn = { + loadingUsers: boolean, + users: User[], + changeUser: (newUser: User) => void, + removeUser: (userId: string) => void, +} + +type UseUserData = { + loadingUser: boolean, + user: User, +} + +export const useUsers = (): UseUserReturn => { + const [loadingUsers, setLoading] = useState(true) + const [users, setUsers] = useState([]) + + const changeUser = (newUser: User) => { + setUsers(users.map(user => user.id.toString() === newUser.id.toString() ? newUser : user)) + } + + useOnce(async () => { + try { + const response = await fetch(import.meta.env.VITE_ZTOR_URL + '/users', { + credentials: 'include', + }) + if (response.status === 401) { + redirectToLogin() + return + } + if (response.status === 500) { + return + } + + setUsers(usersFromJson(await response.text())) + } catch (error) { + alert((error as Error).message) + } finally { + setLoading(false) + } + }) + + const removeUser = (userId: any) => { + setUsers(users.filter(user => user.id.toString() !== userId.toString())) + } + + return { + loadingUsers, + users, + changeUser, + removeUser, + } +} + +export const redirectToLogin = () => { + window.location.href = import.meta.env.VITE_ZTOR_URL + '/login?redirectUrl=' + encodeURIComponent(window.location.href) +} diff --git a/frontend/src/admin/user-form.tsx b/frontend/src/admin/user-form.tsx new file mode 100644 index 00000000..f4c5cb6b --- /dev/null +++ b/frontend/src/admin/user-form.tsx @@ -0,0 +1,127 @@ +import React, { FormEvent, FunctionComponent, useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { PrimeReactProvider } from "primereact/api"; +import { InputText } from "primereact/inputtext"; +import { Button } from "primereact/button"; +import { User } from "zero-zummon"; +import { redirectToLogin } from "./use-users"; + +export const UserForm: FunctionComponent = () => { + const {userId} = useParams<{ userId: string }>(); + const [user, setUser] = useState(null); + const [originalData, setOriginalData] = useState(null); + + const [loading, setLoading] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const navigate = useNavigate(); + + const handleCancel = () => { + if (originalData) { + setUser(originalData); // Revert to original data + } + setIsEditing(false); + }; + + const handleEditToggle = () => { + setIsEditing(true); + }; + + const handleInputChange =(e: React.ChangeEvent) => { + const { name, value } = e.target; + setUser((prev) => ({ ...prev, [name]: value } as User)); + }; + + useEffect(() => { + if (userId) { + const fetchUser = async () => { + setLoading(true); + try { + const response = await fetch(`${import.meta.env.VITE_ZTOR_URL}/users/${userId}`, { + credentials: "include", + }); + if (response.status === 401) { + redirectToLogin(); + return; + } + if (response.ok) { + const userData = await response.json(); + setUser(userData); + setOriginalData(userData); + } else { + alert(`Error fetching user: ${response.statusText}`); + } + } catch (error) { + alert((error as Error).message); + } finally { + setLoading(false); + } + }; + fetchUser(); + } else { + setIsEditing(true); + } + }, [userId]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + try { + const method = userId ? "PUT" : "POST"; + const url = `${import.meta.env.VITE_ZTOR_URL}/users` + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(user), + }); + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.ok) { + navigate(`/users`); + } else { + const errorData = await response.json(); + alert(`Error: ${errorData.message}`); + } + } finally { + setIsEditing(false); + setLoading(false); + } + }; + + return ( + +
+

{userId ? "Edit User" : "Add User"}

+
+ + + +
+ {isEditing ? ( + <> +
+ +
+
+ ); +}; diff --git a/frontend/src/admin/users.tsx b/frontend/src/admin/users.tsx new file mode 100644 index 00000000..b22fb9ff --- /dev/null +++ b/frontend/src/admin/users.tsx @@ -0,0 +1,58 @@ +import React, {FunctionComponent} from "react"; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import {useUsers} from "./use-users"; +import {PrimeReactProvider} from "primereact/api"; +import {User} from "zero-zummon" + +import "primereact/resources/themes/lara-light-cyan/theme.css" +import 'primeicons/primeicons.css' +import {DeleteButton} from "./delete-button"; +import {EditButton} from "./edit-button"; +import {Button} from "primereact/button"; +import {useNavigate} from "react-router-dom" + +export const Users: FunctionComponent = () => { + const {loadingUsers, users, changeUser, removeUser} = useUsers() + const navigate = useNavigate(); + + return ( + +
+

Users List

+
+ + + + ( +
*': { + margin: `${1 / 6}rem` + }, + }}> + + +
+ )}/> +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index a9772f9d..eee5ae20 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -72,6 +72,10 @@ export const ZeroHeader: FunctionComponent = () => { Projects + loadContent('/users')} css={buttonStyle}> + + Users + loadContent('/simulation')} css={buttonStyle}> Simulation diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 24b7bc29..ea8760d1 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -7,7 +7,9 @@ import {LoginWidget} from "./user/login"; import {BedrijvenFormV1} from "./components/bedrijven-form-v1"; import {Surveys} from "./admin/surveys"; import {Projects} from "./admin/projects"; +import {Users} from "./admin/users"; import {ProjectForm} from "./admin/project-form"; +import {UserForm} from "./admin/user-form"; import {fetchSurveyById, SurveyById, SurveyByIdLoaderData} from "./components/company-survey-v2/survey-by-id" import {Intro} from "./components/intro" import {ExcelImport} from "./excel-import/excel-import" @@ -26,6 +28,9 @@ export const router = createBrowserRouter([ {path: "/projects", element: }, {path: "/projects/new-project", element: }, {path: "/projects/:projectId", element: }, + {path: "/users", element: }, + {path: "/users/new-user", element: }, + {path: "/users/:userId", element: }, {path: "/simulation", element: }, ], }, diff --git a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt index 642dc0ef..cda4bec6 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt @@ -267,7 +267,7 @@ class SurveyRepository( val userId = row[CompanySurveyTable.createdById] ?: return null return com.zenmo.zummon.User( - row[UserTable.id].toKotlinUuid(), + row[UserTable.id], row[UserTable.note], ) } diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt deleted file mode 100644 index 918dc2a7..00000000 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zenmo.orm.user - -import com.zenmo.zummon.companysurvey.Project -import java.util.UUID - -data class User( - // Keycloak id - val id: UUID, - val projects: List = emptyList(), - val note: String, -) diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt index 42d25f73..444659f9 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt @@ -1,5 +1,6 @@ package com.zenmo.orm.user +import com.zenmo.zummon.User import com.zenmo.orm.companysurvey.ProjectRepository import com.zenmo.orm.user.table.UserProjectTable import com.zenmo.orm.user.table.UserTable @@ -53,10 +54,24 @@ class UserRepository( } } - fun getUserById(id: UUID): User? { + fun getUserById(id: UUID): User { return getUsers( (UserTable.id eq id) - ).firstOrNull() + ).first() + } + + @OptIn(ExperimentalUuidApi::class) + fun save( + user: User, + ) { + transaction(db) { + UserTable.upsertReturning() { + it[id] = user.id + it[UserTable.note] = user.note + }.map { + hydrateUser(it) + }.first() + } } fun saveUser( diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 0a437bc8..cce4239e 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -10,6 +10,8 @@ import com.zenmo.ztor.errorMessageToJson import com.zenmo.ztor.user.getUserId import com.zenmo.zummon.companysurvey.Survey import com.zenmo.zummon.companysurvey.Project +import com.zenmo.zummon.User + import io.ktor.http.* import io.ktor.serialization.* import io.ktor.server.application.* @@ -28,6 +30,68 @@ fun Application.configureDatabases(): Database { val deeplinkService = DeeplinkService(DeeplinkRepository(db)) routing { + // List users for current user + get("/users") { + call.respond(HttpStatusCode.OK, userRepository.getUsers()) + } + + // Get one user that belongs to the user + get("/users/{userId}") { + val userId = UUID.fromString(call.parameters["userId"]) + + val adminUserId = call.getUserId() + if (adminUserId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + + call.respond(HttpStatusCode.OK, userRepository.getUserById(userId)) + } + + // Create + post("/users") { + val user: User? + try { + user = call.receive() + } catch (e: BadRequestException) { + if (e.cause is JsonConvertException) { + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.cause?.message)) + return@post + } + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.message)) + return@post + } + + val userId = call.getUserId() + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@post + } + + val newUser = userRepository.save(user) + + call.respond(HttpStatusCode.Created, newUser) + } + + // Update + put("/users") { + val user: User? + try { + user = call.receive() + } catch (e: BadRequestException) { + if (e.cause is JsonConvertException) { + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.cause?.message)) + return@put + } + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.message)) + return@put + } + + val newUser = userRepository.save(user) + + call.respond(HttpStatusCode.OK, newUser) + } + // List projects for current user get("/projects") { val userId = call.getUserId() @@ -68,7 +132,6 @@ fun Application.configureDatabases(): Database { } val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@post diff --git a/zummon/src/commonMain/kotlin/User.kt b/zummon/src/commonMain/kotlin/User.kt index cc882379..fb259e94 100644 --- a/zummon/src/commonMain/kotlin/User.kt +++ b/zummon/src/commonMain/kotlin/User.kt @@ -1,9 +1,12 @@ package com.zenmo.zummon + +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import com.zenmo.zummon.BenasherUuidSerializer import com.zenmo.zummon.companysurvey.Project import kotlinx.serialization.Serializable import kotlin.js.JsExport -import kotlin.uuid.Uuid /** * This object is intended to be enriched with Keycloak data. @@ -16,3 +19,13 @@ data class User( val note: String, val projects: List = emptyList() ) + +@JsExport +fun usersFromJson(json: String): Array { + return kotlinx.serialization.json.Json.decodeFromString>(json) +} + +@JsExport +fun userFromJson(json: String): User { + return kotlinx.serialization.json.Json.decodeFromString(json) +} From ef4dc3a111a778d361d4fab27ec7bef789b7386c Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Sat, 11 Jan 2025 16:53:15 -0500 Subject: [PATCH 02/18] Add Admin flag on User Admin flag to determine if the user has admin privileges. --- frontend/src/admin/user-form.tsx | 19 ++++++++++++++-- frontend/src/admin/users.tsx | 14 +++++++++++- migrations/V33__admin_flag_to_user.sql | 1 + .../com/zenmo/orm/user/UserRepository.kt | 7 ++++++ .../kotlin/com/zenmo/orm/user/table/User.kt | 5 +++++ .../com/zenmo/ztor/plugins/Databases.kt | 22 ++++++++++++++++++- zummon/src/commonMain/kotlin/User.kt | 4 ++-- 7 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 migrations/V33__admin_flag_to_user.sql diff --git a/frontend/src/admin/user-form.tsx b/frontend/src/admin/user-form.tsx index f4c5cb6b..2a788d88 100644 --- a/frontend/src/admin/user-form.tsx +++ b/frontend/src/admin/user-form.tsx @@ -27,8 +27,10 @@ export const UserForm: FunctionComponent = () => { }; const handleInputChange =(e: React.ChangeEvent) => { - const { name, value } = e.target; - setUser((prev) => ({ ...prev, [name]: value } as User)); + const { name, value, type, checked } = e.target; + setUser((prev) => ({...prev, + [name]: type === "checkbox" ? checked : value, + } as User)); }; useEffect(() => { @@ -109,6 +111,19 @@ export const UserForm: FunctionComponent = () => { onChange={handleInputChange} disabled={!isEditing} /> +
+ +
{isEditing ? ( diff --git a/frontend/src/admin/users.tsx b/frontend/src/admin/users.tsx index b22fb9ff..e19b1a58 100644 --- a/frontend/src/admin/users.tsx +++ b/frontend/src/admin/users.tsx @@ -40,7 +40,19 @@ export const Users: FunctionComponent = () => { filterDisplay="row" > - + ( +
+ {user.isAdmin ? ( + + ) : ( + + )} +
+ )} + /> (
= emptyList() + val projects: List = emptyList(), + val isAdmin: Boolean = false ) @JsExport From 0902e76378e366812b358263005837a0f0667e12 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Mon, 13 Jan 2025 10:41:10 -0500 Subject: [PATCH 03/18] hide users in menu --- frontend/src/admin/use-users.ts | 6 ----- frontend/src/components/zero-header.tsx | 4 +-- .../com/zenmo/ztor/plugins/Databases.kt | 26 +++++++------------ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/frontend/src/admin/use-users.ts b/frontend/src/admin/use-users.ts index 5f610843..02744110 100644 --- a/frontend/src/admin/use-users.ts +++ b/frontend/src/admin/use-users.ts @@ -1,7 +1,6 @@ import {useState} from "react"; import {useOnce} from "../hooks/use-once"; import {User, usersFromJson } from "zero-zummon" -import {useNavigate} from "react-router-dom"; type UseUserReturn = { loadingUsers: boolean, @@ -10,11 +9,6 @@ type UseUserReturn = { removeUser: (userId: string) => void, } -type UseUserData = { - loadingUser: boolean, - user: User, -} - export const useUsers = (): UseUserReturn => { const [loadingUsers, setLoading] = useState(true) const [users, setUsers] = useState([]) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index eee5ae20..2f7dc351 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -72,10 +72,10 @@ export const ZeroHeader: FunctionComponent = () => { Projects - loadContent('/users')} css={buttonStyle}> + {/* loadContent('/users')} css={buttonStyle}> Users - + */} loadContent('/simulation')} css={buttonStyle}> Simulation diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 24308388..1e3308a1 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -35,23 +35,18 @@ fun Application.configureDatabases(): Database { get("/users") { val userId = call.getUserId() - println("userId " + userId) - - // if (userId == null) { - // call.respond(HttpStatusCode.Unauthorized) - // return@get - // } - - // val isAdmin = userRepository.isAdmin(userId) - // println("isAdmin $isAdmin") + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + val isAdmin = userRepository.isAdmin(userId) - // if (!isAdmin) { - // call.respond(HttpStatusCode.Forbidden, "Access denied") - // return@get - // } + if (!isAdmin) { + call.respond(HttpStatusCode.Forbidden, "Access denied") + return@get + } val users = userRepository.getUsers() - println("All users " + users) call.respond(HttpStatusCode.OK, users) } @@ -128,7 +123,6 @@ fun Application.configureDatabases(): Database { val projectId = UUID.fromString(call.parameters["projectId"]) val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@get @@ -265,7 +259,6 @@ fun Application.configureDatabases(): Database { delete("/company-surveys/{surveyId}") { val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@delete @@ -303,7 +296,6 @@ fun Application.configureDatabases(): Database { // set active state put("/company-surveys/{surveyId}/include-in-simulation") { val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@put From ac2a8493989326d247a51501febb402ba9c63946 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 15 Jan 2025 10:22:14 -0500 Subject: [PATCH 04/18] Update Databases.kt check if user and admin on each end point --- .../com/zenmo/ztor/plugins/Databases.kt | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 1e3308a1..87af1399 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -23,6 +23,8 @@ import io.ktor.server.routing.* import org.jetbrains.exposed.sql.Database import java.util.* + + fun Application.configureDatabases(): Database { val db: Database = connectToPostgres() val userRepository = UserRepository(db) @@ -30,41 +32,47 @@ fun Application.configureDatabases(): Database { val projectRepository = ProjectRepository(db) val deeplinkService = DeeplinkService(DeeplinkRepository(db)) + fun authenticateAndAuthorize(call: ApplicationCall, userRepository: UserRepository): Boolean { + val userId = call.getUserId() + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized, "User not authenticated") + return false + } + + val isAdmin = userRepository.isAdmin(userId) + if (!isAdmin) { + call.respond(HttpStatusCode.Forbidden, "Access denied") + return false + } + + return true + } + routing { // List users for current user - get("/users") { - val userId = call.getUserId() + get("/users") {\ + if (!authenticateAndAuthorize(call, userRepository)) return@get - if (userId == null) { - call.respond(HttpStatusCode.Unauthorized) - return@get - } - val isAdmin = userRepository.isAdmin(userId) - - if (!isAdmin) { - call.respond(HttpStatusCode.Forbidden, "Access denied") - return@get + try { + val users = userRepository.getUsers() + call.respond(HttpStatusCode.OK, users) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, "Failed to fetch users: ${e.message}") } - - val users = userRepository.getUsers() - call.respond(HttpStatusCode.OK, users) } // Get one user that belongs to the user get("/users/{userId}") { - val userId = UUID.fromString(call.parameters["userId"]) - - val adminUserId = call.getUserId() - if (adminUserId == null) { - call.respond(HttpStatusCode.Unauthorized) - return@get - } + if (!authenticateAndAuthorize(call, userRepository)) return@get - call.respond(HttpStatusCode.OK, userRepository.getUserById(userId)) + val user = userRepository.getUserById(userId) + call.respond(HttpStatusCode.OK, user) } // Create post("/users") { + if (!authenticateAndAuthorize(call, userRepository)) return@get + val user: User? try { user = call.receive() @@ -82,6 +90,12 @@ fun Application.configureDatabases(): Database { call.respond(HttpStatusCode.Unauthorized) return@post } + val isAdmin = userRepository.isAdmin(userId) + + if (!isAdmin) { + call.respond(HttpStatusCode.Forbidden, "Access denied") + return@get + } val newUser = userRepository.save(user) @@ -90,6 +104,8 @@ fun Application.configureDatabases(): Database { // Update put("/users") { + if (!authenticateAndAuthorize(call, userRepository)) return@get + val user: User? try { user = call.receive() @@ -308,6 +324,5 @@ fun Application.configureDatabases(): Database { call.respond(HttpStatusCode.OK) } } - return db } From 37454732ce5802237d6706577b7a5d2ab7e2181c Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Mon, 20 Jan 2025 11:49:59 -0500 Subject: [PATCH 05/18] authenticate and authorize all the user endpoints handle general response Delete User --- frontend/src/admin/use-users.ts | 4 +-- .../com/zenmo/ztor/plugins/Databases.kt | 33 +++++++++---------- zummon/src/commonMain/kotlin/User.kt | 6 ++-- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/frontend/src/admin/use-users.ts b/frontend/src/admin/use-users.ts index 02744110..ac7ab132 100644 --- a/frontend/src/admin/use-users.ts +++ b/frontend/src/admin/use-users.ts @@ -26,8 +26,8 @@ export const useUsers = (): UseUserReturn => { redirectToLogin() return } - if (response.status === 500) { - return + if (!response.ok) { + throw new Error(`Could not load users: ${response.status} ${response.statusText}`) } setUsers(usersFromJson(await response.text())) diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 87af1399..1027a2d2 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -32,7 +32,7 @@ fun Application.configureDatabases(): Database { val projectRepository = ProjectRepository(db) val deeplinkService = DeeplinkService(DeeplinkRepository(db)) - fun authenticateAndAuthorize(call: ApplicationCall, userRepository: UserRepository): Boolean { + suspend fun authenticateAndAuthorize(call: ApplicationCall, userRepository: UserRepository): Boolean { val userId = call.getUserId() if (userId == null) { call.respond(HttpStatusCode.Unauthorized, "User not authenticated") @@ -47,10 +47,10 @@ fun Application.configureDatabases(): Database { return true } - + routing { // List users for current user - get("/users") {\ + get("/users") { if (!authenticateAndAuthorize(call, userRepository)) return@get try { @@ -64,14 +64,14 @@ fun Application.configureDatabases(): Database { // Get one user that belongs to the user get("/users/{userId}") { if (!authenticateAndAuthorize(call, userRepository)) return@get - + val userId = UUID.fromString(call.parameters["userId"]) val user = userRepository.getUserById(userId) call.respond(HttpStatusCode.OK, user) } // Create post("/users") { - if (!authenticateAndAuthorize(call, userRepository)) return@get + if (!authenticateAndAuthorize(call, userRepository)) return@post val user: User? try { @@ -85,18 +85,6 @@ fun Application.configureDatabases(): Database { return@post } - val userId = call.getUserId() - if (userId == null) { - call.respond(HttpStatusCode.Unauthorized) - return@post - } - val isAdmin = userRepository.isAdmin(userId) - - if (!isAdmin) { - call.respond(HttpStatusCode.Forbidden, "Access denied") - return@get - } - val newUser = userRepository.save(user) call.respond(HttpStatusCode.Created, newUser) @@ -104,7 +92,7 @@ fun Application.configureDatabases(): Database { // Update put("/users") { - if (!authenticateAndAuthorize(call, userRepository)) return@get + if (!authenticateAndAuthorize(call, userRepository)) return@put val user: User? try { @@ -117,12 +105,21 @@ fun Application.configureDatabases(): Database { call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.message)) return@put } + println(user) val newUser = userRepository.save(user) + println(newUser) call.respond(HttpStatusCode.OK, newUser) } + delete("/users/{userId}") { + if (!authenticateAndAuthorize(call, userRepository)) return@delete + val userId = UUID.fromString(call.parameters["userId"]) + userRepository.deleteUserById(userId) + call.respond(HttpStatusCode.OK) + } + // List projects for current user get("/projects") { val userId = call.getUserId() diff --git a/zummon/src/commonMain/kotlin/User.kt b/zummon/src/commonMain/kotlin/User.kt index 278faf23..6966d06d 100644 --- a/zummon/src/commonMain/kotlin/User.kt +++ b/zummon/src/commonMain/kotlin/User.kt @@ -13,8 +13,10 @@ import kotlin.js.JsExport */ @Serializable @JsExport -data class User( - val id: Uuid = Uuid.random(), +data class User +constructor( + @Serializable(with = BenasherUuidSerializer::class) + val id: Uuid = uuid4(), val note: String, val projects: List = emptyList(), val isAdmin: Boolean = false From d62832198fac37eae1fb050e935c8b8cba8e33f6 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Sat, 11 Jan 2025 13:09:26 -0500 Subject: [PATCH 06/18] List Users Finaaalllyy, list users again in a new branch --- frontend/src/admin/use-users.ts | 61 +++++++++ frontend/src/admin/user-form.tsx | 127 ++++++++++++++++++ frontend/src/admin/users.tsx | 58 ++++++++ frontend/src/components/zero-header.tsx | 4 + frontend/src/router.tsx | 5 + .../orm/companysurvey/SurveyRepository.kt | 2 +- .../main/kotlin/com/zenmo/orm/user/User.kt | 11 -- .../com/zenmo/orm/user/UserRepository.kt | 19 ++- .../com/zenmo/ztor/plugins/Databases.kt | 65 ++++++++- zummon/src/commonMain/kotlin/User.kt | 21 ++- 10 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 frontend/src/admin/use-users.ts create mode 100644 frontend/src/admin/user-form.tsx create mode 100644 frontend/src/admin/users.tsx delete mode 100644 zorm/src/main/kotlin/com/zenmo/orm/user/User.kt diff --git a/frontend/src/admin/use-users.ts b/frontend/src/admin/use-users.ts new file mode 100644 index 00000000..5f610843 --- /dev/null +++ b/frontend/src/admin/use-users.ts @@ -0,0 +1,61 @@ +import {useState} from "react"; +import {useOnce} from "../hooks/use-once"; +import {User, usersFromJson } from "zero-zummon" +import {useNavigate} from "react-router-dom"; + +type UseUserReturn = { + loadingUsers: boolean, + users: User[], + changeUser: (newUser: User) => void, + removeUser: (userId: string) => void, +} + +type UseUserData = { + loadingUser: boolean, + user: User, +} + +export const useUsers = (): UseUserReturn => { + const [loadingUsers, setLoading] = useState(true) + const [users, setUsers] = useState([]) + + const changeUser = (newUser: User) => { + setUsers(users.map(user => user.id.toString() === newUser.id.toString() ? newUser : user)) + } + + useOnce(async () => { + try { + const response = await fetch(import.meta.env.VITE_ZTOR_URL + '/users', { + credentials: 'include', + }) + if (response.status === 401) { + redirectToLogin() + return + } + if (response.status === 500) { + return + } + + setUsers(usersFromJson(await response.text())) + } catch (error) { + alert((error as Error).message) + } finally { + setLoading(false) + } + }) + + const removeUser = (userId: any) => { + setUsers(users.filter(user => user.id.toString() !== userId.toString())) + } + + return { + loadingUsers, + users, + changeUser, + removeUser, + } +} + +export const redirectToLogin = () => { + window.location.href = import.meta.env.VITE_ZTOR_URL + '/login?redirectUrl=' + encodeURIComponent(window.location.href) +} diff --git a/frontend/src/admin/user-form.tsx b/frontend/src/admin/user-form.tsx new file mode 100644 index 00000000..f4c5cb6b --- /dev/null +++ b/frontend/src/admin/user-form.tsx @@ -0,0 +1,127 @@ +import React, { FormEvent, FunctionComponent, useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { PrimeReactProvider } from "primereact/api"; +import { InputText } from "primereact/inputtext"; +import { Button } from "primereact/button"; +import { User } from "zero-zummon"; +import { redirectToLogin } from "./use-users"; + +export const UserForm: FunctionComponent = () => { + const {userId} = useParams<{ userId: string }>(); + const [user, setUser] = useState(null); + const [originalData, setOriginalData] = useState(null); + + const [loading, setLoading] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const navigate = useNavigate(); + + const handleCancel = () => { + if (originalData) { + setUser(originalData); // Revert to original data + } + setIsEditing(false); + }; + + const handleEditToggle = () => { + setIsEditing(true); + }; + + const handleInputChange =(e: React.ChangeEvent) => { + const { name, value } = e.target; + setUser((prev) => ({ ...prev, [name]: value } as User)); + }; + + useEffect(() => { + if (userId) { + const fetchUser = async () => { + setLoading(true); + try { + const response = await fetch(`${import.meta.env.VITE_ZTOR_URL}/users/${userId}`, { + credentials: "include", + }); + if (response.status === 401) { + redirectToLogin(); + return; + } + if (response.ok) { + const userData = await response.json(); + setUser(userData); + setOriginalData(userData); + } else { + alert(`Error fetching user: ${response.statusText}`); + } + } catch (error) { + alert((error as Error).message); + } finally { + setLoading(false); + } + }; + fetchUser(); + } else { + setIsEditing(true); + } + }, [userId]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setLoading(true); + try { + const method = userId ? "PUT" : "POST"; + const url = `${import.meta.env.VITE_ZTOR_URL}/users` + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(user), + }); + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.ok) { + navigate(`/users`); + } else { + const errorData = await response.json(); + alert(`Error: ${errorData.message}`); + } + } finally { + setIsEditing(false); + setLoading(false); + } + }; + + return ( + +
+

{userId ? "Edit User" : "Add User"}

+
+ + + +
+ {isEditing ? ( + <> +
+ +
+
+ ); +}; diff --git a/frontend/src/admin/users.tsx b/frontend/src/admin/users.tsx new file mode 100644 index 00000000..b22fb9ff --- /dev/null +++ b/frontend/src/admin/users.tsx @@ -0,0 +1,58 @@ +import React, {FunctionComponent} from "react"; +import { DataTable } from 'primereact/datatable'; +import { Column } from 'primereact/column'; +import {useUsers} from "./use-users"; +import {PrimeReactProvider} from "primereact/api"; +import {User} from "zero-zummon" + +import "primereact/resources/themes/lara-light-cyan/theme.css" +import 'primeicons/primeicons.css' +import {DeleteButton} from "./delete-button"; +import {EditButton} from "./edit-button"; +import {Button} from "primereact/button"; +import {useNavigate} from "react-router-dom" + +export const Users: FunctionComponent = () => { + const {loadingUsers, users, changeUser, removeUser} = useUsers() + const navigate = useNavigate(); + + return ( + +
+

Users List

+
+ + + + ( +
*': { + margin: `${1 / 6}rem` + }, + }}> + + +
+ )}/> +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index a9772f9d..eee5ae20 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -72,6 +72,10 @@ export const ZeroHeader: FunctionComponent = () => { Projects
+ loadContent('/users')} css={buttonStyle}> + + Users + loadContent('/simulation')} css={buttonStyle}> Simulation diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 24b7bc29..ea8760d1 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -7,7 +7,9 @@ import {LoginWidget} from "./user/login"; import {BedrijvenFormV1} from "./components/bedrijven-form-v1"; import {Surveys} from "./admin/surveys"; import {Projects} from "./admin/projects"; +import {Users} from "./admin/users"; import {ProjectForm} from "./admin/project-form"; +import {UserForm} from "./admin/user-form"; import {fetchSurveyById, SurveyById, SurveyByIdLoaderData} from "./components/company-survey-v2/survey-by-id" import {Intro} from "./components/intro" import {ExcelImport} from "./excel-import/excel-import" @@ -26,6 +28,9 @@ export const router = createBrowserRouter([ {path: "/projects", element: }, {path: "/projects/new-project", element: }, {path: "/projects/:projectId", element: }, + {path: "/users", element: }, + {path: "/users/new-user", element: }, + {path: "/users/:userId", element: }, {path: "/simulation", element: }, ], }, diff --git a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt index 642dc0ef..cda4bec6 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt @@ -267,7 +267,7 @@ class SurveyRepository( val userId = row[CompanySurveyTable.createdById] ?: return null return com.zenmo.zummon.User( - row[UserTable.id].toKotlinUuid(), + row[UserTable.id], row[UserTable.note], ) } diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt deleted file mode 100644 index 918dc2a7..00000000 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/User.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zenmo.orm.user - -import com.zenmo.zummon.companysurvey.Project -import java.util.UUID - -data class User( - // Keycloak id - val id: UUID, - val projects: List = emptyList(), - val note: String, -) diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt index 42d25f73..444659f9 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt @@ -1,5 +1,6 @@ package com.zenmo.orm.user +import com.zenmo.zummon.User import com.zenmo.orm.companysurvey.ProjectRepository import com.zenmo.orm.user.table.UserProjectTable import com.zenmo.orm.user.table.UserTable @@ -53,10 +54,24 @@ class UserRepository( } } - fun getUserById(id: UUID): User? { + fun getUserById(id: UUID): User { return getUsers( (UserTable.id eq id) - ).firstOrNull() + ).first() + } + + @OptIn(ExperimentalUuidApi::class) + fun save( + user: User, + ) { + transaction(db) { + UserTable.upsertReturning() { + it[id] = user.id + it[UserTable.note] = user.note + }.map { + hydrateUser(it) + }.first() + } } fun saveUser( diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 0a437bc8..cce4239e 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -10,6 +10,8 @@ import com.zenmo.ztor.errorMessageToJson import com.zenmo.ztor.user.getUserId import com.zenmo.zummon.companysurvey.Survey import com.zenmo.zummon.companysurvey.Project +import com.zenmo.zummon.User + import io.ktor.http.* import io.ktor.serialization.* import io.ktor.server.application.* @@ -28,6 +30,68 @@ fun Application.configureDatabases(): Database { val deeplinkService = DeeplinkService(DeeplinkRepository(db)) routing { + // List users for current user + get("/users") { + call.respond(HttpStatusCode.OK, userRepository.getUsers()) + } + + // Get one user that belongs to the user + get("/users/{userId}") { + val userId = UUID.fromString(call.parameters["userId"]) + + val adminUserId = call.getUserId() + if (adminUserId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + + call.respond(HttpStatusCode.OK, userRepository.getUserById(userId)) + } + + // Create + post("/users") { + val user: User? + try { + user = call.receive() + } catch (e: BadRequestException) { + if (e.cause is JsonConvertException) { + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.cause?.message)) + return@post + } + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.message)) + return@post + } + + val userId = call.getUserId() + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@post + } + + val newUser = userRepository.save(user) + + call.respond(HttpStatusCode.Created, newUser) + } + + // Update + put("/users") { + val user: User? + try { + user = call.receive() + } catch (e: BadRequestException) { + if (e.cause is JsonConvertException) { + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.cause?.message)) + return@put + } + call.respond(HttpStatusCode.BadRequest, errorMessageToJson(e.message)) + return@put + } + + val newUser = userRepository.save(user) + + call.respond(HttpStatusCode.OK, newUser) + } + // List projects for current user get("/projects") { val userId = call.getUserId() @@ -68,7 +132,6 @@ fun Application.configureDatabases(): Database { } val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@post diff --git a/zummon/src/commonMain/kotlin/User.kt b/zummon/src/commonMain/kotlin/User.kt index cc882379..0ac1339f 100644 --- a/zummon/src/commonMain/kotlin/User.kt +++ b/zummon/src/commonMain/kotlin/User.kt @@ -1,9 +1,12 @@ package com.zenmo.zummon + +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import com.zenmo.zummon.BenasherUuidSerializer import com.zenmo.zummon.companysurvey.Project import kotlinx.serialization.Serializable import kotlin.js.JsExport -import kotlin.uuid.Uuid /** * This object is intended to be enriched with Keycloak data. @@ -11,8 +14,20 @@ import kotlin.uuid.Uuid */ @Serializable @JsExport -data class User( - val id: Uuid = Uuid.random(), +data class User +constructor( + @Serializable(with = BenasherUuidSerializer::class) + val id: Uuid = uuid4(), val note: String, val projects: List = emptyList() ) + +@JsExport +fun usersFromJson(json: String): Array { + return kotlinx.serialization.json.Json.decodeFromString>(json) +} + +@JsExport +fun userFromJson(json: String): User { + return kotlinx.serialization.json.Json.decodeFromString(json) +} From 35cc0bf69a18107295c9beab6a3af4064868b473 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Sat, 11 Jan 2025 16:53:15 -0500 Subject: [PATCH 07/18] Add Admin flag on User Admin flag to determine if the user has admin privileges. --- frontend/src/admin/user-form.tsx | 19 ++++++++++++++-- frontend/src/admin/users.tsx | 14 +++++++++++- migrations/V33__admin_flag_to_user.sql | 1 + .../com/zenmo/orm/user/UserRepository.kt | 7 ++++++ .../kotlin/com/zenmo/orm/user/table/User.kt | 5 +++++ .../com/zenmo/ztor/plugins/Databases.kt | 22 ++++++++++++++++++- zummon/src/commonMain/kotlin/User.kt | 4 ++-- 7 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 migrations/V33__admin_flag_to_user.sql diff --git a/frontend/src/admin/user-form.tsx b/frontend/src/admin/user-form.tsx index f4c5cb6b..2a788d88 100644 --- a/frontend/src/admin/user-form.tsx +++ b/frontend/src/admin/user-form.tsx @@ -27,8 +27,10 @@ export const UserForm: FunctionComponent = () => { }; const handleInputChange =(e: React.ChangeEvent) => { - const { name, value } = e.target; - setUser((prev) => ({ ...prev, [name]: value } as User)); + const { name, value, type, checked } = e.target; + setUser((prev) => ({...prev, + [name]: type === "checkbox" ? checked : value, + } as User)); }; useEffect(() => { @@ -109,6 +111,19 @@ export const UserForm: FunctionComponent = () => { onChange={handleInputChange} disabled={!isEditing} /> +
+ +
{isEditing ? ( diff --git a/frontend/src/admin/users.tsx b/frontend/src/admin/users.tsx index b22fb9ff..e19b1a58 100644 --- a/frontend/src/admin/users.tsx +++ b/frontend/src/admin/users.tsx @@ -40,7 +40,19 @@ export const Users: FunctionComponent = () => { filterDisplay="row" > - + ( +
+ {user.isAdmin ? ( + + ) : ( + + )} +
+ )} + /> (
= emptyList() + val projects: List = emptyList(), + val isAdmin: Boolean = false ) @JsExport From 19be1c799ccae2592d396cea65a46288f081141f Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Mon, 13 Jan 2025 10:41:10 -0500 Subject: [PATCH 08/18] hide users in menu --- frontend/src/admin/use-users.ts | 6 ----- frontend/src/components/zero-header.tsx | 4 +-- .../com/zenmo/ztor/plugins/Databases.kt | 26 +++++++------------ 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/frontend/src/admin/use-users.ts b/frontend/src/admin/use-users.ts index 5f610843..02744110 100644 --- a/frontend/src/admin/use-users.ts +++ b/frontend/src/admin/use-users.ts @@ -1,7 +1,6 @@ import {useState} from "react"; import {useOnce} from "../hooks/use-once"; import {User, usersFromJson } from "zero-zummon" -import {useNavigate} from "react-router-dom"; type UseUserReturn = { loadingUsers: boolean, @@ -10,11 +9,6 @@ type UseUserReturn = { removeUser: (userId: string) => void, } -type UseUserData = { - loadingUser: boolean, - user: User, -} - export const useUsers = (): UseUserReturn => { const [loadingUsers, setLoading] = useState(true) const [users, setUsers] = useState([]) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index eee5ae20..2f7dc351 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -72,10 +72,10 @@ export const ZeroHeader: FunctionComponent = () => { Projects - loadContent('/users')} css={buttonStyle}> + {/* loadContent('/users')} css={buttonStyle}> Users - + */} loadContent('/simulation')} css={buttonStyle}> Simulation diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index 24308388..1e3308a1 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -35,23 +35,18 @@ fun Application.configureDatabases(): Database { get("/users") { val userId = call.getUserId() - println("userId " + userId) - - // if (userId == null) { - // call.respond(HttpStatusCode.Unauthorized) - // return@get - // } - - // val isAdmin = userRepository.isAdmin(userId) - // println("isAdmin $isAdmin") + if (userId == null) { + call.respond(HttpStatusCode.Unauthorized) + return@get + } + val isAdmin = userRepository.isAdmin(userId) - // if (!isAdmin) { - // call.respond(HttpStatusCode.Forbidden, "Access denied") - // return@get - // } + if (!isAdmin) { + call.respond(HttpStatusCode.Forbidden, "Access denied") + return@get + } val users = userRepository.getUsers() - println("All users " + users) call.respond(HttpStatusCode.OK, users) } @@ -128,7 +123,6 @@ fun Application.configureDatabases(): Database { val projectId = UUID.fromString(call.parameters["projectId"]) val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@get @@ -265,7 +259,6 @@ fun Application.configureDatabases(): Database { delete("/company-surveys/{surveyId}") { val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@delete @@ -303,7 +296,6 @@ fun Application.configureDatabases(): Database { // set active state put("/company-surveys/{surveyId}/include-in-simulation") { val userId = call.getUserId() - println("User ID: $userId") if (userId == null) { call.respond(HttpStatusCode.Unauthorized) return@put From 3e4de10fff973e0534cd3ad03cfc1d56751c2b96 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Tue, 14 Jan 2025 23:37:11 -0500 Subject: [PATCH 09/18] show is admin in menu --- frontend/src/components/zero-header.tsx | 13 +++-- frontend/src/user/use-user.ts | 65 +++++++++++++++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index 2f7dc351..710bab37 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -3,6 +3,7 @@ import {Button} from "primereact/button"; import {Sidebar} from "primereact/sidebar"; import {css} from "@emotion/react"; import {To, useNavigate} from "react-router-dom"; +import {useUser} from "../user/use-user"; const sidebarStyle = css({ width: '16rem', @@ -31,6 +32,8 @@ const buttonStyle = css({ }); export const ZeroHeader: FunctionComponent = () => { + const { isLoading, isLoggedIn, username, isAdmin } = useUser() + const [visible, setVisible] = useState(false); const navigate = useNavigate(); @@ -72,10 +75,12 @@ export const ZeroHeader: FunctionComponent = () => { Projects - {/* loadContent('/users')} css={buttonStyle}> - - Users - */} + {isAdmin && ( + loadContent('/users')} css={buttonStyle}> + + Users + + )} loadContent('/simulation')} css={buttonStyle}> Simulation diff --git a/frontend/src/user/use-user.ts b/frontend/src/user/use-user.ts index b8936023..5caa35e9 100644 --- a/frontend/src/user/use-user.ts +++ b/frontend/src/user/use-user.ts @@ -1,36 +1,77 @@ import {useOnce} from "../hooks/use-once"; -import {useState} from "react"; +import {useState, useEffect} from "react"; +import {User, userFromJson } from "zero-zummon" type UseUserReturn = { isLoading: boolean, isLoggedIn?: boolean, username?: string, + isAdmin?: boolean, } export const useUser = (): UseUserReturn => { + const [userId, setUserId] = useState() + const [state, setState] = useState({ isLoading: true, isLoggedIn: undefined, username: undefined, + isAdmin: false, }) + useEffect(() => { + if (userId) { + const fetchUser = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_ZTOR_URL}/users/${userId}`, { + credentials: 'include', + }) + if (response.ok) { + const userData = await response.json(); + setState((prevState) => ({ + ...prevState, + isLoading: false, + isLoggedIn: true, + isAdmin: userData.isAdmin || false, + })); + } else { + alert(`Error fetching user: ${response.statusText}`); + } + } catch (error) { + alert((error as Error).message) + } finally { + setState({isLoading: false}) + } + }; + fetchUser(); + } + }, [userId]); + + useOnce(async () => { try { const response = await fetch(import.meta.env.VITE_ZTOR_URL + "/user-info", { credentials: "include", }) - if (response.status == 401) { - setState({ - isLoading: false, - isLoggedIn: false, - }) - } else { - const userInfo: any = await response.json() - setState({ + + if (response.status === 401) { + redirectToLogin() + return + } + if (response.status === 500) { + return + } + + if (response.ok) { + const userInfo = await response.json(); + setUserId(userInfo.sub); + + setState((prevState) => ({ + ...prevState, isLoading: false, isLoggedIn: true, username: userInfo.preferred_username, - }) + })); } } catch (e) { console.error(e) @@ -43,4 +84,8 @@ export const useUser = (): UseUserReturn => { }) return state +} + +export const redirectToLogin = () => { + window.location.href = import.meta.env.VITE_ZTOR_URL + '/login?redirectUrl=' + encodeURIComponent(window.location.href) } \ No newline at end of file From c298179efbed9d500d5545a9bd8035a12b274195 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Mon, 20 Jan 2025 16:50:14 -0500 Subject: [PATCH 10/18] Load is admin on user info --- compose.yaml | 4 +- frontend/src/user/use-user.ts | 38 +++---------------- .../com/zenmo/ztor/plugins/Authentication.kt | 18 ++++++++- .../kotlin/com/zenmo/ztor/user/userInfo.kt | 8 ++++ 4 files changed, 31 insertions(+), 37 deletions(-) create mode 100644 ztor/src/main/kotlin/com/zenmo/ztor/user/userInfo.kt diff --git a/compose.yaml b/compose.yaml index 5c561e69..baecf7fd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -232,7 +232,7 @@ services: - 127.0.0.1:2015:2015 postgres: - image: postgres:17.2 + image: postgres:13.9 ports: - 127.0.0.1:5432:5432 volumes: @@ -242,7 +242,7 @@ services: - docker/local/postgres.env postgres-github-actions: - image: postgres:17.2 + image: postgres:13.9 environment: POSTGRES_PASSWORD: github-actions diff --git a/frontend/src/user/use-user.ts b/frontend/src/user/use-user.ts index 5caa35e9..c1319936 100644 --- a/frontend/src/user/use-user.ts +++ b/frontend/src/user/use-user.ts @@ -19,35 +19,6 @@ export const useUser = (): UseUserReturn => { isAdmin: false, }) - useEffect(() => { - if (userId) { - const fetchUser = async () => { - try { - const response = await fetch(`${import.meta.env.VITE_ZTOR_URL}/users/${userId}`, { - credentials: 'include', - }) - if (response.ok) { - const userData = await response.json(); - setState((prevState) => ({ - ...prevState, - isLoading: false, - isLoggedIn: true, - isAdmin: userData.isAdmin || false, - })); - } else { - alert(`Error fetching user: ${response.statusText}`); - } - } catch (error) { - alert((error as Error).message) - } finally { - setState({isLoading: false}) - } - }; - fetchUser(); - } - }, [userId]); - - useOnce(async () => { try { const response = await fetch(import.meta.env.VITE_ZTOR_URL + "/user-info", { @@ -58,19 +29,20 @@ export const useUser = (): UseUserReturn => { redirectToLogin() return } - if (response.status === 500) { - return + if (!response.ok) { + throw new Error(`Failed to get user: ${response.statusText}`) } if (response.ok) { const userInfo = await response.json(); - setUserId(userInfo.sub); + setUserId(userInfo.decodedAccessToken.sub); setState((prevState) => ({ ...prevState, isLoading: false, isLoggedIn: true, - username: userInfo.preferred_username, + username: userInfo.decodedAccessToken.preferred_username, + isAdmin: userInfo.isAdmin })); } } catch (e) { diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Authentication.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Authentication.kt index a9260863..563dfadb 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Authentication.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Authentication.kt @@ -1,6 +1,7 @@ package com.zenmo.ztor.plugins import com.zenmo.ztor.user.UserSession +import com.zenmo.ztor.user.UserInfo import com.zenmo.ztor.user.decodeAccessToken import io.ktor.client.* import io.ktor.client.* @@ -18,6 +19,9 @@ import io.ktor.server.plugins.forwardedheaders.* import kotlinx.html.a import kotlinx.html.body import kotlinx.html.p +import com.zenmo.orm.connectToPostgres +import org.jetbrains.exposed.sql.Database +import com.zenmo.orm.user.UserRepository val applicationHttpClient = HttpClient(CIO) { install(ContentNegotiation) { @@ -29,6 +33,9 @@ val applicationHttpClient = HttpClient(CIO) { * After https://ktor.io/docs/server-oauth.html */ fun Application.configureAuthentication() { + val db: Database = connectToPostgres() + val userRepository = UserRepository(db) + // This reads the X-Forwarded-Proto header. // This allows us to set the secure cookie below. install(XForwardedHeaders) @@ -95,11 +102,18 @@ fun Application.configureAuthentication() { } get("/user-info") { val userSession: UserSession? = call.sessions.get() + if (userSession == null) { - // frontend can show login button call.respondText("Not logged in", status=HttpStatusCode.Unauthorized) } else { - call.respond(userSession.getDecodedAccessToken()) + val userId = userSession.getUserId() + val isAdmin = userRepository.isAdmin(userId) + + val response = UserInfo( + isAdmin = isAdmin, + decodedAccessToken = userSession.getDecodedAccessToken() + ) + call.respond(response) } } get("/home") { diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/user/userInfo.kt b/ztor/src/main/kotlin/com/zenmo/ztor/user/userInfo.kt new file mode 100644 index 00000000..5d44556d --- /dev/null +++ b/ztor/src/main/kotlin/com/zenmo/ztor/user/userInfo.kt @@ -0,0 +1,8 @@ +package com.zenmo.ztor.user +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfo( + val isAdmin: Boolean, + val decodedAccessToken: AccessTokenPayload +) From 00a3c653b44a3eabf8be2dc1a8226688a69d5a35 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Tue, 21 Jan 2025 13:53:10 -0500 Subject: [PATCH 11/18] fix type --- zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt index ec1db042..2feae8a1 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt @@ -113,7 +113,7 @@ class UserRepository( protected fun hydrateUser(row: ResultRow): User { return User( - id = row[UserTable.id].toKotlinUuid(), + id = row[UserTable.id], note = row[UserTable.note], isAdmin = row[UserTable.isAdmin], projects = emptyList(), // data from different table From 35b638b28f293e7f77247d000248ce757bf6116e Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Tue, 21 Jan 2025 14:00:10 -0500 Subject: [PATCH 12/18] fix test --- .../com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt b/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt index ed33ed65..893f0e8b 100644 --- a/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt +++ b/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt @@ -159,7 +159,7 @@ class SurveyRepositoryTest { val createdBy = surveyLoadedAfterCreate?.createdBy assertNotNull(createdBy) assertEquals("Jaap", createdBy.note) - assertEquals(jaapId.toKotlinUuid(), createdBy.id) + assertEquals(jaapId, createdBy.id) // edit survey surveyRepository.save(surveyLoadedAfterCreate, pietId) @@ -167,7 +167,7 @@ class SurveyRepositoryTest { val createdBy2 = surveyLoadedAfterEdit?.createdBy assertNotNull(createdBy2) assertEquals("Jaap", createdBy2.note) - assertEquals(jaapId.toKotlinUuid(), createdBy2.id) + assertEquals(jaapId, createdBy2.id) } private fun wipeSequence(survey: Survey) From e81255550842f87e2999afdbdf1a2acd48a74626 Mon Sep 17 00:00:00 2001 From: Erik van Velzen Date: Tue, 21 Jan 2025 22:13:10 +0100 Subject: [PATCH 13/18] Align uuid types --- .../com/zenmo/orm/companysurvey/SurveyRepository.kt | 2 +- .../main/kotlin/com/zenmo/orm/user/UserRepository.kt | 4 ++-- .../zenmo/orm/companysurvey/SurveyRepositoryTest.kt | 4 ++-- zummon/src/commonMain/kotlin/User.kt | 10 +++------- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt index cda4bec6..642dc0ef 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/companysurvey/SurveyRepository.kt @@ -267,7 +267,7 @@ class SurveyRepository( val userId = row[CompanySurveyTable.createdById] ?: return null return com.zenmo.zummon.User( - row[UserTable.id], + row[UserTable.id].toKotlinUuid(), row[UserTable.note], ) } diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt index 2feae8a1..16bf213e 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt @@ -68,7 +68,7 @@ class UserRepository( ) { transaction(db) { UserTable.upsertReturning() { - it[id] = user.id + it[id] = user.id.toJavaUuid() it[UserTable.note] = user.note it[UserTable.isAdmin] = user.isAdmin }.map { @@ -113,7 +113,7 @@ class UserRepository( protected fun hydrateUser(row: ResultRow): User { return User( - id = row[UserTable.id], + id = row[UserTable.id].toKotlinUuid(), note = row[UserTable.note], isAdmin = row[UserTable.isAdmin], projects = emptyList(), // data from different table diff --git a/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt b/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt index 893f0e8b..ed33ed65 100644 --- a/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt +++ b/zorm/src/test/kotlin/com/zenmo/orm/companysurvey/SurveyRepositoryTest.kt @@ -159,7 +159,7 @@ class SurveyRepositoryTest { val createdBy = surveyLoadedAfterCreate?.createdBy assertNotNull(createdBy) assertEquals("Jaap", createdBy.note) - assertEquals(jaapId, createdBy.id) + assertEquals(jaapId.toKotlinUuid(), createdBy.id) // edit survey surveyRepository.save(surveyLoadedAfterCreate, pietId) @@ -167,7 +167,7 @@ class SurveyRepositoryTest { val createdBy2 = surveyLoadedAfterEdit?.createdBy assertNotNull(createdBy2) assertEquals("Jaap", createdBy2.note) - assertEquals(jaapId, createdBy2.id) + assertEquals(jaapId.toKotlinUuid(), createdBy2.id) } private fun wipeSequence(survey: Survey) diff --git a/zummon/src/commonMain/kotlin/User.kt b/zummon/src/commonMain/kotlin/User.kt index 6966d06d..6bf576c8 100644 --- a/zummon/src/commonMain/kotlin/User.kt +++ b/zummon/src/commonMain/kotlin/User.kt @@ -1,11 +1,9 @@ package com.zenmo.zummon -import com.benasher44.uuid.Uuid -import com.benasher44.uuid.uuid4 -import com.zenmo.zummon.BenasherUuidSerializer import com.zenmo.zummon.companysurvey.Project import kotlinx.serialization.Serializable import kotlin.js.JsExport +import kotlin.uuid.Uuid /** * This object is intended to be enriched with Keycloak data. @@ -13,10 +11,8 @@ import kotlin.js.JsExport */ @Serializable @JsExport -data class User -constructor( - @Serializable(with = BenasherUuidSerializer::class) - val id: Uuid = uuid4(), +data class User( + val id: Uuid = Uuid.random(), val note: String, val projects: List = emptyList(), val isAdmin: Boolean = false From 4653fa2f5a084dfc6cbe6f266d15eed8643dd554 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 22 Jan 2025 10:00:26 -0500 Subject: [PATCH 14/18] clean code --- frontend/src/user/use-user.ts | 22 ++++++++----------- .../com/zenmo/ztor/plugins/Databases.kt | 18 --------------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/frontend/src/user/use-user.ts b/frontend/src/user/use-user.ts index c1319936..47cbac9d 100644 --- a/frontend/src/user/use-user.ts +++ b/frontend/src/user/use-user.ts @@ -1,6 +1,6 @@ import {useOnce} from "../hooks/use-once"; -import {useState, useEffect} from "react"; -import {User, userFromJson } from "zero-zummon" +import {useState} from "react"; +import { redirectToLogin } from "../admin/use-users"; type UseUserReturn = { isLoading: boolean, @@ -10,8 +10,6 @@ type UseUserReturn = { } export const useUser = (): UseUserReturn => { - const [userId, setUserId] = useState() - const [state, setState] = useState({ isLoading: true, isLoggedIn: undefined, @@ -25,17 +23,19 @@ export const useUser = (): UseUserReturn => { credentials: "include", }) - if (response.status === 401) { - redirectToLogin() - return - } if (!response.ok) { throw new Error(`Failed to get user: ${response.statusText}`) } + + if (response.status === 500) { + setState(prevState => ({ + ...prevState, + isAdmin: false, + })) + } if (response.ok) { const userInfo = await response.json(); - setUserId(userInfo.decodedAccessToken.sub); setState((prevState) => ({ ...prevState, @@ -56,8 +56,4 @@ export const useUser = (): UseUserReturn => { }) return state -} - -export const redirectToLogin = () => { - window.location.href = import.meta.env.VITE_ZTOR_URL + '/login?redirectUrl=' + encodeURIComponent(window.location.href) } \ No newline at end of file diff --git a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt index a0f30132..5848cd14 100644 --- a/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt +++ b/ztor/src/main/kotlin/com/zenmo/ztor/plugins/Databases.kt @@ -23,8 +23,6 @@ import io.ktor.server.routing.* import org.jetbrains.exposed.sql.Database import java.util.* - - fun Application.configureDatabases(): Database { val db: Database = connectToPostgres() val userRepository = UserRepository(db) @@ -32,22 +30,6 @@ fun Application.configureDatabases(): Database { val projectRepository = ProjectRepository(db) val deeplinkService = DeeplinkService(DeeplinkRepository(db)) - suspend fun authenticateAndAuthorize(call: ApplicationCall, userRepository: UserRepository): Boolean { - val userId = call.getUserId() - if (userId == null) { - call.respond(HttpStatusCode.Unauthorized, "User not authenticated") - return false - } - - val isAdmin = userRepository.isAdmin(userId) - if (!isAdmin) { - call.respond(HttpStatusCode.Forbidden, "Access denied") - return false - } - - return true - } - routing { suspend fun RoutingContext.asAdmin(body: suspend () -> Unit) { val userId = call.getUserId() From 46988bc4349a05cdd27588edd2649b0ae7ab03e0 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 22 Jan 2025 10:34:54 -0500 Subject: [PATCH 15/18] Add log in buttom --- frontend/src/components/zero-header.tsx | 32 ++++++++++++++++++------- frontend/src/user/use-user.ts | 3 +-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/zero-header.tsx b/frontend/src/components/zero-header.tsx index 710bab37..9f9d8267 100644 --- a/frontend/src/components/zero-header.tsx +++ b/frontend/src/components/zero-header.tsx @@ -4,6 +4,7 @@ import {Sidebar} from "primereact/sidebar"; import {css} from "@emotion/react"; import {To, useNavigate} from "react-router-dom"; import {useUser} from "../user/use-user"; +import { redirectToLogin } from "../admin/use-users"; const sidebarStyle = css({ width: '16rem', @@ -43,6 +44,7 @@ export const ZeroHeader: FunctionComponent = () => { } return ( setVisible(false)} css={sidebarStyle}> diff --git a/frontend/src/user/use-user.ts b/frontend/src/user/use-user.ts index 47cbac9d..45cb2604 100644 --- a/frontend/src/user/use-user.ts +++ b/frontend/src/user/use-user.ts @@ -1,6 +1,5 @@ import {useOnce} from "../hooks/use-once"; import {useState} from "react"; -import { redirectToLogin } from "../admin/use-users"; type UseUserReturn = { isLoading: boolean, @@ -26,7 +25,7 @@ export const useUser = (): UseUserReturn => { if (!response.ok) { throw new Error(`Failed to get user: ${response.statusText}`) } - + if (response.status === 500) { setState(prevState => ({ ...prevState, From 167f7fabe737b273a05b0cb1b13ca66619cc900a Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 22 Jan 2025 10:37:27 -0500 Subject: [PATCH 16/18] change version --- compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose.yaml b/compose.yaml index baecf7fd..5c561e69 100644 --- a/compose.yaml +++ b/compose.yaml @@ -232,7 +232,7 @@ services: - 127.0.0.1:2015:2015 postgres: - image: postgres:13.9 + image: postgres:17.2 ports: - 127.0.0.1:5432:5432 volumes: @@ -242,7 +242,7 @@ services: - docker/local/postgres.env postgres-github-actions: - image: postgres:13.9 + image: postgres:17.2 environment: POSTGRES_PASSWORD: github-actions From 466685fab2c6df5b638d3fa64bab054ad6cffa98 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Wed, 22 Jan 2025 12:49:42 -0500 Subject: [PATCH 17/18] change version join --- frontend/src/admin/use-projects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/admin/use-projects.ts b/frontend/src/admin/use-projects.ts index d45749c2..b1f1a6ee 100644 --- a/frontend/src/admin/use-projects.ts +++ b/frontend/src/admin/use-projects.ts @@ -33,7 +33,7 @@ export const useProjects = (): UseProjectReturn => { return } if (response.status === 500) { - return + throw new Error(`Failed: ${response.statusText}`) } setProjects(projectsFromJson(await response.text())) From 5af51dd45cc0455a60dcbf72c98ef74f0ce79aa0 Mon Sep 17 00:00:00 2001 From: Maria Cano Date: Fri, 24 Jan 2025 14:31:50 -0500 Subject: [PATCH 18/18] NoSuchElementException --- zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt index 16bf213e..4ae34b10 100644 --- a/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt +++ b/zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt @@ -107,7 +107,12 @@ class UserRepository( } fun isAdmin(userId: UUID): Boolean { - val user = getUserById(userId) + val user = try { + getUserById(userId) + } catch (e: NoSuchElementException) { + null + } + return user?.isAdmin ?: false }