From 733b249dc9d4c9645075c9c90329dd66e1eeebea Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Thu, 10 Oct 2024 00:50:59 +0200 Subject: [PATCH 01/20] Add input/display fields to the modal according to the action --- src/components/UserModal.tsx | 203 +++++++++++++++++++++++++-- src/screens/home/admin/UsersView.tsx | 8 -- 2 files changed, 194 insertions(+), 17 deletions(-) diff --git a/src/components/UserModal.tsx b/src/components/UserModal.tsx index 5293e414..f1cb47e0 100644 --- a/src/components/UserModal.tsx +++ b/src/components/UserModal.tsx @@ -1,11 +1,17 @@ +import { RegistrationKey, Role, User } from '@luna/api/auth/types'; import { Button, + Checkbox, + Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, + Select, + SelectItem, } from '@nextui-org/react'; +import { useEffect, useState } from 'react'; export interface UserModalProps { id: number; @@ -13,25 +19,204 @@ export interface UserModalProps { show: boolean; setShow: (show: boolean) => void; } + +// TODO: maybe split up the modal into AddUserModal, ShowUserModal, EditUserModal and DeleteUserModal +// FIXME: console warning: "WARN: A component changed from uncontrolled to controlled." export function UserModal({ id, action, show, setShow }: UserModalProps) { + const [user, setUser] = useState(null); + const [password, setPassword] = useState(''); + + // initialize modal state + useEffect(() => { + // only initialize when the modal is shown + if (!show) return; + + if (action === 'add') { + setUser({ + username: '', + }); + setPassword(''); + return; + } + // TODO: remove test data and query the API + const now = new Date(); + // TODO: call GET /users//roles + const roles: Role[] = [ + { + id: 1, + name: 'Testrole', + createdAt: now, + updatedAt: now, + }, + ]; + // TODO: call GET /users/ + const registrationKey: RegistrationKey = { + id: 1, + key: 'Test-Registration-Key', + description: 'Test-Registration-Key for testing purposes', + createdAt: now, + updatedAt: now, + expiresAt: now, + permanent: false, + }; + const user: User = { + username: 'Testuser', + email: 'test@example.com', + roles, + createdAt: now, + updatedAt: now, + lastSeen: now, + permanentApiToken: false, + registrationKey, + }; + + setPassword(''); + setUser(user); + }, [id, action, show]); + + const addUser = () => { + const payload = { + username: user?.username, + password: password, + email: user?.email, + permanent_api_token: user?.permanentApiToken, + }; + console.log('adding user:', payload); + // TODO: call POST /users + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const editUser = () => { + const payload = { + username: user?.username, + password: password, + email: user?.email, + permanent_api_token: user?.permanentApiToken, + }; + console.log('updating user:', payload); + // TODO: call PUT /users/ + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const deleteUser = () => { + console.log('deleting user with id', id); + // TODO: call DELETE /users/ + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setUser(null); + setPassword(''); + } + setShow(isOpen); + }; + return ( - setShow(isOpen)}> + {onClose => ( <> {action.charAt(0).toUpperCase() + action.slice(1)} User - ID: {id} - - + Permanent API Token + + {action === 'view' && ( + <> + {/* TODO: find better component for displaying list of roles */} + + + + )} + + + {action === 'add' && ( + + )} + {action === 'edit' && ( + + )} + {action === 'delete' && ( + + )} + )} diff --git a/src/screens/home/admin/UsersView.tsx b/src/screens/home/admin/UsersView.tsx index c3b88036..4a440868 100644 --- a/src/screens/home/admin/UsersView.tsx +++ b/src/screens/home/admin/UsersView.tsx @@ -103,9 +103,6 @@ export function UsersView() { E-Mail - {/* TODO: move to details modal: - Roles - */} Created At @@ -118,9 +115,6 @@ export function UsersView() { Permanent API-Token - {/* TODO: move to details modal: - Registration-Key - */} Actions @@ -129,7 +123,6 @@ export function UsersView() { {user.id} {user.username} {user.email} - {/* TODO: move to details modal: {user.roles?.map(role => role.name)} */} {user.createdAt?.toLocaleString()} {user.updatedAt?.toLocaleString()} {user.lastSeen?.toLocaleString()} @@ -144,7 +137,6 @@ export function UsersView() { )} - {/* TODO: move to details modal: {user.registrationKey?.key} */}
From e233aa7abaf83f6841106e95902db01f041fd495 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Thu, 10 Oct 2024 01:13:09 +0200 Subject: [PATCH 02/20] Change user state type to non-optional --- src/components/UserModal.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/UserModal.tsx b/src/components/UserModal.tsx index f1cb47e0..409f23f0 100644 --- a/src/components/UserModal.tsx +++ b/src/components/UserModal.tsx @@ -23,7 +23,7 @@ export interface UserModalProps { // TODO: maybe split up the modal into AddUserModal, ShowUserModal, EditUserModal and DeleteUserModal // FIXME: console warning: "WARN: A component changed from uncontrolled to controlled." export function UserModal({ id, action, show, setShow }: UserModalProps) { - const [user, setUser] = useState(null); + const [user, setUser] = useState({ username: '' }); const [password, setPassword] = useState(''); // initialize modal state @@ -76,10 +76,10 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { const addUser = () => { const payload = { - username: user?.username, + username: user.username, password: password, - email: user?.email, - permanent_api_token: user?.permanentApiToken, + email: user.email, + permanent_api_token: user.permanentApiToken, }; console.log('adding user:', payload); // TODO: call POST /users @@ -89,10 +89,10 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { const editUser = () => { const payload = { - username: user?.username, + username: user.username, password: password, - email: user?.email, - permanent_api_token: user?.permanentApiToken, + email: user.email, + permanent_api_token: user.permanentApiToken, }; console.log('updating user:', payload); // TODO: call PUT /users/ @@ -109,7 +109,7 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { const onOpenChange = (isOpen: boolean) => { if (!isOpen) { - setUser(null); + setUser({ username: '' }); setPassword(''); } setShow(isOpen); @@ -129,7 +129,7 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { )} { if (!user) return; setUser({ ...user, username }); @@ -147,7 +147,7 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { )} { if (!user) return; setUser({ ...user, email }); @@ -158,23 +158,23 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { <> )} { if (!user) return; setUser({ @@ -189,12 +189,12 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { {action === 'view' && ( <> {/* TODO: find better component for displaying list of roles */} - {role => {role.name}} From 3be4b3cc00d7986ee5f0034fc4083d8b1da32950 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Thu, 10 Oct 2024 01:14:49 +0200 Subject: [PATCH 03/20] Only show password field description on edit action --- src/components/UserModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/UserModal.tsx b/src/components/UserModal.tsx index 409f23f0..1c128377 100644 --- a/src/components/UserModal.tsx +++ b/src/components/UserModal.tsx @@ -140,7 +140,11 @@ export function UserModal({ id, action, show, setShow }: UserModalProps) { From ddb2bed958fcfc0a449d8cdbbe78787e055ae04c Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Wed, 16 Oct 2024 11:09:01 +0200 Subject: [PATCH 04/20] Split UserModal into different modals for Add, Details, Edit and Delete --- src/api/auth/types/User.ts | 22 +++ src/components/UserAddModal.tsx | 105 ++++++++++++ src/components/UserDeleteModal.tsx | 149 +++++++++++++++++ src/components/UserDetailsModal.tsx | 148 +++++++++++++++++ src/components/UserEditModal.tsx | 161 +++++++++++++++++++ src/components/UserModal.tsx | 230 --------------------------- src/screens/home/admin/UsersView.tsx | 50 ++++-- 7 files changed, 620 insertions(+), 245 deletions(-) create mode 100644 src/components/UserAddModal.tsx create mode 100644 src/components/UserDeleteModal.tsx create mode 100644 src/components/UserDetailsModal.tsx create mode 100644 src/components/UserEditModal.tsx delete mode 100644 src/components/UserModal.tsx diff --git a/src/api/auth/types/User.ts b/src/api/auth/types/User.ts index 9c1a7e93..115ad0a2 100644 --- a/src/api/auth/types/User.ts +++ b/src/api/auth/types/User.ts @@ -12,3 +12,25 @@ export interface User { permanentApiToken?: boolean; registrationKey?: RegistrationKey; } + +export function newUninitializedUser(): User { + return { + id: 0, + username: '', + email: '', + roles: [], + createdAt: new Date(0), + updatedAt: new Date(0), + lastSeen: new Date(0), + permanentApiToken: false, + registrationKey: { + id: 0, + key: '', + description: '', + createdAt: new Date(0), + updatedAt: new Date(0), + expiresAt: new Date(0), + permanent: false, + }, + }; +} diff --git a/src/components/UserAddModal.tsx b/src/components/UserAddModal.tsx new file mode 100644 index 00000000..d3870f2c --- /dev/null +++ b/src/components/UserAddModal.tsx @@ -0,0 +1,105 @@ +import { User } from '@luna/api/auth/types'; +import { + Button, + Checkbox, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@nextui-org/react'; +import { useEffect, useState } from 'react'; + +export interface UserAddModalProps { + show: boolean; + setShow: (show: boolean) => void; +} + +export function UserAddModal({ show, setShow }: UserAddModalProps) { + const [user, setUser] = useState({ username: '' }); + const [password, setPassword] = useState(''); + + // initialize/reset modal state + useEffect(() => { + setUser({ + username: '', + }); + setPassword(''); + }, [show]); + + const addUser = () => { + const payload = { + username: user.username, + password: password, + email: user.email, + permanent_api_token: user.permanentApiToken, + }; + console.log('adding user:', payload); + // TODO: call POST /users + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setUser({ username: '' }); + setPassword(''); + } + setShow(isOpen); + }; + + return ( + + + {onClose => ( + <> + Add User + + { + if (!user) return; + setUser({ ...user, username }); + }} + /> + + { + if (!user) return; + setUser({ ...user, email }); + }} + /> + { + if (!user) return; + setUser({ + ...user, + permanentApiToken, + }); + }} + > + Permanent API Token + + + + + + + + )} + + + ); +} diff --git a/src/components/UserDeleteModal.tsx b/src/components/UserDeleteModal.tsx new file mode 100644 index 00000000..d742d4fe --- /dev/null +++ b/src/components/UserDeleteModal.tsx @@ -0,0 +1,149 @@ +import { + newUninitializedUser, + RegistrationKey, + Role, + User, +} from '@luna/api/auth/types'; +import { + Button, + Checkbox, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@nextui-org/react'; +import { useEffect, useState } from 'react'; + +export interface UserDeleteModalProps { + id: number; + show: boolean; + setShow: (show: boolean) => void; +} + +export function UserDeleteModal({ id, show, setShow }: UserDeleteModalProps) { + const [user, setUser] = useState(newUninitializedUser()); + + // initialize modal state + useEffect(() => { + // only initialize when the modal is shown + if (!show) return; + + // TODO: remove test data and query the API + const now = new Date(); + // TODO: call GET /users//roles + const roles: Role[] = [ + { + id: 1, + name: 'Testrole', + createdAt: now, + updatedAt: now, + }, + ]; + // TODO: call GET /users/ + const registrationKey: RegistrationKey = { + id: 1, + key: 'Test-Registration-Key', + description: 'Test-Registration-Key for testing purposes', + createdAt: now, + updatedAt: now, + expiresAt: now, + permanent: false, + }; + const user: User = { + username: 'Testuser', + email: 'test@example.com', + roles, + createdAt: now, + updatedAt: now, + lastSeen: now, + permanentApiToken: false, + registrationKey, + }; + + setUser(user); + }, [id, show]); + + const deleteUser = () => { + console.log('deleting user with id', id); + // TODO: call DELETE /users/ + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setUser(newUninitializedUser()); + } + setShow(isOpen); + }; + + return ( + + + {onClose => ( + <> + Delete User + + + + { + if (!user) return; + setUser({ ...user, username }); + }} + isDisabled + /> + { + if (!user) return; + setUser({ ...user, email }); + }} + isDisabled + /> + + + + { + if (!user) return; + setUser({ + ...user, + permanentApiToken, + }); + }} + isDisabled + > + Permanent API Token + + + + + + + + )} + + + ); +} diff --git a/src/components/UserDetailsModal.tsx b/src/components/UserDetailsModal.tsx new file mode 100644 index 00000000..e7b1476a --- /dev/null +++ b/src/components/UserDetailsModal.tsx @@ -0,0 +1,148 @@ +import { + newUninitializedUser, + RegistrationKey, + Role, + User, +} from '@luna/api/auth/types'; +import { + Button, + Checkbox, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Select, + SelectItem, +} from '@nextui-org/react'; +import { useEffect, useState } from 'react'; + +export interface UserShowModalProps { + id: number; + show: boolean; + setShow: (show: boolean) => void; +} + +export function UserDetailsModal({ id, show, setShow }: UserShowModalProps) { + const [user, setUser] = useState(newUninitializedUser()); + + // initialize modal state + useEffect(() => { + // only initialize when the modal is shown + if (!show) return; + + // TODO: remove test data and query the API + const now = new Date(); + // TODO: call GET /users//roles + const roles: Role[] = [ + { + id: 1, + name: 'Testrole', + createdAt: now, + updatedAt: now, + }, + ]; + // TODO: call GET /users/ + const registrationKey: RegistrationKey = { + id: 1, + key: 'Test-Registration-Key', + description: 'Test-Registration-Key for testing purposes', + createdAt: now, + updatedAt: now, + expiresAt: now, + permanent: false, + }; + const user: User = { + username: 'Testuser', + email: 'test@example.com', + roles, + createdAt: now, + updatedAt: now, + lastSeen: now, + permanentApiToken: false, + registrationKey, + }; + setUser(user); + }, [id, show]); + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setUser(newUninitializedUser()); + } + setShow(isOpen); + }; + return ( + + + {onClose => ( + <> + User + + + { + if (!user) return; + setUser({ ...user, username }); + }} + isDisabled + /> + { + if (!user) return; + setUser({ ...user, email }); + }} + isDisabled + /> + + + + + { + if (!user) return; + setUser({ + ...user, + permanentApiToken, + }); + }} + isDisabled + > + Permanent API Token + + {/* TODO: find better component for displaying list of roles */} + + + + + + + + )} + + + ); +} diff --git a/src/components/UserEditModal.tsx b/src/components/UserEditModal.tsx new file mode 100644 index 00000000..3c20d1e5 --- /dev/null +++ b/src/components/UserEditModal.tsx @@ -0,0 +1,161 @@ +import { + newUninitializedUser, + RegistrationKey, + Role, + User, +} from '@luna/api/auth/types'; +import { + Button, + Checkbox, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@nextui-org/react'; +import { useEffect, useState } from 'react'; + +export interface UserEditModalProps { + id: number; + show: boolean; + setShow: (show: boolean) => void; +} + +export function UserEditModal({ id, show, setShow }: UserEditModalProps) { + const [user, setUser] = useState(newUninitializedUser()); + const [password, setPassword] = useState(''); + + // initialize modal state + useEffect(() => { + if (!show) return; // only initialize when the modal is shown + + // TODO: remove test data and query the API + const now = new Date(); + // TODO: call GET /users//roles + const roles: Role[] = [ + { + id: 1, + name: 'Testrole', + createdAt: now, + updatedAt: now, + }, + ]; + // TODO: call GET /users/ + const registrationKey: RegistrationKey = { + id: 1, + key: 'Test-Registration-Key', + description: 'Test-Registration-Key for testing purposes', + createdAt: now, + updatedAt: now, + expiresAt: now, + permanent: false, + }; + const user: User = { + username: 'Testuser', + email: 'test@example.com', + roles, + createdAt: now, + updatedAt: now, + lastSeen: now, + permanentApiToken: false, + registrationKey, + }; + + setPassword(''); + setUser(user); + }, [id, show]); + + const editUser = () => { + const payload = { + username: user.username, + password: password, + email: user.email, + permanent_api_token: user.permanentApiToken, + }; + console.log('updating user:', payload); + // TODO: call PUT /users/ + // TODO: feedback from the request (success, error) + onOpenChange(false); + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setUser(newUninitializedUser()); + setPassword(''); + } + setShow(isOpen); + }; + + return ( + + + {onClose => ( + <> + Edit User + + + { + if (!user) return; + setUser({ ...user, username }); + }} + /> + + + { + if (!user) return; + setUser({ ...user, email }); + }} + /> + + + + { + if (!user) return; + setUser({ + ...user, + permanentApiToken, + }); + }} + > + Permanent API Token + + + + + + + + )} + + + ); +} diff --git a/src/components/UserModal.tsx b/src/components/UserModal.tsx deleted file mode 100644 index 1c128377..00000000 --- a/src/components/UserModal.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { RegistrationKey, Role, User } from '@luna/api/auth/types'; -import { - Button, - Checkbox, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Select, - SelectItem, -} from '@nextui-org/react'; -import { useEffect, useState } from 'react'; - -export interface UserModalProps { - id: number; - action: 'view' | 'add' | 'edit' | 'delete'; - show: boolean; - setShow: (show: boolean) => void; -} - -// TODO: maybe split up the modal into AddUserModal, ShowUserModal, EditUserModal and DeleteUserModal -// FIXME: console warning: "WARN: A component changed from uncontrolled to controlled." -export function UserModal({ id, action, show, setShow }: UserModalProps) { - const [user, setUser] = useState({ username: '' }); - const [password, setPassword] = useState(''); - - // initialize modal state - useEffect(() => { - // only initialize when the modal is shown - if (!show) return; - - if (action === 'add') { - setUser({ - username: '', - }); - setPassword(''); - return; - } - // TODO: remove test data and query the API - const now = new Date(); - // TODO: call GET /users//roles - const roles: Role[] = [ - { - id: 1, - name: 'Testrole', - createdAt: now, - updatedAt: now, - }, - ]; - // TODO: call GET /users/ - const registrationKey: RegistrationKey = { - id: 1, - key: 'Test-Registration-Key', - description: 'Test-Registration-Key for testing purposes', - createdAt: now, - updatedAt: now, - expiresAt: now, - permanent: false, - }; - const user: User = { - username: 'Testuser', - email: 'test@example.com', - roles, - createdAt: now, - updatedAt: now, - lastSeen: now, - permanentApiToken: false, - registrationKey, - }; - - setPassword(''); - setUser(user); - }, [id, action, show]); - - const addUser = () => { - const payload = { - username: user.username, - password: password, - email: user.email, - permanent_api_token: user.permanentApiToken, - }; - console.log('adding user:', payload); - // TODO: call POST /users - // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const editUser = () => { - const payload = { - username: user.username, - password: password, - email: user.email, - permanent_api_token: user.permanentApiToken, - }; - console.log('updating user:', payload); - // TODO: call PUT /users/ - // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const deleteUser = () => { - console.log('deleting user with id', id); - // TODO: call DELETE /users/ - // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - setUser({ username: '' }); - setPassword(''); - } - setShow(isOpen); - }; - - return ( - - - {onClose => ( - <> - - {action.charAt(0).toUpperCase() + action.slice(1)} User - - - {action !== 'add' && ( - - )} - { - if (!user) return; - setUser({ ...user, username }); - }} - isDisabled={action === 'view' || action === 'delete'} - /> - {(action === 'add' || action === 'edit') && ( - - )} - { - if (!user) return; - setUser({ ...user, email }); - }} - isDisabled={action === 'view' || action === 'delete'} - /> - {action !== 'add' && ( - <> - - - - - )} - { - if (!user) return; - setUser({ - ...user, - permanentApiToken, - }); - }} - isDisabled={action === 'view' || action === 'delete'} - > - Permanent API Token - - {action === 'view' && ( - <> - {/* TODO: find better component for displaying list of roles */} - - - - )} - - - {action === 'add' && ( - - )} - {action === 'edit' && ( - - )} - {action === 'delete' && ( - - )} - - - - )} - - - ); -} diff --git a/src/screens/home/admin/UsersView.tsx b/src/screens/home/admin/UsersView.tsx index 4a440868..fbd09d3a 100644 --- a/src/screens/home/admin/UsersView.tsx +++ b/src/screens/home/admin/UsersView.tsx @@ -1,5 +1,8 @@ import { User } from '@luna/api/auth/types'; -import { UserModal } from '@luna/components/UserModal'; +import { UserAddModal } from '@luna/components/UserAddModal'; +import { UserDeleteModal } from '@luna/components/UserDeleteModal'; +import { UserDetailsModal } from '@luna/components/UserDetailsModal'; +import { UserEditModal } from '@luna/components/UserEditModal'; import { AuthContext } from '@luna/contexts/AuthContext'; import { HomeContent } from '@luna/screens/home/HomeContent'; import { getOrThrow } from '@luna/utils/result'; @@ -61,10 +64,11 @@ export function UsersView() { }, }); - const [userModal, setUserModal] = useState<{ - id: number; - action: 'add' | 'view' | 'edit' | 'delete'; - } | null>(null); + const [showUserAddModal, setShowUserAddModal] = useState(false); + const [showUserEditModal, setShowUserEditModal] = useState(false); + const [showUserDetailsModal, setShowUserDetailsModal] = useState(false); + const [showUserDeleteModal, setShowUserDeleteModal] = useState(false); + const [userId, setUserId] = useState(0); return ( // TODO: Lazy rendering @@ -72,18 +76,31 @@ export function UsersView() { title="Users" toolbar={ - } > - !show && setUserModal(null)} - > + + + + { - setUserModal({ id: user.id ?? 0, action: 'view' }); + setUserId(user.id ?? 0); + setShowUserDetailsModal(true); }} > @@ -151,7 +169,8 @@ export function UsersView() { { - setUserModal({ id: user.id ?? 0, action: 'edit' }); + setUserId(user.id ?? 0); + setShowUserEditModal(true); }} > @@ -159,7 +178,8 @@ export function UsersView() { { - setUserModal({ id: user.id ?? 0, action: 'delete' }); + setUserId(user.id ?? 0); + setShowUserDeleteModal(true); }} > From 4932d26ee79c45540076afb8b332c82900a12b56 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Wed, 16 Oct 2024 17:14:19 +0200 Subject: [PATCH 05/20] Wrap callbacks in useCallback and refactor --- src/components/UserAddModal.tsx | 35 +++++++++++----------------- src/components/UserDeleteModal.tsx | 28 ++++++++-------------- src/components/UserDetailsModal.tsx | 19 +++++---------- src/components/UserEditModal.tsx | 31 +++++++++--------------- src/screens/home/admin/UsersView.tsx | 14 +++++------ 5 files changed, 47 insertions(+), 80 deletions(-) diff --git a/src/components/UserAddModal.tsx b/src/components/UserAddModal.tsx index 317b1a12..de0a2878 100644 --- a/src/components/UserAddModal.tsx +++ b/src/components/UserAddModal.tsx @@ -1,4 +1,4 @@ -import { User } from '@luna/api/auth/types'; +import { newUninitializedUser, User } from '@luna/api/auth/types'; import { Button, Checkbox, @@ -9,26 +9,25 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export interface UserAddModalProps { - show: boolean; - setShow: (show: boolean) => void; + isOpen: boolean; + setOpen: (show: boolean) => void; } -export function UserAddModal({ show, setShow }: UserAddModalProps) { - const [user, setUser] = useState({ username: '' }); +export function UserAddModal({ isOpen, setOpen }: UserAddModalProps) { + const [user, setUser] = useState(newUninitializedUser()); const [password, setPassword] = useState(''); // initialize/reset modal state useEffect(() => { - setUser({ - username: '', - }); + if (!isOpen) return; + setUser(newUninitializedUser()); setPassword(''); - }, [show]); + }, [isOpen]); - const addUser = () => { + const addUser = useCallback(() => { const payload = { username: user.username, password, @@ -38,19 +37,11 @@ export function UserAddModal({ show, setShow }: UserAddModalProps) { console.log('adding user:', payload); // TODO: call POST /users // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - setUser({ username: '' }); - setPassword(''); - } - setShow(isOpen); - }; + setOpen(false); + }, [setOpen, user, password]); return ( - + {onClose => ( <> diff --git a/src/components/UserDeleteModal.tsx b/src/components/UserDeleteModal.tsx index d742d4fe..24a54f2e 100644 --- a/src/components/UserDeleteModal.tsx +++ b/src/components/UserDeleteModal.tsx @@ -14,21 +14,20 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export interface UserDeleteModalProps { id: number; - show: boolean; - setShow: (show: boolean) => void; + isOpen: boolean; + setOpen: (open: boolean) => void; } -export function UserDeleteModal({ id, show, setShow }: UserDeleteModalProps) { +export function UserDeleteModal({ id, isOpen, setOpen }: UserDeleteModalProps) { const [user, setUser] = useState(newUninitializedUser()); // initialize modal state useEffect(() => { - // only initialize when the modal is shown - if (!show) return; + if (!isOpen) return; // TODO: remove test data and query the API const now = new Date(); @@ -63,24 +62,17 @@ export function UserDeleteModal({ id, show, setShow }: UserDeleteModalProps) { }; setUser(user); - }, [id, show]); + }, [id, isOpen]); - const deleteUser = () => { + const deleteUser = useCallback(() => { console.log('deleting user with id', id); // TODO: call DELETE /users/ // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - setUser(newUninitializedUser()); - } - setShow(isOpen); - }; + setOpen(false); + }, [id, setOpen]); return ( - + {onClose => ( <> diff --git a/src/components/UserDetailsModal.tsx b/src/components/UserDetailsModal.tsx index e7b1476a..8bc8dcc7 100644 --- a/src/components/UserDetailsModal.tsx +++ b/src/components/UserDetailsModal.tsx @@ -20,17 +20,16 @@ import { useEffect, useState } from 'react'; export interface UserShowModalProps { id: number; - show: boolean; - setShow: (show: boolean) => void; + isOpen: boolean; + setOpen: (open: boolean) => void; } -export function UserDetailsModal({ id, show, setShow }: UserShowModalProps) { +export function UserDetailsModal({ id, isOpen, setOpen }: UserShowModalProps) { const [user, setUser] = useState(newUninitializedUser()); // initialize modal state useEffect(() => { - // only initialize when the modal is shown - if (!show) return; + if (!isOpen) return; // TODO: remove test data and query the API const now = new Date(); @@ -64,16 +63,10 @@ export function UserDetailsModal({ id, show, setShow }: UserShowModalProps) { registrationKey, }; setUser(user); - }, [id, show]); + }, [id, isOpen]); - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - setUser(newUninitializedUser()); - } - setShow(isOpen); - }; return ( - + {onClose => ( <> diff --git a/src/components/UserEditModal.tsx b/src/components/UserEditModal.tsx index 4e3a41f8..895e7f66 100644 --- a/src/components/UserEditModal.tsx +++ b/src/components/UserEditModal.tsx @@ -14,21 +14,21 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export interface UserEditModalProps { id: number; - show: boolean; - setShow: (show: boolean) => void; + isOpen: boolean; + setOpen: (open: boolean) => void; } -export function UserEditModal({ id, show, setShow }: UserEditModalProps) { +export function UserEditModal({ id, isOpen, setOpen }: UserEditModalProps) { const [user, setUser] = useState(newUninitializedUser()); const [password, setPassword] = useState(''); // initialize modal state useEffect(() => { - if (!show) return; // only initialize when the modal is shown + if (!isOpen) return; // TODO: remove test data and query the API const now = new Date(); @@ -64,31 +64,23 @@ export function UserEditModal({ id, show, setShow }: UserEditModalProps) { setPassword(''); setUser(user); - }, [id, show]); + }, [id, isOpen]); - const editUser = () => { + const editUser = useCallback(() => { const payload = { username: user.username, password, email: user.email, permanent_api_token: user.permanentApiToken, }; - console.log('updating user:', payload); + console.log('updating user', id, ':', payload); // TODO: call PUT /users/ // TODO: feedback from the request (success, error) - onOpenChange(false); - }; - - const onOpenChange = (isOpen: boolean) => { - if (!isOpen) { - setUser(newUninitializedUser()); - setPassword(''); - } - setShow(isOpen); - }; + setOpen(false); + }, [id, setOpen, user, password]); return ( - + {onClose => ( <> @@ -103,7 +95,6 @@ export function UserEditModal({ id, show, setShow }: UserEditModalProps) { setUser({ ...user, username }); }} /> - } > - +
Date: Sat, 19 Oct 2024 14:25:46 +0200 Subject: [PATCH 06/20] Make fields in types required --- src/components/UserDeleteModal.tsx | 7 ++-- src/components/UserDetailsModal.tsx | 7 ++-- src/components/UserEditModal.tsx | 7 ++-- src/components/UserSnippet.tsx | 2 +- src/contexts/api/auth/convert.ts | 38 +++++++++---------- .../api/auth/types/RegistrationKey.ts | 10 ++--- src/contexts/api/auth/types/Role.ts | 4 +- src/contexts/api/auth/types/Token.ts | 8 ++-- src/contexts/api/auth/types/User.ts | 14 +++---- src/screens/home/admin/UsersView.tsx | 12 +++--- .../home/displays/DisplayInspector.tsx | 2 +- 11 files changed, 54 insertions(+), 57 deletions(-) diff --git a/src/components/UserDeleteModal.tsx b/src/components/UserDeleteModal.tsx index d0702ead..5c86317e 100644 --- a/src/components/UserDeleteModal.tsx +++ b/src/components/UserDeleteModal.tsx @@ -51,6 +51,7 @@ export function UserDeleteModal({ id, isOpen, setOpen }: UserDeleteModalProps) { permanent: false, }; const user: User = { + id: 1, username: 'Testuser', email: 'test@example.com', roles, @@ -100,17 +101,17 @@ export function UserDeleteModal({ id, isOpen, setOpen }: UserDeleteModalProps) { /> : null} + description={} /> diff --git a/src/contexts/api/auth/convert.ts b/src/contexts/api/auth/convert.ts index 9e691281..0f099b03 100644 --- a/src/contexts/api/auth/convert.ts +++ b/src/contexts/api/auth/convert.ts @@ -26,20 +26,22 @@ export function signupToApi(signup: Signup): generated.RegisterPayload { export function tokenFromApi(apiToken: generated.APIToken): Token { return { value: apiToken.api_token!, - expiresAt: apiToken.expires_at ? new Date(apiToken.expires_at) : undefined, + expiresAt: new Date(apiToken.expires_at!), + username: apiToken.username!, + roles: apiToken.roles!, }; } export function userFromApi(apiUser: generated.User): User { return { - id: apiUser.id, + id: apiUser.id!, username: apiUser.username!, - email: apiUser.email, - roles: undefined, // TODO: change role to roles - createdAt: apiUser.created_at ? new Date(apiUser.created_at) : undefined, - updatedAt: apiUser.updated_at ? new Date(apiUser.updated_at) : undefined, - lastSeen: apiUser.last_login ? new Date(apiUser.last_login) : undefined, - permanentApiToken: apiUser.permanent_api_token, + email: apiUser.email!, + roles: [], // TODO: re-run code generator and use roles + createdAt: new Date(apiUser.created_at!), + updatedAt: new Date(apiUser.updated_at!), + lastSeen: new Date(apiUser.last_login!), + permanentApiToken: apiUser.permanent_api_token!, registrationKey: apiUser.registration_key ? registrationKeyFromApi(apiUser.registration_key) : undefined, @@ -50,18 +52,12 @@ export function registrationKeyFromApi( registrationKey: generated.RegistrationKey ): RegistrationKey { return { - id: registrationKey.id ?? 0, - key: registrationKey.key ?? '', - description: registrationKey.description, - createdAt: registrationKey.created_at - ? new Date(registrationKey.created_at) - : undefined, - updatedAt: registrationKey.updated_at - ? new Date(registrationKey.updated_at) - : undefined, - expiresAt: registrationKey.expires_at - ? new Date(registrationKey.expires_at) - : undefined, - permanent: registrationKey.permanent, + id: registrationKey.id!, + key: registrationKey.key!, + description: registrationKey.description!, + createdAt: new Date(registrationKey.created_at!), + updatedAt: new Date(registrationKey.updated_at!), + expiresAt: new Date(registrationKey.expires_at!), + permanent: registrationKey.permanent!, }; } diff --git a/src/contexts/api/auth/types/RegistrationKey.ts b/src/contexts/api/auth/types/RegistrationKey.ts index aa969486..cf094ab2 100644 --- a/src/contexts/api/auth/types/RegistrationKey.ts +++ b/src/contexts/api/auth/types/RegistrationKey.ts @@ -1,9 +1,9 @@ export interface RegistrationKey { id: number; key: string; - description?: string; - createdAt?: Date; - updatedAt?: Date; - expiresAt?: Date; - permanent?: boolean; + description: string; + createdAt: Date; + updatedAt: Date; + expiresAt: Date; + permanent: boolean; } diff --git a/src/contexts/api/auth/types/Role.ts b/src/contexts/api/auth/types/Role.ts index 6bff4ca6..f20c0c6f 100644 --- a/src/contexts/api/auth/types/Role.ts +++ b/src/contexts/api/auth/types/Role.ts @@ -1,6 +1,6 @@ export interface Role { id: number; name: string; - createdAt?: Date; - updatedAt?: Date; + createdAt: Date; + updatedAt: Date; } diff --git a/src/contexts/api/auth/types/Token.ts b/src/contexts/api/auth/types/Token.ts index ab3eb3f7..8e43eca6 100644 --- a/src/contexts/api/auth/types/Token.ts +++ b/src/contexts/api/auth/types/Token.ts @@ -1,8 +1,6 @@ -import { Role } from '@luna/contexts/api/auth/types/Role'; - export interface Token { value: string; - expiresAt?: Date; - username?: string; - roles?: Role[]; + expiresAt: Date; + username: string; + roles: string[]; } diff --git a/src/contexts/api/auth/types/User.ts b/src/contexts/api/auth/types/User.ts index 0e139fc8..3efd6be9 100644 --- a/src/contexts/api/auth/types/User.ts +++ b/src/contexts/api/auth/types/User.ts @@ -2,14 +2,14 @@ import { Role } from '@luna/contexts/api/auth/types'; import { RegistrationKey } from '@luna/contexts/api/auth/types/RegistrationKey'; export interface User { - id?: number; + id: number; username: string; - email?: string; - roles?: Role[]; - createdAt?: Date; - updatedAt?: Date; - lastSeen?: Date; - permanentApiToken?: boolean; + email: string; + roles: Role[]; + createdAt: Date; + updatedAt: Date; + lastSeen: Date; + permanentApiToken: boolean; registrationKey?: RegistrationKey; } diff --git a/src/screens/home/admin/UsersView.tsx b/src/screens/home/admin/UsersView.tsx index 118e3979..48a30547 100644 --- a/src/screens/home/admin/UsersView.tsx +++ b/src/screens/home/admin/UsersView.tsx @@ -160,9 +160,9 @@ export function UsersView() { {user.id} {user.username} {user.email} - {user.createdAt?.toLocaleString()} - {user.updatedAt?.toLocaleString()} - {user.lastSeen?.toLocaleString()} + {user.createdAt.toLocaleString()} + {user.updatedAt.toLocaleString()} + {user.lastSeen.toLocaleString()} {user.permanentApiToken ? ( @@ -180,7 +180,7 @@ export function UsersView() { { - setUserId(user.id ?? 0); + setUserId(user.id); setShowUserDetailsModal(true); }} /> @@ -189,7 +189,7 @@ export function UsersView() { { - setUserId(user.id ?? 0); + setUserId(user.id); setShowUserEditModal(true); }} /> @@ -198,7 +198,7 @@ export function UsersView() { { - setUserId(user.id ?? 0); + setUserId(user.id); setShowUserDeleteModal(true); }} /> diff --git a/src/screens/home/displays/DisplayInspector.tsx b/src/screens/home/displays/DisplayInspector.tsx index 7965ce23..9bb2a908 100644 --- a/src/screens/home/displays/DisplayInspector.tsx +++ b/src/screens/home/displays/DisplayInspector.tsx @@ -12,7 +12,7 @@ export function DisplayInspector({ username }: DisplayInspectorProps) { const { user: me } = useContext(AuthContext); const isMeOrAdmin = username === me?.username || - me?.roles?.find(role => role.name === 'admin') !== undefined; + me?.roles.find(role => role.name === 'admin') !== undefined; return (
From 65b4ba7a0c176822f5f3c46536b828f7a168f843 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Sat, 19 Oct 2024 15:33:38 +0200 Subject: [PATCH 07/20] Add api implementation for user CRUD --- src/contexts/api/auth/AuthContext.tsx | 70 +++++++++++++++++-- src/contexts/api/auth/convert.ts | 12 ++++ .../auth/types/CreateOrUpdateUserPayload.ts | 6 ++ 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 src/contexts/api/auth/types/CreateOrUpdateUserPayload.ts diff --git a/src/contexts/api/auth/AuthContext.tsx b/src/contexts/api/auth/AuthContext.tsx index 72348ae5..0f2c38ec 100644 --- a/src/contexts/api/auth/AuthContext.tsx +++ b/src/contexts/api/auth/AuthContext.tsx @@ -1,6 +1,7 @@ import * as convert from '@luna/contexts/api/auth/convert'; import * as generated from '@luna/contexts/api/auth/generated'; import { Login, Signup, Token, User } from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; import { useInitRef } from '@luna/hooks/useInitRef'; import { Pagination, slicePage } from '@luna/utils/pagination'; import { errorResult, okResult, Result } from '@luna/utils/result'; @@ -34,9 +35,17 @@ export interface AuthContextValue { /** Fetches all users. */ getAllUsers(pagination?: Pagination): Promise>; - - /** Fetches the public users. */ - getPublicUsers(pagination?: Pagination): Promise>; + /** Gets a user by id */ + getUserById(id: number): Promise>; + /** Creates a new user */ + createUser(payload: CreateOrUpdateUserPayload): Promise>; + /** Updates an existing user */ + updateUser( + id: number, + payload: CreateOrUpdateUserPayload + ): Promise>; + /** Deletes a user */ + deleteUser(id: number): Promise>; } export const AuthContext = createContext({ @@ -47,7 +56,10 @@ export const AuthContext = createContext({ logIn: async () => errorResult('No auth context for logging in'), logOut: async () => errorResult('No auth context for logging out'), getAllUsers: async () => errorResult('No auth context for fetching users'), - getPublicUsers: async () => errorResult('No auth context for fetching users'), + getUserById: async () => errorResult('No auth context for fetching users'), + createUser: async () => errorResult('No auth context for fetching users'), + updateUser: async () => errorResult('No auth context for fetching users'), + deleteUser: async () => errorResult('No auth context for fetching users'), }); interface AuthContextProviderProps { @@ -159,9 +171,53 @@ export function AuthContextProvider({ children }: AuthContextProviderProps) { } }, - async getPublicUsers(pagination) { - // TODO: We currently don't have a concept of public users (Heimdall) - return this.getAllUsers(pagination); + async getUserById(id: number) { + try { + const apiUserResponse = await apiRef.current.users.getUserByName(id); + return okResult(convert.userFromApi(apiUserResponse.data)); + } catch (error) { + return errorResult( + `Fetching user with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async createUser(payload: CreateOrUpdateUserPayload) { + try { + await apiRef.current.users.usersCreate( + convert.createOrUpdateUserPayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Creating user failed: ${await formatError(error)}` + ); + } + }, + + async updateUser(id: number, payload: CreateOrUpdateUserPayload) { + try { + await apiRef.current.users.usersUpdate( + id, + convert.createOrUpdateUserPayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Updating user with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async deleteUser(id: number) { + try { + await apiRef.current.users.usersDelete(id); + return okResult(undefined); + } catch (error) { + return errorResult( + `Deleting user with id ${id} failed: ${await formatError(error)}` + ); + } }, }), [apiRef, isInitialized, token, user] diff --git a/src/contexts/api/auth/convert.ts b/src/contexts/api/auth/convert.ts index 0f099b03..4d45ac99 100644 --- a/src/contexts/api/auth/convert.ts +++ b/src/contexts/api/auth/convert.ts @@ -6,6 +6,7 @@ import { User, RegistrationKey, } from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; export function loginToApi(login?: Login): generated.LoginPayload { return { @@ -61,3 +62,14 @@ export function registrationKeyFromApi( permanent: registrationKey.permanent!, }; } + +export function createOrUpdateUserPayloadToApi( + payload: CreateOrUpdateUserPayload +): generated.CreateOrUpdateUserPayload { + return { + username: payload.username, + password: payload.password, + email: payload.email, + permanent_api_token: payload.permanent_api_token, + }; +} diff --git a/src/contexts/api/auth/types/CreateOrUpdateUserPayload.ts b/src/contexts/api/auth/types/CreateOrUpdateUserPayload.ts new file mode 100644 index 00000000..e7c51566 --- /dev/null +++ b/src/contexts/api/auth/types/CreateOrUpdateUserPayload.ts @@ -0,0 +1,6 @@ +export interface CreateOrUpdateUserPayload { + username: string; + password: string; + email: string; + permanent_api_token: boolean; +} From 14d545b0c961561f2e547bf7e757bd24c7f52b74 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Sat, 19 Oct 2024 15:39:27 +0200 Subject: [PATCH 08/20] Fix ModelContext use getAllUsers instead of getPublicUsers --- src/contexts/api/model/ModelContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contexts/api/model/ModelContext.tsx b/src/contexts/api/model/ModelContext.tsx index 00e7c45c..872fadf9 100644 --- a/src/contexts/api/model/ModelContext.tsx +++ b/src/contexts/api/model/ModelContext.tsx @@ -103,7 +103,7 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) { async function* () { if (!isLoggedIn || !client) return; try { - const users = getOrThrow(await auth.getPublicUsers()); + const users = getOrThrow(await auth.getAllUsers()); // Make sure that every user has at least a black frame for (const { username } of users) { yield { username, frame: new Uint8Array(LIGHTHOUSE_FRAME_BYTES) }; From b6510e272b3242cfcc19f8a29f97b831d3f662d2 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Sat, 19 Oct 2024 16:01:26 +0200 Subject: [PATCH 09/20] Fetch data and access the API --- src/components/UserAddModal.tsx | 22 ++++++--- src/components/UserDeleteModal.tsx | 56 ++++++--------------- src/components/UserDetailsModal.tsx | 56 ++++++--------------- src/components/UserEditModal.tsx | 75 +++++++++++------------------ 4 files changed, 72 insertions(+), 137 deletions(-) diff --git a/src/components/UserAddModal.tsx b/src/components/UserAddModal.tsx index c73726f7..ab10f49b 100644 --- a/src/components/UserAddModal.tsx +++ b/src/components/UserAddModal.tsx @@ -1,4 +1,6 @@ +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; import { newUninitializedUser, User } from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; import { Button, Checkbox, @@ -9,7 +11,7 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; export interface UserAddModalProps { isOpen: boolean; @@ -27,18 +29,24 @@ export function UserAddModal({ isOpen, setOpen }: UserAddModalProps) { setPassword(''); }, [isOpen]); - const addUser = useCallback(() => { - const payload = { + const auth = useContext(AuthContext); + + const addUser = useCallback(async () => { + const payload: CreateOrUpdateUserPayload = { username: user.username, password, email: user.email, permanent_api_token: user.permanentApiToken, }; - console.log('adding user:', payload); - // TODO: call POST /users - // TODO: feedback from the request (success, error) + const result = await auth.createUser(payload); + if (result.ok) { + console.log('added user:', payload); + } else { + console.log('failed to add user:', result.error); + } + // TODO: UI feedback from the request (success, error) setOpen(false); - }, [setOpen, user, password]); + }, [setOpen, user, password, auth]); return ( diff --git a/src/components/UserDeleteModal.tsx b/src/components/UserDeleteModal.tsx index 5c86317e..2c3fb99e 100644 --- a/src/components/UserDeleteModal.tsx +++ b/src/components/UserDeleteModal.tsx @@ -1,9 +1,5 @@ -import { - newUninitializedUser, - RegistrationKey, - Role, - User, -} from '@luna/contexts/api/auth/types'; +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; +import { newUninitializedUser, User } from '@luna/contexts/api/auth/types'; import { Button, Checkbox, @@ -14,7 +10,7 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; export interface UserDeleteModalProps { id: number; @@ -25,45 +21,23 @@ export interface UserDeleteModalProps { export function UserDeleteModal({ id, isOpen, setOpen }: UserDeleteModalProps) { const [user, setUser] = useState(newUninitializedUser()); + const auth = useContext(AuthContext); + // initialize modal state useEffect(() => { if (!isOpen) return; - // TODO: remove test data and query the API - const now = new Date(); - // TODO: call GET /users//roles - const roles: Role[] = [ - { - id: 1, - name: 'Testrole', - createdAt: now, - updatedAt: now, - }, - ]; - // TODO: call GET /users/ - const registrationKey: RegistrationKey = { - id: 1, - key: 'Test-Registration-Key', - description: 'Test-Registration-Key for testing purposes', - createdAt: now, - updatedAt: now, - expiresAt: now, - permanent: false, + const fetchUser = async () => { + const userResult = await auth.getUserById(id); + if (userResult.ok) { + setUser(userResult.value); + } else { + console.log('Fetching user failed:', userResult.error); + setUser(newUninitializedUser()); + } }; - const user: User = { - id: 1, - username: 'Testuser', - email: 'test@example.com', - roles, - createdAt: now, - updatedAt: now, - lastSeen: now, - permanentApiToken: false, - registrationKey, - }; - - setUser(user); - }, [id, isOpen]); + fetchUser(); + }, [id, isOpen, auth]); const deleteUser = useCallback(() => { console.log('deleting user with id', id); diff --git a/src/components/UserDetailsModal.tsx b/src/components/UserDetailsModal.tsx index a8e72b82..36dd42b4 100644 --- a/src/components/UserDetailsModal.tsx +++ b/src/components/UserDetailsModal.tsx @@ -1,9 +1,5 @@ -import { - newUninitializedUser, - RegistrationKey, - Role, - User, -} from '@luna/contexts/api/auth/types'; +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; +import { newUninitializedUser, User } from '@luna/contexts/api/auth/types'; import { Button, Checkbox, @@ -16,7 +12,7 @@ import { Select, SelectItem, } from '@nextui-org/react'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; export interface UserShowModalProps { id: number; @@ -27,44 +23,22 @@ export interface UserShowModalProps { export function UserDetailsModal({ id, isOpen, setOpen }: UserShowModalProps) { const [user, setUser] = useState(newUninitializedUser()); + const auth = useContext(AuthContext); + // initialize modal state useEffect(() => { if (!isOpen) return; - - // TODO: remove test data and query the API - const now = new Date(); - // TODO: call GET /users//roles - const roles: Role[] = [ - { - id: 1, - name: 'Testrole', - createdAt: now, - updatedAt: now, - }, - ]; - // TODO: call GET /users/ - const registrationKey: RegistrationKey = { - id: 1, - key: 'Test-Registration-Key', - description: 'Test-Registration-Key for testing purposes', - createdAt: now, - updatedAt: now, - expiresAt: now, - permanent: false, - }; - const user: User = { - id: 1, - username: 'Testuser', - email: 'test@example.com', - roles, - createdAt: now, - updatedAt: now, - lastSeen: now, - permanentApiToken: false, - registrationKey, + const fetchUser = async () => { + const userResult = await auth.getUserById(id); + if (userResult.ok) { + setUser(userResult.value); + } else { + console.log('Failed to fetch user:', userResult.error); + setUser(newUninitializedUser); + } }; - setUser(user); - }, [id, isOpen]); + fetchUser(); + }, [auth, id, isOpen, user]); return ( diff --git a/src/components/UserEditModal.tsx b/src/components/UserEditModal.tsx index 722698df..df5df658 100644 --- a/src/components/UserEditModal.tsx +++ b/src/components/UserEditModal.tsx @@ -1,9 +1,7 @@ -import { - newUninitializedUser, - RegistrationKey, - Role, - User, -} from '@luna/contexts/api/auth/types'; +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; + +import { newUninitializedUser, User } from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; import { Button, Checkbox, @@ -14,7 +12,7 @@ import { ModalFooter, ModalHeader, } from '@nextui-org/react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useState } from 'react'; export interface UserEditModalProps { id: number; @@ -26,59 +24,40 @@ export function UserEditModal({ id, isOpen, setOpen }: UserEditModalProps) { const [user, setUser] = useState(newUninitializedUser()); const [password, setPassword] = useState(''); + const auth = useContext(AuthContext); + // initialize modal state useEffect(() => { if (!isOpen) return; - - // TODO: remove test data and query the API - const now = new Date(); - // TODO: call GET /users//roles - const roles: Role[] = [ - { - id: 1, - name: 'Testrole', - createdAt: now, - updatedAt: now, - }, - ]; - // TODO: call GET /users/ - const registrationKey: RegistrationKey = { - id: 1, - key: 'Test-Registration-Key', - description: 'Test-Registration-Key for testing purposes', - createdAt: now, - updatedAt: now, - expiresAt: now, - permanent: false, - }; - const user: User = { - id: 1, - username: 'Testuser', - email: 'test@example.com', - roles, - createdAt: now, - updatedAt: now, - lastSeen: now, - permanentApiToken: false, - registrationKey, + const fetchUser = async () => { + const userResult = await auth.getUserById(id); + if (userResult.ok) { + setUser(userResult.value); + setPassword(''); + } else { + setUser(newUninitializedUser()); + setPassword(''); + } }; + fetchUser(); + }, [id, isOpen, auth]); - setPassword(''); - setUser(user); - }, [id, isOpen]); - - const editUser = useCallback(() => { - const payload = { + const editUser = useCallback(async () => { + const payload: CreateOrUpdateUserPayload = { username: user.username, password, email: user.email, permanent_api_token: user.permanentApiToken, }; - console.log('updating user', id, ':', payload); - // TODO: call PUT /users/ + const result = await auth.updateUser(id, payload); + if (result.ok) { + console.log('Updated user', id, ':', payload); + } else { + console.log('Update user failed:', result.error); + } // TODO: feedback from the request (success, error) setOpen(false); - }, [id, setOpen, user, password]); + }, [user, password, auth, id, setOpen]); return ( From e20ba74d8bbc3375d944e7e39e9f729ccfe26e23 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 12 Nov 2024 23:00:29 +0100 Subject: [PATCH 10/20] Implement remaining API routes in AuthContext --- src/contexts/api/auth/AuthContext.tsx | 193 +++++++++++++++++- src/contexts/api/auth/convert.ts | 31 +++ src/contexts/api/auth/generated.ts | 2 +- .../CreateOrUpdateRegistrationKeyPayload.ts | 6 + .../auth/types/CreateOrUpdateRolePayload.ts | 3 + 5 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 src/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload.ts create mode 100644 src/contexts/api/auth/types/CreateOrUpdateRolePayload.ts diff --git a/src/contexts/api/auth/AuthContext.tsx b/src/contexts/api/auth/AuthContext.tsx index 0f2c38ec..ff233d7d 100644 --- a/src/contexts/api/auth/AuthContext.tsx +++ b/src/contexts/api/auth/AuthContext.tsx @@ -1,6 +1,15 @@ import * as convert from '@luna/contexts/api/auth/convert'; import * as generated from '@luna/contexts/api/auth/generated'; -import { Login, Signup, Token, User } from '@luna/contexts/api/auth/types'; +import { + Login, + RegistrationKey, + Role, + Signup, + Token, + User, +} from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateRegistrationKeyPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload'; +import { CreateOrUpdateRolePayload } from '@luna/contexts/api/auth/types/CreateOrUpdateRolePayload'; import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; import { useInitRef } from '@luna/hooks/useInitRef'; import { Pagination, slicePage } from '@luna/utils/pagination'; @@ -46,6 +55,34 @@ export interface AuthContextValue { ): Promise>; /** Deletes a user */ deleteUser(id: number): Promise>; + /** Fetches all roles */ + getAllRoles(): Promise>; + /** Gets a role by id */ + getRoleById(id: number): Promise>; + /** Creates a new role */ + createRole(payload: CreateOrUpdateRolePayload): Promise>; + /** Updates an existing role */ + updateRole( + id: number, + payload: CreateOrUpdateRolePayload + ): Promise>; + /** Deletes a role */ + deleteRole(id: number): Promise>; + /** Fetches all registration keys */ + getAllRegistrationKeys(): Promise>; + /** Gets a registration key by id */ + getRegistrationKeyById(id: number): Promise>; + /** Creates a new registration key */ + createRegistrationKey( + payload: CreateOrUpdateRegistrationKeyPayload + ): Promise>; + /** Updates an existing registration key */ + updateRegistrationKey( + id: number, + payload: CreateOrUpdateRegistrationKeyPayload + ): Promise>; + /** Deletes a registration key */ + deleteRegistrationKey(id: number): Promise>; } export const AuthContext = createContext({ @@ -56,10 +93,25 @@ export const AuthContext = createContext({ logIn: async () => errorResult('No auth context for logging in'), logOut: async () => errorResult('No auth context for logging out'), getAllUsers: async () => errorResult('No auth context for fetching users'), - getUserById: async () => errorResult('No auth context for fetching users'), - createUser: async () => errorResult('No auth context for fetching users'), - updateUser: async () => errorResult('No auth context for fetching users'), - deleteUser: async () => errorResult('No auth context for fetching users'), + getUserById: async () => errorResult('No auth context for fetching user'), + createUser: async () => errorResult('No auth context for creating user'), + updateUser: async () => errorResult('No auth context for updating user'), + deleteUser: async () => errorResult('No auth context for deleting user'), + getAllRoles: async () => errorResult('No auth context for fetching roles'), + getRoleById: async () => errorResult('No auth context for fetching role'), + createRole: async () => errorResult('No auth context for creating role'), + updateRole: async () => errorResult('No auth context for updating role'), + deleteRole: async () => errorResult('No auth context for deleting role'), + getAllRegistrationKeys: async () => + errorResult('No auth context for fetching registration keys'), + getRegistrationKeyById: async () => + errorResult('No auth context for fetching registration key'), + createRegistrationKey: async () => + errorResult('No auth context for creating registration key'), + updateRegistrationKey: async () => + errorResult('No auth context for updating registration key'), + deleteRegistrationKey: async () => + errorResult('No auth context for deleting registration key'), }); interface AuthContextProviderProps { @@ -219,6 +271,137 @@ export function AuthContextProvider({ children }: AuthContextProviderProps) { ); } }, + + async getAllRoles() { + try { + const apiRolesResponse = await apiRef.current.roles.rolesList(); + let apiRoles: generated.Role[] = apiRolesResponse.data; + return okResult(apiRoles.map(convert.roleFromApi)); + } catch (error) { + return errorResult( + `Fetching all roles failed: ${await formatError(error)}` + ); + } + }, + + async getRoleById(id: number) { + try { + const apiRoleResponse = await apiRef.current.roles.rolesDetail(id); + return okResult(convert.roleFromApi(apiRoleResponse.data)); + } catch (error) { + return errorResult( + `Fetching role with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async createRole(payload: CreateOrUpdateRolePayload) { + try { + await apiRef.current.roles.rolesCreate( + convert.createOrUpdateRolePayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Creating role failed: ${await formatError(error)}` + ); + } + }, + + async updateRole(id: number, payload: CreateOrUpdateRolePayload) { + try { + await apiRef.current.roles.rolesUpdate( + id, + convert.createOrUpdateRolePayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Updating role with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async deleteRole(id: number) { + try { + await apiRef.current.roles.rolesDelete(id); + return okResult(undefined); + } catch (error) { + return errorResult( + `Deleting role with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async getAllRegistrationKeys() { + try { + const apiRegKeysResponse = + await apiRef.current.registrationKeys.registrationKeysList(); + let apiRegKeys: generated.Role[] = apiRegKeysResponse.data; + return okResult(apiRegKeys.map(convert.registrationKeyFromApi)); + } catch (error) { + return errorResult( + `Fetching all registration keys failed: ${await formatError(error)}` + ); + } + }, + + async getRegistrationKeyById(id: number) { + try { + const apiRegKeyResponse = + await apiRef.current.registrationKeys.registrationKeysDetail(id); + return okResult( + convert.registrationKeyFromApi(apiRegKeyResponse.data) + ); + } catch (error) { + return errorResult( + `Fetching registration key with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async createRegistrationKey( + payload: CreateOrUpdateRegistrationKeyPayload + ) { + try { + await apiRef.current.registrationKeys.registrationKeysCreate( + convert.createOrUpdateRegistrationKeyPayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Creating registration key failed: ${await formatError(error)}` + ); + } + }, + + async updateRegistrationKey( + id: number, + payload: CreateOrUpdateRegistrationKeyPayload + ) { + try { + await apiRef.current.registrationKeys.registrationKeysUpdate( + id, + convert.createOrUpdateRegistrationKeyPayloadToApi(payload) + ); + return okResult(undefined); + } catch (error) { + return errorResult( + `Updating registration key with id ${id} failed: ${await formatError(error)}` + ); + } + }, + + async deleteRegistrationKey(id: number) { + try { + await apiRef.current.registrationKeys.registrationKeysDelete(id); + return okResult(undefined); + } catch (error) { + return errorResult( + `Deleting registration key with id ${id} failed: ${await formatError(error)}` + ); + } + }, }), [apiRef, isInitialized, token, user] ); diff --git a/src/contexts/api/auth/convert.ts b/src/contexts/api/auth/convert.ts index 4d45ac99..1fab80ff 100644 --- a/src/contexts/api/auth/convert.ts +++ b/src/contexts/api/auth/convert.ts @@ -5,7 +5,10 @@ import { Token, User, RegistrationKey, + Role, } from '@luna/contexts/api/auth/types'; +import { CreateOrUpdateRegistrationKeyPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload'; +import { CreateOrUpdateRolePayload } from '@luna/contexts/api/auth/types/CreateOrUpdateRolePayload'; import { CreateOrUpdateUserPayload } from '@luna/contexts/api/auth/types/CreateOrUpdateUserPayload'; export function loginToApi(login?: Login): generated.LoginPayload { @@ -63,6 +66,15 @@ export function registrationKeyFromApi( }; } +export function roleFromApi(role: generated.Role): Role { + return { + id: role.id!, + name: role.name!, + createdAt: new Date(role.created_at!), + updatedAt: new Date(role.updated_at!), + }; +} + export function createOrUpdateUserPayloadToApi( payload: CreateOrUpdateUserPayload ): generated.CreateOrUpdateUserPayload { @@ -73,3 +85,22 @@ export function createOrUpdateUserPayloadToApi( permanent_api_token: payload.permanent_api_token, }; } + +export function createOrUpdateRolePayloadToApi( + payload: CreateOrUpdateRolePayload +): generated.CreateOrUpdateRolePayload { + return { + name: payload.name, + }; +} + +export function createOrUpdateRegistrationKeyPayloadToApi( + payload: CreateOrUpdateRegistrationKeyPayload +): generated.CreateRegistrationKeyPayload { + return { + key: payload.key, + description: payload.description, + expires_at: payload.expires_at.toISOString(), + permanent: payload.permanent, + }; +} diff --git a/src/contexts/api/auth/generated.ts b/src/contexts/api/auth/generated.ts index ba701e82..529507d7 100644 --- a/src/contexts/api/auth/generated.ts +++ b/src/contexts/api/auth/generated.ts @@ -505,7 +505,7 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/roles`, method: 'GET', query: query, diff --git a/src/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload.ts b/src/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload.ts new file mode 100644 index 00000000..29711023 --- /dev/null +++ b/src/contexts/api/auth/types/CreateOrUpdateRegistrationKeyPayload.ts @@ -0,0 +1,6 @@ +export interface CreateOrUpdateRegistrationKeyPayload { + key: string; + description: string; + expires_at: Date; + permanent: boolean; +} diff --git a/src/contexts/api/auth/types/CreateOrUpdateRolePayload.ts b/src/contexts/api/auth/types/CreateOrUpdateRolePayload.ts new file mode 100644 index 00000000..6dcd8ebf --- /dev/null +++ b/src/contexts/api/auth/types/CreateOrUpdateRolePayload.ts @@ -0,0 +1,3 @@ +export interface CreateOrUpdateRolePayload { + name: string; +} From efe5934528b8f97d8f8160dfb2362d45fe28394c Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 12 Nov 2024 23:00:48 +0100 Subject: [PATCH 11/20] Add views for roles and registration keys --- src/routes/admin.tsx | 19 +- .../home/admin/RegistrationKeysView.tsx | 162 ++++++++++++++++++ src/screens/home/admin/RolesView.tsx | 144 ++++++++++++++++ src/screens/home/sidebar/SidebarRoutes.tsx | 8 + 4 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 src/screens/home/admin/RegistrationKeysView.tsx create mode 100644 src/screens/home/admin/RolesView.tsx diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index d5c7384a..18cd942f 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,6 +1,8 @@ import { AdminView } from '@luna/screens/home/admin/AdminView'; import { MonitorView } from '@luna/screens/home/admin/MonitorView'; +import { RegistrationKeysView } from '@luna/screens/home/admin/RegistrationKeysView'; import { ResourcesView } from '@luna/screens/home/admin/ResourcesView'; +import { RolesView } from '@luna/screens/home/admin/RolesView'; import { SettingsView } from '@luna/screens/home/admin/SettingsView'; import { UsersView } from '@luna/screens/home/admin/UsersView'; import { RouteObject } from 'react-router-dom'; @@ -25,15 +27,14 @@ export const adminRoute: RouteObject = { path: 'users', element: , }, - // TODO: - // { - // path: 'roles', - // element: , - // }, - // { - // path: 'registration-keys', - // element: , - // }, + { + path: 'roles', + element: , + }, + { + path: 'registration-keys', + element: , + }, { path: 'settings', element: , diff --git a/src/screens/home/admin/RegistrationKeysView.tsx b/src/screens/home/admin/RegistrationKeysView.tsx new file mode 100644 index 00000000..8fd80c74 --- /dev/null +++ b/src/screens/home/admin/RegistrationKeysView.tsx @@ -0,0 +1,162 @@ +// TODO: enable linter when done +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { RegistrationKey } from '@luna/contexts/api/auth/types'; +import { SearchBar } from '@luna/components/SearchBar'; +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; +import { HomeContent } from '@luna/screens/home/HomeContent'; +import { getOrThrow } from '@luna/utils/result'; +import { + Button, + Chip, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, +} from '@nextui-org/react'; +import { useAsyncList } from '@react-stately/data'; +import { IconEye, IconPencil, IconPlus, IconTrash } from '@tabler/icons-react'; +import { useContext, useState } from 'react'; + +export function RegistrationKeysView() { + const auth = useContext(AuthContext); + + const [isLoading, setLoading] = useState(false); + + const keys = useAsyncList({ + initialSortDescriptor: { + column: 'id', + direction: 'ascending', + }, + async load({ cursor, sortDescriptor, filterText }) { + try { + if (cursor !== undefined) { + setLoading(false); + } + let items = getOrThrow(await auth.getAllRegistrationKeys()); + return { items }; + } catch (error) { + console.error( + `Could not fetch registration keys for registration keys view: ${error}` + ); + return { items: [] }; + } + }, + // TODO: correct sorting + }); + + const [showKeyAddModal, setShowKeyAddModal] = useState(false); + const [showKeyEditModal, setShowKeyEditModal] = useState(false); + const [showKeyDetailsModal, setShowKeyDetailsModal] = useState(false); + const [showKeyDeleteModal, setShowKeyDeleteModal] = useState(false); + const [keyId, setKeyId] = useState(0); + + return ( + // TODO: Lazy rendering + + + + + +
+ } + > +
+ + + ID + + + Key + + + Description + + + Created At + + + Updated At + + + Expires At + + + Permanent + + Actions + + + {key => ( + + {key.id} + {key.key} + {key.description} + {key.createdAt.toLocaleString()} + {key.updatedAt.toLocaleString()} + {key.expiresAt.toLocaleString()} + + {key.permanent ? ( + + true + + ) : ( + + false + + )} + + +
+ + { + setKeyId(key.id); + setShowKeyDetailsModal(true); + }} + /> + + + { + setKeyId(key.id); + setShowKeyEditModal(true); + }} + /> + + + { + setKeyId(key.id); + setShowKeyDeleteModal(true); + }} + /> + +
+
+
+ )} +
+
+ + ); +} diff --git a/src/screens/home/admin/RolesView.tsx b/src/screens/home/admin/RolesView.tsx new file mode 100644 index 00000000..e4fe5958 --- /dev/null +++ b/src/screens/home/admin/RolesView.tsx @@ -0,0 +1,144 @@ +// TODO: enable linter when done +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Role } from '@luna/contexts/api/auth/types'; +import { SearchBar } from '@luna/components/SearchBar'; +import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; +import { HomeContent } from '@luna/screens/home/HomeContent'; +import { getOrThrow } from '@luna/utils/result'; +import { + Button, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, +} from '@nextui-org/react'; +import { useAsyncList } from '@react-stately/data'; +import { + IconCategoryPlus, + IconEye, + IconPencil, + IconTrash, +} from '@tabler/icons-react'; +import { useContext, useState } from 'react'; + +export function RolesView() { + const auth = useContext(AuthContext); + + const [isLoading, setLoading] = useState(false); + + const roles = useAsyncList({ + initialSortDescriptor: { + column: 'id', + direction: 'ascending', + }, + async load({ cursor, sortDescriptor, filterText }) { + try { + if (cursor !== undefined) { + setLoading(false); + } + let items = getOrThrow(await auth.getAllRoles()); + return { items }; + } catch (error) { + console.error(`Could not fetch roles for roles view: ${error}`); + return { items: [] }; + } + }, + // TODO: correct sorting + }); + + // TODO: role add/details/edit/delete modals + + const [showRoleAddModal, setShowRoleAddModal] = useState(false); + const [showRoleEditModal, setShowRoleEditModal] = useState(false); + const [showRoleDetailsModal, setShowRoleDetailsModal] = useState(false); + const [showRoleDeleteModal, setShowRoleDeleteModal] = useState(false); + const [roleId, setRoleId] = useState(0); + + return ( + // TODO: Lazy rendering + + + + + +
+ } + > + + + + ID + + + Name + + + Created At + + + Updated At + + Actions + + + {role => ( + + {role.id} + {role.name} + {role.createdAt.toLocaleString()} + {role.updatedAt.toLocaleString()} + +
+ + { + setRoleId(role.id); + setShowRoleDetailsModal(true); + }} + /> + + + { + setRoleId(role.id); + setShowRoleEditModal(true); + }} + /> + + + { + setRoleId(role.id); + setShowRoleDeleteModal(true); + }} + /> + +
+
+
+ )} +
+
+ + ); +} diff --git a/src/screens/home/sidebar/SidebarRoutes.tsx b/src/screens/home/sidebar/SidebarRoutes.tsx index 13234f6e..8700bbdf 100644 --- a/src/screens/home/sidebar/SidebarRoutes.tsx +++ b/src/screens/home/sidebar/SidebarRoutes.tsx @@ -3,8 +3,10 @@ import { RouteLink } from '@luna/components/RouteLink'; import { truncate } from '@luna/utils/string'; import { IconBuildingLighthouse, + IconCategory, IconFolder, IconHeartRateMonitor, + IconKey, IconSettings, IconTower, IconUsers, @@ -35,6 +37,12 @@ export const SidebarRoutes = memo( path="/admin/monitor" /> } name="Users" path="/admin/users" /> + } name="Roles" path="/admin/roles" /> + } + name="Registration Keys" + path="/admin/registration-keys" + /> } name="Settings" From 097b589e6665c29e52234f42d47924c0ac771ad2 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 12 Nov 2024 23:09:08 +0100 Subject: [PATCH 12/20] Fix request loop caused by useEffect dependency on user that also sets the user --- src/components/UserDetailsModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UserDetailsModal.tsx b/src/components/UserDetailsModal.tsx index 36dd42b4..e2c4e529 100644 --- a/src/components/UserDetailsModal.tsx +++ b/src/components/UserDetailsModal.tsx @@ -38,7 +38,7 @@ export function UserDetailsModal({ id, isOpen, setOpen }: UserShowModalProps) { } }; fetchUser(); - }, [auth, id, isOpen, user]); + }, [auth, id, isOpen]); return ( From 202ba326f65df85d1829e851d81b765fc4d4f552 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 20:55:14 +0100 Subject: [PATCH 13/20] Hide unused options card --- src/screens/home/displays/DisplayInspector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/home/displays/DisplayInspector.tsx b/src/screens/home/displays/DisplayInspector.tsx index 9bb2a908..bf605caa 100644 --- a/src/screens/home/displays/DisplayInspector.tsx +++ b/src/screens/home/displays/DisplayInspector.tsx @@ -1,7 +1,7 @@ import { AuthContext } from '@luna/contexts/api/auth/AuthContext'; import { DisplayInspectorApiTokenCard } from '@luna/screens/home/displays/DisplayInspectorApiTokenCard'; import { DisplayInspectorInputCard } from '@luna/screens/home/displays/DisplayInspectorInputCard'; -import { DisplayInspectorOptionsCard } from '@luna/screens/home/displays/DisplayInspectorOptionsCard'; +// import { DisplayInspectorOptionsCard } from '@luna/screens/home/displays/DisplayInspectorOptionsCard'; import { useContext } from 'react'; export interface DisplayInspectorProps { @@ -16,7 +16,7 @@ export function DisplayInspector({ username }: DisplayInspectorProps) { return (
- + {/* */} {isMeOrAdmin ? ( <> From c125b9e498d3a486d8d806a27a175923c61d6ab6 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 20:56:03 +0100 Subject: [PATCH 14/20] Fix error handling when fetching metrics --- src/contexts/api/model/ModelContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contexts/api/model/ModelContext.tsx b/src/contexts/api/model/ModelContext.tsx index 872fadf9..48094883 100644 --- a/src/contexts/api/model/ModelContext.tsx +++ b/src/contexts/api/model/ModelContext.tsx @@ -151,7 +151,7 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) { users, async getLaserMetrics() { const message = await client?.getLaserMetrics(); - if (!message) { + if (!message || message.RNUM >= 400) { return errorResult('Model server provided no laser metrics'); } return okResult(message.PAYL); From 9c12d88c06e06d7c113469b5d189bc87897f5824 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 20:56:33 +0100 Subject: [PATCH 15/20] Add mouse input toggle --- src/screens/home/displays/DisplayInspectorInputCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/screens/home/displays/DisplayInspectorInputCard.tsx b/src/screens/home/displays/DisplayInspectorInputCard.tsx index b27344b5..d91c4a77 100644 --- a/src/screens/home/displays/DisplayInspectorInputCard.tsx +++ b/src/screens/home/displays/DisplayInspectorInputCard.tsx @@ -6,6 +6,7 @@ export function DisplayInspectorInputCard() { return ( } title="Input">
+ Mouse Keyboard Controller
From 99a400e52a27dd15233f87a82d25daff3a1b04de Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 20:58:57 +0100 Subject: [PATCH 16/20] Hide admin area in sidebar from normal users and rename Monitor -> Monitoring --- src/routes/admin.tsx | 2 +- src/screens/home/sidebar/SidebarRoutes.tsx | 56 ++++++++++++---------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 18cd942f..4c9a02a8 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -19,7 +19,7 @@ export const adminRoute: RouteObject = { element: , }, { - path: 'monitor', + path: 'monitoring', element: , children: [], }, diff --git a/src/screens/home/sidebar/SidebarRoutes.tsx b/src/screens/home/sidebar/SidebarRoutes.tsx index 8700bbdf..2d3725b5 100644 --- a/src/screens/home/sidebar/SidebarRoutes.tsx +++ b/src/screens/home/sidebar/SidebarRoutes.tsx @@ -25,30 +25,38 @@ export const SidebarRoutes = memo( ({ isCompact, searchQuery, user, allUsernames }: SidebarRoutesProps) => { return ( <> - } name="Admin" path="/admin"> - } - name="Resources" - path="/admin/resources" - /> - } - name="Monitor" - path="/admin/monitor" - /> - } name="Users" path="/admin/users" /> - } name="Roles" path="/admin/roles" /> - } - name="Registration Keys" - path="/admin/registration-keys" - /> - } - name="Settings" - path="/admin/settings" - /> - + {user?.roles.find(role => role.name === 'admin') !== undefined ? ( + } name="Admin" path="/admin"> + } + name="Resources" + path="/admin/resources" + /> + } + name="Monitoring" + path="/admin/monitoring" + /> + } name="Users" path="/admin/users" /> + } + name="Roles" + path="/admin/roles" + /> + } + name="Registration Keys" + path="/admin/registration-keys" + /> + } + name="Settings" + path="/admin/settings" + /> + + ) : ( + <> + )} } name="Displays" From d7633e40014095b9218a5197aca7678b4004753e Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 20:59:45 +0100 Subject: [PATCH 17/20] Add roles field to the generated API user type and convert the roles correctly for the internal user type --- src/contexts/api/auth/convert.ts | 2 +- src/contexts/api/auth/generated.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/contexts/api/auth/convert.ts b/src/contexts/api/auth/convert.ts index 1fab80ff..85d87da3 100644 --- a/src/contexts/api/auth/convert.ts +++ b/src/contexts/api/auth/convert.ts @@ -41,7 +41,7 @@ export function userFromApi(apiUser: generated.User): User { id: apiUser.id!, username: apiUser.username!, email: apiUser.email!, - roles: [], // TODO: re-run code generator and use roles + roles: apiUser.roles!.map(apiRole => roleFromApi(apiRole)), // TODO: re-run code generator and use roles createdAt: new Date(apiUser.created_at!), updatedAt: new Date(apiUser.updated_at!), lastSeen: new Date(apiUser.last_login!), diff --git a/src/contexts/api/auth/generated.ts b/src/contexts/api/auth/generated.ts index 529507d7..20e24015 100644 --- a/src/contexts/api/auth/generated.ts +++ b/src/contexts/api/auth/generated.ts @@ -106,6 +106,8 @@ export interface User { updated_at?: string; /** must be unique */ username?: string; + /** list of roles */ + roles?: Role[]; } export type QueryParamsType = Record; From baf9a36b064199f162d09bd152ab0d35f7e682c8 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 21:09:06 +0100 Subject: [PATCH 18/20] Implement mouse events (and dragging) with window coordinates --- src/components/Display.tsx | 118 ++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/src/components/Display.tsx b/src/components/Display.tsx index 98a65567..cde13dbe 100644 --- a/src/components/Display.tsx +++ b/src/components/Display.tsx @@ -1,4 +1,4 @@ -import React, { useLayoutEffect, useRef } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import { LIGHTHOUSE_COLOR_CHANNELS, LIGHTHOUSE_COLS, @@ -7,6 +7,11 @@ import { export const DISPLAY_ASPECT_RATIO = 0.8634; +export interface MousePos { + x: number; + y: number; +} + export interface DisplayProps { frame: Uint8Array; width?: number; @@ -16,6 +21,10 @@ export interface DisplayProps { relativeBezelWidth?: number; relativeGutterWidth?: number; className?: string; + strictBoundsChecking?: boolean; + onMouseDown?: (p: MousePos) => void; + onMouseUp?: (p: MousePos) => void; + onMouseDrag?: (p: MousePos) => void; } export function Display({ @@ -27,9 +36,15 @@ export function Display({ relativeBezelWidth = 0.0183, relativeGutterWidth = 0.0064, className, + strictBoundsChecking = false, + onMouseDown = (p: MousePos) => {}, + onMouseUp = (p: MousePos) => {}, + onMouseDrag = (p: MousePos) => {}, }: DisplayProps) { const canvasRef = useRef(null); + const [drag, setDrag] = useState(false); + const [prevCoords, setPrevCoords] = useState(null); // Set up rendering useLayoutEffect(() => { const canvas = canvasRef.current!; @@ -71,18 +86,111 @@ export function Display({ ctx.fillRect(x, 0, gutterWidth, height); } + const midPoints: number[][] = []; // Draw windows for (let j = 0; j < columns; j++) { const x = bezelWidth + j * windowWidth + (j + 1) * gutterWidth; for (let i = 0; i < rows; i++) { const y = i * (1 + spacersPerRow) * windowHeight; + midPoints.push([x + windowWidth / 2, y + windowHeight / 2]); const k = (i * LIGHTHOUSE_COLS + j) * LIGHTHOUSE_COLOR_CHANNELS; const rgb = frame.slice(k, k + LIGHTHOUSE_COLOR_CHANNELS); ctx.fillStyle = `rgb(${rgb.join(',')})`; ctx.fillRect(x, y, windowWidth, windowHeight); } } + + const dist = ([x1, y1]: number[], [x2, y2]: number[]) => { + const xDiff = x1 - x2; + const yDiff = y1 - y2; + return Math.sqrt(xDiff * xDiff + yDiff * yDiff); + }; + + const mouseToWindowCoords = (mouseCoords: number[]) => { + const closestPointIdx = midPoints + .map(p => dist(p, mouseCoords)) + .reduce( + ([aIdx, acc], val, idx) => [ + val < acc ? idx : aIdx, + Math.min(acc, val), + ], + [-1, Infinity] + )[0]; + const closestPoint = midPoints[closestPointIdx]; + const x = closestPoint[0] - windowWidth / 2; + const y = closestPoint[1] - windowHeight / 2; + if ( + strictBoundsChecking && + !( + mouseCoords[0] >= x && + mouseCoords[0] <= x + windowWidth && + mouseCoords[1] >= y && + mouseCoords[1] <= y + windowHeight + ) + ) { + return null; + } + const j = Math.round( + (x - bezelWidth - gutterWidth) / (windowWidth + gutterWidth) + ); + const i = Math.round(y / (windowHeight * (1 + spacersPerRow))); + return [i, j]; + }; + + const onMouseDownHandler = (event: MouseEvent) => { + setDrag(true); + + const rect = canvas.getBoundingClientRect(); + const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top]; + + const windowCoords = mouseToWindowCoords(mouseCoords); + if (!windowCoords) return; // in case of strict bounds checking + setPrevCoords(windowCoords); // for consecutive drag + + onMouseDown({ x: windowCoords[1], y: windowCoords[0] }); + }; + const onMouseUpHandler = (event: MouseEvent) => { + setDrag(false); + + const rect = canvas.getBoundingClientRect(); + const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top]; + + const windowCoords = mouseToWindowCoords(mouseCoords); + if (!windowCoords) return; // in case of strict bounds checking + setPrevCoords(windowCoords); // for consecutive drag + + onMouseUp({ x: windowCoords[1], y: windowCoords[0] }); + }; + + const onMouseDragHandler = (event: MouseEvent) => { + if (!drag) return; + const rect = canvas.getBoundingClientRect(); + const mouseCoords = [event.clientX - rect.left, event.clientY - rect.top]; + + const windowCoords = mouseToWindowCoords(mouseCoords); + if (!windowCoords) return; // in case of strict bounds checking + + // don't emit drag events if coords haven't changed + if ( + prevCoords && + prevCoords[0] === windowCoords[0] && + prevCoords[1] === windowCoords[1] + ) { + return; + } + setPrevCoords(windowCoords); + onMouseDrag({ x: windowCoords[1], y: windowCoords[0] }); + }; + canvas.style.cursor = 'crosshair'; + canvas.addEventListener('mousedown', onMouseDownHandler); + canvas.addEventListener('mousemove', onMouseDragHandler); + canvas.addEventListener('mouseup', onMouseUpHandler); + return () => { + canvas.removeEventListener('mousedown', onMouseDownHandler); + canvas.removeEventListener('mousemove', onMouseDragHandler); + canvas.removeEventListener('mouseup', onMouseUpHandler); + }; }, [ customWidth, aspectRatio, @@ -91,6 +199,14 @@ export function Display({ columns, relativeBezelWidth, relativeGutterWidth, + strictBoundsChecking, + setDrag, + drag, + setPrevCoords, + prevCoords, + onMouseDown, + onMouseUp, + onMouseDrag, ]); return ; From ca03b48021d8401d1632cedd5b6e541c0505c667 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Tue, 11 Feb 2025 21:17:44 +0100 Subject: [PATCH 19/20] Implement the monitoring view to display the metrics data interactively --- src/screens/home/admin/MonitorView.tsx | 272 +++++++++++++++++++++++-- 1 file changed, 258 insertions(+), 14 deletions(-) diff --git a/src/screens/home/admin/MonitorView.tsx b/src/screens/home/admin/MonitorView.tsx index 8c618d8a..bc365f02 100644 --- a/src/screens/home/admin/MonitorView.tsx +++ b/src/screens/home/admin/MonitorView.tsx @@ -1,10 +1,29 @@ -import { DISPLAY_ASPECT_RATIO, Display } from '@luna/components/Display'; +import { + DISPLAY_ASPECT_RATIO, + Display, + MousePos, +} from '@luna/components/Display'; +import { ModelContext } from '@luna/contexts/api/model/ModelContext'; import { Breakpoint, useBreakpoint } from '@luna/hooks/useBreakpoint'; import { useEventListener } from '@luna/hooks/useEventListener'; import { HomeContent } from '@luna/screens/home/HomeContent'; import { throttle } from '@luna/utils/schedule'; -import { LIGHTHOUSE_FRAME_BYTES } from 'nighthouse/browser'; -import { useMemo, useRef, useState } from 'react'; +import { Button, Card, CardBody, CardHeader, Chip } from '@nextui-org/react'; +import { LIGHTHOUSE_COLS, LIGHTHOUSE_FRAME_BYTES } from 'nighthouse/browser'; +// import { +// LaserMetrics, +// RoomMetrics, +// RoomV2Metrics, +// } from 'nighthouse/out/common/protocol/metrics'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +// import testMetrics from './statusLamps.json'; // TODO: remove testMetrics export function MonitorView() { const [maxSize, setMaxSize] = useState({ width: 0, height: 0 }); @@ -34,26 +53,251 @@ export function MonitorView() { ? maxSize.width : maxSize.height * DISPLAY_ASPECT_RATIO; - // TODO: somehow get the huge JSON object from LaSer streamed through Beacon into here - // then set the color of a pixel according to the state of the lamp/network-controller - // e.g. green: ok, yellow: lamp missing, red: room missing (or whatever) - // TODO: add an overlay to show all metrics when hovering/clicking a window/room - // maybe also show room boundaries e.g. alternating color shades - const frame = new Uint8Array(LIGHTHOUSE_FRAME_BYTES); - for (let i = 0; i < frame.length; i++) { - frame[i] = 255; - } + const model = useContext(ModelContext); + const [metrics, setMetrics] = useState(null); // TODO: change when LaserMetrics includes room + + const [selectedWindow, setSelectedWindow] = useState(0); + + const getLatestMetrics = useCallback(async () => { + // setMetrics(testMetrics); // TODO: change back from test data to fetched data + const m = await model.getLaserMetrics(); // TODO: stream metrics instead + if (m.ok) { + setMetrics(m.value); + } + }, [model]); + + // get the metrics on load + useEffect(() => { + getLatestMetrics(); + }, [getLatestMetrics]); + + // fill the frame with colors according to the metrics data + const frame = useMemo(() => { + const frame = new Uint8Array(LIGHTHOUSE_FRAME_BYTES); + if (!metrics || !metrics.rooms) { + return frame; + } + // alternate between light and dark color to visualize room borders + let parity = false; + let i = 0; + for (const room of metrics!.rooms!) { + if (room.api_version !== 2) continue; + const endIdx = i + 3 * room.lamp_metrics.length; + // controller works? + if (room.controller_metrics.responding) { + let lampIdx = 0; + for (; i < endIdx; i += 3) { + // lamp works? + if (room.lamp_metrics[lampIdx].responding) { + frame[i + 1] = parity ? 255 : 128; // green + } else { + // lamp down -> magenta + frame[i] = parity ? 255 : 128; + frame[i + 2] = parity ? 255 : 128; + } + lampIdx++; + } + } else { + // controller down + for (; i < endIdx; i += 3) { + frame[i] = parity ? 255 : 128; // red + } + } + parity = !parity; + } + // show the selected window in white + if (selectedWindow != null) { + frame[selectedWindow * 3] = 255; + frame[selectedWindow * 3 + 1] = 255; + frame[selectedWindow * 3 + 2] = 255; + } + return frame; + }, [metrics, selectedWindow]); + + // search for the correct room metrics from a single index into the lamp array + const roomMetricsFromIndex = useCallback( + (lampIdx: number) => { + if (!metrics) return null; + let currIdx = 0; + for (const room of metrics.rooms) { + if ( + lampIdx >= currIdx && + lampIdx < currIdx + room.lamp_metrics.length + ) { + return room; + } + currIdx += room.lamp_metrics.length; + } + return null; + }, + [metrics] + ); + + // set the selected window index on click + const onMouseDown = useCallback((p: MousePos) => { + const lampIdx = p.y * LIGHTHOUSE_COLS + p.x; + setSelectedWindow(lampIdx); + }, []); + + // get the selected rooms metrics for rendering + const selectedRoomMetrics = useMemo( + () => roomMetricsFromIndex(selectedWindow), + [roomMetricsFromIndex, selectedWindow] + ); + + // TODO: more appealing UI (maybe tables, inputs or custom stuff?) return ( - +
- +
+ <> + {/* TODO: auto-refresh (polling) or streaming metrics */} + + + {selectedRoomMetrics ? ( + <> + + Room {selectedRoomMetrics.room} + + +
API-Version: {selectedRoomMetrics.api_version}
+
+ Responding: + {selectedRoomMetrics.controller_metrics.responding ? ( + + true + + ) : ( + + false + + )} +
+
+ Ping Latency: + {selectedRoomMetrics.controller_metrics.ping_latency_ms}ms +
+
+ Firmware-Version: + {selectedRoomMetrics.controller_metrics.firmware_version} +
+
+ Uptime: {selectedRoomMetrics.controller_metrics.uptime}s +
+
+ Frames received (total): + {selectedRoomMetrics.controller_metrics.frames} +
+
+ Current frames per second (FPS): + {selectedRoomMetrics.controller_metrics.fps} +
+
+ Core temperature (not very accurate): + {selectedRoomMetrics.controller_metrics.core_temperature} + °C +
+
+ Board temperature (accurate): + {selectedRoomMetrics.controller_metrics.board_temperature} + °C +
+
+ Shunt voltage: + {selectedRoomMetrics.controller_metrics.shunt_voltage}V +
+
+ Voltage: {selectedRoomMetrics.controller_metrics.voltage}V +
+
+ Power: {selectedRoomMetrics.controller_metrics.power}W +
+
+ Current: {selectedRoomMetrics.controller_metrics.current}A +
+
+ Number of lamps responding/connected:{' '} + {selectedRoomMetrics.lamp_metrics.reduce( + (a: number, v: any) => a + (v.responding ? 1 : 0), + 0 + )} + /{selectedRoomMetrics.lamp_metrics.length} +
+
+ + ) : ( + <> + )} +
+ + {selectedRoomMetrics ? ( + <> + + Lamps: + + + {selectedRoomMetrics.lamp_metrics.map( + (lamp: any, idx: number) => ( + <> +
+
+ Lamp {idx + 1}: +
+ Responding:{' '} + {lamp.responding ? ( + + true + + ) : ( + + false + + )} +
+
Firmware-Version: {lamp.firmware_version}
+
Uptime (not very accurate): {lamp.uptime}s
+
Timeout: {lamp.timeout}s
+
+ Temperature (not very accurate): {lamp.temperature} + °C +
+
+ Fuse tripped?{' '} + {lamp.fuse_tripped ? ( + + Yes + + ) : ( + + No + + )} +
+
Flashing status: {lamp.flashing_status}
+
+ + ) + )} +
+ + ) : ( + <> + )} +
+
); From 324a0bc5d33a969ac9ef2173503ad558b4aa09d6 Mon Sep 17 00:00:00 2001 From: Nico Biernat Date: Mon, 24 Feb 2025 17:59:03 +0100 Subject: [PATCH 20/20] Handle error when fetching metrics --- src/contexts/api/model/ModelContext.tsx | 12 ++++++++---- src/screens/home/admin/MonitorView.tsx | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/contexts/api/model/ModelContext.tsx b/src/contexts/api/model/ModelContext.tsx index 48094883..3f934445 100644 --- a/src/contexts/api/model/ModelContext.tsx +++ b/src/contexts/api/model/ModelContext.tsx @@ -150,11 +150,15 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) { () => ({ users, async getLaserMetrics() { - const message = await client?.getLaserMetrics(); - if (!message || message.RNUM >= 400) { - return errorResult('Model server provided no laser metrics'); + try { + const message = await client?.getLaserMetrics(); + if (!message || message.RNUM >= 400) { + return errorResult('Model server provided no laser metrics'); + } + return okResult(message.PAYL); + } catch (error) { + return errorResult(error); } - return okResult(message.PAYL); }, }), [client, users] diff --git a/src/screens/home/admin/MonitorView.tsx b/src/screens/home/admin/MonitorView.tsx index bc365f02..ef67c920 100644 --- a/src/screens/home/admin/MonitorView.tsx +++ b/src/screens/home/admin/MonitorView.tsx @@ -63,6 +63,8 @@ export function MonitorView() { const m = await model.getLaserMetrics(); // TODO: stream metrics instead if (m.ok) { setMetrics(m.value); + } else { + console.log(m.error); } }, [model]);