Skip to content
Snippets Groups Projects
Commit 1484a1ae authored by Bjarke Madsen's avatar Bjarke Madsen
Browse files

improve user management and add sidebar for admins on the survey pages

parent 4b379646
No related branches found
No related tags found
No related merge requests found
Showing
with 943 additions and 368 deletions
...@@ -70,8 +70,8 @@ import ServicesPage from "./pages/Services/Services"; ...@@ -70,8 +70,8 @@ import ServicesPage from "./pages/Services/Services";
// Survey pages // Survey pages
import SurveyLanding from "./survey/Landing"; import SurveyLanding from "./survey/Landing";
import SurveyContainerComponent from "./survey/SurveyContainerComponent"; import SurveyContainerComponent from "./survey/SurveyContainerComponent";
import SurveyManagementComponent from "./survey/SurveyManagementComponent"; import SurveyManagementComponent from "./survey/management/SurveyManagementComponent";
import UserManagementComponent from "./survey/UserManagementComponent"; import UserManagementComponent from "./survey/management/UserManagementComponent";
const GlobalLayout = () => { const GlobalLayout = () => {
// this component is needed to provide a global layout for the app, including the navbar and footer, // this component is needed to provide a global layout for the app, including the navbar and footer,
... ...
......
...@@ -8,9 +8,11 @@ import PreviewProvider from "./providers/PreviewProvider"; ...@@ -8,9 +8,11 @@ import PreviewProvider from "./providers/PreviewProvider";
import NrenProvider from "./providers/NrenProvider"; import NrenProvider from "./providers/NrenProvider";
import MatomoProvider from "./matomo/MatomoProvider"; import MatomoProvider from "./matomo/MatomoProvider";
import ConsentProvider from "./providers/ConsentProvider"; import ConsentProvider from "./providers/ConsentProvider";
import ConfigProvider from "./providers/ConfigProvider";
function Providers({ children }): ReactElement { function Providers({ children }): ReactElement {
return ( return (
<ConfigProvider>
<ConsentProvider> <ConsentProvider>
<MatomoProvider> <MatomoProvider>
<SidebarProvider> <SidebarProvider>
...@@ -28,6 +30,7 @@ function Providers({ children }): ReactElement { ...@@ -28,6 +30,7 @@ function Providers({ children }): ReactElement {
</SidebarProvider> </SidebarProvider>
</MatomoProvider> </MatomoProvider>
</ConsentProvider> </ConsentProvider>
</ConfigProvider>
); );
} }
... ...
......
...@@ -5,11 +5,12 @@ import { AiOutlineClose, AiOutlinePlus } from 'react-icons/ai'; ...@@ -5,11 +5,12 @@ import { AiOutlineClose, AiOutlinePlus } from 'react-icons/ai';
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
survey?: boolean;
} }
// check if click outside of sidebar, then toggle it // check if click outside of sidebar, then toggle it
const Sidebar: React.FC<Props> = ({ children }) => { const Sidebar: React.FC<Props> = ({ children, survey }) => {
const [show, setShow] = useState<boolean>(false); const [show, setShow] = useState<boolean>(false);
...@@ -34,12 +35,20 @@ const Sidebar: React.FC<Props> = ({ children }) => { ...@@ -34,12 +35,20 @@ const Sidebar: React.FC<Props> = ({ children }) => {
} }
}); });
const classNames: string[] = [];
if (!show) {
classNames.push('no-sidebar')
}
if (survey) {
classNames.push('survey')
}
return ( return (
<div className="sidebar-wrapper"> <div className="sidebar-wrapper">
<nav className={show ? '' : 'no-sidebar'} id="sidebar"> <nav className={classNames.join(" ")} id="sidebar">
<div className={`menu-items`}>{children}</div> <div className={`menu-items`}>{children}</div>
</nav> </nav>
<div className="toggle-btn" onClick={toggle}> <div className={`toggle-btn${survey ? "-survey" : ""}`} onClick={toggle}>
<div className='toggle-btn-wrapper'> <div className='toggle-btn-wrapper'>
<span>MENU</span> {show ? <span>MENU</span> {show ?
<AiOutlineClose style={{ color: 'white', paddingBottom: "3px", scale: "1.3" }} onClick={toggle} /> : <AiOutlineClose style={{ color: 'white', paddingBottom: "3px", scale: "1.3" }} onClick={toggle} /> :
... ...
......
import { useContext } from "react";
import { configContext, BaseConfig } from "../providers/ConfigProvider";
export function useConfig<T extends BaseConfig>(key: string): Record<string, T> & {
setConfig: (value: T, timeout?: Date) => void
} {
const { getConfig, setConfig } = useContext(configContext);
const configValue = getConfig(key);
return {
[key]: configValue,
setConfig: (value: T, timeout?: Date) => setConfig(key, value, timeout)
} as Record<string, T> & {
setConfig: (value: T, timeout?: Date) => void
};
}
\ No newline at end of file
import React, { createContext, useState } from 'react';
const getConfigFromLocalStorage = () => {
const storedConfig = JSON.parse(localStorage.getItem('config') ?? "{}");
const config = {};
for (const key in storedConfig) {
const value = storedConfig[key];
if (value.expireTime && value.expireTime < Date.now()) {
continue;
}
if (!value) continue;
config[key] = value;
}
return config;
}
const saveConfigToLocalStorage = (config) => {
localStorage.setItem('config', JSON.stringify(config));
}
export type BaseConfig = {
[K: string | number | symbol]: any;
};
type ConfigContext<T extends BaseConfig> = {
getConfig: (key: string) => T | undefined;
setConfig: (key: string, value?: T, timeout?: Date) => void;
};
const configContext = createContext<ConfigContext<BaseConfig>>({
getConfig: () => undefined,
setConfig: () => { }
});
interface Props {
children: React.ReactNode;
}
const ConfigProvider: React.FC<Props> = ({ children }) => {
const [config, setConfig] = useState<BaseConfig>(getConfigFromLocalStorage());
const updateConfig = (key, value?: any, timeout?: Date) => {
if (!key) throw new Error('Valid config key must be provided');
if (value == undefined) {
const newConfig = { ...config };
delete newConfig[key];
setConfig(newConfig);
saveConfigToLocalStorage(newConfig);
return;
}
const newAsString = JSON.stringify(value);
const existingAsString = JSON.stringify(config[key]?.value);
if (newAsString === existingAsString) return;
const expireTime = timeout ? timeout.getTime() : null;
if (expireTime && expireTime < Date.now()) {
throw new Error('Timeout must be in the future');
}
if (expireTime) {
setConfig({ ...config, [key]: { value, expireTime } });
saveConfigToLocalStorage({ ...config, [key]: { value, expireTime } });
} else {
setConfig({ ...config, [key]: { value } });
saveConfigToLocalStorage({ ...config, [key]: { value } });
}
}
const getConfig = (key) => {
const value = config[key];
if (value?.expireTime && value.expireTime < Date.now()) {
// expired, remove from config
updateConfig(key);
return undefined;
}
if (value != undefined) return value.value;
return undefined;
}
return (
<configContext.Provider value={{ getConfig, setConfig: updateConfig }}>
{children}
</configContext.Provider>
);
};
export { configContext };
export default ConfigProvider;
\ No newline at end of file
...@@ -17,9 +17,11 @@ const anonymousUser: User = { 'name': '', email: '', permissions: { admin: false ...@@ -17,9 +17,11 @@ const anonymousUser: User = { 'name': '', email: '', permissions: { admin: false
const userContext = createContext<{ const userContext = createContext<{
user: User; user: User;
logout: () => void; logout: () => void;
setUser: React.Dispatch<React.SetStateAction<User>>;
}>({ }>({
user: anonymousUser, user: anonymousUser,
logout: () => { } logout: () => { },
setUser: () => { }
}); });
...@@ -38,7 +40,7 @@ const UserProvider: React.FC<Props> = ({ children }) => { ...@@ -38,7 +40,7 @@ const UserProvider: React.FC<Props> = ({ children }) => {
}, []); }, []);
return ( return (
<userContext.Provider value={{ user, logout: logoutUser }}> <userContext.Provider value={{ user, logout: logoutUser, setUser }}>
{children} {children}
</userContext.Provider> </userContext.Provider>
); );
... ...
......
...@@ -42,6 +42,14 @@ ...@@ -42,6 +42,14 @@
} }
} }
.sidebar-wrapper>nav.survey {
border: $dark-teal 2px solid;
a:hover {
color: $teal-blue;
}
}
nav.no-sidebar { nav.no-sidebar {
margin-left: -80%; margin-left: -80%;
visibility: hidden; visibility: hidden;
...@@ -63,6 +71,11 @@ nav.no-sidebar { ...@@ -63,6 +71,11 @@ nav.no-sidebar {
user-select: none; /* Standard syntax */ user-select: none; /* Standard syntax */
} }
.toggle-btn-survey {
@extend .toggle-btn;
background-color: $dark-teal;
}
.toggle-btn-wrapper { .toggle-btn-wrapper {
padding: 0.5rem; padding: 0.5rem;
padding-top: 0.7rem; padding-top: 0.7rem;
... ...
......
...@@ -4,6 +4,7 @@ import { Table, Container, Row } from "react-bootstrap"; ...@@ -4,6 +4,7 @@ import { Table, Container, Row } from "react-bootstrap";
import { userContext } from "../providers/UserProvider"; import { userContext } from "../providers/UserProvider";
import { fetchSurveys, fetchActiveSurveyYear } from "./api/survey"; import { fetchSurveys, fetchActiveSurveyYear } from "./api/survey";
import { Survey } from "./api/types"; import { Survey } from "./api/types";
import SurveySidebar from "./management/SurveySidebar";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import useMatomo from "../matomo/UseMatomo"; import useMatomo from "../matomo/UseMatomo";
...@@ -158,12 +159,13 @@ function Landing(): ReactElement { ...@@ -158,12 +159,13 @@ function Landing(): ReactElement {
} }
return ( return (
<>
{isAdmin && <SurveySidebar />}
<Container className="py-5 grey-container"> <Container className="py-5 grey-container">
<Row> <Row>
<div className="center-text"> <div className="center-text">
<h1 className="geant-header">THE GÉANT COMPENDIUM OF NRENS SURVEY</h1> <h1 className="geant-header">THE GÉANT COMPENDIUM OF NRENS SURVEY</h1>
<div className="wordwrap pt-4" style={{ maxWidth: '75rem' }}> <div className="wordwrap pt-4" style={{ maxWidth: '75rem' }}>
<p style={{ textAlign: "left" }}> <p style={{ textAlign: "left" }}>
Hello, Hello,
...@@ -194,6 +196,7 @@ function Landing(): ReactElement { ...@@ -194,6 +196,7 @@ function Landing(): ReactElement {
</div> </div>
</Row> </Row>
</Container > </Container >
</>
); );
} }
... ...
......
import React, { useState, useEffect, useContext } from "react";
import { Container, Row, Table } from "react-bootstrap";
import { userContext } from "../providers/UserProvider";
import { User, Nren } from "./Schema";
async function fetchUsers(): Promise<User[]> {
try {
const response = await fetch('/api/user/list');
const userList = await response.json();
return userList
} catch (error) {
return [];
}
}
async function fetchNrens(): Promise<Nren[]> {
try {
const response = await fetch('/api/nren/list');
const userList = await response.json();
return userList
} catch (error) {
return [];
}
}
const updateUser = async (id, changes) => {
const body = {
id: id,
...changes
}
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
};
const response = await fetch(`/api/user/${id}`, requestOptions)
const data = await response.json()
if (!response.ok) {
throw new Error(data.message);
}
return data.user;
};
const defaultSortFunction = (a, b) => {
if (a.permissions.active && !b.permissions.active) {
return -1;
} else if (!a.permissions.active && b.permissions.active) {
return 1;
} else if (a.permissions.active && b.permissions.active) {
if (a.role === 'admin' && b.role !== 'admin') {
return 1;
} else if (a.role !== 'admin' && b.role === 'admin') {
return -1;
} else {
return a.name.localeCompare(b.name)
}
} else {
return a.name.localeCompare(b.name)
}
}
function UserManagementComponent() {
const [users, setUsers] = useState<User[]>([]);
const [nrens, setNrens] = useState<Nren[]>([]);
const { user: loggedInUser } = useContext(userContext);
const [sortColumn, setSortColumn] = useState({ idx: -1, asc: true });
const [sortedUsers, setSortedUsers] = useState<User[]>([]);
useEffect(() => {
fetchUsers().then((userList) => {
setUsers(userList);
setSortedUsers(userList.sort(defaultSortFunction))
});
fetchNrens().then((nrenList) => {
setNrens(nrenList.sort((a, b) => a.name.localeCompare(b.name)))
})
}, []);
useEffect(() => {
setSortedUsers([...users.sort(defaultSortFunction)])
}, [users])
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, user: User) => {
const index = users.findIndex((u) => u.id === user.id);
const updatedUsers = [...users];
const { name } = event.target;
const update = {};
if (name === 'active') {
update[name] = (event.target as HTMLInputElement).checked
} else {
update[name] = event.target.value;
}
updateUser(user.id, update).then((user) => {
updatedUsers[index] = user;
setUsers(updatedUsers);
}).catch((error) => {
alert(error.message);
});
};
const findNrenId = (value) => {
return nrens.find((nren) => {
return nren.id == value || nren.name == value
})?.id
}
const setSort = (index) => {
let func;
if (index === sortColumn.idx || ((index === 5 || index === 0) && sortColumn.idx === -1)) {
// reverse sort
if (index === 5 || index === 0) {
// hack not to show the sort icon on column 0 and 5
index = -1
}
setSortColumn({ idx: index, asc: !sortColumn.asc })
setSortedUsers([...sortedUsers.reverse()])
return
}
if (index === 0) {
// user ID is a UUID which is meaningless to sort by, so use default sort function
func = defaultSortFunction
setSortColumn({ idx: -1, asc: true })
} else if (index === 1) {
// sort by active
func = (a, b) => {
if (a.permissions.active && !b.permissions.active) {
return -1;
} else if (!a.permissions.active && b.permissions.active) {
return 1;
} else {
return 0;
}
}
setSortColumn({ idx: index, asc: true })
} else if (index === 2) {
// sort by role
func = (a, b) => {
return a.role.localeCompare(b.role)
}
setSortColumn({ idx: index, asc: true })
} else if (index === 3) {
// sort by email
func = (a, b) => {
return a.email.localeCompare(b.email)
}
setSortColumn({ idx: index, asc: true })
} else if (index === 4) {
// sort by name
func = (a, b) => {
return a.name.localeCompare(b.name)
}
setSortColumn({ idx: index, asc: true })
} else if (index === 5) {
// use the default sort function, OIDC sub has no meaning when sorting
func = defaultSortFunction
setSortColumn({ idx: -1, asc: true })
} else if (index === 6) {
// sort by NREN
func = (a, b) => {
if (a.nrens.length === 0 && b.nrens.length === 0) {
return 0;
} else if (a.nrens.length === 0) {
return -1;
} else if (b.nrens.length === 0) {
return 1;
} else {
return a.nrens[0].localeCompare(b.nrens[0])
}
}
setSortColumn({ idx: index, asc: true })
} else {
func = defaultSortFunction
setSortColumn({ idx: index, asc: true })
}
setSortedUsers(users.sort(func))
}
// build the aria-sort attribute for each column with spreadable objects..
// this is apparently the easiest way to do it conditionally
const ariaSort = {}
for (let i = 0; i <= 6; i++) {
ariaSort[i] = sortColumn.idx === i ? ({ 'aria-sort': sortColumn.asc ? 'ascending' : 'descending' }) : null
}
return (
<Container style={{ maxWidth: '90vw', }}>
<Row>
<h1> User Management Page</h1>
<Table>
<thead>
<tr>
<th {...ariaSort[0]} onClick={() => setSort(0)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Id</th>
<th {...ariaSort[1]} onClick={() => setSort(1)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Active</th>
<th {...ariaSort[2]} onClick={() => setSort(2)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Role</th>
<th {...ariaSort[3]} onClick={() => setSort(3)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Email</th>
<th {...ariaSort[4]} onClick={() => setSort(4)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>Full Name</th>
<th {...ariaSort[5]} onClick={() => setSort(5)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>OIDC Sub</th>
<th {...ariaSort[6]} onClick={() => setSort(6)} className='pt-3 sortable' style={{ border: "1px solid #ddd" }}>NREN</th>
</tr>
</thead>
<tbody>
{sortedUsers.map(user => (
<tr key={user.id}>
<td style={{ border: "1px dotted #ddd" }}>{user.id}</td>
<td style={{ border: "1px dotted #ddd" }}>
{user.id == loggedInUser.id ? 'Active' : <input
type="checkbox"
name="active"
checked={user.permissions.active}
onChange={(event) => handleInputChange(event, user)}
/>}
</td>
<td style={{ border: "1px dotted #ddd" }}>
{user.id == loggedInUser.id ? (user.role.charAt(0).toUpperCase()
+ user.role.slice(1)) : <select
name="role"
defaultValue={user.role}
onChange={(event) => handleInputChange(event, user)}>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="observer">Observer</option>
</select>}
</td>
<td style={{ border: "1px dotted #ddd" }}>
{user.email}
</td>
<td style={{ border: "1px dotted #ddd" }}>
{user.name}
</td>
<td style={{ border: "1px dotted #ddd" }}>
{user.oidc_sub}
</td>
<td style={{ border: '1px dotted #ddd' }}>
<select
name="nren"
multiple={false}
value={user.nrens.length > 0 ? findNrenId(user.nrens[0]) : ""}
onChange={(event) => handleInputChange(event, user)}>
<option value="">
Select NREN
</option>
{nrens.map((nren) => (
<option key={'nren_' + nren.id} value={nren.id}>
{nren.name}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</Table>
</Row>
</Container>
);
}
export default UserManagementComponent;
...@@ -5,24 +5,14 @@ import Row from 'react-bootstrap/Row'; ...@@ -5,24 +5,14 @@ import Row from 'react-bootstrap/Row';
import Table from 'react-bootstrap/Table'; import Table from 'react-bootstrap/Table';
import Container from "react-bootstrap/Container"; import Container from "react-bootstrap/Container";
import toast, { Toaster } from "react-hot-toast"; import toast, { Toaster } from "react-hot-toast";
import { useNavigate } from 'react-router-dom'; import { SurveyStatus } from "../Schema";
import { SurveyStatus } from "./Schema"; import { fetchSurveys } from "../api/survey";
import { fetchSurveys } from "./api/survey"; import { Survey } from "../api/types";
import { Survey } from "./api/types"; import SurveySidebar from "./SurveySidebar";
import { Spinner } from "react-bootstrap"; import { Spinner } from "react-bootstrap";
import StatusButton from "./StatusButton"; import StatusButton from "../StatusButton";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { debounce } from "lodash"
function debounce(func, wait) {
let timeout: NodeJS.Timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
clearTimeout(timeout);
}, wait);
};
}
function updateSurveyNotes(year: number, nren_id: number, notes: string) { function updateSurveyNotes(year: number, nren_id: number, notes: string) {
fetch('/api/survey/' + year + '/' + nren_id + '/notes', { fetch('/api/survey/' + year + '/' + nren_id + '/notes', {
...@@ -130,12 +120,12 @@ function SurveyManagementComponent() { ...@@ -130,12 +120,12 @@ function SurveyManagementComponent() {
const newSurveyAllowed = surveys.length > 0 && surveys.every(s => s.status == SurveyStatus.published); const newSurveyAllowed = surveys.length > 0 && surveys.every(s => s.status == SurveyStatus.published);
const navigate = useNavigate();
const previewLink = window.location.origin + "/data?preview"; const previewLink = window.location.origin + "/data?preview";
return ( return (
<div className="py-5 grey-container"> <>
<SurveySidebar />
<Container className="py-5 grey-container">
<Container style={{ maxWidth: '100rem' }}> <Container style={{ maxWidth: '100rem' }}>
<Row > <Row >
<Toaster /> <Toaster />
...@@ -242,7 +232,8 @@ function SurveyManagementComponent() { ...@@ -242,7 +232,8 @@ function SurveyManagementComponent() {
</Accordion> </Accordion>
</Row> </Row>
</Container> </Container>
</div> </Container>
</>
); );
} }
... ...
......
import React from 'react';
import Link from '../../components/sidebar/LinkWithHighlight';
import Sidebar from '../../components/sidebar/SideBar';
const SurveySidebar = () => {
return (
<Sidebar survey>
<h5 className="section-title">Management Links</h5>
<Link to="/survey">
<span>Survey Home</span>
</Link>
<Link to="/survey/admin/users">
<span>Compendium User Management</span>
</Link>
<Link to="/survey/admin/surveys">
<span>Compendium Survey Management</span>
</Link>
</Sidebar>
)
}
export default SurveySidebar
\ No newline at end of file
import React, { useState, useEffect, useContext } from "react";
import { Container, Table, Row, Form, InputGroup, Accordion, Button } from "react-bootstrap";
import toast, { Toaster } from "react-hot-toast";
import { userContext } from "../../providers/UserProvider";
import { useConfig } from "../../helpers/useConfig";
import { User, Nren } from "../Schema";
import SurveySidebar from "./SurveySidebar";
import { FaCheck } from "react-icons/fa";
import { debounce } from "lodash";
async function fetchUsers(): Promise<User[]> {
try {
const response = await fetch("/api/user/list");
return await response.json()
} catch (error) {
return [];
}
}
async function fetchNrens(): Promise<Nren[]> {
try {
const response = await fetch("/api/nren/list");
return await response.json()
} catch (error) {
return [];
}
}
async function updateUser(id, changes): Promise<User> {
const body = {
id: id,
...changes
}
const requestOptions = {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
};
const response = await fetch(`/api/user/${id}`, requestOptions)
const data = await response.json()
if (!response.ok) {
throw new Error(data.message);
}
toast.success(data.message);
return data.user;
}
async function deleteUser(user: User) {
const confirmDelete = window.confirm(`Are you sure you want to delete ${user.name} (${user.email})?`);
if (!confirmDelete) {
return false;
}
const requestOptions = {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
};
const response = await fetch(`/api/user/${user.id}`, requestOptions)
const data = await response.json()
if (!response.ok) {
throw new Error(data.message);
}
toast.success(data.message);
return true;
}
const defaultSortFunction = (a: User, b: User) => {
if (a.role !== "admin" && b.role === "admin") {
return 1;
} else if (a.role === "admin" && b.role !== "admin") {
return -1;
} else if (a.role === "user" && b.role !== "user") {
return 1;
} else if (b.role === "user" && a.role !== "user") {
return -1;
} else if (!a.permissions.active && b.permissions.active) {
return 1;
} else if (a.permissions.active && !b.permissions.active) {
return -1;
}
return a.name.localeCompare(b.name)
}
type UserManagementConfig = {
shownColumns: { [key: string]: boolean }
}
function UserManagementComponent() {
const [users, setUsers] = useState<User[]>([]);
const [nrens, setNrens] = useState<Nren[]>([]);
const { user: loggedInUser, setUser: updateCurrentUser } = useContext(userContext);
const [sortColumn, setSortColumn] = useState({ column: "ID", asc: true });
const [filter, setFilter] = useState("");
const { setConfig, user_management } = useConfig<UserManagementConfig>("user_management");
const setColumnVisibility = (column: string, show: boolean) => {
const newConfig = user_management ?? {};
const columnConfig = newConfig?.shownColumns;
if (!columnConfig) {
setConfig({ ...newConfig, shownColumns: { [column]: show } });
return;
}
setConfig({ ...newConfig, shownColumns: { ...columnConfig, [column]: show } });
}
const getColumnVisibility = (column: string): boolean => {
const config = user_management;
if (!config) {
return true;
}
const columnConfig = config["shownColumns"];
if (!columnConfig) {
return true;
}
const columnShown = columnConfig[column];
return columnShown ?? true;
}
useEffect(() => {
fetchUsers().then((userList) => {
setUsers(userList);
});
fetchNrens().then((nrenList) => {
setNrens(nrenList.sort((a, b) => a.name.localeCompare(b.name)))
})
}, []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, user: User) => {
const index = users.findIndex((u) => u.id === user.id);
const updatedUsers = [...users];
const { name } = event.target;
const update = {};
if (name === "active") {
update[name] = (event.target as HTMLInputElement).checked
} else {
update[name] = event.target.value;
}
updateUser(user.id, update).then((user) => {
if (user.id === loggedInUser.id) {
updateCurrentUser(user);
} else {
updatedUsers[index] = user;
setUsers(updatedUsers);
}
}).catch((error) => {
toast.error(error.message);
});
};
const findNrenId = (value) => {
return nrens.find((nren) => {
return nren.id == value || nren.name == value
})?.id
}
const sortByActive = (a: User, b: User) => {
if (a.permissions.active && !b.permissions.active) {
return 1;
} else if (!a.permissions.active && b.permissions.active) {
return -1;
} else {
return defaultSortFunction(a, b)
}
}
const sortBy = (property: keyof User) => {
return (a: User, b: User) => {
const valueA = a[property];
const valueB = b[property];
if (property === "nrens") {
if (a.nrens.length === 0 && b.nrens.length === 0) {
return defaultSortFunction(a, b)
} else if (a.nrens.length === 0) {
return -1;
} else if (b.nrens.length === 0) {
return 1;
} else {
return a.nrens[0].localeCompare(b.nrens[0])
}
}
if (typeof valueA !== "string" || typeof valueB !== "string") {
return defaultSortFunction(a, b)
}
const sortVal = valueA.localeCompare(valueB);
if (sortVal === 0) {
return defaultSortFunction(a, b)
}
return sortVal;
}
}
const columns = ["ID", "Active", "Role", "Email", "Full Name", "OIDC Sub", "NREN", "Actions"]
const sortFunctions = {
[columns[1]]: sortByActive,
[columns[2]]: sortBy("role"),
[columns[3]]: sortBy("email"),
[columns[4]]: sortBy("name"),
[columns[6]]: sortBy("nrens"),
}
const setSort = (column) => {
if (column === sortColumn.column) {
setSortColumn({ column, asc: !sortColumn.asc })
}
else {
setSortColumn({ column, asc: true })
}
}
// This controls which column to put a sort icon on.
// We only add it to columns that don"t use the default sort function, or to the ID column.
const ariaSort = {}
if (Array.from(Object.keys(sortFunctions)).includes(sortColumn.column)) {
ariaSort[sortColumn.column] = ({ "aria-sort": sortColumn.asc ? "ascending" : "descending" })
} else {
ariaSort[columns[0]] = ({ "aria-sort": sortColumn.asc ? "ascending" : "descending" })
}
const sortFunction = sortFunctions[sortColumn.column] ?? defaultSortFunction
const filteredUsers = filter ? users.filter((u) => u.email.includes(filter) || u.name.includes(filter)) : users
const userList = filteredUsers.filter((u) => u.id !== loggedInUser.id).sort(sortFunction)
if (!sortColumn.asc) {
userList.reverse()
}
return (
<>
<SurveySidebar />
<Toaster />
<Container className="py-5 grey-container">
<Row className="d-flex justify-content-center align-items-center flex-column">
<div className="text-center w-100 mb-3">
<h3>User Management Page</h3>
</div>
<Accordion className="mb-3" style={{ width: "30rem" }}>
<Accordion.Item eventKey="0">
<Accordion.Header>
<span className="me-2">Column Visibility</span>
<small className="text-muted">Choose which columns to display</small>
</Accordion.Header>
<Accordion.Body>
<Form.Control as="div" className="p-3">
<small className="text-muted mb-2 d-block">
Select which columns you want to display in the table below. Unchecked columns will be hidden.
</small>
<div className="d-grid" style={{
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: "10px"
}}>
{columns.map((column) => (
<Form.Check
key={column}
type="checkbox"
id={`column-${column}`}
label={column}
checked={getColumnVisibility(column)}
onChange={(e) => setColumnVisibility(column, e.target.checked)}
/>
))}
</div>
</Form.Control>
</Accordion.Body>
</Accordion.Item>
</Accordion>
<InputGroup className="mb-3" style={{ width: "30rem" }}>
<InputGroup.Text id="search-text">
Search
</InputGroup.Text>
<Form.Control
placeholder="Search by email/name"
aria-label="Search"
onInput={debounce((e) => setFilter((e.target as HTMLInputElement).value), 200)}
/>
<Button variant="outline-secondary" onClick={() => {
setFilter("")
}}>Clear</Button>
</InputGroup>
</Row>
<div className="d-flex justify-content-center">
<div style={{ maxWidth: "100rem" }}>
<Table className="user-management-table" bordered >
<colgroup>
{getColumnVisibility(columns[0]) && <col span={1} style={{ width: "8rem" }} />}
{getColumnVisibility(columns[1]) && <col span={1} style={{ width: "3rem" }} />}
{getColumnVisibility(columns[2]) && <col span={1} style={{ width: "4.5rem" }} />}
{getColumnVisibility(columns[3]) && <col span={1} style={{ width: "7rem" }} />}
{getColumnVisibility(columns[4]) && <col span={1} style={{ width: "5rem" }} />}
{getColumnVisibility(columns[5]) && <col span={1} style={{ width: "5rem" }} />}
{getColumnVisibility(columns[6]) && <col span={1} style={{ width: "6rem" }} />}
{getColumnVisibility(columns[7]) && <col span={1} style={{ width: "3rem" }} />}
</colgroup>
<thead>
<tr>
{columns.map((column) => (
getColumnVisibility(column) &&
<th key={column} {...ariaSort[column]} onClick={() => setSort(column)} className="sortable fixed-column" style={{ border: "1px solid #ddd" }}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{(!filter ? [loggedInUser] : []).concat(userList).map(user => (
<tr key={user.id} style={{ fontWeight: user.id == loggedInUser.id ? "bold" : "normal" }}>
{getColumnVisibility(columns[0]) && <td style={{ border: "1px dotted #ddd" }}>{user.id}</td>}
{getColumnVisibility(columns[1]) && <td style={{ border: "1px dotted #ddd" }}>
{user.id == loggedInUser.id ? <FaCheck /> : <input
type="checkbox"
name="active"
checked={user.permissions.active}
onChange={(event) => handleInputChange(event, user)}
/>}
</td>}
{getColumnVisibility(columns[2]) && <td style={{ border: "1px dotted #ddd" }}>
{user.id == loggedInUser.id ? (user.role.charAt(0).toUpperCase()
+ user.role.slice(1)) : <select
name="role"
defaultValue={user.role}
onChange={(event) => handleInputChange(event, user)}
style={{ width: "100%" }}>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="observer">Observer</option>
</select>}
</td>}
{getColumnVisibility(columns[3]) && <td style={{ border: "1px dotted #ddd" }}>
{user.email}
</td>}
{getColumnVisibility(columns[4]) && <td style={{ border: "1px dotted #ddd" }}>
{user.name}
</td>}
{getColumnVisibility(columns[5]) && <td style={{ border: "1px dotted #ddd" }}>
{user.oidc_sub}
</td>}
{getColumnVisibility(columns[6]) && <td style={{ border: "1px dotted #ddd" }}>
<select
name="nren"
multiple={false}
value={user.nrens.length > 0 ? findNrenId(user.nrens[0]) : ""}
onChange={(event) => handleInputChange(event, user)}>
<option value="">
Select NREN
</option>
{nrens.map((nren) => (
<option key={nren.id} value={nren.id}>
{nren.name}
</option>
))}
</select>
</td>}
{getColumnVisibility(columns[7]) && <td style={{ border: "1px dotted #ddd" }}>
{user.id !== loggedInUser.id && <Button variant="danger" onClick={async () => {
if (user.id === loggedInUser.id) {
toast.error("You cannot delete yourself")
return
}
const success = await deleteUser(user)
if (success) {
setUsers(users.filter((u) => u.id !== user.id))
}
}}>
Delete</Button>}
</td>}
</tr>
))}
</tbody>
</Table>
</div>
</div>
</Container>
</>
);
}
export default UserManagementComponent;
...@@ -84,3 +84,11 @@ class User(UserMixin, db.Model): ...@@ -84,3 +84,11 @@ class User(UserMixin, db.Model):
if len(self.nrens) == 0: if len(self.nrens) == 0:
return None return None
return self.nrens[0] return self.nrens[0]
def __hash__(self):
return hash(self.id)
def __eq__(self, other):
if not isinstance(other, User):
return False
return self.id == other.id
...@@ -151,12 +151,17 @@ def update_user_view(user_id) -> Any: ...@@ -151,12 +151,17 @@ def update_user_view(user_id) -> Any:
elif 'nren' in update_data: elif 'nren' in update_data:
new_nrens = None new_nrens = None
new_id = update_data['nren']
if not new_id:
# if no ID is provided, remove all NRENs
user.nrens = []
else:
try: try:
new_nrens = db.session.scalars(select(NREN).filter(NREN.id == update_data['nren'])).all() new_nrens = db.session.scalars(select(NREN).filter(NREN.id == update_data['nren'])).all()
logger.info(f'Updating NRENs for user {user.id} to {new_nrens}') logger.info(f'Updating NRENs for user {user.id} to {new_nrens}')
user.nrens = [nren for nren in new_nrens] user.nrens = [nren for nren in new_nrens]
except Exception: except Exception:
return jsonify({'success': False, 'message': 'No valid NREN IDs provided.'}), 400 return jsonify({'success': False, 'message': 'Error when updating user NREN'}), 500
db.session.commit() db.session.commit()
return jsonify({'success': True, 'message': 'User updated successfully', 'user': _extract_user(user)}) return jsonify({'success': True, 'message': 'User updated successfully', 'user': _extract_user(user)})
...@@ -174,3 +179,32 @@ def update_user_view(user_id) -> Any: ...@@ -174,3 +179,32 @@ def update_user_view(user_id) -> Any:
return jsonify({"success": False, 'message': 'User not found.'}), 404 return jsonify({"success": False, 'message': 'User not found.'}), 404
return _update_user_data(user, body) return _update_user_data(user, body)
@routes.route('/<user_id>', methods=['DELETE'])
@common.require_accepts_json
@login_required
@admin_required
def delete_user_view(user_id) -> Any:
"""
Handler for /api/user/<user_id> via DELETE request.
Deletes a user from the database, given a user ID.
User cannot delete themselves.
:param user_id: the user ID to delete
:return:
"""
user = db.session.execute(select(User).filter_by(id=user_id)).scalar()
if not user:
return jsonify({"success": False, 'message': 'User not found.'}), 404
if user == current_user:
return jsonify({'success': False, 'message': 'Cannot delete yourself.'}), 400
db.session.delete(user)
db.session.commit()
return jsonify({'success': True, 'message': f'User {user.email} deleted successfully'})
Source diff could not be displayed: it is too large. Options to address this: view the blob.
Source diff could not be displayed: it is too large. Options to address this: view the blob.
...@@ -2382,6 +2382,15 @@ ...@@ -2382,6 +2382,15 @@
!*** external {"root":"Survey","commonjs2":"survey-core","commonjs":"survey-core","amd":"survey-core"} ***! !*** external {"root":"Survey","commonjs2":"survey-core","commonjs":"survey-core","amd":"survey-core"} ***!
\*********************************************************************************************************/ \*********************************************************************************************************/
/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
/** /**
* @license React * @license React
* react-dom.production.min.js * react-dom.production.min.js
... ...
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment