From b12be53c3169e4402a3a6ff5e85cee10ea951040 Mon Sep 17 00:00:00 2001 From: Antoine Jaussoin Date: Sat, 23 Apr 2022 20:19:21 +0100 Subject: [PATCH] Admin panel improvements (#394) --- .gitignore | 3 +- README.md | 5 + backend/package.json | 2 +- backend/src/auth/register/register-user.ts | 5 +- backend/src/db/actions/delete.ts | 42 +++-- backend/src/index.ts | 57 ++++++ docs/package.json | 2 +- frontend/package.json | 2 +- frontend/src/api/index.ts | 32 +++- frontend/src/auth/AccountMenu.tsx | 24 ++- frontend/src/auth/useIsAdmin.ts | 8 + frontend/src/translations/ar.ts | 1 + frontend/src/translations/de.ts | 1 + frontend/src/translations/en.ts | 1 + frontend/src/translations/es.ts | 1 + frontend/src/translations/fr.ts | 1 + frontend/src/translations/hu.ts | 1 + frontend/src/translations/it.ts | 1 + frontend/src/translations/ja.ts | 1 + frontend/src/translations/nl.ts | 1 + frontend/src/translations/pl.ts | 1 + frontend/src/translations/pt-br.ts | 1 + frontend/src/translations/ru.ts | 1 + frontend/src/translations/types.ts | 1 + frontend/src/translations/zh-cn.ts | 1 + frontend/src/translations/zh-tw.ts | 1 + frontend/src/views/account/AccountPage.tsx | 9 +- .../src/views/account/delete/DeleteModal.tsx | 61 ++++-- frontend/src/views/admin/AdminPage.tsx | 87 ++++++++- frontend/src/views/admin/ChangePassword.tsx | 5 +- frontend/src/views/admin/DeleteAccount.tsx | 37 ++++ frontend/src/views/admin/NewAccountModal.tsx | 176 ++++++++++++++++++ integration/package.json | 2 +- package.json | 2 +- 34 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 frontend/src/auth/useIsAdmin.ts create mode 100644 frontend/src/views/admin/DeleteAccount.tsx create mode 100644 frontend/src/views/admin/NewAccountModal.tsx diff --git a/.gitignore b/.gitignore index ec2020447..0983c7d26 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ configuration.json .docusaurus output.txt -vulnerabilities.md \ No newline at end of file +vulnerabilities.md +/esm \ No newline at end of file diff --git a/README.md b/README.md index e7be19425..b816fbf18 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,11 @@ This will run a demo version, which you can turn into a fully licenced version b ## Versions History +### Version 4.15.0 (not released) + +- Improve Admin dashboard for Self-Hosted, allowing the admin to add and delete users +- Fix GDPR account deletion, which did not work when the user had written messages (chat) + ### Version 4.14.1 (hotfix) - Remove CSRF code, causing random issues diff --git a/backend/package.json b/backend/package.json index 659842d54..8fe94689e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@retrospected/backend", - "version": "4.14.1", + "version": "4.15.0", "license": "GNU GPLv3", "private": true, "scripts": { diff --git a/backend/src/auth/register/register-user.ts b/backend/src/auth/register/register-user.ts index 77a5c3a7e..615665bce 100644 --- a/backend/src/auth/register/register-user.ts +++ b/backend/src/auth/register/register-user.ts @@ -6,7 +6,8 @@ import { getIdentityByUsername, registerUser } from '../../db/actions/users'; import { canSendEmails } from '../../email/utils'; export default async function registerPasswordUser( - details: RegisterPayload + details: RegisterPayload, + skipValidation = false ): Promise { const existingIdentity = await getIdentityByUsername( 'password', @@ -24,7 +25,7 @@ export default async function registerPasswordUser( type: 'password', username: details.username, password: hashedPassword, - emailVerification: canSendEmails() ? v4() : undefined, + emailVerification: !skipValidation && canSendEmails() ? v4() : undefined, language: details.language, }); diff --git a/backend/src/db/actions/delete.ts b/backend/src/db/actions/delete.ts index c294db5b7..f53f3ca1a 100644 --- a/backend/src/db/actions/delete.ts +++ b/backend/src/db/actions/delete.ts @@ -21,21 +21,33 @@ export async function deleteAccount( } return await transaction(async (manager) => { - await deleteVisits(manager, options.deleteSessions, user, anonymousAccount); - await deleteVotes(manager, options.deleteVotes, user, anonymousAccount); - await deletePosts(manager, options.deletePosts, user, anonymousAccount); - await deleteSessions( - manager, - options.deleteSessions, - user, - anonymousAccount - ); - await deleteUserAccount(manager, user); + await delMessages(manager, options.deleteSessions, user, anonymousAccount); + await delVisits(manager, options.deleteSessions, user, anonymousAccount); + await delVotes(manager, options.deleteVotes, user, anonymousAccount); + await delPosts(manager, options.deletePosts, user, anonymousAccount); + await delSessions(manager, options.deleteSessions, user, anonymousAccount); + await delUserAccount(manager, user); return true; }); } -async function deleteVisits( +async function delMessages( + manager: EntityManager, + hardDelete: boolean, + user: UserView, + anon: UserIdentityEntity +) { + if (hardDelete) { + await manager.query('delete from messages where user_id = $1', [user.id]); + } else { + await manager.query('update messages set user_id = $1 where user_id = $2', [ + anon.user.id, + user.id, + ]); + } +} + +async function delVisits( manager: EntityManager, hardDelete: boolean, user: UserView, @@ -51,7 +63,7 @@ async function deleteVisits( } } -async function deleteVotes( +async function delVotes( manager: EntityManager, hardDelete: boolean, user: UserView, @@ -67,7 +79,7 @@ async function deleteVotes( } } -async function deletePosts( +async function delPosts( manager: EntityManager, hardDelete: boolean, user: UserView, @@ -98,7 +110,7 @@ async function deletePosts( } } -async function deleteSessions( +async function delSessions( manager: EntityManager, hardDelete: boolean, user: UserView, @@ -138,7 +150,7 @@ async function deleteSessions( } } -async function deleteUserAccount(manager: EntityManager, user: UserView) { +async function delUserAccount(manager: EntityManager, user: UserView) { await manager.query( ` update users set default_template_id = null where default_template_id in (select id from templates where created_by_id = $1) diff --git a/backend/src/index.ts b/backend/src/index.ts index 0d3e75d1a..53242f121 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -421,6 +421,63 @@ db().then(() => { } }); + app.delete('/api/user/:identityId', heavyLoadLimiter, async (req, res) => { + const user = await getUserViewFromRequest(req); + if (!user || user.email !== config.SELF_HOSTED_ADMIN) { + res + .status(403) + .send('Deleting a user is only allowed for the self-hosted admin.'); + return; + } + const userToDelete = await getUserView(req.params.identityId); + if (userToDelete) { + const result = await deleteAccount( + userToDelete, + req.body as DeleteAccountPayload + ); + res.status(200).send(result); + } else { + res.status(404).send('User not found'); + } + }); + + app.post('/api/user', heavyLoadLimiter, async (req, res) => { + const user = await getUserViewFromRequest(req); + if (!user || user.email !== config.SELF_HOSTED_ADMIN) { + res + .status(403) + .send('Adding a user is only allowed for the self-hosted admin.'); + return; + } + if (config.DISABLE_PASSWORD_REGISTRATION) { + res.status(403).send('Password accounts registration is disabled.'); + return; + } + + const registerPayload = req.body as RegisterPayload; + if ( + (await getIdentityByUsername('password', registerPayload.username)) !== + null + ) { + res.status(403).send('User already exists'); + return; + } + const identity = await registerPasswordUser(registerPayload, true); + if (!identity) { + res.status(500).send(); + } else { + const userView = await getUserView(identity.id); + if (userView) { + res.status(200).send({ + loggedIn: false, + user: userView.toJson(), + }); + } else { + res.status(500).send(); + } + } + }); + app.post('/api/validate', heavyLoadLimiter, async (req, res) => { const validatePayload = req.body as ValidateEmailPayload; const identity = await getPasswordIdentity(validatePayload.email); diff --git a/docs/package.json b/docs/package.json index 5b04a7afe..35fb29731 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "4.14.1", + "version": "4.15.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/frontend/package.json b/frontend/package.json index 4e501ca48..144005b4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@retrospected/frontend", - "version": "4.14.1", + "version": "4.15.0", "license": "GNU GPLv3", "private": true, "dependencies": { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e270564df..a4fc818aa 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -99,11 +99,30 @@ interface RegisterResponse { loggedIn: boolean; } +export async function addUser( + name: string, + email: string, + password: string, + language: string +) { + return registerBase(name, email, password, language, `/api/user`); +} + export async function register( name: string, email: string, password: string, language: string +) { + return registerBase(name, email, password, language, `/api/register`); +} + +async function registerBase( + name: string, + email: string, + password: string, + language: string, + endpoint: string ): Promise { const payload: RegisterPayload = { username: email, @@ -112,7 +131,7 @@ export async function register( language, }; try { - const response = await fetch(`/api/register`, { + const response = await fetch(endpoint, { method: 'POST', ...requestConfig(), body: JSON.stringify(payload), @@ -217,6 +236,17 @@ export async function deleteAccount( } } +export async function deleteUser( + user: FullUser, + options: DeleteAccountPayload +): Promise { + try { + return await fetchDelete(`/api/user/${user.identityId}`, options); + } catch (err) { + return false; + } +} + export async function getGiphyUrl(giphyId: string): Promise { try { const response = await fetch( diff --git a/frontend/src/auth/AccountMenu.tsx b/frontend/src/auth/AccountMenu.tsx index 4cb206ab5..c455da73b 100644 --- a/frontend/src/auth/AccountMenu.tsx +++ b/frontend/src/auth/AccountMenu.tsx @@ -11,9 +11,10 @@ import { logout } from '../api'; import UserContext from './Context'; import Avatar from '../components/Avatar'; import { useNavigate } from 'react-router-dom'; -import { Logout, Star } from '@mui/icons-material'; +import { Key, Logout, Star } from '@mui/icons-material'; import { colors, Divider, ListItemIcon, ListItemText } from '@mui/material'; import AccountCircle from '@mui/icons-material/AccountCircle'; +import useIsAdmin from './useIsAdmin'; const AccountMenu = () => { const translations = useTranslation(); @@ -24,6 +25,9 @@ const AccountMenu = () => { const navigate = useNavigate(); const closeMenu = useCallback(() => setMenuOpen(false), []); const openMenu = useCallback(() => setMenuOpen(true), []); + const user = useUser(); + const isAdmin = useIsAdmin(); + const isNotAnon = user && user.accountType !== 'anonymous'; const handleModalOpen = useCallback( (evt: React.MouseEvent) => { @@ -48,12 +52,16 @@ const AccountMenu = () => { setMenuOpen(false); }, [navigate]); + const handleAdmin = useCallback(() => { + navigate('/admin'); + setMenuOpen(false); + }, [navigate]); + const handleSubscribe = useCallback(() => { navigate('/subscribe'); setMenuOpen(false); }, [navigate]); - const user = useUser(); if (user) { return (
@@ -81,7 +89,7 @@ const AccountMenu = () => { Go Pro! ) : null} - {user && user.accountType !== 'anonymous' ? ( + {isNotAnon ? ( @@ -89,7 +97,15 @@ const AccountMenu = () => { {translations.Header.account} ) : null} - {user && user.accountType !== 'anonymous' ? : null} + {isAdmin ? ( + + + + + {translations.Header.adminPanel} + + ) : null} + {isAdmin || isNotAnon ? : null} diff --git a/frontend/src/auth/useIsAdmin.ts b/frontend/src/auth/useIsAdmin.ts new file mode 100644 index 000000000..99077f021 --- /dev/null +++ b/frontend/src/auth/useIsAdmin.ts @@ -0,0 +1,8 @@ +import useBackendCapabilities from '../global/useBackendCapabilities'; +import useUser from './useUser'; + +export default function useIsAdmin() { + const user = useUser(); + const backend = useBackendCapabilities(); + return user?.email === backend.adminEmail; +} diff --git a/frontend/src/translations/ar.ts b/frontend/src/translations/ar.ts index 908d3ac1d..bfe15a176 100644 --- a/frontend/src/translations/ar.ts +++ b/frontend/src/translations/ar.ts @@ -6,6 +6,7 @@ export default { leave: 'غادر', summaryMode: 'النّمط الملخّص', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'إختيار اللُّغة', diff --git a/frontend/src/translations/de.ts b/frontend/src/translations/de.ts index 4ebbaa2f0..1c8e179bd 100644 --- a/frontend/src/translations/de.ts +++ b/frontend/src/translations/de.ts @@ -6,6 +6,7 @@ export default { leave: 'Verlassen', summaryMode: 'Zusammenfassungsmodus', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Sprache auswählen', diff --git a/frontend/src/translations/en.ts b/frontend/src/translations/en.ts index 69e57efb5..7d8901019 100644 --- a/frontend/src/translations/en.ts +++ b/frontend/src/translations/en.ts @@ -8,6 +8,7 @@ export default { leave: 'Leave', summaryMode: 'Summary Mode', account: 'My Account', + adminPanel: 'Administration Panel', }, LanguagePicker: { header: 'Choose a language', diff --git a/frontend/src/translations/es.ts b/frontend/src/translations/es.ts index 432295b5c..891698d2f 100644 --- a/frontend/src/translations/es.ts +++ b/frontend/src/translations/es.ts @@ -6,6 +6,7 @@ export default { leave: 'Salir', summaryMode: 'Modo resumido', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Escoje un idioma', diff --git a/frontend/src/translations/fr.ts b/frontend/src/translations/fr.ts index 1593a22b1..70adda974 100644 --- a/frontend/src/translations/fr.ts +++ b/frontend/src/translations/fr.ts @@ -8,6 +8,7 @@ export default { leave: 'Sortir', summaryMode: 'Mode Résumé', account: 'Mon compte', + adminPanel: 'Gestion des utilisateurs', }, LanguagePicker: { header: 'Changez de langue', diff --git a/frontend/src/translations/hu.ts b/frontend/src/translations/hu.ts index a21fe5d34..15769d224 100644 --- a/frontend/src/translations/hu.ts +++ b/frontend/src/translations/hu.ts @@ -6,6 +6,7 @@ export default { leave: 'Távozás', summaryMode: 'Összesített mód', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Válassz nyelvet', diff --git a/frontend/src/translations/it.ts b/frontend/src/translations/it.ts index fa5159261..56a41b326 100644 --- a/frontend/src/translations/it.ts +++ b/frontend/src/translations/it.ts @@ -7,6 +7,7 @@ export default { leave: 'Abbandona', summaryMode: 'Modalità sommario', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Scegli una lingua', diff --git a/frontend/src/translations/ja.ts b/frontend/src/translations/ja.ts index 595995735..bc567995a 100644 --- a/frontend/src/translations/ja.ts +++ b/frontend/src/translations/ja.ts @@ -6,6 +6,7 @@ export default { leave: '退室', summaryMode: '要約モード', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: '言語を選択', diff --git a/frontend/src/translations/nl.ts b/frontend/src/translations/nl.ts index 2fea7dca7..7d425571f 100644 --- a/frontend/src/translations/nl.ts +++ b/frontend/src/translations/nl.ts @@ -7,6 +7,7 @@ export default { leave: 'Verlaten', summaryMode: 'Samenvatting', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Kies een taal', diff --git a/frontend/src/translations/pl.ts b/frontend/src/translations/pl.ts index f1a8a4c75..88d50a56a 100644 --- a/frontend/src/translations/pl.ts +++ b/frontend/src/translations/pl.ts @@ -6,6 +6,7 @@ export default { leave: 'Wyjdź', summaryMode: 'Tryb Podsumowania', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Wybierz język', diff --git a/frontend/src/translations/pt-br.ts b/frontend/src/translations/pt-br.ts index 7e10462cc..8decda500 100644 --- a/frontend/src/translations/pt-br.ts +++ b/frontend/src/translations/pt-br.ts @@ -6,6 +6,7 @@ export default { leave: 'Sair', summaryMode: 'Modo Sumário', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Escolha uma língua', diff --git a/frontend/src/translations/ru.ts b/frontend/src/translations/ru.ts index ee1359b8a..c55ddd02c 100644 --- a/frontend/src/translations/ru.ts +++ b/frontend/src/translations/ru.ts @@ -6,6 +6,7 @@ export default { logout: 'Выйти с учётной записи', summaryMode: 'Показать итоги', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: 'Выбрать язык', diff --git a/frontend/src/translations/types.ts b/frontend/src/translations/types.ts index 9bdb1bfcc..80022a891 100644 --- a/frontend/src/translations/types.ts +++ b/frontend/src/translations/types.ts @@ -9,6 +9,7 @@ export interface Translation { leave?: string; summaryMode?: string; account?: string; + adminPanel?: string; }; LanguagePicker: { header?: string; diff --git a/frontend/src/translations/zh-cn.ts b/frontend/src/translations/zh-cn.ts index e175c9635..73502b016 100644 --- a/frontend/src/translations/zh-cn.ts +++ b/frontend/src/translations/zh-cn.ts @@ -6,6 +6,7 @@ export default { leave: '离开', summaryMode: '主旨模式', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: '切换语言', diff --git a/frontend/src/translations/zh-tw.ts b/frontend/src/translations/zh-tw.ts index 5e95d2011..d51cfd22d 100644 --- a/frontend/src/translations/zh-tw.ts +++ b/frontend/src/translations/zh-tw.ts @@ -6,6 +6,7 @@ export default { leave: '離開', summaryMode: '主旨模式', account: undefined, + adminPanel: undefined, }, LanguagePicker: { header: '切換語言', diff --git a/frontend/src/views/account/AccountPage.tsx b/frontend/src/views/account/AccountPage.tsx index addcf50e5..2b608108c 100644 --- a/frontend/src/views/account/AccountPage.tsx +++ b/frontend/src/views/account/AccountPage.tsx @@ -142,9 +142,12 @@ function AccountPage() { {translations.deleteAccount.deleteData} - {deleteModalOpen ? ( - - ) : null} + diff --git a/frontend/src/views/account/delete/DeleteModal.tsx b/frontend/src/views/account/delete/DeleteModal.tsx index 7db6dc657..6a642dfaf 100644 --- a/frontend/src/views/account/delete/DeleteModal.tsx +++ b/frontend/src/views/account/delete/DeleteModal.tsx @@ -17,28 +17,37 @@ import { import { noop } from 'lodash'; import { useCallback, useContext, useState } from 'react'; import styled from '@emotion/styled'; -import useUser from '../../../auth/useUser'; -import { DeleteAccountPayload } from 'common'; -import { deleteAccount, logout } from '../../../api'; +import { DeleteAccountPayload, FullUser } from 'common'; +import { deleteAccount, deleteUser, logout } from '../../../api'; import UserContext from '../../../auth/Context'; import { useNavigate } from 'react-router'; import { useConfirm } from 'material-ui-confirm'; import { useSnackbar } from 'notistack'; import useTranslations from '../../../translations'; import { trackEvent } from '../../../track'; +import useUser from 'auth/useUser'; type DeleteModalProps = { + open: boolean; + user: FullUser; onClose: () => void; + onDelete?: (user: FullUser) => void; }; -export function DeleteModal({ onClose }: DeleteModalProps) { +export function DeleteModal({ + open, + user, + onClose, + onDelete, +}: DeleteModalProps) { const fullScreen = useMediaQuery('(max-width:600px)'); const [deleteSessions, setDeleteSessions] = useState(false); const [deletePosts, setDeletePosts] = useState(false); const [deleteVotes, setDeleteVotes] = useState(false); + const currentUser = useUser(); + const isOwnAccount = currentUser && currentUser.id === user.id; const { setUser } = useContext(UserContext); const { enqueueSnackbar } = useSnackbar(); - const user = useUser(); const push = useNavigate(); const confirm = useConfirm(); const { @@ -65,23 +74,42 @@ export function DeleteModal({ onClose }: DeleteModalProps) { }) .then(async () => { trackEvent('account/gdpr/delete-account'); - const success = await deleteAccount(payload); - if (success) { - logout(); - setUser(null); - push('/'); + if (isOwnAccount) { + const success = await deleteAccount(payload); + if (success) { + logout(); + setUser(null); + push('/'); + } else { + enqueueSnackbar( + 'Deleting your account failed. Please contact support: support@retrospected.com', + { variant: 'error' } + ); + onClose(); + } } else { - enqueueSnackbar( - 'Deleting your account failed. Please contact support: support@retrospected.com', - { variant: 'error' } - ); - onClose(); + const success = await deleteUser(user, payload); + if (success) { + enqueueSnackbar( + `User ${user.name} (${user.email}) has been deleted.`, + { variant: 'success' } + ); + if (onDelete) { + onDelete(user); + } + } else { + enqueueSnackbar('Deleting the account failed.', { + variant: 'error', + }); + onClose(); + } } }) .catch(() => { onClose(); }); }, [ + isOwnAccount, user, deletePosts, deleteSessions, @@ -90,6 +118,7 @@ export function DeleteModal({ onClose }: DeleteModalProps) { setUser, confirm, onClose, + onDelete, translations, enqueueSnackbar, ]); @@ -103,7 +132,7 @@ export function DeleteModal({ onClose }: DeleteModalProps) { fullScreen={fullScreen} maxWidth="sm" fullWidth - open + open={open} onClose={onClose} > diff --git a/frontend/src/views/admin/AdminPage.tsx b/frontend/src/views/admin/AdminPage.tsx index 44eedda07..482b2c685 100644 --- a/frontend/src/views/admin/AdminPage.tsx +++ b/frontend/src/views/admin/AdminPage.tsx @@ -1,17 +1,50 @@ -import { Alert } from '@mui/material'; +import { Alert, Button } from '@mui/material'; import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { FullUser } from 'common'; import useUser from '../../auth/useUser'; import useStateFetch from '../../hooks/useStateFetch'; -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import styled from '@emotion/styled'; import ChangePassword from './ChangePassword'; import useBackendCapabilities from '../../global/useBackendCapabilities'; +import { Add, Search } from '@mui/icons-material'; +import useModal from 'hooks/useModal'; +import { NewAccountModal } from './NewAccountModal'; +import Input from 'components/Input'; +import { DeleteAccount } from './DeleteAccount'; export default function AdminPage() { const user = useUser(); const backend = useBackendCapabilities(); - const [users] = useStateFetch('/api/admin/users', []); + const [users, setUsers] = useStateFetch('/api/admin/users', []); + const [addOpened, handleAddOpen, handleAddClose] = useModal(); + const [search, setSearch] = useState(''); + + const onAdd = useCallback( + (user: FullUser) => { + setUsers((users) => [user, ...users]); + handleAddClose(); + }, + [setUsers, handleAddClose] + ); + + const onDelete = useCallback( + (user: FullUser) => { + setUsers((users) => users.filter((u) => u.id !== user.id)); + }, + [setUsers] + ); + + const filteredUsers = useMemo(() => { + if (!search) { + return users; + } + return users.filter( + (u) => + u.name.toLowerCase().includes(search.toLowerCase()) || + (u.email ? u.email.toLowerCase().includes(search.toLowerCase()) : false) + ); + }, [search, users]); const columns: GridColDef[] = useMemo(() => { return [ @@ -20,16 +53,23 @@ export default function AdminPage() { { field: '', headerName: 'Actions', - width: 200, - renderCell: (p) => , + width: 300, + renderCell: (p) => ( + + + + + ), }, ] as GridColDef[]; - }, []); + }, [onDelete]); if (!backend.selfHosted) { - - This page is only accessible for self-hosted instances. - ; + return ( + + This page is only accessible for self-hosted instances. + + ); } if (!user || user.email !== backend.adminEmail) { return ( @@ -41,12 +81,39 @@ export default function AdminPage() { } return ( - +
+ } + value={search} + onChangeValue={setSearch} + style={{ margin: 0, flex: 1 }} + /> + +
+ +
); } const Container = styled.div` + display: flex; + flex-direction: column; height: calc(100vh - 65px); width: 100%; `; + +const Header = styled.div` + display: flex; + gap: 5px; + margin: 10px; +`; + +const Actions = styled.div``; diff --git a/frontend/src/views/admin/ChangePassword.tsx b/frontend/src/views/admin/ChangePassword.tsx index 4ac375978..cf8f5190e 100644 --- a/frontend/src/views/admin/ChangePassword.tsx +++ b/frontend/src/views/admin/ChangePassword.tsx @@ -12,6 +12,7 @@ import { useCallback, useState } from 'react'; import { FullUser } from 'common'; import Input from '../../components/Input'; import { changePassword } from './api'; +import { Key } from '@mui/icons-material'; type ChangePasswordProps = { user: FullUser; @@ -41,7 +42,9 @@ export default function ChangePassword({ user }: ChangePasswordProps) { }, [enqueueSnackbar, user, newPassword]); return ( <> - + Change password for {user.email} diff --git a/frontend/src/views/admin/DeleteAccount.tsx b/frontend/src/views/admin/DeleteAccount.tsx new file mode 100644 index 000000000..a23e7adce --- /dev/null +++ b/frontend/src/views/admin/DeleteAccount.tsx @@ -0,0 +1,37 @@ +import { Delete } from '@mui/icons-material'; +import { Button } from '@mui/material'; +import { FullUser } from 'common'; +import useModal from 'hooks/useModal'; +import { useCallback } from 'react'; +import { DeleteModal } from 'views/account/delete/DeleteModal'; + +type DeleteAccountProps = { + user: FullUser; + onDelete: (user: FullUser) => void; +}; + +export function DeleteAccount({ user, onDelete }: DeleteAccountProps) { + const [deleteOpened, handleDeleteOpen, handleDeleteClose] = useModal(); + + const handleDelete = useCallback( + (user: FullUser) => { + onDelete(user); + handleDeleteClose(); + }, + [onDelete, handleDeleteClose] + ); + + return ( + <> + + + + ); +} diff --git a/frontend/src/views/admin/NewAccountModal.tsx b/frontend/src/views/admin/NewAccountModal.tsx new file mode 100644 index 000000000..6fa624359 --- /dev/null +++ b/frontend/src/views/admin/NewAccountModal.tsx @@ -0,0 +1,176 @@ +import { Suspense, useCallback, useState, useMemo, lazy } from 'react'; +import Button from '@mui/material/Button'; +import { + Alert, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; +import useTranslations, { useLanguage } from '../../translations'; +import Input from '../../components/Input'; +import { Person, Email, VpnKey } from '@mui/icons-material'; +import { addUser } from '../../api'; +import { validate } from 'isemail'; +import useBackendCapabilities from 'global/useBackendCapabilities'; +import { FullUser } from 'common'; + +type NewAccountModalProps = { + open: boolean; + onClose: () => void; + onAdd: (user: FullUser) => void; +}; + +const PasswordStrength = lazy( + () => + import( + 'react-password-strength-bar' /* webpackChunkName: "password-strength" */ + ) +); + +export function NewAccountModal({ + open, + onAdd, + onClose, +}: NewAccountModalProps) { + const { Register: translations, AuthCommon: authTranslations } = + useTranslations(); + const language = useLanguage(); + const [registerName, setRegisterName] = useState(''); + const [registerEmail, setRegisterEmail] = useState(''); + const [registerPassword, setRegisterPassword] = useState(''); + const [passwordScore, setPasswordScore] = useState(0); + const [generalError, setGeneralError] = useState(null); + const [isSuccessful, setIsSuccessful] = useState(false); + const { disablePasswordRegistration } = useBackendCapabilities(); + + const validEmail = useMemo(() => { + return validate(registerEmail); + }, [registerEmail]); + + const validName = registerName.length > 3; + + const handleRegistration = useCallback(async () => { + const response = await addUser( + registerName, + registerEmail, + registerPassword, + language.value + ); + if (response.error) { + switch (response.error) { + case 'already-exists': + setGeneralError(translations.errorAlreadyRegistered!); + return; + default: + setGeneralError(translations.errorGeneral!); + return; + } + } else if (response.user) { + setIsSuccessful(true); + onAdd(response.user); + } else { + setIsSuccessful(false); + } + }, [ + registerName, + registerEmail, + registerPassword, + language.value, + translations, + + onAdd, + ]); + + if (disablePasswordRegistration) { + return ( + + Registration is disabled by your administrator. Ask your administrator + to create an account for you. + + ); + } + + return ( + + Create a new user + + {isSuccessful ? ( + {translations.messageSuccess} + ) : ( + <> + {!!generalError ? ( + + {generalError} + + ) : null} + + } + required + /> + } + required + error={!validEmail && registerEmail.length > 0} + helperText={ + !validEmail && registerEmail.length > 0 + ? translations.errorInvalidEmail + : undefined + } + /> + } + required + /> + }> + + + + )} + + + + {!isSuccessful ? ( + + ) : undefined} + + + ); +} diff --git a/integration/package.json b/integration/package.json index 0de0e346e..791b31dd3 100644 --- a/integration/package.json +++ b/integration/package.json @@ -1,6 +1,6 @@ { "name": "retro-board-integration", - "version": "4.14.1", + "version": "4.15.0", "description": "Integrations tests", "main": "index.js", "directories": { diff --git a/package.json b/package.json index f43751342..dce58ecb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "retrospected", - "version": "4.14.1", + "version": "4.15.0", "description": "An agile retrospective board - Powering www.retrospected.com", "private": true, "scripts": {