Skip to content

Commit

Permalink
Display users (#212)
Browse files Browse the repository at this point in the history
* List Users

Finaaalllyy, list users again in a new branch

* Add Admin flag on User

Admin flag to determine if the user has admin privileges.

* hide users in menu

* Update Databases.kt

check if user and admin on each end point

* authenticate and authorize all the user endpoints

handle general response

Delete User

* Remove benasher44/uuid

* refactor authentication

* Allow to enter Keycloak ID

Still hidden in the table, only to create or edit.

---------

Co-authored-by: Erik van Velzen <[email protected]>
  • Loading branch information
macano and Erikvv authored Jan 21, 2025
1 parent c47b12c commit 2b5a6f3
Show file tree
Hide file tree
Showing 13 changed files with 418 additions and 33 deletions.
3 changes: 1 addition & 2 deletions frontend/src/admin/project-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,7 @@ export const ProjectForm: FunctionComponent = () => {
if (response.ok) {
navigate(`/projects`);
} else {
const errorData = await response.json();
alert(`Error: ${errorData.message}`);
alert(`Error fetching project: ${response.statusText}`);
}
} finally {
setIsEditing(false);
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/admin/use-users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {useState} from "react";
import {useOnce} from "../hooks/use-once";
import {User, usersFromJson } from "zero-zummon"

type UseUserReturn = {
loadingUsers: boolean,
users: User[],
changeUser: (newUser: User) => void,
removeUser: (userId: string) => void,
}

export const useUsers = (): UseUserReturn => {
const [loadingUsers, setLoading] = useState(true)
const [users, setUsers] = useState<User[]>([])

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.ok) {
throw new Error(`Could not load users: ${response.status} ${response.statusText}`)
}

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)
}
148 changes: 148 additions & 0 deletions frontend/src/admin/user-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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<User | null>(null);
const [originalData, setOriginalData] = useState<User | null>(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<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setUser((prev) => ({...prev,
[name]: type === "checkbox" ? checked : 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) {
alert(`Error: ${response.statusText}`);
}

navigate(`/users`);
} finally {
setIsEditing(false);
setLoading(false);
}
};

return (
<PrimeReactProvider>
<div style={{ padding: "20px", maxWidth: "500px", margin: "0 auto" }}>
<h3>{userId ? "Edit User" : "Add User"}</h3>
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: "10px" }}
>
<label htmlFor="name">Keycloak ID:</label>
<InputText
id="id"
name="id"
value={user?.id || ""}
onChange={handleInputChange}
disabled={!isEditing}
/>
<label htmlFor="name">Note:</label>
<InputText
id="note"
name="note"
value={user?.note || ""}
onChange={handleInputChange}
disabled={!isEditing}
/>
<div>
<label htmlFor="isAdmin" style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={user?.isAdmin || false}
onChange={handleInputChange}
disabled={!isEditing}
/>
Admin
</label>
</div>

<div style={{ display: "flex", justifyContent: "space-between", marginTop: "10px" }}>
{isEditing ? (
<>
<Button label="Cancel" onClick={handleCancel} type="button" disabled={loading} />
<Button label={loading ? "Saving..." : "Save"} type="submit" disabled={loading} />
</>
) : (
<Button label="Edit" onClick={handleEditToggle} type="button" disabled={loading} />
)}
</div>
</form>
</div>
</PrimeReactProvider>
);
};
70 changes: 70 additions & 0 deletions frontend/src/admin/users.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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 (
<PrimeReactProvider>
<div css={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1em 1em',
boxShadow: '1px solid #ddd'
}}>
<h3>Users List</h3>
<Button
label="Nieuw"
icon="pi pi-pencil"
onClick={(event) => navigate(`/users/new-user`)}
/>
</div>
<DataTable
value={users}
loading={loadingUsers}
sortField="created"
sortOrder={-1}
filterDisplay="row"
>
<Column field="note" header="Note" sortable filter/>
<Column
field="isAdmin"
header="Admin"
body={(user: User) => (
<div style={{ textAlign: 'center' }}>
{user.isAdmin ? (
<span style={{ color: 'green' }}></span>
) : (
<span style={{ color: 'red' }}></span>
)}
</div>
)}
/>
<Column body={(user: User) => (
<div css={{
display: 'flex',
'> *': {
margin: `${1 / 6}rem`
},
}}>
<DeleteButton type="users" id={user.id} onDelete={removeUser}/>
<EditButton type="users" id={user.id}/>
</div>
)}/>
</DataTable>
</PrimeReactProvider>
)
}
4 changes: 4 additions & 0 deletions frontend/src/components/zero-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export const ZeroHeader: FunctionComponent<PropsWithChildren & {}> = () => {
<i className="pi pi-fw pi-file" style={{marginRight: '0.5em'}}></i>
Projects
</a>
{/* <a onClick={() => loadContent('/users')} css={buttonStyle}>
<i className="pi pi-fw pi-file" style={{marginRight: '0.5em'}}></i>
Users
</a> */}
<a onClick={() => loadContent('/simulation')} css={buttonStyle}>
<i className="pi pi-fw pi-file" style={{marginRight: '0.5em'}}></i>
Simulation
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -26,6 +28,9 @@ export const router = createBrowserRouter([
{path: "/projects", element: <Projects />},
{path: "/projects/new-project", element: <ProjectForm />},
{path: "/projects/:projectId", element: <ProjectForm />},
{path: "/users", element: <Users />},
{path: "/users/new-user", element: <UserForm />},
{path: "/users/:userId", element: <UserForm />},
{path: "/simulation", element: <Simulation />},
],
},
Expand Down
1 change: 1 addition & 0 deletions migrations/V33__admin_flag_to_user.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;
11 changes: 0 additions & 11 deletions zorm/src/main/kotlin/com/zenmo/orm/user/User.kt

This file was deleted.

30 changes: 27 additions & 3 deletions zorm/src/main/kotlin/com/zenmo/orm/user/UserRepository.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,8 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid

class UserRepository(
private val db: Database,
Expand Down Expand Up @@ -53,10 +56,25 @@ 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.toJavaUuid()
it[UserTable.note] = user.note
it[UserTable.isAdmin] = user.isAdmin
}.map {
hydrateUser(it)
}.first()
}
}

fun saveUser(
Expand Down Expand Up @@ -88,10 +106,16 @@ class UserRepository(
}
}

fun isAdmin(userId: UUID): Boolean {
val user = getUserById(userId)
return user?.isAdmin ?: false
}

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
)
}
Expand Down
Loading

0 comments on commit 2b5a6f3

Please sign in to comment.