mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Admin Tools: Manage users and Manage projects pages
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import UserListController from './UserListController.mjs'
|
||||
import ProjectListController from './ProjectListController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init AdminTools router')
|
||||
|
||||
webRouter.get('/user/activate', UserListController.activateAccountPage)
|
||||
AuthenticationController.addEndpointToLoginWhitelist('/user/activate')
|
||||
|
||||
webRouter.get('/admin/user',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.manageUsersPage
|
||||
)
|
||||
webRouter.post(
|
||||
'/admin/user/create',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.registerNewUser
|
||||
)
|
||||
webRouter.post('/admin/user/:userId/send-activation',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.sendActivationEmail
|
||||
)
|
||||
webRouter.get('/admin/user/:userId/info',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.getAdditionalUserInfo,
|
||||
)
|
||||
webRouter.post('/admin/users',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.getUsersJson
|
||||
)
|
||||
webRouter.post('/admin/user/:userId/delete',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.deleteUser
|
||||
)
|
||||
webRouter.post('/admin/user/:userId/update',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.updateUser,
|
||||
)
|
||||
webRouter.delete('/admin/user/:userId',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.purgeDeletedUser
|
||||
)
|
||||
webRouter.post('/admin/user/:userId/restore',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
UserListController.restoreDeletedUser
|
||||
)
|
||||
webRouter.post('/admin/user/:userId/projects',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.getProjectsJson
|
||||
)
|
||||
|
||||
webRouter.get('/admin/project',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.manageProjectsPage
|
||||
)
|
||||
webRouter.post('/admin/project/:project_id/trash',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.trashProjectForUser
|
||||
)
|
||||
webRouter.post('/admin/project/:project_id/untrash',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.untrashProjectForUser
|
||||
)
|
||||
webRouter.delete('/admin/project/:project_id/purge',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.purgeDeletedProject
|
||||
)
|
||||
webRouter.post('/admin/project/:project_id/undelete',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
ProjectListController.undeleteProject
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import _ from 'lodash'
|
||||
import Path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import logger from '@overleaf/logger'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.js'
|
||||
import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs'
|
||||
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
|
||||
import { OError } from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import { User } from '../../../../app/src/models/User.js'
|
||||
import { Project } from '../../../../app/src/models/Project.js'
|
||||
import { DeletedProject } from '../../../../app/src/models/DeletedProject.js'
|
||||
import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs'
|
||||
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.js'
|
||||
|
||||
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
async function manageProjectsPage(req, res, next) {
|
||||
const projectsBlobPending = _getProjects().catch(err => {
|
||||
logger.err({ err }, 'projects listing in background failed')
|
||||
return undefined
|
||||
})
|
||||
|
||||
const prefetchedProjectsBlob = await projectsBlobPending
|
||||
|
||||
Metrics.inc('project-list-prefetch-projects', 1, {
|
||||
status: prefetchedProjectsBlob ? 'success' : 'error',
|
||||
})
|
||||
|
||||
res.render(Path.resolve(__dirname, '../views/manage-projects-react'), {
|
||||
title: 'Manage Projects',
|
||||
prefetchedProjectsBlob,
|
||||
})
|
||||
}
|
||||
|
||||
async function getProjectsJson(req, res) {
|
||||
const { filters, page, sort } = req.body
|
||||
const { userId } = req.params
|
||||
|
||||
const projectsPage = await _getProjects(userId, filters, sort, page)
|
||||
res.json(projectsPage)
|
||||
}
|
||||
|
||||
async function _getProjects(
|
||||
userId = null,
|
||||
filters = {},
|
||||
sort = { by: 'lastUpdated', order: 'desc' },
|
||||
page = { size: 20 }
|
||||
) {
|
||||
|
||||
const projection = {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
lastUpdated: 1,
|
||||
lastUpdatedBy: 1,
|
||||
lastOpened: 1,
|
||||
trashed: 1,
|
||||
owner_ref: 1,
|
||||
}
|
||||
|
||||
const actualProjects = await Project.find(
|
||||
userId == null ? {} : { owner_ref: userId },
|
||||
projection,
|
||||
).lean().exec()
|
||||
|
||||
const delProjection = Object.fromEntries(
|
||||
Object.keys(projection).map(k => [`project.${k}`, 1])
|
||||
)
|
||||
delProjection['deleterData.deletedAt'] = 1
|
||||
delProjection['deleterData.deleterId'] = 1
|
||||
|
||||
const deletedProjects = await DeletedProject.find(
|
||||
userId == null ? { project: { $type: 'object' } } : { 'project.owner_ref': userId },
|
||||
delProjection
|
||||
).lean().exec()
|
||||
|
||||
const formattedActualProjects = _formatProjects(actualProjects, _formatProjectInfo)
|
||||
const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo)
|
||||
const formattedProjects = [...formattedActualProjects, ...formattedDeletedProjects]
|
||||
const filteredProjects = _applyFilters(formattedProjects, filters)
|
||||
const projects = _sortAndPaginate(filteredProjects, sort, page)
|
||||
|
||||
return {
|
||||
totalSize: filteredProjects.length,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
function _formatProjects(projects, formatProjectInfo) {
|
||||
const yearAgo = new Date()
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
||||
const formattedProjects = []
|
||||
|
||||
for (const project of projects) {
|
||||
formattedProjects.push(
|
||||
formatProjectInfo(project, yearAgo)
|
||||
)
|
||||
}
|
||||
return formattedProjects
|
||||
}
|
||||
|
||||
function _applyFilters(projects, filters) {
|
||||
if (!_hasActiveFilter(filters)) {
|
||||
return projects
|
||||
}
|
||||
return projects.filter(project => _matchesFilters(project, filters))
|
||||
}
|
||||
|
||||
function _sortAndPaginate(projects, sort, page) {
|
||||
if (
|
||||
(sort.by && !['lastUpdated', 'title', 'deletedAt'].includes(sort.by)) ||
|
||||
(sort.order && !['asc', 'desc'].includes(sort.order))
|
||||
) {
|
||||
throw new OError('Invalid sorting criteria', { sort })
|
||||
}
|
||||
const sortedProjects = _.orderBy(
|
||||
projects,
|
||||
[sort.by || 'lastUpdated'],
|
||||
[sort.order || 'desc']
|
||||
)
|
||||
return sortedProjects
|
||||
}
|
||||
|
||||
function _formatProjectInfo(project, maxDate) {
|
||||
const owner_ref = project.owner_ref
|
||||
const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false
|
||||
|
||||
return {
|
||||
id: project._id.toString(),
|
||||
name: project.name,
|
||||
owner: project.owner_ref,
|
||||
lastUpdated: project.lastUpdated?.toISOString(),
|
||||
lastUpdatedBy: project.lastUpdatedBy,
|
||||
inactive: project.lastOpened < maxDate,
|
||||
trashed,
|
||||
deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
function _formatDeletedProjectInfo(deletedProject, maxDate) {
|
||||
const project = deletedProject.project
|
||||
const owner_ref = project.owner_ref
|
||||
const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false
|
||||
|
||||
return {
|
||||
id: project._id.toString(),
|
||||
name: project.name,
|
||||
owner: owner_ref,
|
||||
lastUpdated: project.lastUpdated?.toISOString(),
|
||||
lastUpdatedBy: project.lastUpdatedBy,
|
||||
inactive: project.lastOpened < maxDate,
|
||||
trashed,
|
||||
deleted: true,
|
||||
deletedAt: deletedProject.deleterData?.deletedAt?.toISOString(),
|
||||
deletedBy: deletedProject.deleterData?.deleterId,
|
||||
}
|
||||
}
|
||||
|
||||
function _matchesFilters(project, filters) {
|
||||
if (filters.owned && (project.trashed || project.deleted)) {
|
||||
return false
|
||||
}
|
||||
if (filters.trashed && (!project.trashed || project.deleted)) {
|
||||
return false
|
||||
}
|
||||
if (filters.deleted && !project.deleted) {
|
||||
return false
|
||||
}
|
||||
if (filters.inactive && (project.trashed || project.deleted || !project.inactive)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.search?.length &&
|
||||
project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function _hasActiveFilter(filters) {
|
||||
return Boolean(
|
||||
filters.owned ||
|
||||
filters.inactive ||
|
||||
filters.trashed ||
|
||||
filters.deleted ||
|
||||
filters.search?.length
|
||||
)
|
||||
}
|
||||
|
||||
async function trashProjectForUser(req, res) {
|
||||
const projectId = req.params.project_id
|
||||
const { userId } = req.body
|
||||
await ProjectDeleter.promises.trashProject(projectId, userId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function untrashProjectForUser(req, res) {
|
||||
const projectId = req.params.project_id
|
||||
const { userId } = req.body
|
||||
await ProjectDeleter.promises.untrashProject(projectId, userId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function undeleteProject(req, res) {
|
||||
const projectId = req.params.project_id
|
||||
const { userId } = req.body
|
||||
const undelededProject = await ProjectDeleter.promises.undeleteProject(projectId, { userId })
|
||||
await ProjectDeleter.promises.untrashProject(projectId, userId)
|
||||
|
||||
return res.json({
|
||||
name: undelededProject.name,
|
||||
})
|
||||
}
|
||||
|
||||
async function purgeDeletedProject(req, res) {
|
||||
const projectId = req.params.project_id
|
||||
await ProjectDeleter.promises.expireDeletedProject(projectId)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
export default {
|
||||
manageProjectsPage: expressify(manageProjectsPage),
|
||||
getProjectsJson: expressify(getProjectsJson),
|
||||
undeleteProject: expressify(undeleteProject),
|
||||
purgeDeletedProject: expressify(purgeDeletedProject),
|
||||
trashProjectForUser: expressify(trashProjectForUser),
|
||||
untrashProjectForUser: expressify(untrashProjectForUser),
|
||||
}
|
||||
570
services/web/modules/admin-tools/app/src/UserListController.mjs
Normal file
570
services/web/modules/admin-tools/app/src/UserListController.mjs
Normal file
@@ -0,0 +1,570 @@
|
||||
import Path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import _ from 'lodash'
|
||||
import crypto from 'crypto'
|
||||
import Settings from '@overleaf/settings'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import logger from '@overleaf/logger'
|
||||
import { User } from '../../../../app/src/models/User.js'
|
||||
import { DeletedUser } from '../../../../app/src/models/DeletedUser.js'
|
||||
import { DeletedProject } from '../../../../app/src/models/DeletedProject.js'
|
||||
import { expressify, promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js'
|
||||
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
|
||||
import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js'
|
||||
import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.js'
|
||||
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
|
||||
import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
|
||||
import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs'
|
||||
import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs'
|
||||
import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
|
||||
import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.js'
|
||||
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs'
|
||||
import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js'
|
||||
import { db } from '../../../../app/src/infrastructure/mongodb.js'
|
||||
|
||||
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const externalAuth = process.env.EXTERNAL_AUTH ? process.env.EXTERNAL_AUTH.split(' ') : []
|
||||
const availableAuthMethods = ['local', ...externalAuth]
|
||||
|
||||
const userIsAdminUpdatedOnLogin = Object.fromEntries(
|
||||
availableAuthMethods.map(m => [
|
||||
m,
|
||||
Boolean(Settings[m]?.attAdmin) && Boolean(Settings[m]?.valAdmin)
|
||||
])
|
||||
)
|
||||
const userDetailsUpdatedOnLogin = Object.fromEntries(
|
||||
availableAuthMethods.map(m => [
|
||||
m,
|
||||
Boolean(Settings[m]?.updateUserDetailsOnLogin)
|
||||
])
|
||||
)
|
||||
|
||||
async function _sendActivationEmail(idString) {
|
||||
const user = await User.findById(idString, { email: 1, _id: 0 }).lean()
|
||||
if (!user) {
|
||||
throw new OError('User does not exist, cannot send activation email', { userId: idString })
|
||||
}
|
||||
const ONE_WEEK = 7 * 24 * 60 * 60 // seconds
|
||||
const token = await OneTimeTokenHandler.promises.getNewToken(
|
||||
'password',
|
||||
{ user_id: idString, email: user.email },
|
||||
{ expiresIn: ONE_WEEK }
|
||||
)
|
||||
const setNewPasswordUrl = `${Settings.siteUrl}/user/activate?token=${token}&user_id=${idString}`
|
||||
await EmailHandler.promises.sendEmail('registered', { to: user.email, setNewPasswordUrl })
|
||||
.catch(error => {
|
||||
throw new OError('Failed to send activation email', { error: error.message, email: user.email })
|
||||
})
|
||||
return setNewPasswordUrl
|
||||
}
|
||||
|
||||
function cleanupSession(req) {
|
||||
// cleanup redirects at the end of the redirect chain
|
||||
delete req.session.postCheckoutRedirect
|
||||
delete req.session.postLoginRedirect
|
||||
delete req.session.postOnboardingRedirect
|
||||
}
|
||||
|
||||
async function manageUsersPage(req, res, next) {
|
||||
cleanupSession(req)
|
||||
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
const usersBlobPending = _getUsers().catch(err => {
|
||||
logger.err({ err }, 'users listing in background failed')
|
||||
return undefined
|
||||
})
|
||||
|
||||
const prefetchedUsersBlob = await usersBlobPending
|
||||
|
||||
Metrics.inc('user-list-prefetch-users', 1, {
|
||||
status: prefetchedUsersBlob ? 'success' : 'error',
|
||||
})
|
||||
|
||||
res.render(Path.resolve(__dirname, '../views/manage-users-react'), {
|
||||
title: 'Manage Users',
|
||||
prefetchedUsersBlob,
|
||||
availableAuthMethods,
|
||||
userDetailsUpdatedOnLogin,
|
||||
userIsAdminUpdatedOnLogin,
|
||||
})
|
||||
}
|
||||
|
||||
async function registerNewUser(req, res, next) {
|
||||
const { email, isExternal, isAdmin } = req.body
|
||||
if (email == null || email === '') {
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, 'Email address is empty')
|
||||
}
|
||||
delete req.body.isExternal
|
||||
req.body.password = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await UserRegistrationHandler.promises.registerNewUser(req.body)
|
||||
} catch (err) {
|
||||
if (err.message == 'EmailAlreadyRegistered') {
|
||||
return HttpErrorHandler.conflict(req, res, 'email_already_registered')
|
||||
}
|
||||
if (err.message === 'InvalidEmailError') {
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, 'email_address_is_invalid')
|
||||
}
|
||||
if (err.message === 'InvalidPasswordError') {
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, 'try_again')
|
||||
}
|
||||
OError.tag(err, 'error user registration', {
|
||||
email,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
try {
|
||||
const reversedHostname = user.email
|
||||
.split('@')[1]
|
||||
.split('')
|
||||
.reverse()
|
||||
.join('')
|
||||
const update = {
|
||||
$set: { isAdmin, emails: [{ email, reversedHostname, confirmedAt: Date.now() }] },
|
||||
}
|
||||
if (isExternal) {
|
||||
update.$unset = { hashedPassword: "" }
|
||||
} else {
|
||||
await _sendActivationEmail(user._id.toString())
|
||||
}
|
||||
await User.updateOne({ _id: user._id }, update).exec()
|
||||
} catch (err) {
|
||||
OError.tag(err, 'error finishing user registration', {
|
||||
email: user.email,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
|
||||
const authMethods = isExternal ? [] : ['local']
|
||||
const { id, first_name, last_name, signUpDate } = user
|
||||
const newUser = { id, email, firstName: first_name, lastName: last_name, isAdmin, signUpDate, inactive: true, deleted: false, authMethods }
|
||||
res.json({ user: newUser })
|
||||
}
|
||||
|
||||
async function sendActivationEmail(req, res, next) {
|
||||
const { userId } = req.params
|
||||
try {
|
||||
await _sendActivationEmail(userId)
|
||||
} catch (err) {
|
||||
logger.warn({ err })
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, 'Error sending activation email')
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function getUsersJson(req, res) {
|
||||
const { filters, page, sort } = req.body
|
||||
const usersPage = await _getUsers(filters, sort, page)
|
||||
res.json(usersPage)
|
||||
}
|
||||
|
||||
|
||||
async function activateAccountPage(req, res, next) {
|
||||
// An 'activation' is actually just a password reset on an account that
|
||||
// was set with a random password originally.
|
||||
if (req.query.user_id == null || req.query.token == null) {
|
||||
return ErrorController.notFound(req, res)
|
||||
}
|
||||
|
||||
if (typeof req.query.user_id !== 'string') {
|
||||
return ErrorController.forbidden(req, res)
|
||||
}
|
||||
|
||||
const user = await UserGetter.promises.getUser(req.query.user_id, {
|
||||
email: 1,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return ErrorController.notFound(req, res)
|
||||
}
|
||||
|
||||
req.session.doLoginAfterPasswordReset = true
|
||||
|
||||
res.render(Path.resolve(__dirname, '../views/activate'), {
|
||||
title: 'activate_account',
|
||||
email: user.email,
|
||||
token: req.query.token,
|
||||
})
|
||||
}
|
||||
|
||||
async function _getUsers(
|
||||
filters = {},
|
||||
sort = { by: 'name', order: 'asc' },
|
||||
page = { size: 20 }
|
||||
) {
|
||||
const projection = {
|
||||
_id: 1,
|
||||
email: 1,
|
||||
first_name: 1,
|
||||
last_name: 1,
|
||||
lastActive: 1,
|
||||
lastLoggedIn: 1,
|
||||
signUpDate: 1,
|
||||
loginCount: 1,
|
||||
isAdmin: 1,
|
||||
hashedPassword: 1,
|
||||
samlIdentifiers: 1,
|
||||
thirdPartyIdentifiers: 1,
|
||||
suspended: 1,
|
||||
}
|
||||
const projectionDeleted = {};
|
||||
for (const key of Object.keys(projection)) {
|
||||
projectionDeleted[key] = `$user.${key}`
|
||||
}
|
||||
projectionDeleted.deletedAt = '$deleterData.deletedAt'
|
||||
|
||||
const activeUsers = await UserGetter.promises.getUsers({}, projection)
|
||||
const deletedUsers = await DeletedUser.aggregate([
|
||||
{ $match: { user: { $type: 'object' } } },
|
||||
{ $project: projectionDeleted },
|
||||
])
|
||||
|
||||
const allUsers = [...activeUsers, ...deletedUsers]
|
||||
|
||||
const formattedUsers = _formatUsers(allUsers)
|
||||
const filteredUsers = _applyFilters(formattedUsers, filters)
|
||||
const users = _sortAndPaginate(filteredUsers, sort, page)
|
||||
|
||||
return {
|
||||
totalSize: filteredUsers.length,
|
||||
users,
|
||||
}
|
||||
}
|
||||
|
||||
function _formatUsers(users) {
|
||||
const formattedUsers = []
|
||||
const yearAgo = new Date()
|
||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
||||
|
||||
for (const user of users) {
|
||||
formattedUsers.push(
|
||||
_formatUserInfo(user, yearAgo)
|
||||
)
|
||||
}
|
||||
|
||||
return formattedUsers
|
||||
}
|
||||
|
||||
function _applyFilters(users, filters) {
|
||||
if (!_hasActiveFilter(filters)) {
|
||||
return users
|
||||
}
|
||||
return users.filter(user => _matchesFilters(user, filters))
|
||||
}
|
||||
|
||||
function _sortAndPaginate(users, sort, page) {
|
||||
if (
|
||||
(sort.by && !['lastActive', 'signUpDate', 'email', 'name', 'deletedAt'].includes(sort.by)) ||
|
||||
(sort.order && !['asc', 'desc'].includes(sort.order))
|
||||
) {
|
||||
throw new OError('Invalid sorting criteria', { sort })
|
||||
}
|
||||
|
||||
const sortedUsers = _.orderBy(
|
||||
users,
|
||||
[sort.by || 'signUpDate'],
|
||||
[sort.order || 'desc']
|
||||
)
|
||||
return sortedUsers
|
||||
}
|
||||
|
||||
function _formatUserInfo(user, maxDate) {
|
||||
let authMethods = []
|
||||
if (availableAuthMethods.includes('local') && user.hashedPassword) authMethods.push('local')
|
||||
if (availableAuthMethods.includes('saml') && user.samlIdentifiers.length > 0) authMethods.push('saml')
|
||||
if (availableAuthMethods.includes('oidc') && user.thirdPartyIdentifiers.length > 0) authMethods.push('oidc')
|
||||
// If none of the above, mark as LDAP
|
||||
if (availableAuthMethods.includes('ldap') && authMethods.length === 0 && user.loginCount !== 0) authMethods.push('ldap')
|
||||
|
||||
// if not all user's authentications methods update a property on login, allow admin to update that property
|
||||
const allowUpdateDetails = authMethods.length === 0 || !authMethods.every(m => userDetailsUpdatedOnLogin[m])
|
||||
const allowUpdateIsAdmin = authMethods.length === 0 || !authMethods.every(m => userIsAdminUpdatedOnLogin[m])
|
||||
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
isAdmin: user.isAdmin,
|
||||
loginCount: user.loginCount,
|
||||
signUpDate: user.signUpDate,
|
||||
lastActive: user.lastActive,
|
||||
lastLoggedIn: user.lastLoggedIn,
|
||||
authMethods,
|
||||
allowUpdateDetails,
|
||||
allowUpdateIsAdmin,
|
||||
...(user.suspended && { suspended: user.suspended }),
|
||||
inactive: !user.lastActive || user.lastActive < maxDate,
|
||||
...(user.deletedAt && { deletedAt: user.deletedAt }),
|
||||
deleted: Boolean(user.deletedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function _matchesFilters(user, filters) {
|
||||
if (
|
||||
filters.search?.length &&
|
||||
user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
|
||||
user.first_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
|
||||
user.last_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
|
||||
) { return false }
|
||||
// Deleted users only match the 'deleted' filter
|
||||
if (user.deleted) return Boolean(filters.deleted)
|
||||
if (filters.all) return true
|
||||
if (filters.admin) return user.isAdmin
|
||||
if (filters.inactive && !user.inactive) return false
|
||||
if (filters.suspended && !user.suspended) return false
|
||||
for (const method of availableAuthMethods) {
|
||||
if (filters[method] && !user.authMethods.includes(method)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function _hasActiveFilter(filters) {
|
||||
return Boolean(
|
||||
filters.deleted ||
|
||||
filters.all ||
|
||||
filters.admin ||
|
||||
filters.inactive ||
|
||||
filters.suspended ||
|
||||
filters.local ||
|
||||
filters.saml ||
|
||||
filters.oidc ||
|
||||
filters.ldap ||
|
||||
filters.search?.length
|
||||
)
|
||||
}
|
||||
|
||||
async function deleteUser(req, res, next) {
|
||||
const deleterUserId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { userId } = req.params
|
||||
const { sendEmail, toUserId } = req.body
|
||||
|
||||
logger.debug({ deleterUserId, userId }, 'admin is trying to delete user account')
|
||||
|
||||
if (toUserId) {
|
||||
try {
|
||||
await OwnershipTransferHandler.promises.transferAllProjectsToUser({
|
||||
fromUserId: userId,
|
||||
toUserId,
|
||||
ipAddress: '0.0.0.0',
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ userId, toUserId }, err.message)
|
||||
const message = 'Failed to transfer projects ownership'
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await UserDeleter.promises.deleteUser(userId, {
|
||||
deleterUser: { '_id': deleterUserId },
|
||||
ipAddress: req.ip,
|
||||
skipEmail: !sendEmail,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ deleterUser, userId }, err.message)
|
||||
if (toUserId) {
|
||||
try { // failed to delete user, try to transfer all projects back
|
||||
await OwnershipTransferHandler.promises.transferAllProjectsToUser({
|
||||
toUserId,
|
||||
fromUserId: userId,
|
||||
ipAddress: '0.0.0.0',
|
||||
})
|
||||
} catch (e) {
|
||||
logger.warn({ toUserId, userId }, 'Failed to transfer the projects ownership back: ' + e.message)
|
||||
}
|
||||
}
|
||||
const message = 'Something went wrong. Does the account still exist?'
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
||||
}
|
||||
|
||||
const deletedUser = await DeletedUser.findOne(
|
||||
{ 'user._id': userId }, { 'deleterData.deletedAt': 1 }
|
||||
).lean()
|
||||
|
||||
res.json({ deletedAt: deletedUser.deleterData.deletedAt })
|
||||
}
|
||||
|
||||
async function purgeDeletedUser(req, res, next) {
|
||||
const deleterUserId = SessionManager.getLoggedInUserId(req.session)
|
||||
const userId = req.params.userId
|
||||
|
||||
logger.debug({ deleterUserId, userId }, 'admin is trying to purge deleted user account')
|
||||
try {
|
||||
UserDeleter.promises.expireDeletedUser(userId)
|
||||
} catch (err) {
|
||||
logger.warn({ restorerId, userId }, err.message)
|
||||
const message = 'Something went wrong. The user is already deleted?'
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function restoreDeletedUser(req, res, next) {
|
||||
const restorerId = SessionManager.getLoggedInUserId(req.session)
|
||||
const userId = req.params.userId
|
||||
|
||||
logger.debug({ restorerId, userId }, 'admin is trying to restore deleted user')
|
||||
|
||||
let userData
|
||||
try {
|
||||
const deletedEntry = await DeletedUser.findOne( { "user._id": userId }).lean()
|
||||
userData = deletedEntry?.user
|
||||
if (!userData) {
|
||||
const message = 'Something went wrong. The user is purged?'
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
||||
}
|
||||
|
||||
const exists = await User.findOne({ email: userData.email }, { _id: 1 }).lean()
|
||||
if (exists) {
|
||||
const message = req.i18n.translate('email_already_registered')
|
||||
return HttpErrorHandler.conflict(req, res, message)
|
||||
}
|
||||
|
||||
userData.suspended = false
|
||||
await User.create(userData)
|
||||
await DeletedUser.deleteOne({ "user._id": userId })
|
||||
|
||||
} catch (err) {
|
||||
const message = req.i18n.translate('generic_something_went_wrong')
|
||||
return HttpErrorHandler.legacyInternal(
|
||||
req, res, message,
|
||||
OError.tag(err, 'problem restoring deleted user', {
|
||||
userId,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await DeletedProject.find({ "project.owner_ref": userId }).exec()
|
||||
logger.info(
|
||||
{ userId, projectCount: projects.length },
|
||||
'found user projects to restore'
|
||||
)
|
||||
await promiseMapWithLimit(5, projects, project =>
|
||||
ProjectDeleter.promises.undeleteProject(project.deleterData.deletedProjectId, { suffix: "" }))
|
||||
} catch (err) {
|
||||
logger.info({ userId }, err.message)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
restoredId: userData._id.toString(),
|
||||
email: userData.email,
|
||||
})
|
||||
}
|
||||
|
||||
async function updateUser(req, res, next) {
|
||||
const userId = req.params.userId
|
||||
const actorUserId = SessionManager.getLoggedInUserId(req.session)
|
||||
req.logger.addFields({ actorUserId })
|
||||
const { body } = req
|
||||
|
||||
const projection = Object.fromEntries(Object.keys(body).map(k => [k, 1]))
|
||||
const user = await User.findById(userId, projection).exec()
|
||||
|
||||
if (user == null) {
|
||||
throw new OError('problem updating user settings', { userId })
|
||||
}
|
||||
|
||||
let emailIsUpdated = false
|
||||
const newEmail = body.email?.trim().toLowerCase()
|
||||
if (newEmail != null && newEmail !== user.email) { // email is updated
|
||||
if (newEmail.indexOf('@') === -1) {
|
||||
const message = req.i18n.translate('email_address_is_invalid')
|
||||
return HttpErrorHandler.unprocessableEntity(req, res, message)
|
||||
}
|
||||
const auditLog = { initiatorId: actorUserId, ipAddress: req.ip }
|
||||
|
||||
try {
|
||||
await UserUpdater.promises.changeEmailAddress(userId, newEmail, auditLog)
|
||||
emailIsUpdated = true
|
||||
} catch (err) {
|
||||
if (err instanceof Errors.EmailExistsError) {
|
||||
const message = req.i18n.translate('email_already_registered')
|
||||
return HttpErrorHandler.conflict(req, res, message)
|
||||
} else {
|
||||
const message = req.i18n.translate('problem_changing_email_address')
|
||||
return HttpErrorHandler.legacyInternal(
|
||||
req, res, message,
|
||||
OError.tag(err, 'problem changing email address', {
|
||||
userId,
|
||||
newEmail,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
if (userId == actorUserId) {
|
||||
SessionManager.setInSessionUser(req.session, {
|
||||
email: newEmail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const update = {}
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (key === "email") continue
|
||||
if (value === user[key]) continue
|
||||
update[key] = typeof value === "string" ? value.trim() : value
|
||||
}
|
||||
Object.assign(user, update)
|
||||
try {
|
||||
await user.save()
|
||||
} catch (err) {
|
||||
throw new OError('problem updating user settings', { userId })
|
||||
}
|
||||
if (userId == actorUserId) {
|
||||
const sessionUpdate = {}
|
||||
if (update.first_name != null) sessionUpdate.first_name = update.first_name
|
||||
if (update.last_name != null) sessionUpdate.last_name = update.last_name
|
||||
SessionManager.setInSessionUser(req.session, sessionUpdate)
|
||||
}
|
||||
if (emailIsUpdated) update["email"] = newEmail
|
||||
|
||||
return res.json(update)
|
||||
}
|
||||
|
||||
async function _getActivationLink(userId) {
|
||||
try {
|
||||
const tokenDoc = await db.tokens.findOne({
|
||||
use: 'password',
|
||||
'data.user_id': userId,
|
||||
expiresAt: { $gt: new Date() },
|
||||
usedAt: { $exists: false },
|
||||
peekCount: { $not: { $gte: OneTimeTokenHandler.MAX_PEEKS } },
|
||||
})
|
||||
if (!tokenDoc) {
|
||||
return null
|
||||
}
|
||||
return `${Settings.siteUrl}/user/activate?token=${tokenDoc.token}&user_id=${userId}`
|
||||
} catch (err) {
|
||||
logger.warn({ userId }, 'Failed to get activation link' + err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdditionalUserInfo(req, res, next) {
|
||||
const { userId } = req.params
|
||||
const activationLink = await _getActivationLink(userId)
|
||||
res.json({ activationLink })
|
||||
}
|
||||
|
||||
export default {
|
||||
manageUsersPage: expressify(manageUsersPage),
|
||||
getUsersJson: expressify(getUsersJson),
|
||||
getAdditionalUserInfo: expressify(getAdditionalUserInfo),
|
||||
registerNewUser: expressify(registerNewUser),
|
||||
activateAccountPage: expressify(activateAccountPage),
|
||||
sendActivationEmail: expressify(sendActivationEmail),
|
||||
deleteUser: expressify(deleteUser),
|
||||
restoreDeletedUser: expressify(restoreDeletedUser),
|
||||
purgeDeletedUser: expressify(purgeDeletedUser),
|
||||
updateUser: expressify(updateUser),
|
||||
}
|
||||
1
services/web/modules/admin-tools/app/src/tsconfig.json
Normal file
1
services/web/modules/admin-tools/app/src/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "../../../../tsconfig.backend.json" }
|
||||
75
services/web/modules/admin-tools/app/views/activate.pug
Normal file
75
services/web/modules/admin-tools/app/views/activate.pug
Normal file
@@ -0,0 +1,75 @@
|
||||
extends ../../../../app/views/layout-website-redesign
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
|
||||
include ../../../../app/views/_mixins/material_symbol
|
||||
|
||||
block content
|
||||
main#main-content.content.content-alt
|
||||
.container
|
||||
.col-lg-6.col-xl-4.m-auto
|
||||
.notification-list
|
||||
.notification.notification-type-success(aria-live='off' role='alert')
|
||||
.notification-content-and-cta
|
||||
.notification-icon
|
||||
+material-symbol('check_circle')
|
||||
.notification-content
|
||||
p
|
||||
| #{translate("nearly_activated")}
|
||||
|
||||
h1.h3 #{translate("please_set_a_password")}
|
||||
|
||||
form(
|
||||
name='activationForm'
|
||||
data-ol-async-form
|
||||
action='/user/password/set'
|
||||
method='POST'
|
||||
)
|
||||
+formMessages
|
||||
|
||||
+customFormMessage('token-expired', 'danger')
|
||||
| #{translate("activation_token_expired")}
|
||||
|
||||
+customFormMessage('invalid-password', 'danger')
|
||||
| #{translate('invalid_password')}
|
||||
|
||||
+customFormMessage('password-must-be-different', 'danger')
|
||||
| #{translate('password_change_password_must_be_different')}
|
||||
|
||||
input(name='_csrf' type='hidden' value=csrfToken)
|
||||
input(name='passwordResetToken' type='hidden' value=token)
|
||||
|
||||
.form-group
|
||||
label(for='emailField') #{translate("email")}
|
||||
input#emailField.form-control(
|
||||
name='email'
|
||||
aria-label='email'
|
||||
type='email'
|
||||
placeholder='email@example.com'
|
||||
autocomplete='username'
|
||||
value=email
|
||||
required
|
||||
disabled
|
||||
)
|
||||
.form-group
|
||||
label(for='passwordField') #{translate("password")}
|
||||
input#passwordField.form-control(
|
||||
name='password'
|
||||
type='password'
|
||||
placeholder='********'
|
||||
autocomplete='new-password'
|
||||
autofocus
|
||||
required
|
||||
minlength=settings.passwordStrengthOptions.length.min
|
||||
)
|
||||
.actions
|
||||
button.btn.btn-primary(
|
||||
type='submit'
|
||||
data-ol-disabled-inflight
|
||||
aria-label=translate('activate')
|
||||
)
|
||||
span(data-ol-inflight='idle')
|
||||
| #{translate('activate')}
|
||||
span(hidden data-ol-inflight='pending')
|
||||
| #{translate('activating')}…
|
||||
@@ -0,0 +1,28 @@
|
||||
extends ../../../../app/views/layout-react
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/admin-tools/pages/manage-projects'
|
||||
|
||||
block vars
|
||||
- const suppressNavContentLinks = true
|
||||
- const suppressNavbar = true
|
||||
- const suppressFooter = true
|
||||
- const suppressPugCookieBanner = true
|
||||
|
||||
block append meta
|
||||
meta(
|
||||
name='ol-prefetchedProjectsBlob'
|
||||
data-type='json'
|
||||
content=prefetchedProjectsBlob
|
||||
)
|
||||
if suggestedLanguageSubdomainConfig
|
||||
meta(
|
||||
name='ol-suggestedLanguage'
|
||||
data-type='json'
|
||||
content=Object.assign(suggestedLanguageSubdomainConfig, {
|
||||
lngName: translate(suggestedLanguageSubdomainConfig.lngCode),
|
||||
imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'),
|
||||
})
|
||||
)
|
||||
block content
|
||||
#manage-projects-root
|
||||
@@ -0,0 +1,29 @@
|
||||
extends ../../../../app/views/layout-react
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/admin-tools/pages/manage-users'
|
||||
|
||||
block vars
|
||||
- const suppressNavContentLinks = true
|
||||
- const suppressNavbar = true
|
||||
- const suppressFooter = true
|
||||
- const suppressPugCookieBanner = true
|
||||
|
||||
block append meta
|
||||
meta(name='ol-availableAuthMethods' data-type='json' content=availableAuthMethods)
|
||||
meta(
|
||||
name='ol-prefetchedUsersBlob'
|
||||
data-type='json'
|
||||
content=prefetchedUsersBlob
|
||||
)
|
||||
if suggestedLanguageSubdomainConfig
|
||||
meta(
|
||||
name='ol-suggestedLanguage'
|
||||
data-type='json'
|
||||
content=Object.assign(suggestedLanguageSubdomainConfig, {
|
||||
lngName: translate(suggestedLanguageSubdomainConfig.lngCode),
|
||||
imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'),
|
||||
})
|
||||
)
|
||||
block content
|
||||
#manage-users-root
|
||||
@@ -0,0 +1,31 @@
|
||||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { UserListProvider } from './user-list/context/user-list-context'
|
||||
import { ProjectListProvider } from './project-list/context/project-list-context'
|
||||
import ProjectListRoot from './project-list/components/project-list-root'
|
||||
|
||||
function ManageProjectsRoot() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<SplitTestProvider>
|
||||
<UserSettingsProvider>
|
||||
<UserListProvider>
|
||||
<ProjectListProvider projectsOwnerId={null}>
|
||||
<ProjectListRoot />
|
||||
</ProjectListProvider>
|
||||
</UserListProvider>
|
||||
</UserSettingsProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(ManageProjectsRoot, () => (
|
||||
<GenericErrorBoundaryFallback />
|
||||
))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { UsersPageProvider, useUsersPageContext } from './users-page-context.tsx'
|
||||
import { UserListProvider } from './user-list/context/user-list-context'
|
||||
import UserListRoot from './user-list/components/user-list-root'
|
||||
import { ProjectListProvider } from './project-list/context/project-list-context'
|
||||
import ProjectListRoot from './project-list/components/project-list-root'
|
||||
|
||||
function UsersPageSelector() {
|
||||
const { page } = useUsersPageContext()
|
||||
|
||||
if (page.type === 'projects') {
|
||||
return (
|
||||
<ProjectListProvider projectsOwnerId={page.userId}>
|
||||
<ProjectListRoot />
|
||||
</ProjectListProvider>
|
||||
)
|
||||
}
|
||||
return <UserListRoot />
|
||||
}
|
||||
|
||||
function ManageUsersRoot() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<SplitTestProvider>
|
||||
<UserSettingsProvider>
|
||||
<UsersPageProvider>
|
||||
<UserListProvider>
|
||||
<UsersPageSelector />
|
||||
</UserListProvider>
|
||||
</UsersPageProvider>
|
||||
</UserSettingsProvider>
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(ManageUsersRoot, () => (
|
||||
<GenericErrorBoundaryFallback />
|
||||
))
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ManageProjectsRoot from '../manage-projects-root'
|
||||
|
||||
const element = document.getElementById('manage-projects-root')
|
||||
if (element) {
|
||||
const root = createRoot(element)
|
||||
root.render(<ManageProjectsRoot />)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ManageUsersRoot from '../manage-users-root'
|
||||
|
||||
const element = document.getElementById('manage-users-root')
|
||||
if (element) {
|
||||
const root = createRoot(element)
|
||||
root.render(<ManageUsersRoot />)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import DownloadProjectButton from '../table/cells/action-buttons/download-project-button'
|
||||
import TrashProjectButton from '../table/cells/action-buttons/trash-project-button'
|
||||
import UntrashProjectButton from '../table/cells/action-buttons/untrash-project-button'
|
||||
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
|
||||
import RestoreProjectButton from '../table/cells/action-buttons/restore-project-button'
|
||||
import PurgeProjectButton from '../table/cells/action-buttons/purge-project-button'
|
||||
import TransferProjectButton from '../table/cells/action-buttons/transfer-project-button'
|
||||
import { Project } from '../../../../../types/project/api'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
|
||||
type ActionDropdownProps = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
function ActionsDropdown({ project }: ActionDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
id={`project-actions-dropdown-toggle-btn-${project.id}`}
|
||||
bsPrefix="dropdown-table-button-toggle"
|
||||
>
|
||||
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<DownloadProjectButton project={project}>
|
||||
{(text, downloadProject) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={downloadProject}
|
||||
leadingIcon="download"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</DownloadProjectButton>
|
||||
<TransferProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="swap_horiz"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</TransferProjectButton>
|
||||
<TrashProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="delete"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</TrashProjectButton>
|
||||
<UntrashProjectButton project={project}>
|
||||
{(text, untrashProject) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={untrashProject}
|
||||
leadingIcon="restore_page"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</UntrashProjectButton>
|
||||
<DeleteProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="block"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</DeleteProjectButton>
|
||||
<RestoreProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="restore"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</RestoreProjectButton>
|
||||
<PurgeProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="delete_forever"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</PurgeProjectButton>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionsDropdown
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Filter,
|
||||
useProjectListContext,
|
||||
} from '../../context/project-list-context'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import BackToUserList from '../back-to-user-list'
|
||||
import ProjectsFilterMenu from '../projects-filter-menu'
|
||||
|
||||
type ItemProps = {
|
||||
filter: Filter
|
||||
text: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Item({ filter, text, onClick }: ItemProps) {
|
||||
const { selectFilter } = useProjectListContext()
|
||||
const handleClick = () => {
|
||||
selectFilter(filter)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectsFilterMenu filter={filter}>
|
||||
{isActive => (
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
trailingIcon={isActive ? 'check' : undefined}
|
||||
active={isActive}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ProjectsFilterMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectsDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(() => t('all_projects'))
|
||||
const { filter } = useProjectListContext()
|
||||
const filterTranslations = useRef<Record<Filter, string>>({
|
||||
owned: t('all_projects'),
|
||||
inactive: t('inactive_projects'),
|
||||
trashed: t('trashed_projects'),
|
||||
deleted: t('deleted_projects'),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(filterTranslations.current[filter])
|
||||
}, [filter, t])
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownToggle
|
||||
id="projects-types-dropdown-toggle-btn"
|
||||
className="ps-0 mb-0 btn-transparent h4"
|
||||
size="lg"
|
||||
aria-label={t('filter_projects')}
|
||||
>
|
||||
<span className="text-truncate" aria-hidden>
|
||||
{title}
|
||||
</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<li role="none">
|
||||
<Item filter="owned" text={t('all_projects')} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<Item filter="inactive" text={t('inactive_projects')} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<Item filter="trashed" text={t('trashed_projects')} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<Item filter="deleted" text={t('deleted_projects')} />
|
||||
</li>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsDropdown
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSort from '../../hooks/use-sort'
|
||||
import withContent, { SortBtnProps } from '../sort/with-content'
|
||||
import { useProjectListContext } from '../../context/project-list-context'
|
||||
import { Sort } from '../../../../../types/project/api'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
|
||||
function Item({ onClick, text, iconType }: SortBtnProps) {
|
||||
return (
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={onClick}
|
||||
trailingIcon={iconType}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemWithContent = withContent(Item)
|
||||
|
||||
function SortByDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(() => t('last_modified'))
|
||||
const { filter, sort } = useProjectListContext()
|
||||
const { handleSort } = useSort()
|
||||
const sortByTranslations = useRef<Record<Sort['by'], string>>({
|
||||
title: t('title'),
|
||||
lastUpdated: t('last_modified'),
|
||||
deletedAt: t('deleted_at'),
|
||||
})
|
||||
|
||||
const handleClick = (by: Sort['by']) => {
|
||||
setTitle(sortByTranslations.current[by])
|
||||
handleSort(by)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(sortByTranslations.current[sort.by])
|
||||
}, [sort.by])
|
||||
|
||||
return (
|
||||
<Dropdown className="projects-sort-dropdown" align="end">
|
||||
<DropdownToggle
|
||||
id="projects-sort-dropdown"
|
||||
className="pe-0 mb-0 btn-transparent"
|
||||
size="sm"
|
||||
aria-label={t('sort_projects')}
|
||||
>
|
||||
<span className="text-truncate" aria-hidden>
|
||||
{title}
|
||||
</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<DropdownHeader className="text-uppercase">
|
||||
{t('sort_by')}:
|
||||
</DropdownHeader>
|
||||
<ItemWithContent
|
||||
column="title"
|
||||
text={t('title')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('title')}
|
||||
/>
|
||||
<ItemWithContent
|
||||
column="owner"
|
||||
text={t('owner')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('owner')}
|
||||
/>
|
||||
{ filter !== 'deleted' ? (
|
||||
<ItemWithContent
|
||||
column="lastUpdated"
|
||||
text={t('last_modified')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('lastUpdated')}
|
||||
/>
|
||||
) : (
|
||||
<ItemWithContent
|
||||
column="deletedAt"
|
||||
text={t('deleted_at')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('deletedAt')}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortByDropdown
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectListContext } from '../context/project-list-context'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
export default function LoadMore() {
|
||||
const {
|
||||
visibleProjects,
|
||||
hiddenProjectsCount,
|
||||
loadMoreCount,
|
||||
showAllProjects,
|
||||
loadMoreProjects,
|
||||
} = useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
{hiddenProjectsCount > 0 ? (
|
||||
<>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
className="project-list-load-more-button"
|
||||
onClick={() => loadMoreProjects()}
|
||||
>
|
||||
{t('show_x_more_projects', { x: loadMoreCount })}
|
||||
</OLButton>
|
||||
</>
|
||||
) : null}
|
||||
<p>
|
||||
{hiddenProjectsCount > 0 ? (
|
||||
<>
|
||||
<span aria-live="polite">
|
||||
{t('showing_x_out_of_n_projects', {
|
||||
x: visibleProjects.length,
|
||||
n: visibleProjects.length + hiddenProjectsCount,
|
||||
})}
|
||||
</span>{' '}
|
||||
<OLButton
|
||||
variant="link"
|
||||
onClick={() => showAllProjects()}
|
||||
className="btn-inline-link"
|
||||
>
|
||||
{t('show_all_projects')}
|
||||
</OLButton>
|
||||
</>
|
||||
) : (
|
||||
<span aria-live="polite">
|
||||
{t('showing_x_out_of_n_projects', {
|
||||
x: visibleProjects.length,
|
||||
n: visibleProjects.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import ProjectsList from './projects-list'
|
||||
|
||||
type DeleteProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function DeleteProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: DeleteProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setProjectsToDisplay(displayProjects => {
|
||||
return displayProjects.length ? displayProjects : projects
|
||||
})
|
||||
} else {
|
||||
setProjectsToDisplay([])
|
||||
}
|
||||
}, [showModal, projects])
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="delete"
|
||||
actionHandler={actionHandler}
|
||||
title={t('delete_projects')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
>
|
||||
<p>{t('about_to_delete_projects')}</p>
|
||||
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||
<Notification
|
||||
content={t('this_action_can_be_undone_within_limited_period')}
|
||||
type="warning"
|
||||
/>
|
||||
</ProjectsActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteProjectModal
|
||||
@@ -0,0 +1,142 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Project } from '../../../../../types/project/api'
|
||||
import { getUserFacingMessage } from '@/infrastructure/fetch-json'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
|
||||
type ProjectsActionModalProps = {
|
||||
title?: string
|
||||
action: 'transfer' | 'trash' | 'delete' | 'restore' | 'purge'
|
||||
actionHandler: (project: Project, options?: any) => Promise<void>
|
||||
handleCloseModal: () => void
|
||||
projects: Array<Project>
|
||||
showModal: boolean
|
||||
options?: any
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const greenActions = new Set(['restore', 'transfer'])
|
||||
const redActions = new Set(['trash', 'delete', 'purge'])
|
||||
|
||||
function ProjectsActionModal({
|
||||
title,
|
||||
action,
|
||||
actionHandler,
|
||||
handleCloseModal,
|
||||
showModal,
|
||||
projects,
|
||||
options,
|
||||
children,
|
||||
}: ProjectsActionModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [errors, setErrors] = useState<Array<any>>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const variant =
|
||||
redActions.has(action) ? 'danger' :
|
||||
greenActions.has(action) ? 'primary' : 'secondary'
|
||||
|
||||
const actionLabel =
|
||||
action === 'transfer' ? t('change_owner') :
|
||||
t(action)
|
||||
|
||||
async function handleActionForProjects(projects: Array<Project>, options?: any) {
|
||||
const errored = []
|
||||
setIsProcessing(true)
|
||||
setErrors([])
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await actionHandler(project, options)
|
||||
} catch (e) {
|
||||
errored.push({ projectName: project.name, error: e })
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
if (errored.length === 0) {
|
||||
handleCloseModal()
|
||||
} else {
|
||||
setErrors(errored)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModal) {
|
||||
setErrors([])
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (options) {
|
||||
setErrors([])
|
||||
}
|
||||
}, [options])
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
eventTracking.sendMB('admin-user-project-list-page-interaction', {
|
||||
action,
|
||||
isSmallDevice,
|
||||
})
|
||||
}
|
||||
}, [action, showModal])
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
animation
|
||||
show={showModal}
|
||||
onHide={handleCloseModal}
|
||||
id="admin-action-project-modal"
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{title}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
{children}
|
||||
{!isProcessing &&
|
||||
errors.length > 0 &&
|
||||
errors.map((error, i) => (
|
||||
<div className="notification-list" key={i}>
|
||||
<Notification
|
||||
type="error"
|
||||
title={error.projectName}
|
||||
content={getUserFacingMessage(error.error) as string}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant={variant}
|
||||
onClick={() => handleActionForProjects(projects, options)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{actionLabel}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProjectsActionModal)
|
||||
@@ -0,0 +1,28 @@
|
||||
import classnames from 'classnames'
|
||||
import { Project } from '../../../../../types/project/api'
|
||||
|
||||
type ProjectsToDisplayProps = {
|
||||
projects: Project[]
|
||||
projectsToDisplay: Project[]
|
||||
}
|
||||
|
||||
function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) {
|
||||
return (
|
||||
<ul>
|
||||
{projectsToDisplay.map(project => (
|
||||
<li
|
||||
key={`projects-action-list-${project.id}`}
|
||||
className={classnames({
|
||||
'list-style-check-green': !projects.some(
|
||||
({ id }) => id === project.id
|
||||
),
|
||||
})}
|
||||
>
|
||||
<b>{project.name}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsList
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import ProjectsList from './projects-list'
|
||||
|
||||
type PurgeProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function PurgeProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: PurgeProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setProjectsToDisplay(displayProjects => {
|
||||
return displayProjects.length ? displayProjects : projects
|
||||
})
|
||||
} else {
|
||||
setProjectsToDisplay([])
|
||||
}
|
||||
}, [showModal, projects])
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="purge"
|
||||
actionHandler={actionHandler}
|
||||
title={t('permanently_delete_projects')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
>
|
||||
<p>{t('about_to_permanently_delete_projects')}</p>
|
||||
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||
<Notification
|
||||
content={t('this_action_cannot_be_undone')}
|
||||
type="warning"
|
||||
/>
|
||||
</ProjectsActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeProjectModal
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import ProjectsList from './projects-list'
|
||||
|
||||
type RestoreProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function RestoreProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: RestoreProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setProjectsToDisplay(displayProjects => {
|
||||
return displayProjects.length ? displayProjects : projects
|
||||
})
|
||||
} else {
|
||||
setProjectsToDisplay([])
|
||||
}
|
||||
}, [showModal, projects])
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="restore"
|
||||
actionHandler={actionHandler}
|
||||
title={t('restore_projects')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
>
|
||||
<p>{t('about_to_restore_projects')}</p>
|
||||
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||
</ProjectsActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreProjectModal
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMemo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import ProjectsList from './projects-list'
|
||||
import SelectOwnerForm from '../select-owner-form'
|
||||
import { useUserListContext } from '../../../user-list/context/user-list-context'
|
||||
import { useProjectListContext } from '../../context/project-list-context'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import sortUsers from '../../../user-list/util/sort-users'
|
||||
|
||||
type TransferProjectModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function TransferProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: TransferProjectModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||
[]
|
||||
)
|
||||
const { loadedUsers } = useUserListContext()
|
||||
const { projectsOwnerId } = useProjectListContext()
|
||||
|
||||
const potentialOwners = useMemo(() => {
|
||||
if (!loadedUsers) return null;
|
||||
const sortedUsers = sortUsers(loadedUsers, { by: 'name', order: 'asc' })
|
||||
const result: UserRef[] = []
|
||||
for (const user of sortedUsers) {
|
||||
if (!user.deleted && user.id !== projectsOwnerId) {
|
||||
result.push({
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [loadedUsers, projectsOwnerId])
|
||||
|
||||
const [newOwner, setNewOwner] = useState<UserRef | null>(null)
|
||||
const [sendEmails, setSendEmails] = useState(false)
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!newOwner) return null
|
||||
|
||||
return {
|
||||
user_id: newOwner.id,
|
||||
skipEmails: !sendEmails,
|
||||
}
|
||||
}, [newOwner, sendEmails])
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setNewOwner(null)
|
||||
setSendEmails(false)
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setProjectsToDisplay(displayProjects => {
|
||||
return displayProjects.length ? displayProjects : projects
|
||||
})
|
||||
} else {
|
||||
setProjectsToDisplay([])
|
||||
}
|
||||
}, [showModal, projects])
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSendEmails(e.currentTarget.checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="transfer"
|
||||
actionHandler={actionHandler}
|
||||
title={t('change_project_owner')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
options={options}
|
||||
>
|
||||
<p>{t('ownership_of_projects_will_be_transferred')}</p>
|
||||
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||
|
||||
<OLForm className="add-collabs">
|
||||
<OLFormGroup>
|
||||
<SelectOwnerForm
|
||||
loading={!potentialOwners}
|
||||
users={potentialOwners || []}
|
||||
value={newOwner}
|
||||
onChange={setNewOwner}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="send_notification_emails_checkbox">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
name="sendEmails"
|
||||
label={t('send_notification_emails_to_users')}
|
||||
checked={sendEmails}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLForm>
|
||||
</ProjectsActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferProjectModal
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectsActionModal from './projects-action-modal'
|
||||
import ProjectsList from './projects-list'
|
||||
|
||||
type TrashProjectPropsModalProps = Pick<
|
||||
React.ComponentProps<typeof ProjectsActionModal>,
|
||||
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function TrashProjectModal({
|
||||
projects,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: TrashProjectPropsModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setProjectsToDisplay(displayProjects => {
|
||||
return displayProjects.length ? displayProjects : projects
|
||||
})
|
||||
} else {
|
||||
setProjectsToDisplay([])
|
||||
}
|
||||
}, [showModal, projects])
|
||||
|
||||
return (
|
||||
<ProjectsActionModal
|
||||
action="trash"
|
||||
actionHandler={actionHandler}
|
||||
title={t('trash_projects')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
projects={projects}
|
||||
>
|
||||
<p>{t('about_to_trash_projects')}</p>
|
||||
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||
<p>
|
||||
{t('trashing_projects_wont_affect_user_collaborators')}
|
||||
</p>
|
||||
</ProjectsActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrashProjectModal
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectListTable from './table/project-list-table'
|
||||
import SearchForm from './search-form'
|
||||
import ProjectsDropdown from './dropdown/projects-dropdown'
|
||||
import SortByDropdown from './dropdown/sort-by-dropdown'
|
||||
import ProjectTools from './table/project-tools/project-tools'
|
||||
import ProjectListTitle from './title/project-list-title'
|
||||
import LoadMore from './load-more'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import { TableContainer } from '@/shared/components/table'
|
||||
import DashApiError from '@/features/project-list/components/dash-api-error'
|
||||
import getMeta from '@/utils/meta'
|
||||
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
||||
import Footer from '@/shared/components/footer/footer'
|
||||
import SidebarDsNav from './sidebar/sidebar-ds-nav'
|
||||
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
|
||||
import { getUserName } from '../util/user'
|
||||
import { useProjectListContext } from '../context/project-list-context'
|
||||
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
|
||||
|
||||
export function ProjectListDsNav() {
|
||||
|
||||
const navbarProps = getMeta('ol-navbar')
|
||||
const footerProps = getMeta('ol-footer')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
error,
|
||||
searchText,
|
||||
setSearchText,
|
||||
selectedProjects,
|
||||
filter,
|
||||
projectsOwnerId,
|
||||
} = useProjectListContext()
|
||||
const { getUserNameById } = useUserIdentityContext()
|
||||
|
||||
const userName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users')
|
||||
const tableTopArea = (
|
||||
<div className="pt-2 pb-3 d-md-none d-flex gap-3">
|
||||
<div className="pt-1 fs-5 fw-bold" translate="no">
|
||||
{userName}
|
||||
</div>
|
||||
<SearchForm
|
||||
inputValue={searchText}
|
||||
setInputValue={setSearchText}
|
||||
className="overflow-hidden flex-grow-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="project-ds-nav-page website-redesign">
|
||||
<DefaultNavbar
|
||||
{...navbarProps}
|
||||
overleafLogo={overleafLogo}
|
||||
showCloseIcon
|
||||
/>
|
||||
<div className="project-list-wrapper">
|
||||
<SidebarDsNav />
|
||||
<div className="project-ds-nav-content-and-messages">
|
||||
<div className="project-ds-nav-content">
|
||||
<div className="project-ds-nav-main">
|
||||
{error ? <DashApiError /> : ''}
|
||||
<main aria-labelledby="main-content">
|
||||
<div className="project-list-header-row position-relative">
|
||||
<ProjectListTitle
|
||||
filter={filter}
|
||||
className="text-truncate d-none d-md-block"
|
||||
/>
|
||||
<div className="project-tools">
|
||||
<div className="d-none d-md-block">
|
||||
{selectedProjects.length !== 0 && <ProjectTools />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-ds-nav-project-list">
|
||||
<OLRow className="d-none d-md-block">
|
||||
<OLCol lg={7}>
|
||||
<SearchForm
|
||||
inputValue={searchText}
|
||||
setInputValue={setSearchText}
|
||||
/>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
<div className="mt-1 d-md-none">
|
||||
<div
|
||||
role="toolbar"
|
||||
className="projects-toolbar"
|
||||
aria-label={t('projects')}
|
||||
>
|
||||
<ProjectsDropdown />
|
||||
<SortByDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TableContainer bordered>
|
||||
{tableTopArea}
|
||||
<ProjectListTable />
|
||||
</TableContainer>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<LoadMore />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Footer {...footerProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
useProjectListContext,
|
||||
} from '../context/project-list-context'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LoadingBranded from '@/shared/components/loading-branded'
|
||||
import { ProjectListDsNav } from './project-list-ds-nav'
|
||||
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
|
||||
import useThemedPage from '@/shared/hooks/use-themed-page'
|
||||
|
||||
export default function ProjectListRoot() {
|
||||
|
||||
useThemedPage('themed-project-dashboard')
|
||||
const { isLoading, loadProgress } = useProjectListContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
eventTracking.sendMB('loads_v2_dash', {})
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoadingBranded loadProgress={loadProgress} label={t('loading')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DsNavStyleProvider>
|
||||
<ProjectListDsNav />
|
||||
</DsNavStyleProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Filter, useProjectListContext } from '../context/project-list-context'
|
||||
|
||||
type ProjectsMenuFilterType = {
|
||||
children: (isActive: boolean) => React.ReactElement
|
||||
filter: Filter
|
||||
}
|
||||
|
||||
function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) {
|
||||
const { filter: activeFilter } = useProjectListContext()
|
||||
const isActive = filter === activeFilter
|
||||
|
||||
return children(isActive)
|
||||
}
|
||||
|
||||
export default ProjectsFilterMenu
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import classnames from 'classnames'
|
||||
import { MergeAndOverride } from '../../../../../../types/utils'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
type SearchFormOwnProps = {
|
||||
inputValue: string
|
||||
setInputValue: (input: string) => void
|
||||
}
|
||||
|
||||
type SearchFormProps = MergeAndOverride<
|
||||
React.ComponentProps<typeof OLForm>,
|
||||
SearchFormOwnProps
|
||||
>
|
||||
|
||||
function SearchForm({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
className,
|
||||
...props
|
||||
}: SearchFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const placeholderMessage = t('search_projects')
|
||||
const placeholder = `${placeholderMessage}…`
|
||||
|
||||
const handleChange: React.ComponentProps<
|
||||
typeof OLFormControl
|
||||
>['onChange'] = e => {
|
||||
eventTracking.sendMB('admin-user-project-list-page-interaction', {
|
||||
action: 'search',
|
||||
isSmallDevice,
|
||||
})
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
|
||||
const handleClear = () => setInputValue('')
|
||||
|
||||
return (
|
||||
<OLForm
|
||||
className={classnames('project-search', className)}
|
||||
role="search"
|
||||
onSubmit={e => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
<OLFormGroup>
|
||||
<OLCol>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
prepend={<MaterialIcon type="search" />}
|
||||
append={
|
||||
inputValue.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="form-control-search-clear-btn"
|
||||
aria-label={t('clear_search')}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</OLCol>
|
||||
</OLFormGroup>
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchForm
|
||||
@@ -0,0 +1,194 @@
|
||||
import classnames from 'classnames'
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCombobox } from 'downshift'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import { UserRef } from '../../../../types/project/api'
|
||||
import { getUserName } from '../util/user'
|
||||
|
||||
const FILTER_DELAY_MS = 200
|
||||
const MAX_RESULTS = 100
|
||||
|
||||
function getDisplayName(user: UserRef) {
|
||||
return `${getUserName(user)} <${user.email}>`
|
||||
}
|
||||
|
||||
const SelectOwnerForm = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
loading: boolean
|
||||
users: UserRef[]
|
||||
value: UserRef | null
|
||||
onChange: (user: UserRef | null) => void
|
||||
}
|
||||
>(function SelectOwnerForm(
|
||||
{ loading, users, value, onChange },
|
||||
forwardedRef
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!forwardedRef) return
|
||||
|
||||
if (typeof forwardedRef === 'function') {
|
||||
forwardedRef(inputRef.current)
|
||||
} else {
|
||||
forwardedRef.current = inputRef.current
|
||||
}
|
||||
}, [forwardedRef])
|
||||
|
||||
const lastSelectedRef = useRef<UserRef | null>(value)
|
||||
|
||||
const [inputValue, setInputValue] = useState(
|
||||
value ? getDisplayName(value) : ''
|
||||
)
|
||||
const [debouncedInput, setDebouncedInput] = useState(inputValue)
|
||||
|
||||
useEffect(() => {
|
||||
lastSelectedRef.current = value
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setDebouncedInput(inputValue)
|
||||
}, FILTER_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
}, [inputValue])
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!debouncedInput) {
|
||||
return users.slice(0, MAX_RESULTS)
|
||||
}
|
||||
|
||||
const query = debouncedInput.toLowerCase()
|
||||
const result: UserRef[] = []
|
||||
|
||||
for (const user of users) {
|
||||
const label = getDisplayName(user).toLowerCase()
|
||||
|
||||
if (label.includes(query)) {
|
||||
result.push(user)
|
||||
if (result.length >= MAX_RESULTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}, [users, debouncedInput])
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
highlightedIndex,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
getLabelProps,
|
||||
} = useCombobox<UserRef>({
|
||||
items: filteredOptions,
|
||||
selectedItem: value,
|
||||
inputValue,
|
||||
itemToString: item => (item ? getDisplayName(item) : ''),
|
||||
defaultHighlightedIndex: 0,
|
||||
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
setInputValue(inputValue ?? '')
|
||||
},
|
||||
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
if (selectedItem) {
|
||||
lastSelectedRef.current = selectedItem
|
||||
onChange(selectedItem)
|
||||
setInputValue(getDisplayName(selectedItem))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="tags-input tags-new">
|
||||
<OLFormLabel className="small" {...getLabelProps()}>
|
||||
{t('new_owner')}
|
||||
{loading && <OLSpinner size="sm" className="ms-2" />}
|
||||
</OLFormLabel>
|
||||
|
||||
<div className="host">
|
||||
<div
|
||||
className="tags form-control d-flex align-items-center gap-1"
|
||||
onClick={focusInput}
|
||||
>
|
||||
{value && <MaterialIcon type="person" />}
|
||||
|
||||
<input
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
className: 'input',
|
||||
size: inputValue.length ? inputValue.length + 5 : 5,
|
||||
type: 'email',
|
||||
placeholder: t('select_a_new_owner_for_projects'),
|
||||
onBlur: () => {
|
||||
const last = lastSelectedRef.current
|
||||
if (last) {
|
||||
setInputValue(getDisplayName(last))
|
||||
} else {
|
||||
setInputValue('')
|
||||
onChange(null)
|
||||
}
|
||||
},
|
||||
onKeyDown: e => {
|
||||
if (e.key === 'Enter' && highlightedIndex === -1) {
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={classnames(
|
||||
'dropdown-menu select-dropdown-menu w-100',
|
||||
{ show: isOpen }
|
||||
)}
|
||||
>
|
||||
{isOpen && filteredOptions.length === 0 && (
|
||||
<li className="dropdown-item text-muted">
|
||||
{t('No results')}
|
||||
</li>
|
||||
)}
|
||||
|
||||
{isOpen &&
|
||||
filteredOptions.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<DropdownItem
|
||||
as="span"
|
||||
role={undefined}
|
||||
leadingIcon="person"
|
||||
className={classnames({
|
||||
active: index === highlightedIndex,
|
||||
})}
|
||||
>
|
||||
{getDisplayName(item)}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default SelectOwnerForm
|
||||
@@ -0,0 +1,158 @@
|
||||
import classnames from 'classnames'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { User as UserIcon } from '@phosphor-icons/react'
|
||||
import { usePersistedResize } from '@/shared/hooks/use-resize'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import SidebarFilters from './sidebar-filters'
|
||||
import { getUserName } from '../../util/user'
|
||||
import { useUserIdentityContext } from '../../../user-list/context/user-identity-context'
|
||||
import { useProjectListContext } from '../../context/project-list-context'
|
||||
|
||||
import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll'
|
||||
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
|
||||
|
||||
function SidebarDsNav() {
|
||||
const { t } = useTranslation()
|
||||
const [showAccountDropdown, setShowAccountDropdown] = useState(false)
|
||||
const [showHelpDropdown, setShowHelpDropdown] = useState(false)
|
||||
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
|
||||
name: 'users-and-projects-sidebar',
|
||||
})
|
||||
const sendMB = useSendProjectListMB()
|
||||
const { sessionUser } = getMeta('ol-navbar')
|
||||
const { containerRef, scrolledUp } = useScrolled()
|
||||
const themedDsNav = useFeatureFlag('themed-project-dashboard')
|
||||
|
||||
const { getUserNameById } = useUserIdentityContext()
|
||||
const { projectsOwnerId } = useProjectListContext()
|
||||
|
||||
const ownerName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users')
|
||||
const handleBack = () => {
|
||||
if (history.length > 1) {
|
||||
history.back()
|
||||
} else {
|
||||
history.replaceState({ type: 'users' }, '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="project-list-sidebar-wrapper-react d-none d-md-flex"
|
||||
{...getTargetProps({
|
||||
style: {
|
||||
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<nav
|
||||
className="flex-grow flex-shrink"
|
||||
aria-label={t('project_categories_tags')}
|
||||
>
|
||||
<div className="ps-3 fs-5 fw-bold" translate="no">
|
||||
{ownerName}
|
||||
</div>
|
||||
<div
|
||||
className="project-list-sidebar-scroll"
|
||||
ref={containerRef}
|
||||
data-testid="project-list-sidebar-scroll"
|
||||
>
|
||||
<SidebarFilters />
|
||||
{projectsOwnerId && (
|
||||
<ul className="list-unstyled project-list-filters">
|
||||
<li>
|
||||
<button type="button" onClick={handleBack}>
|
||||
{t('back_to_user_list')}
|
||||
</button>
|
||||
</li>
|
||||
<li aria-hidden="true">
|
||||
<hr />
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
className={classnames(
|
||||
'ds-nav-sidebar-lower',
|
||||
scrolledUp && 'show-shadow'
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className="d-flex flex-row gap-3 mb-2"
|
||||
aria-label={t('account_help')}
|
||||
>
|
||||
{sessionUser && (
|
||||
<>
|
||||
<Dropdown
|
||||
className="ds-nav-icon-dropdown"
|
||||
onToggle={show => {
|
||||
setShowAccountDropdown(show)
|
||||
if (show) {
|
||||
sendMB('menu-expand', {
|
||||
item: 'account',
|
||||
location: 'sidebar',
|
||||
})
|
||||
}
|
||||
}}
|
||||
role="menu"
|
||||
>
|
||||
<Dropdown.Toggle role="menuitem" aria-label={t('Account')}>
|
||||
<OLTooltip
|
||||
description={t('Account')}
|
||||
id="open-account"
|
||||
overlayProps={{
|
||||
placement: 'top',
|
||||
}}
|
||||
hidden={showAccountDropdown}
|
||||
>
|
||||
<div>
|
||||
<UserIcon size={24} />
|
||||
</div>
|
||||
</OLTooltip>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu
|
||||
as="ul"
|
||||
role="menu"
|
||||
align="end"
|
||||
popperConfig={{
|
||||
modifiers: [
|
||||
{ name: 'offset', options: { offset: [-50, 5] } },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<AccountMenuItems
|
||||
sessionUser={sessionUser}
|
||||
showSubscriptionLink={false}
|
||||
showThemeToggle={themedDsNav}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="ds-nav-ds-name" translate="no">
|
||||
<span>Extended CE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
{...getHandleProps({
|
||||
style: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
right: '-2px',
|
||||
height: '100%',
|
||||
width: '4px',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarDsNav
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Filter,
|
||||
useProjectListContext,
|
||||
} from '../../context/project-list-context'
|
||||
import ProjectsFilterMenu from '../projects-filter-menu'
|
||||
|
||||
type SidebarFilterProps = {
|
||||
filter: Filter
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarFilter({ filter, text }: SidebarFilterProps) {
|
||||
const { selectFilter } = useProjectListContext()
|
||||
|
||||
return (
|
||||
<ProjectsFilterMenu filter={filter}>
|
||||
{isActive => (
|
||||
<li className={isActive ? 'active' : ''}>
|
||||
<button type="button" onClick={() => selectFilter(filter)}>
|
||||
{text}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ProjectsFilterMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SidebarFilters() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled project-list-filters">
|
||||
<SidebarFilter filter="owned" text={t('all_projects')} />
|
||||
<SidebarFilter filter="inactive" text={t('inactive_projects')} />
|
||||
<SidebarFilter filter="trashed" text={t('trashed_projects')} />
|
||||
<SidebarFilter filter="deleted" text={t('deleted_projects')} />
|
||||
<li aria-hidden="true">
|
||||
<hr />
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Sort } from '../../../../../types/project/api'
|
||||
|
||||
type SortBtnOwnProps = {
|
||||
column: string
|
||||
sort: Sort
|
||||
text: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type WithContentProps = {
|
||||
iconType?: string
|
||||
screenReaderText: string
|
||||
}
|
||||
|
||||
export type SortBtnProps = SortBtnOwnProps & WithContentProps
|
||||
|
||||
function withContent<T extends SortBtnOwnProps>(
|
||||
WrappedComponent: React.ComponentType<T & WithContentProps>
|
||||
) {
|
||||
function WithContent(hocProps: T) {
|
||||
const { t } = useTranslation()
|
||||
const { column, text, sort } = hocProps
|
||||
let iconType
|
||||
|
||||
let screenReaderText = t('sort_by_x', { x: text })
|
||||
|
||||
if (column === sort.by) {
|
||||
iconType =
|
||||
sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt'
|
||||
screenReaderText = t('reverse_x_sort_order', { x: text })
|
||||
}
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...hocProps}
|
||||
iconType={iconType}
|
||||
screenReaderText={screenReaderText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return WithContent
|
||||
}
|
||||
|
||||
export default withContent
|
||||
@@ -0,0 +1,84 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import DeleteProjectModal from '../../../modals/delete-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { deleteProject } from '../../../../util/api'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
|
||||
type DeleteProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
|
||||
|
||||
if (!project.trashed || project.deleted) return null
|
||||
|
||||
const { toggleSelectedProject, updateProjectViewData } = useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('delete')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleDeleteProject = useCallback(async () => {
|
||||
await deleteProject(project.id)
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
deleted: true,
|
||||
})
|
||||
}, [project, toggleSelectedProject, updateProjectViewData])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<DeleteProjectModal
|
||||
projects={[project]}
|
||||
actionHandler={handleDeleteProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<DeleteProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<DeleteProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-delete-project-${project.id}`}
|
||||
id={`delete-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="block"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</DeleteProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(DeleteProjectButton)
|
||||
export { DeleteProjectButtonTooltip }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
|
||||
type DownloadProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, downloadProject: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function DownloadProjectButton({
|
||||
project,
|
||||
children,
|
||||
}: DownloadProjectButtonProps) {
|
||||
|
||||
if (project.deleted) return null
|
||||
|
||||
const { t } = useTranslation()
|
||||
const text = t('download_zip_file')
|
||||
const location = useLocation()
|
||||
|
||||
const downloadProject = useCallback(() => {
|
||||
eventTracking.sendMB('admin-user-project-list-page-interaction', {
|
||||
action: 'downloadZip',
|
||||
projectId: project.id,
|
||||
isSmallDevice,
|
||||
})
|
||||
location.assign(`/project/${project.id}/download/zip`)
|
||||
}, [project, location])
|
||||
|
||||
return children(text, downloadProject)
|
||||
}
|
||||
|
||||
const DownloadProjectButtonTooltip = memo(
|
||||
function DownloadProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<DownloadProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<DownloadProjectButton project={project}>
|
||||
{(text, downloadProject) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-download-project-${project.id}`}
|
||||
id={`download-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={downloadProject}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="download"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</DownloadProjectButton>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default memo(DownloadProjectButton)
|
||||
export { DownloadProjectButtonTooltip }
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import PurgeProjectModal from '../../../modals/purge-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { purgeProject } from '../../../../util/api'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
|
||||
type PurgeProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) {
|
||||
|
||||
if (!project.deleted) return null
|
||||
|
||||
const { removeProjectFromView } = useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('purge')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handlePurgeProject = useCallback(async () => {
|
||||
await purgeProject(project.id)
|
||||
removeProjectFromView(project)
|
||||
}, [project, removeProjectFromView])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<PurgeProjectModal
|
||||
projects={[project]}
|
||||
actionHandler={handlePurgeProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PurgeProjectButtonTooltip = memo(function PurgeProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<PurgeProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<PurgeProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-purge-project-${project.id}`}
|
||||
id={`purge-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="delete_forever"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</PurgeProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(PurgeProjectButton)
|
||||
export { PurgeProjectButtonTooltip }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import RestoreProjectModal from '../../../modals/restore-project-modal'
|
||||
import { undeleteProject } from '../../../../util/api'
|
||||
|
||||
type RestoreProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function RestoreProjectButton({
|
||||
project,
|
||||
children,
|
||||
}: RestoreProjectButtonProps) {
|
||||
const { toggleSelectedProject, updateProjectViewData } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('restore')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleRestoreProject = useCallback(() => {
|
||||
// const ownerId = project.owner ?? getMeta('ol-user_id')
|
||||
return undeleteProject(project.id, project.owner).then(data => {
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
...data,
|
||||
deleted: false,
|
||||
trashed: false,
|
||||
})
|
||||
})
|
||||
}, [project, toggleSelectedProject, updateProjectViewData])
|
||||
|
||||
if (!project.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<RestoreProjectModal
|
||||
projects={[project]}
|
||||
actionHandler={handleRestoreProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const RestoreProjectButtonTooltip = memo(function RestoreProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<RestoreProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<RestoreProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-restore-project-${project.id}`}
|
||||
id={`restore-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="restore"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</RestoreProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(RestoreProjectButton)
|
||||
export { RestoreProjectButtonTooltip }
|
||||
@@ -0,0 +1,93 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import TransferProjectModal from '../../../modals/transfer-project-modal'
|
||||
import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api'
|
||||
|
||||
type TransferProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function TransferProjectButton({ project, children }: TransferProjectButtonProps) {
|
||||
|
||||
if (project.deleted) return null
|
||||
|
||||
const {
|
||||
removeProjectFromView,
|
||||
updateProjectViewData,
|
||||
toggleSelectedProject,
|
||||
projectsOwnerId }
|
||||
= useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('change_owner')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleTransferProject = useCallback(async (project: Project, options: TransferOwnershipOptions ) => {
|
||||
await transferProjectOwnership(project.id, options)
|
||||
if (!projectsOwnerId) {
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
owner: options.user_id,
|
||||
})
|
||||
toggleSelectedProject(project.id, false)
|
||||
} else {
|
||||
removeProjectFromView(project)
|
||||
}
|
||||
}, [project, removeProjectFromView, updateProjectViewData])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<TransferProjectModal
|
||||
projects={[project]}
|
||||
actionHandler={handleTransferProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TransferProjectButtonTooltip = memo(function TransferProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<TransferProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<TransferProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-transfer-project-${project.id}`}
|
||||
id={`trnsfer-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="swap_horiz"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</TransferProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(TransferProjectButton)
|
||||
export { TransferProjectButtonTooltip }
|
||||
@@ -0,0 +1,86 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import TrashProjectModal from '../../../modals/trash-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { trashProjectForUser } from '../../../../util/api'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
|
||||
type TrashProjectButtonProps = {
|
||||
project: Project
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
|
||||
|
||||
if (project.trashed || project.deleted ) return null
|
||||
|
||||
const { toggleSelectedProject, updateProjectViewData } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('trash')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleTrashProject = useCallback(async () => {
|
||||
await trashProjectForUser(project.id, project.owner)
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
trashed: true,
|
||||
archived: false,
|
||||
})
|
||||
}, [project, toggleSelectedProject, updateProjectViewData])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<TrashProjectModal
|
||||
projects={[project]}
|
||||
actionHandler={handleTrashProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<TrashProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<TrashProjectButton project={project}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-trash-project-${project.id}`}
|
||||
id={`trash-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="delete"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</TrashProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(TrashProjectButton)
|
||||
export { TrashProjectButtonTooltip }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo, useCallback } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { untrashProjectForUser } from '../../../../util/api'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
|
||||
type UntrashProjectButtonProps = {
|
||||
project: Project
|
||||
children: (
|
||||
text: string,
|
||||
untrashProject: () => Promise<void>
|
||||
) => React.ReactElement
|
||||
}
|
||||
|
||||
function UntrashProjectButton({
|
||||
project,
|
||||
children,
|
||||
}: UntrashProjectButtonProps) {
|
||||
|
||||
if (!project.trashed || project.deleted) return null
|
||||
|
||||
const { t } = useTranslation()
|
||||
const text = t('untrash')
|
||||
const { toggleSelectedProject, updateProjectViewData } =
|
||||
useProjectListContext()
|
||||
|
||||
const handleUntrashProject = useCallback(async () => {
|
||||
await untrashProjectForUser(project.id, project.owner)
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({ ...project, trashed: false })
|
||||
}, [project, toggleSelectedProject, updateProjectViewData])
|
||||
|
||||
return children(text, handleUntrashProject)
|
||||
}
|
||||
|
||||
const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({
|
||||
project,
|
||||
}: Pick<UntrashProjectButtonProps, 'project'>) {
|
||||
return (
|
||||
<UntrashProjectButton project={project}>
|
||||
{(text, handleUntrashProject) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-untrash-project-${project.id}`}
|
||||
id={`untrash-project-${project.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleUntrashProject}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="restore_page"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</UntrashProjectButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(UntrashProjectButton)
|
||||
export { UntrashProjectButtonTooltip }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Project } from '../../../../../../types/project/api'
|
||||
import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button'
|
||||
import { TransferProjectButtonTooltip } from './action-buttons/transfer-project-button'
|
||||
import { TrashProjectButtonTooltip } from './action-buttons/trash-project-button'
|
||||
import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-button'
|
||||
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
|
||||
import { RestoreProjectButtonTooltip } from './action-buttons/restore-project-button'
|
||||
import { PurgeProjectButtonTooltip } from './action-buttons/purge-project-button'
|
||||
|
||||
type ActionsCellProps = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export default function ActionsCell({ project }: ActionsCellProps) {
|
||||
return (
|
||||
<>
|
||||
<DownloadProjectButtonTooltip project={project} />
|
||||
<TransferProjectButtonTooltip project={project} />
|
||||
<TrashProjectButtonTooltip project={project} />
|
||||
<UntrashProjectButtonTooltip project={project} />
|
||||
<DeleteProjectButtonTooltip project={project} />
|
||||
<RestoreProjectButtonTooltip project={project} />
|
||||
<PurgeProjectButtonTooltip project={project} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatDate, fromNowDate } from '@/utils/dates'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
|
||||
type DateCellProps = {
|
||||
projectId: string
|
||||
actorName: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export default function DateCell({ projectId, actorName, date }: DateCellProps) {
|
||||
const fromNow = fromNowDate(date)
|
||||
const tooltipText = formatDate(date)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
key={`tooltip-date-${projectId}`}
|
||||
id={`tooltip-date-${projectId}`}
|
||||
description={tooltipText}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<span translate="no">
|
||||
{t('last_updated_date_by_x', {
|
||||
lastUpdatedDate: fromNow,
|
||||
person: actorName,
|
||||
})}
|
||||
</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ChangeEvent, memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectListContext } from '../../context/project-list-context'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
|
||||
export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
|
||||
({ projectId, projectName }) => {
|
||||
const { t } = useTranslation()
|
||||
const { selectedProjectIds, toggleSelectedProject } =
|
||||
useProjectListContext()
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
toggleSelectedProject(projectId, event.target.checked)
|
||||
},
|
||||
[projectId, toggleSelectedProject]
|
||||
)
|
||||
|
||||
return (
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
checked={selectedProjectIds.has(projectId)}
|
||||
aria-label={t('select_project', { project: projectName })}
|
||||
data-project-id={projectId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ProjectCheckbox.displayName = 'ProjectCheckbox'
|
||||
@@ -0,0 +1,10 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const ProjectListOwnerName = memo<{ ownerName: string }>(
|
||||
({ ownerName }) => {
|
||||
const { t } = useTranslation()
|
||||
return <span translate="no"> — {t('owned_by_x', { x: ownerName })}</span>
|
||||
}
|
||||
)
|
||||
ProjectListOwnerName.displayName = 'ProjectListOwnerName'
|
||||
@@ -0,0 +1,62 @@
|
||||
import { memo } from 'react'
|
||||
import OwnerCell from './cells/owner-cell'
|
||||
import DateCell from './cells/date-cell'
|
||||
import { Filter } from '../../context/project-list-context'
|
||||
import ActionsCell from './cells/actions-cell'
|
||||
import ActionsDropdown from '../dropdown/actions-dropdown'
|
||||
import { Project } from '../../../../../types/project/api'
|
||||
import { ProjectCheckbox } from './project-checkbox'
|
||||
import { ProjectListOwnerName } from './project-list-owner-name'
|
||||
import { useUserIdentityContext } from '../../../user-list/context/user-identity-context'
|
||||
|
||||
type ProjectListTableRowProps = {
|
||||
project: Project
|
||||
selected: boolean
|
||||
filter: Filter
|
||||
}
|
||||
function ProjectListTableRow({ project, selected, filter }: ProjectListTableRowProps) {
|
||||
const { getUserNameById } = useUserIdentityContext()
|
||||
const ownerName = getUserNameById(project.owner)
|
||||
const actorName = filter !== 'deleted' ?
|
||||
getUserNameById(project.lastUpdatedBy) :
|
||||
getUserNameById(project.deletedBy)
|
||||
const eventDate = filter !== 'deleted' ? project.lastUpdated : project.deletedAt
|
||||
|
||||
return (
|
||||
<tr className={selected ? 'table-active' : undefined}>
|
||||
<td className="dash-cell-checkbox d-none d-md-table-cell">
|
||||
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||
</td>
|
||||
<td className="dash-cell-name" translate="no">
|
||||
{project.name}
|
||||
</td>
|
||||
<td className="dash-cell-date-owner pb-0 d-md-none">
|
||||
<DateCell
|
||||
projectId={project.id}
|
||||
actorName={actorName}
|
||||
date={eventDate}
|
||||
/>
|
||||
<ProjectListOwnerName ownerName={ownerName} />
|
||||
</td>
|
||||
<td className="dash-cell-owner d-none d-md-table-cell">
|
||||
<span translate="no">{ownerName}</span>
|
||||
</td>
|
||||
<td className="dash-cell-date d-none d-md-table-cell">
|
||||
<DateCell
|
||||
projectId={project.id}
|
||||
actorName={actorName}
|
||||
date={eventDate}
|
||||
/>
|
||||
</td>
|
||||
<td className="dash-cell-actions">
|
||||
<div className="d-none d-md-block">
|
||||
<ActionsCell project={project} />
|
||||
</div>
|
||||
<div className="d-md-none">
|
||||
<ActionsDropdown project={project} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
export default memo(ProjectListTableRow)
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ProjectListTableRow from './project-list-table-row'
|
||||
import { useProjectListContext } from '../../context/project-list-context'
|
||||
import useSort from '../../hooks/use-sort'
|
||||
import withContent, { SortBtnProps } from '../sort/with-content'
|
||||
import OLTable from '@/shared/components/ol/ol-table'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
|
||||
return (
|
||||
<button
|
||||
className="table-header-sort-btn d-none d-md-inline-block"
|
||||
onClick={onClick}
|
||||
aria-label={screenReaderText}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{iconType && <MaterialIcon type={iconType} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const SortByButton = withContent(SortBtn)
|
||||
|
||||
function ProjectListTable() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
visibleProjects,
|
||||
sort,
|
||||
selectedProjects,
|
||||
selectOrUnselectAllProjects,
|
||||
filter,
|
||||
} = useProjectListContext()
|
||||
const { handleSort } = useSort()
|
||||
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAllProjectsCheckboxChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
selectOrUnselectAllProjects(event.target.checked)
|
||||
},
|
||||
[selectOrUnselectAllProjects]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (checkAllRef.current) {
|
||||
checkAllRef.current.indeterminate =
|
||||
selectedProjects.length > 0 &&
|
||||
selectedProjects.length !== visibleProjects.length
|
||||
}
|
||||
}, [selectedProjects, visibleProjects])
|
||||
return (
|
||||
<OLTable className="project-dash-table" container={false} hover>
|
||||
<caption className="visually-hidden">{t('projects_list')}</caption>
|
||||
<thead className="visually-hidden-max-md">
|
||||
<tr>
|
||||
<th
|
||||
className="dash-cell-checkbox d-none d-md-table-cell"
|
||||
aria-label={t('select_projects')}
|
||||
>
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleAllProjectsCheckboxChange}
|
||||
checked={
|
||||
visibleProjects.length === selectedProjects.length &&
|
||||
visibleProjects.length !== 0
|
||||
}
|
||||
disabled={visibleProjects.length === 0}
|
||||
aria-label={t('select_all_projects')}
|
||||
inputRef={checkAllRef}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-name"
|
||||
aria-label={t('title')}
|
||||
aria-sort={
|
||||
sort.by === 'title'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="title"
|
||||
text={t('title')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('title')}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-date-owner d-md-none"
|
||||
aria-label={t('date_and_owner')}
|
||||
>
|
||||
{t('date_and_owner')}
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-owner d-none d-md-table-cell"
|
||||
aria-label={t('owner')}
|
||||
aria-sort={
|
||||
sort.by === 'owner'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="owner"
|
||||
text={t('owner')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('owner')}
|
||||
/>
|
||||
</th>
|
||||
{filter !== 'deleted' ? (
|
||||
<th
|
||||
className="dash-cell-date d-none d-md-table-cell"
|
||||
aria-label={t('last_modified')}
|
||||
aria-sort={
|
||||
sort.by === 'lastUpdated'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="lastUpdated"
|
||||
text={t('last_modified')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('lastUpdated')}
|
||||
/>
|
||||
</th>
|
||||
) : (
|
||||
<th
|
||||
className="dash-cell-date d-none d-md-table-cell"
|
||||
aria-label={t('deleted_at')}
|
||||
aria-sort={
|
||||
sort.by === 'deletedAt'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="deletedAt"
|
||||
text={t('deleted_at')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('deletedAt')}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="dash-cell-actions" aria-label={t('actions')}>
|
||||
{t('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleProjects.length > 0 ? (
|
||||
visibleProjects.map(p => (
|
||||
<ProjectListTableRow
|
||||
project={p}
|
||||
selected={selectedProjects.some(({ id }) => id === p.id)}
|
||||
key={p.id}
|
||||
filter={filter}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<tr className="no-projects">
|
||||
<td className="text-center" colSpan={4}>
|
||||
{t('no_projects')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</OLTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectListTable
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DeleteProjectModal from '../../../modals/delete-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { deleteProject } from '../../../../util/api'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
|
||||
function DeleteProjectsButton() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
selectedProjects,
|
||||
toggleSelectedProject,
|
||||
updateProjectViewData,
|
||||
} = useProjectListContext()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteProject = async (project: Project) => {
|
||||
await deleteProject(project.id)
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
deleted: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||
{t('delete')}
|
||||
</OLButton>
|
||||
<DeleteProjectModal
|
||||
projects={selectedProjects}
|
||||
actionHandler={handleDeleteProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteProjectsButton
|
||||
@@ -0,0 +1,47 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
|
||||
function DownloadProjectsButton() {
|
||||
const { selectedProjects, selectOrUnselectAllProjects } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('download')
|
||||
const location = useLocation()
|
||||
|
||||
const projectIds = selectedProjects.map(p => p.id)
|
||||
|
||||
const handleDownloadProjects = useCallback(() => {
|
||||
eventTracking.sendMB('admin-user-project-list-page-interaction', {
|
||||
action: 'downloadZips',
|
||||
isSmallDevice,
|
||||
})
|
||||
|
||||
location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`)
|
||||
|
||||
const selected = false
|
||||
selectOrUnselectAllProjects(selected)
|
||||
}, [projectIds, selectOrUnselectAllProjects, location])
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="tooltip-download-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleDownloadProjects}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon="download"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(DownloadProjectsButton)
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PurgeProjectModal from '../../../modals/purge-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { purgeProject } from '../../../../util/api'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
|
||||
function PurgeProjectsButton() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
selectedProjects,
|
||||
removeProjectFromView
|
||||
} = useProjectListContext()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePurgeProject = async (project: Project) => {
|
||||
await purgeProject(project.id)
|
||||
removeProjectFromView(project)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||
{t('purge')}
|
||||
</OLButton>
|
||||
<PurgeProjectModal
|
||||
projects={selectedProjects}
|
||||
actionHandler={handlePurgeProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeProjectsButton
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { undeleteProject } from '../../../../util/api'
|
||||
import RestoreProjectModal from '../../../modals/restore-project-modal'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
|
||||
function RestoreProjectsButton() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
selectedProjects,
|
||||
toggleSelectedProject,
|
||||
updateProjectViewData,
|
||||
} = useProjectListContext()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestoreProject = (project: Project) => {
|
||||
// const ownerId = project.owner ?? getMeta('ol-user_id')
|
||||
const ownerId = project.owner ?? getMeta('ol-user_id')
|
||||
return undeleteProject(project.id, project.owner).then(data => {
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
...data,
|
||||
deleted: false,
|
||||
trashed: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="primary" onClick={handleOpenModal}>
|
||||
{t('restore')}
|
||||
</OLButton>
|
||||
<RestoreProjectModal
|
||||
projects={selectedProjects}
|
||||
actionHandler={handleRestoreProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreProjectsButton
|
||||
@@ -0,0 +1,73 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import TransferProjectModal from '../../../modals/transfer-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
|
||||
function TransferProjectsButton() {
|
||||
const {
|
||||
selectedProjects,
|
||||
removeProjectFromView,
|
||||
updateProjectViewData,
|
||||
toggleSelectedProject,
|
||||
projectsOwnerId }
|
||||
= useProjectListContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const text = t('change_owner')
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleTransferProject = async (project: Project, options: TransferOwnershipOptions ) => {
|
||||
await transferProjectOwnership(project.id, options)
|
||||
if (!projectsOwnerId) {
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
owner: options.user_id,
|
||||
})
|
||||
toggleSelectedProject(project.id, false)
|
||||
} else {
|
||||
removeProjectFromView(project)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLTooltip
|
||||
id="tooltip-transfer-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon="swap_horiz"
|
||||
/>
|
||||
</OLTooltip>
|
||||
<TransferProjectModal
|
||||
projects={selectedProjects}
|
||||
actionHandler={handleTransferProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TransferProjectsButton)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import TrashProjectModal from '../../../modals/trash-project-modal'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { trashProjectForUser } from '../../../../util/api'
|
||||
import { Project } from '../../../../../../../types/project/api'
|
||||
|
||||
function TrashProjectsButton() {
|
||||
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('trash')
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const handleTrashProject = async (project: Project) => {
|
||||
await trashProjectForUser(project.id, project.owner)
|
||||
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({
|
||||
...project,
|
||||
trashed: true,
|
||||
archived: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLTooltip
|
||||
id="tooltip-trash-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon="delete"
|
||||
/>
|
||||
</OLTooltip>
|
||||
<TrashProjectModal
|
||||
projects={selectedProjects}
|
||||
actionHandler={handleTrashProject}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TrashProjectsButton)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { memo } from 'react'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectListContext } from '../../../../context/project-list-context'
|
||||
import { untrashProjectForUser } from '../../../../util/api'
|
||||
|
||||
export default function UntrashProjectsButton() {
|
||||
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
|
||||
useProjectListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t('restore')
|
||||
|
||||
const handleUntrashProjects = async () => {
|
||||
for (const project of selectedProjects) {
|
||||
await untrashProjectForUser(project.id, project.owner)
|
||||
toggleSelectedProject(project.id, false)
|
||||
updateProjectViewData({ ...project, trashed: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id="tooltip-download-projects"
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleUntrashProjects}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon="restore"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectListContext } from '../../../context/project-list-context'
|
||||
import TransferProjectsButton from './buttons/transfer-projects-button'
|
||||
import DownloadProjectsButton from './buttons/download-projects-button'
|
||||
import TrashProjectsButton from './buttons/trash-projects-button'
|
||||
import UntrashProjectsButton from './buttons/untrash-projects-button'
|
||||
import DeleteProjectsButton from './buttons/delete-projects-button'
|
||||
import RestoreProjectsButton from './buttons/restore-projects-button'
|
||||
import PurgeProjectsButton from './buttons/purge-projects-button'
|
||||
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
||||
import OLButtonGroup from '@/shared/components/ol/ol-button-group'
|
||||
|
||||
function ProjectTools() {
|
||||
const { t } = useTranslation()
|
||||
const { filter } = useProjectListContext()
|
||||
|
||||
return (
|
||||
<OLButtonToolbar aria-label={t('toolbar_selected_projects')}>
|
||||
<OLButtonGroup
|
||||
aria-label={t('toolbar_selected_projects_management_actions')}
|
||||
>
|
||||
{filter !== 'deleted' && <DownloadProjectsButton />}
|
||||
{filter !== 'deleted' && <TransferProjectsButton />}
|
||||
{filter !== 'deleted' && filter !== 'trashed' && <TrashProjectsButton />}
|
||||
{filter === 'trashed' && <UntrashProjectsButton />}
|
||||
</OLButtonGroup>
|
||||
{filter === 'trashed' && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_projects_remove')}>
|
||||
<DeleteProjectsButton />
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
{(filter === 'deleted') && (
|
||||
<>
|
||||
<OLButtonGroup
|
||||
aria-label={t('toolbar_selected_projects_restore')}
|
||||
>
|
||||
<RestoreProjectsButton />
|
||||
</OLButtonGroup>
|
||||
<OLButtonGroup
|
||||
aria-label={t('toolbar_selected_projects_purge')}
|
||||
>
|
||||
<PurgeProjectsButton />
|
||||
</OLButtonGroup>
|
||||
</>
|
||||
)}
|
||||
</OLButtonToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProjectTools)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classnames from 'classnames'
|
||||
import { Filter } from '../../context/project-list-context'
|
||||
|
||||
function ProjectListTitle({
|
||||
filter,
|
||||
className,
|
||||
}: {
|
||||
filter: Filter
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
let message = t('projects')
|
||||
let extraProps = {}
|
||||
|
||||
switch (filter) {
|
||||
case 'owned':
|
||||
message = t('all_projects')
|
||||
break
|
||||
case 'inactive':
|
||||
message = t('inactive_projects')
|
||||
break
|
||||
case 'trashed':
|
||||
message = t('trashed_projects')
|
||||
break
|
||||
case 'deleted':
|
||||
message = t('deleted_projects')
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<h1
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={classnames('project-list-title', className)}
|
||||
{...extraProps}
|
||||
>
|
||||
{message}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectListTitle
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import usePersistedState from '@/shared/hooks/use-persisted-state'
|
||||
import {
|
||||
GetProjectsResponseBody,
|
||||
Project,
|
||||
Sort,
|
||||
} from '../../../../types/project/api'
|
||||
import { getProjects } from '../util/api'
|
||||
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
|
||||
import sortProjects from '../util/sort-projects'
|
||||
|
||||
const MAX_PROJECT_PER_PAGE = 20
|
||||
|
||||
export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive'
|
||||
|
||||
type FilterMap = {
|
||||
[key in Filter]: Partial<Project> | ((project: Project) => boolean)
|
||||
}
|
||||
|
||||
const filters: FilterMap = {
|
||||
owned: (project) =>
|
||||
project.deleted === false &&
|
||||
project.trashed === false &&
|
||||
project.owner != null,
|
||||
|
||||
trashed: (project) =>
|
||||
project.deleted === false &&
|
||||
project.trashed === true &&
|
||||
project.owner != null,
|
||||
|
||||
deleted: (project) =>
|
||||
project.deleted === true,
|
||||
|
||||
inactive: (project) =>
|
||||
project.deleted === false &&
|
||||
project.trashed === false &&
|
||||
project.inactive === true,
|
||||
}
|
||||
|
||||
export type ProjectListContextValue = {
|
||||
error: Error | null
|
||||
filter: Filter
|
||||
hiddenProjectsCount: number
|
||||
isLoading: ReturnType<typeof useAsync>['isLoading']
|
||||
loadMoreCount: number
|
||||
loadMoreProjects: () => void
|
||||
loadProgress: number
|
||||
removeProjectFromView: (project: Project) => void
|
||||
selectFilter: (filter: Filter) => void
|
||||
selectedProjectIds: Set<string>
|
||||
selectedProjects: Project[]
|
||||
selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
|
||||
searchText: string
|
||||
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
||||
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
setSort: React.Dispatch<React.SetStateAction<Sort>>
|
||||
showAllProjects: () => void
|
||||
sort: Sort
|
||||
toggleSelectedProject: (projectId: string, selected?: boolean) => void
|
||||
totalProjectsCount: number
|
||||
projectsOwnerId: string | null
|
||||
updateProjectViewData: (newProjectData: Project) => void
|
||||
visibleProjects: Project[]
|
||||
}
|
||||
|
||||
export const ProjectListContext = createContext<
|
||||
ProjectListContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
type ProjectListProviderProps = {
|
||||
projectsOwnerId: string | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ProjectListProvider({ projectsOwnerId, children }: ProjectListProviderProps) {
|
||||
const { getUserById } = useUserIdentityContext()
|
||||
|
||||
const prefetchedProjectsBlob = projectsOwnerId ? null : getMeta('ol-prefetchedProjectsBlob')
|
||||
const [loadedProjects, setLoadedProjects] = useState<Project[]>(
|
||||
prefetchedProjectsBlob?.projects ?? []
|
||||
)
|
||||
|
||||
const [maxVisibleProjects, setMaxVisibleProjects] =
|
||||
useState(MAX_PROJECT_PER_PAGE)
|
||||
|
||||
const [loadProgress, setLoadProgress] = useState(
|
||||
prefetchedProjectsBlob ? 100 : 20
|
||||
)
|
||||
|
||||
const [totalProjectsCount, setTotalProjectsCount] = useState<number>(
|
||||
prefetchedProjectsBlob?.totalSize ?? 0
|
||||
)
|
||||
|
||||
const [filter, setFilter] = usePersistedState<Filter>(
|
||||
'admin-project-list-filter',
|
||||
'owned'
|
||||
)
|
||||
const [sort, setSort] = useState<Sort>({
|
||||
by: filter === 'deleted' ? 'deletedAt' : 'lastUpdated',
|
||||
order: 'desc',
|
||||
})
|
||||
const prevSortRef = useRef<Sort>(sort)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
isLoading: loading,
|
||||
isIdle,
|
||||
error,
|
||||
runAsync,
|
||||
} = useAsync<GetProjectsResponseBody>({
|
||||
status: prefetchedProjectsBlob ? 'resolved' : 'pending',
|
||||
data: prefetchedProjectsBlob,
|
||||
})
|
||||
|
||||
const isLoading = isIdle ? true : loading
|
||||
|
||||
useEffect(() => {
|
||||
if (prefetchedProjectsBlob) return
|
||||
|
||||
setLoadProgress(40)
|
||||
|
||||
runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' }))
|
||||
.then(data => {
|
||||
setLoadedProjects(data.projects)
|
||||
setTotalProjectsCount(data.totalSize)
|
||||
})
|
||||
.catch(debugConsole.error)
|
||||
.finally(() => {
|
||||
setLoadProgress(100)
|
||||
})
|
||||
}, [projectsOwnerId, runAsync, prefetchedProjectsBlob])
|
||||
|
||||
const sortedProjects = useMemo(() => {
|
||||
if (prevSortRef.current === sort) return loadedProjects
|
||||
|
||||
const sorted = sortProjects(loadedProjects, sort, getUserById)
|
||||
prevSortRef.current = sort
|
||||
return sorted
|
||||
}, [loadedProjects, sort, getUserById])
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
let result = sortedProjects
|
||||
|
||||
if (searchText.length) {
|
||||
const lower = searchText.toLowerCase()
|
||||
result = result.filter(project =>
|
||||
project.name.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
|
||||
return result.filter(filters[filter])
|
||||
}, [sortedProjects, searchText, filter])
|
||||
|
||||
const visibleProjects = useMemo(() => {
|
||||
return filteredProjects.slice(0, maxVisibleProjects)
|
||||
}, [filteredProjects, maxVisibleProjects])
|
||||
|
||||
const hiddenProjectsCount = Math.max(
|
||||
filteredProjects.length - visibleProjects.length,
|
||||
0
|
||||
)
|
||||
|
||||
const loadMoreCount = Math.min(
|
||||
hiddenProjectsCount,
|
||||
MAX_PROJECT_PER_PAGE
|
||||
)
|
||||
|
||||
const showAllProjects = useCallback(() => {
|
||||
setMaxVisibleProjects(v => v + hiddenProjectsCount)
|
||||
}, [hiddenProjectsCount])
|
||||
|
||||
const loadMoreProjects = useCallback(() => {
|
||||
setMaxVisibleProjects(v => v + loadMoreCount)
|
||||
}, [maxVisibleProjects])
|
||||
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState(
|
||||
() => new Set<string>()
|
||||
)
|
||||
|
||||
const toggleSelectedProject = useCallback(
|
||||
(projectId: string, selected?: boolean) => {
|
||||
setSelectedProjectIds(prevSelectedProjectIds => {
|
||||
const selectedProjectIds = new Set(prevSelectedProjectIds)
|
||||
if (selected === true) {
|
||||
selectedProjectIds.add(projectId)
|
||||
} else if (selected === false) {
|
||||
selectedProjectIds.delete(projectId)
|
||||
} else if (selectedProjectIds.has(projectId)) {
|
||||
selectedProjectIds.delete(projectId)
|
||||
} else {
|
||||
selectedProjectIds.add(projectId)
|
||||
}
|
||||
return selectedProjectIds
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const selectedProjects = useMemo(() => {
|
||||
return visibleProjects.filter(project => selectedProjectIds.has(project.id))
|
||||
}, [selectedProjectIds, visibleProjects])
|
||||
|
||||
|
||||
const selectOrUnselectAllProjects = useCallback(
|
||||
(checked: any) => {
|
||||
setSelectedProjectIds(prevSelectedProjectIds => {
|
||||
const selectedProjectIds = new Set(prevSelectedProjectIds)
|
||||
for (const project of visibleProjects) {
|
||||
if (checked) {
|
||||
selectedProjectIds.add(project.id)
|
||||
} else {
|
||||
selectedProjectIds.delete(project.id)
|
||||
}
|
||||
}
|
||||
return selectedProjectIds
|
||||
})
|
||||
},
|
||||
[visibleProjects]
|
||||
)
|
||||
|
||||
const selectFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
setFilter(filter)
|
||||
|
||||
setSort(prev => {
|
||||
if (filter === 'deleted' && prev.by === 'lastUpdated') {
|
||||
return { ...prev, by: 'deletedAt' }
|
||||
}
|
||||
if (filter !== 'deleted' && prev.by === 'deletedAt') {
|
||||
return { ...prev, by: 'lastUpdated' }
|
||||
}
|
||||
return prev
|
||||
})
|
||||
|
||||
selectOrUnselectAllProjects(false)
|
||||
},
|
||||
[selectOrUnselectAllProjects]
|
||||
)
|
||||
|
||||
const updateProjectViewData = useCallback((newProjectData: Project) => {
|
||||
setLoadedProjects(loadedProjects => {
|
||||
return loadedProjects.map(p =>
|
||||
p.id === newProjectData.id ? { ...newProjectData } : p
|
||||
)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeProjectFromView = useCallback((project: Project) => {
|
||||
setLoadedProjects(loadedProjects => {
|
||||
return loadedProjects.filter(p => p.id !== project.id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ProjectListContextValue>(
|
||||
() => ({
|
||||
error,
|
||||
filter,
|
||||
hiddenProjectsCount,
|
||||
isLoading,
|
||||
loadMoreCount,
|
||||
loadMoreProjects,
|
||||
loadProgress,
|
||||
removeProjectFromView,
|
||||
selectFilter,
|
||||
selectedProjects,
|
||||
selectedProjectIds,
|
||||
selectOrUnselectAllProjects,
|
||||
searchText,
|
||||
setSearchText,
|
||||
setSelectedProjectIds,
|
||||
setSort,
|
||||
showAllProjects,
|
||||
sort,
|
||||
toggleSelectedProject,
|
||||
totalProjectsCount,
|
||||
updateProjectViewData,
|
||||
projectsOwnerId,
|
||||
visibleProjects,
|
||||
}),
|
||||
[
|
||||
error,
|
||||
filter,
|
||||
hiddenProjectsCount,
|
||||
isLoading,
|
||||
loadMoreCount,
|
||||
loadMoreProjects,
|
||||
loadProgress,
|
||||
removeProjectFromView,
|
||||
selectFilter,
|
||||
selectedProjectIds,
|
||||
selectedProjects,
|
||||
selectOrUnselectAllProjects,
|
||||
searchText,
|
||||
setSearchText,
|
||||
setSelectedProjectIds,
|
||||
setSort,
|
||||
showAllProjects,
|
||||
sort,
|
||||
toggleSelectedProject,
|
||||
totalProjectsCount,
|
||||
projectsOwnerId,
|
||||
updateProjectViewData,
|
||||
visibleProjects,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<ProjectListContext.Provider value={value}>
|
||||
{children}
|
||||
</ProjectListContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useProjectListContext() {
|
||||
const context = useContext(ProjectListContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'ProjectListContext is only available inside ProjectListProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useProjectListContext } from '../context/project-list-context'
|
||||
import { Sort } from '../../../../types/project/api'
|
||||
import { SortingOrder } from '../../../../../../types/sorting-order'
|
||||
|
||||
const toggleSort = (order: SortingOrder): SortingOrder =>
|
||||
order === 'asc' ? 'desc' : 'asc'
|
||||
|
||||
function useSort() {
|
||||
const { filter, sort, setSort } = useProjectListContext()
|
||||
|
||||
const handleSort = (by: Sort['by']) => {
|
||||
setSort(prev => ({
|
||||
by,
|
||||
order: prev.by === by ? toggleSort(prev.order) : prev.order,
|
||||
}))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filter === 'deleted' && sort.by === 'lastUpdated') {
|
||||
setSort(prev => ({ ...prev, by: 'deletedAt' }))
|
||||
}
|
||||
|
||||
if (filter !== 'deleted' && sort.by === 'deletedAt') {
|
||||
setSort(prev => ({ ...prev, by: 'lastUpdated' }))
|
||||
}
|
||||
}, [filter, sort.by, setSort])
|
||||
|
||||
return { handleSort }
|
||||
}
|
||||
|
||||
export default useSort
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
GetProjectsResponseBody,
|
||||
Sort,
|
||||
} from '../../../../types/project/api'
|
||||
import { deleteJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
export type TransferOwnershipOptions = {
|
||||
user_id: string
|
||||
skipEmails: boolean
|
||||
}
|
||||
|
||||
export function getProjects(
|
||||
params: {
|
||||
userId: string,
|
||||
by: Sort['by']
|
||||
order: Sort['order']
|
||||
}): Promise<GetProjectsResponseBody> {
|
||||
const { userId, ...sort } = params
|
||||
return postJSON(`/admin/user/${userId}/projects`, { body: { sort } })
|
||||
}
|
||||
|
||||
export function deleteProject(projectId: string) {
|
||||
return deleteJSON(`/project/${projectId}`)
|
||||
}
|
||||
|
||||
export function purgeProject(projectId: string) {
|
||||
return deleteJSON(`/admin/project/${projectId}/purge`)
|
||||
}
|
||||
|
||||
export function undeleteProject(projectId: string, userId: string) {
|
||||
return postJSON(`/admin/project/${projectId}/undelete`, { body: { userId } })
|
||||
}
|
||||
|
||||
export function trashProjectForUser(projectId: string, userId: string) {
|
||||
return postJSON(`/admin/project/${projectId}/trash`, { body: { userId } })
|
||||
}
|
||||
|
||||
export function untrashProjectForUser(projectId: string, userId: string) {
|
||||
return postJSON(`/admin/project/${projectId}/untrash`, { body: { userId } })
|
||||
}
|
||||
export function transferProjectOwnership(projectId: string, options: TransferOwnershipOptions) {
|
||||
return postJSON(`/project/${projectId}/transfer-ownership`, { body: { ...options } })
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Project, Sort } from '../../../../types/project/api'
|
||||
import { SortingOrder } from '../../../../../../types/sorting-order'
|
||||
import { Compare } from '../../../../../../types/helpers/array/sort'
|
||||
import { User } from '../../../../types/user/api'
|
||||
|
||||
const order = (order: SortingOrder, projects: Project[]) => {
|
||||
return order === 'asc' ? [...projects] : projects.reverse()
|
||||
}
|
||||
|
||||
export const ownerNameComparator =
|
||||
(getUserById: (userId: string) => User | null) =>
|
||||
(v1: Project, v2: Project) => {
|
||||
const user1 = getUserById(v1.owner)
|
||||
const user2 = getUserById(v2.owner)
|
||||
|
||||
if (!user1) {
|
||||
if (!user2) {
|
||||
return v1.lastUpdated < v2.lastUpdated
|
||||
? Compare.SORT_A_BEFORE_B
|
||||
: Compare.SORT_A_AFTER_B
|
||||
}
|
||||
return Compare.SORT_A_AFTER_B
|
||||
}
|
||||
|
||||
if (!user2) {
|
||||
return Compare.SORT_A_BEFORE_B
|
||||
}
|
||||
|
||||
const lastNameCmp = user1.lastName.localeCompare(user2.lastName)
|
||||
if (lastNameCmp !== 0) return lastNameCmp
|
||||
|
||||
const firstNameCmp = user1.firstName.localeCompare(user2.firstName)
|
||||
if (firstNameCmp !== 0) return firstNameCmp
|
||||
|
||||
return v1.lastUpdated < v2.lastUpdated
|
||||
? Compare.SORT_A_BEFORE_B
|
||||
: Compare.SORT_A_AFTER_B
|
||||
}
|
||||
|
||||
export const defaultComparator = (
|
||||
v1: Project,
|
||||
v2: Project,
|
||||
key: 'name' | 'lastUpdated' | 'deletedAt'
|
||||
) => {
|
||||
const value1 = v1[key]?.toLowerCase()
|
||||
const value2 = v2[key]?.toLowerCase()
|
||||
|
||||
if (value1 !== value2) {
|
||||
if (value1 === undefined) return Compare.SORT_A_BEFORE_B
|
||||
if (value2 === undefined) return Compare.SORT_A_AFTER_B
|
||||
return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B
|
||||
}
|
||||
|
||||
return Compare.SORT_KEEP_ORDER
|
||||
}
|
||||
|
||||
export default function sortProjects(
|
||||
projects: Project[],
|
||||
sort: Sort,
|
||||
getUserById: (userId: string) => string
|
||||
) {
|
||||
let sorted = [...projects]
|
||||
|
||||
if (sort.by === 'title') {
|
||||
sorted = sorted.sort((...args) => {
|
||||
return defaultComparator(...args, 'name')
|
||||
})
|
||||
}
|
||||
|
||||
if (sort.by === 'lastUpdated') {
|
||||
sorted = sorted.sort((...args) => {
|
||||
return defaultComparator(...args, 'lastUpdated')
|
||||
})
|
||||
}
|
||||
|
||||
if (sort.by === 'deletedAt') {
|
||||
sorted = sorted.sort((...args) => {
|
||||
return defaultComparator(...args, 'deletedAt')
|
||||
})
|
||||
}
|
||||
|
||||
if (sort.by === 'owner') {
|
||||
sorted.sort(ownerNameComparator(getUserById))
|
||||
}
|
||||
|
||||
return order(sort.order, sorted)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { User } from '../../../../types/user/api'
|
||||
export function getUserName(user: User) {
|
||||
|
||||
if (!user) return '[N/A]'
|
||||
|
||||
const { firstName, lastName, email } = user
|
||||
if (firstName || lastName) {
|
||||
return [firstName, lastName].filter(n => n != null).join(' ')
|
||||
}
|
||||
if (email) {
|
||||
return email
|
||||
}
|
||||
|
||||
return '[Noname]'
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import Button from '@/shared/components/button/button'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { useSendUserListMB } from './user-list-events'
|
||||
import CreateAccountModal from './create-account-button/create-account-modal'
|
||||
|
||||
type Segmentation = {
|
||||
action: string
|
||||
}
|
||||
|
||||
type CreateAccountButtonProps = {
|
||||
id: string
|
||||
buttonText?: string
|
||||
className?: string
|
||||
trackingKey?: string
|
||||
}
|
||||
|
||||
function CreateAccountButton({
|
||||
id,
|
||||
buttonText,
|
||||
className,
|
||||
trackingKey,
|
||||
}: CreateAccountButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const sendUserListMB = useSendUserListMB()
|
||||
|
||||
const handleButtonClick = useCallback(() => {
|
||||
if (trackingKey) {
|
||||
const segmentation: Segmentation = {
|
||||
action: 'create-account-click',
|
||||
}
|
||||
sendMB(trackingKey, segmentation)
|
||||
}
|
||||
|
||||
sendUserListMB('create-account-click')
|
||||
setShowModal(true)
|
||||
}, [sendUserListMB, trackingKey])
|
||||
|
||||
return (
|
||||
<div className="create-account-button-wrapper">
|
||||
<OLButton
|
||||
id={id}
|
||||
className="create-account-button"
|
||||
variant="primary"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
{buttonText || t('create_account')}
|
||||
</OLButton>
|
||||
|
||||
{showModal && (
|
||||
<CreateAccountModal onHide={() => setShowModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateAccountButton
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import ModalContentNewUserForm from './modal-content-new-user-form'
|
||||
import { OLModal } from '@/shared/components/ol/ol-modal'
|
||||
|
||||
type CreateAccountModalProps = {
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
function CreateAccountModal({ onHide }: CreateAccountModalProps) {
|
||||
return (
|
||||
<OLModal
|
||||
show
|
||||
animation
|
||||
onHide={onHide}
|
||||
id="blank-user-modal"
|
||||
backdrop="static"
|
||||
>
|
||||
<ModalContentNewUserForm handleCloseModal={onHide} />
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateAccountModal
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
getUserFacingMessage,
|
||||
postJSON,
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useUserListContext } from '../../context/user-list-context'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
|
||||
type CreateUserResult = {
|
||||
user: User
|
||||
}
|
||||
|
||||
type Props = {
|
||||
handleCloseModal: () => void
|
||||
}
|
||||
|
||||
const availableAuthMethods = getMeta("ol-availableAuthMethods")
|
||||
const onlyLocalAuthEnabled = (availableAuthMethods.length === 1 && availableAuthMethods[0] === 'local')
|
||||
|
||||
function ModalContentNewUserForm({ handleCloseModal }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
|
||||
const [userData, setUserData] = useState({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
isAdmin: false,
|
||||
isExternal: false,
|
||||
})
|
||||
|
||||
const { refreshUsers, addUserToView } = useUserListContext()
|
||||
const [redirecting, setRedirecting] = useState(false)
|
||||
const { isLoading, isError, error, runAsync } = useAsync<CreateUserResult>()
|
||||
|
||||
const createAccount = () => {
|
||||
runAsync(
|
||||
postJSON('/admin/user/create', {
|
||||
body: {
|
||||
email: userData.email.trim(),
|
||||
first_name: userData.firstName.trim(),
|
||||
last_name: userData.lastName.trim(),
|
||||
isAdmin: userData.isAdmin,
|
||||
isExternal: userData.isExternal,
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(data => {
|
||||
addUserToView(data.user)
|
||||
handleCloseModal()
|
||||
})
|
||||
.catch(debugConsole.error)
|
||||
}
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.currentTarget
|
||||
setUserData(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, checked } = e.currentTarget
|
||||
setUserData(prev => ({ ...prev, [name]: checked }))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
createAccount()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('create_account')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{isError && (
|
||||
<div className="notification-list">
|
||||
<Notification
|
||||
type="error"
|
||||
content={t(getUserFacingMessage(error)) as string}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OLForm onSubmit={handleSubmit}>
|
||||
<OLFormGroup controlId="email-address">
|
||||
<OLFormLabel>{t('email_address')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="example@email.com"
|
||||
ref={autoFocusedRef}
|
||||
onChange={handleTextChange}
|
||||
value={userData.email}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId="first-name">
|
||||
<OLFormLabel>{t('first_name')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="firstName"
|
||||
placeholder="Erika"
|
||||
onChange={handleTextChange}
|
||||
value={userData.firstName}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId="last-name">
|
||||
<OLFormLabel>{t('last_name')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="lastName"
|
||||
placeholder="Mustermann"
|
||||
onChange={handleTextChange}
|
||||
value={userData.lastName}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLRow>
|
||||
<OLCol xs={6}>
|
||||
<OLFormGroup controlId="is-admin-checkbox">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
name="isAdmin"
|
||||
label={t('set_admin_account')}
|
||||
checked={userData.isAdmin}
|
||||
aria-label={t('set_admin_account')}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
{(!onlyLocalAuthEnabled &&
|
||||
<OLCol xs={6}>
|
||||
<OLFormGroup controlId="is-external-checkbox">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
name="isExternal"
|
||||
label="External authentication"
|
||||
checked={userData.isExternal}
|
||||
aria-label={"External authentication"}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
)}
|
||||
</OLRow>
|
||||
</OLForm>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={createAccount}
|
||||
disabled={userData.email.trim() === '' ||
|
||||
userData.lastName.trim() === '' ||
|
||||
userData.firstName.trim() === '' ||
|
||||
isLoading || redirecting}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('creating')}
|
||||
>
|
||||
{t('create')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalContentNewUserForm
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import FlagUserButton from '../table/cells/action-buttons/flag-user-button'
|
||||
import DeleteUserButton from '../table/cells/action-buttons/delete-user-button'
|
||||
import UpdateUserButton from '../table/cells/action-buttons/update-user-button'
|
||||
import RestoreUserButton from '../table/cells/action-buttons/restore-user-button'
|
||||
import PurgeUserButton from '../table/cells/action-buttons/purge-user-button'
|
||||
import ShowUserInfoButton from '../table/cells/action-buttons/show-user-info-button'
|
||||
import SendRegEmailButton from '../table/cells/action-buttons/send-reg-email-button'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
|
||||
const flagActions = [
|
||||
{ action: 'suspend', icon: 'pause', unfilled: false },
|
||||
{ action: 'resume', icon: 'resume', unfilled: false },
|
||||
]
|
||||
|
||||
type ActionDropdownProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
function ActionsDropdown({ user }: ActionDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const isSelf = getMeta('ol-user_id') === user.id
|
||||
|
||||
return (
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
id={`user-actions-dropdown-toggle-btn-${user.id}`}
|
||||
bsPrefix="dropdown-table-button-toggle"
|
||||
>
|
||||
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<ShowUserInfoButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="info"
|
||||
unfilled={true}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</ShowUserInfoButton>
|
||||
<UpdateUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="edit"
|
||||
unfilled={true}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</UpdateUserButton>
|
||||
{!isSelf && (
|
||||
<>
|
||||
{flagActions.map(({ action, icon, unfilled }) => (
|
||||
<FlagUserButton key={action} user={user} action={action}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon={icon}
|
||||
unfilled={unfilled}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</FlagUserButton>
|
||||
))}
|
||||
|
||||
<DeleteUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="delete"
|
||||
unfilled
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</DeleteUserButton>
|
||||
|
||||
<RestoreUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="restore"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</RestoreUserButton>
|
||||
|
||||
<PurgeUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="delete_forever"
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</PurgeUserButton>
|
||||
{(user.authMethods.includes('local') && !user.suspended) && (
|
||||
<SendRegEmailButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenModal}
|
||||
leadingIcon="mail"
|
||||
unfilled
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
)}
|
||||
</SendRegEmailButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionsDropdown
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type MenuItemButtonProps = {
|
||||
children: ReactNode
|
||||
onClick?: (e?: React.MouseEvent) => void
|
||||
className?: string
|
||||
afterNode?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MenuItemButton({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
afterNode,
|
||||
...buttonProps
|
||||
}: MenuItemButtonProps) {
|
||||
return (
|
||||
<li role="presentation" className={className}>
|
||||
<button
|
||||
className="menu-item-button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
{...buttonProps}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{afterNode}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownHeader,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import { useUserListContext } from '../../context/user-list-context'
|
||||
import useSort from '../../hooks/use-sort'
|
||||
import withContent, { SortBtnProps } from '../sort/with-content'
|
||||
import { Sort } from '../../../../../types/user/api'
|
||||
|
||||
function Item({ onClick, text, iconType }: SortBtnProps) {
|
||||
return (
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={onClick}
|
||||
trailingIcon={iconType}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemWithContent = withContent(Item)
|
||||
|
||||
function SortByDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(() => t('last_modified'))
|
||||
const { filter, sort } = useUserListContext()
|
||||
const { handleSort } = useSort()
|
||||
const sortByTranslations = useRef<Record<Sort['by'], string>>({
|
||||
name: t('name'),
|
||||
email: t('email'),
|
||||
signUpDate: t('signed_up'),
|
||||
lastActive: t('last_active'),
|
||||
deletedAt: t('deleted_at'),
|
||||
})
|
||||
|
||||
const handleClick = (by: Sort['by']) => {
|
||||
setTitle(sortByTranslations.current[by])
|
||||
handleSort(by)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(sortByTranslations.current[sort.by])
|
||||
}, [sort.by])
|
||||
|
||||
return (
|
||||
<Dropdown className="projects-sort-dropdown" align="end">
|
||||
<DropdownToggle
|
||||
id="projects-sort-dropdown"
|
||||
className="pe-0 mb-0 btn-transparent"
|
||||
size="sm"
|
||||
aria-label={t('sort_projects')}
|
||||
>
|
||||
<span className="text-truncate" aria-hidden>
|
||||
{title}
|
||||
</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<DropdownHeader className="text-uppercase">
|
||||
{t('sort_by')}:
|
||||
</DropdownHeader>
|
||||
<ItemWithContent
|
||||
column="name"
|
||||
text={t('name')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('name')}
|
||||
/>
|
||||
<ItemWithContent
|
||||
column="email"
|
||||
text={t('email')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('email')}
|
||||
/>
|
||||
|
||||
{ filter !== 'deleted' ? (
|
||||
<ItemWithContent
|
||||
column="signUpDate"
|
||||
text={t('signed_up')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('signUpDate')}
|
||||
/>
|
||||
) : (
|
||||
<ItemWithContent
|
||||
column="deletedAt"
|
||||
text={t('deleted_at')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('deletedAt')}
|
||||
/>
|
||||
)}
|
||||
<ItemWithContent
|
||||
column="lastActive"
|
||||
text={t('last_active')}
|
||||
sort={sort}
|
||||
onClick={() => handleClick('lastActive')}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortByDropdown
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Filter,
|
||||
useUserListContext,
|
||||
} from '../../context/user-list-context'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import UsersFilterMenu from '../users-filter-menu'
|
||||
|
||||
type ItemProps = {
|
||||
filter: Filter
|
||||
text: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function Item({ filter, text, onClick }: ItemProps) {
|
||||
const { selectFilter } = useUserListContext()
|
||||
const handleClick = () => {
|
||||
selectFilter(filter)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<UsersFilterMenu filter={filter}>
|
||||
{isActive => (
|
||||
<DropdownItem
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
trailingIcon={isActive ? 'check' : undefined}
|
||||
active={isActive}
|
||||
>
|
||||
{text}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</UsersFilterMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersDropdown() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, filterTranslations } = useUserListContext()
|
||||
|
||||
const title = filterTranslations.get(filter) ?? t('user_category_all')
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownToggle
|
||||
id="users-types-dropdown-toggle-btn"
|
||||
className="ps-0 mb-0 btn-transparent h3"
|
||||
size="lg"
|
||||
aria-label={t('filter_users')}
|
||||
>
|
||||
<span className="text-truncate" aria-hidden>
|
||||
{title}
|
||||
</span>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
{[...filterTranslations.entries()].map(([key, text]) => (
|
||||
<li role="none" key={key}>
|
||||
<Item filter={key} text={text} />
|
||||
</li>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersDropdown
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUserListContext } from '../context/user-list-context'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
|
||||
export default function LoadMore() {
|
||||
const {
|
||||
visibleUsers,
|
||||
hiddenUsersCount,
|
||||
loadMoreCount,
|
||||
showAllUsers,
|
||||
loadMoreUsers,
|
||||
} = useUserListContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
{hiddenUsersCount > 0 ? (
|
||||
<>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
className="user-list-load-more-button"
|
||||
onClick={() => loadMoreUsers()}
|
||||
>
|
||||
{t('show_x_more_users', { x: loadMoreCount })}
|
||||
</OLButton>
|
||||
</>
|
||||
) : null}
|
||||
<p>
|
||||
{hiddenUsersCount > 0 ? (
|
||||
<>
|
||||
<span aria-live="polite">
|
||||
{t('showing_x_out_of_n_users', {
|
||||
x: visibleUsers.length,
|
||||
n: visibleUsers.length + hiddenUsersCount,
|
||||
})}
|
||||
</span>{' '}
|
||||
<OLButton
|
||||
variant="link"
|
||||
onClick={() => showAllUsers()}
|
||||
className="btn-inline-link"
|
||||
>
|
||||
{t('show_all_users')}
|
||||
</OLButton>
|
||||
</>
|
||||
) : (
|
||||
<span aria-live="polite">
|
||||
{t('showing_x_out_of_n_users', {
|
||||
x: visibleUsers.length,
|
||||
n: visibleUsers.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import SelectOwnerForm from '../../../project-list/components/select-owner-form'
|
||||
import { useUserListContext } from '../../../user-list/context/user-list-context'
|
||||
import { UserRef } from '../../../../../types/project/api'
|
||||
import sortUsers from '../../../user-list/util/sort-users'
|
||||
|
||||
type DeleteUserModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function DeleteUserModal({
|
||||
users,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: DeleteUserModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { loadedUsers } = useUserListContext()
|
||||
|
||||
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>([])
|
||||
const [sendEmail, setSendEmail] = useState<boolean>(false)
|
||||
const [transferProjects, setTransferProjects] = useState<boolean>(false)
|
||||
const [newOwner, setNewOwner] = useState<UserRef | null>(null)
|
||||
|
||||
const selectOwnerInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const potentialOwners = useMemo(() => {
|
||||
if (!loadedUsers) return []
|
||||
|
||||
const excludeIds = new Set(users.map(u => u.id))
|
||||
const possibleUsers = loadedUsers.filter(
|
||||
user => !user.deleted && !excludeIds.has(user.id)
|
||||
)
|
||||
return sortUsers(possibleUsers, { by: 'name', order: 'asc' })
|
||||
}, [loadedUsers, users])
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUsersToDisplay(displayUsers => displayUsers.length ? displayUsers : users)
|
||||
setSendEmail(false)
|
||||
setTransferProjects(false)
|
||||
setNewOwner(null)
|
||||
} else {
|
||||
setUsersToDisplay([])
|
||||
}
|
||||
}, [showModal, users])
|
||||
|
||||
useEffect(() => {
|
||||
if (transferProjects) {
|
||||
selectOwnerInputRef.current?.focus()
|
||||
}
|
||||
}, [transferProjects])
|
||||
|
||||
const handleSendEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSendEmail(e.currentTarget.checked)
|
||||
}
|
||||
|
||||
const handleTransferProjectsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTransferProjects(e.currentTarget.checked)
|
||||
if (!e.currentTarget.checked) {
|
||||
setNewOwner(null)
|
||||
}
|
||||
}
|
||||
|
||||
const options = useMemo(() => {
|
||||
return {
|
||||
sendEmail,
|
||||
toUserId: transferProjects && newOwner ? newOwner.id : null,
|
||||
}
|
||||
}, [sendEmail, transferProjects, newOwner])
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="delete"
|
||||
actionHandler={actionHandler}
|
||||
title={t('delete_accounts')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
options={options}
|
||||
actionIsDisabled={transferProjects && !newOwner}
|
||||
>
|
||||
<p>{t('about_to_delete_accounts')}</p>
|
||||
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||
<Notification
|
||||
content={t('this_action_can_be_undone_within_limited_period')}
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<OLForm className="mt-4">
|
||||
<OLFormGroup controlId="send-email-checkbox" className="d-flex">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleSendEmailChange}
|
||||
name="sendEmail"
|
||||
label={t('notify_users_about_account_deletion')}
|
||||
checked={sendEmail}
|
||||
area-label={t('notify_users_about_account_deletion')}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="transfer-projects-checkbox" className="mt-3">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleTransferProjectsChange}
|
||||
name="transferProjects"
|
||||
label={t('transfer_all_projects_to')}
|
||||
checked={transferProjects}
|
||||
area-label={t('transfer_all_projects_to')}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
{transferProjects && (
|
||||
<OLFormGroup className="mt-2">
|
||||
<SelectOwnerForm
|
||||
ref={selectOwnerInputRef}
|
||||
loading={!potentialOwners.length}
|
||||
users={potentialOwners}
|
||||
value={newOwner}
|
||||
onChange={setNewOwner}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
</OLForm>
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteUserModal
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
|
||||
type FlagUserModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'action' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function FlagUserModal({
|
||||
users,
|
||||
action,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: FlagUserModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUsersToDisplay(displayUsers => {
|
||||
return displayUsers.length ? displayUsers : users
|
||||
})
|
||||
} else {
|
||||
setUsersToDisplay([])
|
||||
}
|
||||
}, [showModal, users])
|
||||
|
||||
let userData
|
||||
switch (action) {
|
||||
case 'set_admin':
|
||||
userData = { isAdmin: true }
|
||||
break
|
||||
case 'unset_admin':
|
||||
userData = { isAdmin: false }
|
||||
break
|
||||
case 'suspend':
|
||||
userData = { suspended: true }
|
||||
break
|
||||
case 'resume':
|
||||
userData = { suspended: false }
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action={action}
|
||||
actionHandler={actionHandler}
|
||||
title={t(`${action}_account`)}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
options={{userData}}
|
||||
>
|
||||
<p>{t(`about_to_${action}_accounts`)}</p>
|
||||
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlagUserModal
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
import Notification from '@/shared/components/notification'
|
||||
|
||||
type PurgeUserModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function PurgeUserModal({
|
||||
users,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: PurgeUserModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUsersToDisplay(displayUsers => {
|
||||
return displayUsers.length ? displayUsers : users
|
||||
})
|
||||
} else {
|
||||
setUsersToDisplay([])
|
||||
}
|
||||
}, [showModal, users])
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="purge"
|
||||
actionHandler={actionHandler}
|
||||
title={t('permanently_delete_accounts')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
>
|
||||
<p>{t('about_to_permanently_delete_accounts')}</p>
|
||||
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||
<Notification
|
||||
content={t('this_action_cannot_be_undone')}
|
||||
type="warning"
|
||||
/>
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeUserModal
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
|
||||
type RestoreUserModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function RestoreUserModal({
|
||||
users,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: RestoreUserModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUsersToDisplay(displayUsers => {
|
||||
return displayUsers.length ? displayUsers : users
|
||||
})
|
||||
} else {
|
||||
setUsersToDisplay([])
|
||||
}
|
||||
}, [showModal, users])
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="restore"
|
||||
actionHandler={actionHandler}
|
||||
title={t('restore_accounts')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
>
|
||||
<p>{t('about_to_restore_accounts')}</p>
|
||||
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreUserModal
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
import { useUserListContext } from '../../context/user-list-context'
|
||||
|
||||
type SendRegEmailModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function SendRegEmailModal({
|
||||
users,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: SendRegEmailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedUsers, toggleSelectedUser } = useUserListContext()
|
||||
|
||||
const localUsers = useMemo(
|
||||
() => users.filter(user => user.authMethods?.includes('local') && !user.suspended),
|
||||
[users]
|
||||
)
|
||||
|
||||
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModal) return
|
||||
|
||||
selectedUsers.forEach(user => {
|
||||
if (!user.authMethods?.includes('local') || user.suspended) {
|
||||
toggleSelectedUser(user.id, false)
|
||||
}
|
||||
})
|
||||
// intentionally depends only on showModal
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUsersToDisplay(displayUsers => {
|
||||
return displayUsers.length ? displayUsers : localUsers
|
||||
})
|
||||
} else {
|
||||
setUsersToDisplay([])
|
||||
}
|
||||
}, [showModal, localUsers])
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="resend"
|
||||
actionHandler={actionHandler}
|
||||
title={t('resend_activation_email')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={localUsers}
|
||||
>
|
||||
<p>{t('about_to_resend_activation_email')}</p>
|
||||
<UsersList users={localUsers} usersToDisplay={usersToDisplay} />
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SendRegEmailModal
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLCard from '@/shared/components/ol/ol-card'
|
||||
import OLBadge from '@/shared/components/ol/ol-badge'
|
||||
import { formatDate } from '@/utils/dates'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import { getAdditionalUserInfo } from '../../util/api'
|
||||
|
||||
type ShowUserInfoModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<OLRow className="mb-2">
|
||||
<OLCol xs={4} className="fw-semibold text-muted">
|
||||
{label}
|
||||
</OLCol>
|
||||
<OLCol xs={8}>{value}</OLCol>
|
||||
</OLRow>
|
||||
)
|
||||
}
|
||||
|
||||
function ShowUserInfoModal({
|
||||
users,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: ShowUserInfoModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (users.length !== 1) return null
|
||||
const user = users[0]
|
||||
|
||||
const [activationLink, setActivationLink] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showModal) return
|
||||
|
||||
getAdditionalUserInfo(user.id)
|
||||
.then(({ activationLink }) => {
|
||||
setActivationLink(activationLink)
|
||||
})
|
||||
.catch(() => {
|
||||
setActivationLink(null)
|
||||
})
|
||||
}, [showModal, user.id])
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!activationLink) return
|
||||
|
||||
const markCopied = () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(activationLink).then(markCopied)
|
||||
return
|
||||
}
|
||||
|
||||
// fallback for older browsers
|
||||
const tempInput = document.createElement('input')
|
||||
tempInput.value = activationLink
|
||||
tempInput.style.position = 'fixed'
|
||||
tempInput.style.opacity = '0'
|
||||
document.body.appendChild(tempInput)
|
||||
tempInput.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(tempInput)
|
||||
|
||||
markCopied()
|
||||
}
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="info"
|
||||
title={t('account_information')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
>
|
||||
<OLCard className="mb-3">
|
||||
{(Body) => (
|
||||
<>
|
||||
<Card.Header>{t('Account')}</Card.Header>
|
||||
<Body>
|
||||
<InfoRow label={t('email_address')} value={user.email} />
|
||||
<InfoRow label={t('first_name')} value={user.firstName || '—'} />
|
||||
<InfoRow label={t('last_name')} value={user.lastName || '—'} />
|
||||
|
||||
{user.isAdmin && (
|
||||
<InfoRow
|
||||
label={t('role')}
|
||||
value={
|
||||
<OLBadge bg="danger">
|
||||
{t('user_category_admin')}
|
||||
</OLBadge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activationLink && (
|
||||
<InfoRow
|
||||
label={t('activation_link')}
|
||||
value={
|
||||
<span
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{activationLink}
|
||||
{copied && (
|
||||
<span className="ms-2 text-success">
|
||||
({t('copied')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</OLCard>
|
||||
|
||||
<OLCard>
|
||||
{(Body) => (
|
||||
<>
|
||||
<Card.Header>{t('user_activity')}</Card.Header>
|
||||
<Body>
|
||||
<InfoRow
|
||||
label={t('signed_up')}
|
||||
value={formatDate(user.signUpDate)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('last_logged_in')}
|
||||
value={
|
||||
user.lastLoggedIn
|
||||
? formatDate(user.lastLoggedIn)
|
||||
: t('never')
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('last_active')}
|
||||
value={
|
||||
user.lastActive
|
||||
? formatDate(user.lastActive)
|
||||
: t('never')
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t('login_count')}
|
||||
value={user.loginCount}
|
||||
/>
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</OLCard>
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShowUserInfoModal
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import UsersActionModal from './users-action-modal'
|
||||
import UsersList from './users-list'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus'
|
||||
|
||||
type UpdateUserModalProps = Pick<
|
||||
React.ComponentProps<typeof UsersActionModal>,
|
||||
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||
>
|
||||
const pickUserFields = ({ firstName, lastName, email, isAdmin }) => ({ firstName, lastName, email, isAdmin })
|
||||
|
||||
function UpdateUserModal({
|
||||
users,
|
||||
actionHandler,
|
||||
showModal,
|
||||
handleCloseModal,
|
||||
}: UpdateUserModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
|
||||
|
||||
if (users.length !== 1) return null
|
||||
const [userData, setUserData] = useState(pickUserFields(users[0]))
|
||||
const isSelf = getMeta('ol-user_id') === users[0].id
|
||||
const allowUpdateDetails = users[0].allowUpdateDetails
|
||||
const allowUpdateIsAdmin = users[0].allowUpdateIsAdmin
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
setUserData(pickUserFields(users[0]))
|
||||
}
|
||||
}, [showModal, users])
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.currentTarget
|
||||
setUserData(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, checked } = e.currentTarget
|
||||
setUserData(prev => ({ ...prev, [name]: checked }))
|
||||
}
|
||||
|
||||
return (
|
||||
<UsersActionModal
|
||||
action="update"
|
||||
actionHandler={actionHandler}
|
||||
title={t('update_account_info')}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
users={users}
|
||||
options={{ userData }}
|
||||
>
|
||||
<OLFormGroup controlId="email-address">
|
||||
<OLFormLabel>{t('email_address')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
ref={autoFocusedRef}
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="email"
|
||||
onChange={handleTextChange}
|
||||
value={userData.email}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId="first-name">
|
||||
<OLFormLabel>{t('first_name')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="firstName"
|
||||
onChange={handleTextChange}
|
||||
value={userData.firstName}
|
||||
disabled={!allowUpdateDetails}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId="last-name">
|
||||
<OLFormLabel>{t('last_name')}</OLFormLabel>
|
||||
<OLFormControl
|
||||
maxLength="128"
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
name="lastName"
|
||||
onChange={handleTextChange}
|
||||
value={userData.lastName}
|
||||
disabled={!allowUpdateDetails}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLRow>
|
||||
<OLCol xs={6}>
|
||||
<OLFormGroup controlId="is-admin-checkbox">
|
||||
<OLFormCheckbox
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
name="isAdmin"
|
||||
label={t('set_admin_account')}
|
||||
checked={userData.isAdmin}
|
||||
disabled={isSelf || !allowUpdateIsAdmin}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</UsersActionModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateUserModal
|
||||
@@ -0,0 +1,136 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
import { getUserFacingMessage } from '@/infrastructure/fetch-json'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
|
||||
type UsersActionModalProps = {
|
||||
title?: string
|
||||
action: 'info' | 'update' | 'delete' | 'purge' | 'restore' | 'suspend' | 'resume' | 'set_admin' | 'unset_admin' | 'resend'
|
||||
actionHandler?: (user: User, options?: any) => Promise<void>
|
||||
handleCloseModal: () => void
|
||||
users: Array<User>
|
||||
options?: any
|
||||
showModal: boolean
|
||||
actionIsDisabled?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const greenActions = new Set(['update', 'restore', 'resume', 'unset_admin', 'resend'])
|
||||
const redActions = new Set(['delete', 'purge', 'set_admin', 'suspend'])
|
||||
|
||||
function UsersActionModal({
|
||||
title,
|
||||
action,
|
||||
actionHandler,
|
||||
handleCloseModal,
|
||||
showModal,
|
||||
actionIsDisabled,
|
||||
users,
|
||||
options,
|
||||
children,
|
||||
}: UsersActionModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [errors, setErrors] = useState<Array<any>>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const variant =
|
||||
redActions.has(action) ? 'danger' :
|
||||
greenActions.has(action) ? 'primary' : 'secondary'
|
||||
|
||||
const actionLabel =
|
||||
action === 'update' ? t('confirm') :
|
||||
action === 'info' ? t('close') :
|
||||
t(action)
|
||||
|
||||
async function handleActionForUsers(users: Array<User>, options?: any) {
|
||||
const errored = []
|
||||
setIsProcessing(true)
|
||||
setErrors([])
|
||||
|
||||
if (actionHandler) {
|
||||
for (const user of users) {
|
||||
try {
|
||||
await actionHandler(user, options)
|
||||
} catch (e) {
|
||||
errored.push({ userName: user.email, error: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMounted.current) {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
if (errored.length === 0) {
|
||||
handleCloseModal()
|
||||
} else {
|
||||
setErrors(errored)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
eventTracking.sendMB('admin-user-list-page-interaction', {
|
||||
action,
|
||||
isSmallDevice,
|
||||
})
|
||||
}
|
||||
}, [action, showModal])
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
animation
|
||||
show={showModal}
|
||||
onHide={handleCloseModal}
|
||||
id="action-user-modal"
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{title}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
{children}
|
||||
{!isProcessing &&
|
||||
errors.length > 0 &&
|
||||
errors.map((error, i) => (
|
||||
<div className="notification-list" key={i}>
|
||||
<Notification
|
||||
type="error"
|
||||
title={error.userName}
|
||||
content={getUserFacingMessage(error.error) as string}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
{action !== 'info' && (
|
||||
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
)}
|
||||
<OLButton
|
||||
variant={variant}
|
||||
onClick={() => handleActionForUsers(users, options)}
|
||||
disabled={isProcessing || actionIsDisabled}
|
||||
>
|
||||
{actionLabel}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UsersActionModal)
|
||||
@@ -0,0 +1,29 @@
|
||||
import classnames from 'classnames'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
import { getUserName } from '../../../project-list/util/user'
|
||||
|
||||
type UsersToDisplayProps = {
|
||||
users: User[]
|
||||
usersToDisplay: User[]
|
||||
}
|
||||
|
||||
function UsersList({ users, usersToDisplay }: UsersToDisplayProps) {
|
||||
return (
|
||||
<ul>
|
||||
{usersToDisplay.map(user => (
|
||||
<li
|
||||
key={`users-action-list-${user.id}`}
|
||||
className={classnames({
|
||||
'list-style-check-green': !users.some(
|
||||
({ id }) => id === user.id
|
||||
),
|
||||
})}
|
||||
>
|
||||
<b>{`${getUserName(user)} <${user.email}>`}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersList
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classnames from 'classnames'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import { isSmallDevice } from '@/infrastructure/event-tracking'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLForm from '@/shared/components/ol/ol-form'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { MergeAndOverride } from '../../../../../../types/utils'
|
||||
import { Filter } from '../context/user-list-context'
|
||||
|
||||
type SearchFormOwnProps = {
|
||||
inputValue: string
|
||||
setInputValue: (input: string) => void
|
||||
filter: Filter
|
||||
}
|
||||
|
||||
type SearchFormProps = MergeAndOverride<
|
||||
React.ComponentProps<typeof OLForm>,
|
||||
SearchFormOwnProps
|
||||
>
|
||||
|
||||
function SearchForm({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
filter,
|
||||
className,
|
||||
...props
|
||||
}: SearchFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const placeholder = t('search')+'…'
|
||||
|
||||
const handleChange: React.ComponentProps<
|
||||
typeof OLFormControl
|
||||
>['onChange'] = e => {
|
||||
eventTracking.sendMB('admin-user-list-page-interaction', {
|
||||
action: 'search',
|
||||
isSmallDevice,
|
||||
})
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
|
||||
const handleClear = () => setInputValue('')
|
||||
|
||||
return (
|
||||
<OLForm
|
||||
className={classnames('user-search', className)}
|
||||
role="search"
|
||||
onSubmit={e => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
<OLFormGroup>
|
||||
<OLCol>
|
||||
<OLFormControl
|
||||
name="search"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
prepend={<MaterialIcon type="search" />}
|
||||
append={
|
||||
inputValue.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="form-control-search-clear-btn"
|
||||
aria-label={t('clear_search')}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</OLCol>
|
||||
</OLFormGroup>
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchForm
|
||||
@@ -0,0 +1,130 @@
|
||||
import classnames from 'classnames'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { User as UserIcon } from '@phosphor-icons/react'
|
||||
import { usePersistedResize } from '@/shared/hooks/use-resize'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import SidebarFilters from './sidebar-filters'
|
||||
import CreateAccountButton from '../create-account-button'
|
||||
import { useSendUserListMB } from '../user-list-events'
|
||||
import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll'
|
||||
|
||||
function SidebarDsNav() {
|
||||
const { t } = useTranslation()
|
||||
const [showAccountDropdown, setShowAccountDropdown] = useState(false)
|
||||
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
|
||||
name: 'users-and-projects-sidebar',
|
||||
})
|
||||
const sendMB = useSendUserListMB()
|
||||
const { sessionUser } = getMeta('ol-navbar')
|
||||
const { containerRef, scrolledUp, scrolledDown } = useScrolled()
|
||||
const themedDsNav = useFeatureFlag('themed-project-dashboard')
|
||||
return (
|
||||
<div
|
||||
className="user-list-sidebar-wrapper-react d-none d-md-flex"
|
||||
{...getTargetProps({
|
||||
style: {
|
||||
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<nav
|
||||
className="flex-grow flex-shrink"
|
||||
aria-label={t('user_categories')}
|
||||
>
|
||||
<CreateAccountButton
|
||||
id="create-account-button-sidebar"
|
||||
className={scrolledDown ? 'show-shadow' : undefined}
|
||||
/>
|
||||
<div
|
||||
className="user-list-sidebar-scroll"
|
||||
ref={containerRef}
|
||||
data-testid="user-list-sidebar-scroll"
|
||||
>
|
||||
<SidebarFilters />
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
className={classnames(
|
||||
'ds-nav-sidebar-lower',
|
||||
scrolledUp && 'show-shadow'
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className="d-flex flex-row gap-3 mb-2"
|
||||
aria-label={t('account_help')}
|
||||
>
|
||||
{sessionUser && (
|
||||
<>
|
||||
<Dropdown
|
||||
className="ds-nav-icon-dropdown"
|
||||
onToggle={show => {
|
||||
setShowAccountDropdown(show)
|
||||
if (show) {
|
||||
sendMB('menu-expand', {
|
||||
item: 'account',
|
||||
location: 'sidebar',
|
||||
})
|
||||
}
|
||||
}}
|
||||
role="menu"
|
||||
>
|
||||
<Dropdown.Toggle role="menuitem" aria-label={t('Account')}>
|
||||
<OLTooltip
|
||||
description={t('Account')}
|
||||
id="open-account"
|
||||
overlayProps={{
|
||||
placement: 'top',
|
||||
}}
|
||||
hidden={showAccountDropdown}
|
||||
>
|
||||
<div>
|
||||
<UserIcon size={24} />
|
||||
</div>
|
||||
</OLTooltip>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu
|
||||
as="ul"
|
||||
role="menu"
|
||||
align="end"
|
||||
popperConfig={{
|
||||
modifiers: [
|
||||
{ name: 'offset', options: { offset: [-50, 5] } },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<AccountMenuItems
|
||||
sessionUser={sessionUser}
|
||||
showSubscriptionLink={false}
|
||||
showThemeToggle={themedDsNav}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="ds-nav-ds-name" translate="no">
|
||||
<span>Extended CE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
{...getHandleProps({
|
||||
style: {
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
right: '-2px',
|
||||
height: '100%',
|
||||
width: '4px',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarDsNav
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Filter,
|
||||
useUserListContext,
|
||||
} from '../../context/user-list-context'
|
||||
import UsersFilterMenu from '../users-filter-menu'
|
||||
|
||||
type SidebarFilterProps = {
|
||||
filter: Filter
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
export function SidebarFilter({ filter, text }: SidebarFilterProps) {
|
||||
const { selectFilter } = useUserListContext()
|
||||
|
||||
return (
|
||||
<UsersFilterMenu filter={filter}>
|
||||
{isActive => (
|
||||
<li className={isActive ? 'active' : ''}>
|
||||
<button type="button" onClick={() => selectFilter(filter)}>
|
||||
{text}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</UsersFilterMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SidebarFilters() {
|
||||
const { filterTranslations } = useUserListContext()
|
||||
|
||||
return (
|
||||
<ul className="list-unstyled user-list-filters">
|
||||
{[...filterTranslations.entries()].map(([key, text]) => (
|
||||
<SidebarFilter key={key} filter={key} text={text} />
|
||||
))}
|
||||
<li aria-hidden="true">
|
||||
<hr />
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Sort } from '../../../../../types/user/api'
|
||||
|
||||
type SortBtnOwnProps = {
|
||||
column: string
|
||||
sort: Sort
|
||||
text: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type WithContentProps = {
|
||||
iconType?: string
|
||||
screenReaderText: string
|
||||
}
|
||||
|
||||
export type SortBtnProps = SortBtnOwnProps & WithContentProps
|
||||
|
||||
function withContent<T extends SortBtnOwnProps>(
|
||||
WrappedComponent: React.ComponentType<T & WithContentProps>
|
||||
) {
|
||||
function WithContent(hocProps: T) {
|
||||
const { t } = useTranslation()
|
||||
const { column, text, sort } = hocProps
|
||||
let iconType
|
||||
|
||||
let screenReaderText = t('sort_by_x', { x: text })
|
||||
|
||||
if (column === sort.by) {
|
||||
iconType =
|
||||
sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt'
|
||||
screenReaderText = t('reverse_x_sort_order', { x: text })
|
||||
}
|
||||
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...hocProps}
|
||||
iconType={iconType}
|
||||
screenReaderText={screenReaderText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return WithContent
|
||||
}
|
||||
|
||||
export default withContent
|
||||
@@ -0,0 +1,80 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import DeleteUserModal from '../../../modals/delete-user-modal'
|
||||
import { performDeleteUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type DeleteUserButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function DeleteUserButton({ user, children }: DeleteUserButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const text = t('delete')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
const handleDeleteUser = useCallback((user: User, options: any) => {
|
||||
return performDeleteUser(user, postActions, options)
|
||||
}, [postActions])
|
||||
|
||||
if (user.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<DeleteUserModal
|
||||
users={[user]}
|
||||
actionHandler={handleDeleteUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteUserButtonTooltip = memo(function DeleteUserButtonTooltip({
|
||||
user,
|
||||
}: Pick<DeleteUserButtonProps, 'user'>) {
|
||||
return (
|
||||
<DeleteUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-delete-user-${user.id}`}
|
||||
id={`delete-user-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="delete"
|
||||
unfilled="true"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</DeleteUserButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(DeleteUserButton)
|
||||
export { DeleteUserButtonTooltip }
|
||||
@@ -0,0 +1,103 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import FlagUserModal from '../../../modals/flag-user-modal'
|
||||
import { performUpdateUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type FlagUserButtonProps = {
|
||||
user: User
|
||||
action: string
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function FlagUserButton({ user, action, children }: FlagUserButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
const text = t(action)
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
const handleFlagUser = useCallback((user: User, options: any) => {
|
||||
return performUpdateUser(user, postActions, options)
|
||||
}, [postActions])
|
||||
|
||||
if (user.deleted) return null
|
||||
if (action === "suspend" && user.suspended) return null
|
||||
if (action === "resume" && !user.suspended) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<FlagUserModal
|
||||
users={[user]}
|
||||
action={action}
|
||||
actionHandler={handleFlagUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FlagUserButtonTooltip = memo(function FlagUserButtonTooltip({
|
||||
user, flag
|
||||
}: Pick<FlagUserButtonProps, 'user' | 'flag'>) {
|
||||
|
||||
let action
|
||||
let icon
|
||||
let unfilled
|
||||
switch (flag) {
|
||||
case 'isAdmin':
|
||||
action = user.isAdmin ? 'unset_admin' : 'set_admin'
|
||||
icon = user.isAdmin ? 'remove_moderator' : 'add_moderator'
|
||||
unfilled = true
|
||||
break
|
||||
case 'suspended':
|
||||
action = user.suspended ? 'resume' : 'suspend'
|
||||
icon = user.suspended ? 'resume' : 'pause'
|
||||
unfilled = false
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<FlagUserButton user={user} action={action}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-${action}-user-${user.id}`}
|
||||
id={`${action}-user-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon={icon}
|
||||
unfilled={unfilled}
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</FlagUserButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(FlagUserButton)
|
||||
export { FlagUserButtonTooltip }
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import PurgeUserModal from '../../../modals/purge-user-modal'
|
||||
import { performPurgeUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type PurgeUserButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function PurgeUserButton({ user, children }: PurgeUserButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const text = t('purge')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { removeUserFromView } = useUserListContext()
|
||||
const postActions: PostActions = { removeUserFromView }
|
||||
const handlePurgeUser = useCallback((user: User) => {
|
||||
return performPurgeUser(user, postActions)
|
||||
}, [user, postActions])
|
||||
|
||||
if (!user.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<PurgeUserModal
|
||||
users={[user]}
|
||||
actionHandler={handlePurgeUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PurgeUserButtonTooltip = memo(function PurgeUserButtonTooltip({
|
||||
user,
|
||||
}: Pick<PurgeUserButtonProps, 'user'>) {
|
||||
return (
|
||||
<PurgeUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-purge-user-${user.id}`}
|
||||
id={`purge-user-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="delete_forever"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</PurgeUserButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(PurgeUserButton)
|
||||
export { PurgeUserButtonTooltip }
|
||||
@@ -0,0 +1,79 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import RestoreUserModal from '../../../modals/restore-user-modal'
|
||||
import { performRestoreUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type RestoreUserButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function RestoreUserButton({ user, children }: RestoreUserButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const text = t('restore')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
const handleRestoreUser = useCallback((user: User) => {
|
||||
return performRestoreUser(user, postActions)
|
||||
}, [user, postActions])
|
||||
|
||||
if (!user.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<RestoreUserModal
|
||||
users={[user]}
|
||||
actionHandler={handleRestoreUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const RestoreUserButtonTooltip = memo(function RestoreUserButtonTooltip({
|
||||
user,
|
||||
}: Pick<RestoreUserButtonProps, 'user'>) {
|
||||
return (
|
||||
<RestoreUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-restore-user-${user.id}`}
|
||||
id={`restore-user-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="restore"
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</RestoreUserButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(RestoreUserButton)
|
||||
export { RestoreUserButtonTooltip }
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import SendRegEmailModal from '../../../modals/send-reg-email-modal'
|
||||
import { performSendRegEmail, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type SendRegEmailButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
const text = t('resend')
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser }
|
||||
const handleSendRegEmail = useCallback((user: User) => {
|
||||
return performSendRegEmail(user, postActions)
|
||||
}, [postActions])
|
||||
|
||||
if (user.deleted) return null
|
||||
|
||||
const isHidden = !user.authMethods.includes('local') || user.suspended
|
||||
|
||||
return (
|
||||
<span style={isHidden ? { visibility: 'hidden' } : undefined }>
|
||||
{children(text, handleOpenModal)}
|
||||
<SendRegEmailModal
|
||||
users={[user]}
|
||||
actionHandler={handleSendRegEmail}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const SendRegEmailButtonTooltip = memo(function SendRegEmailButtonTooltip({
|
||||
user,
|
||||
}: Pick<SendRegEmailButtonProps, 'user'>) {
|
||||
|
||||
return (
|
||||
<SendRegEmailButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-send-reg-email-${user.id}`}
|
||||
id={`send-reg-email-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon={'mail'}
|
||||
unfilled={true}
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</SendRegEmailButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(SendRegEmailButton)
|
||||
export { SendRegEmailButtonTooltip }
|
||||
@@ -0,0 +1,77 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import ShowUserInfoModal from '../../../modals/show-user-info-modal'
|
||||
|
||||
type ShowUserInfoButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const text = t('info')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const handleShowUserInfo = useCallback((user: User) => {
|
||||
return performShowUserInfo(user)
|
||||
}, [])
|
||||
|
||||
if (user.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<ShowUserInfoModal
|
||||
users={[user]}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ShowUserInfoButtonTooltip = memo(function ShowUserInfoButtonTooltip({
|
||||
user,
|
||||
}: Pick<ShowUserInfoButtonProps, 'user'>) {
|
||||
return (
|
||||
<ShowUserInfoButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-show-user-info-${user.id}`}
|
||||
id={`show-user-info-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="info"
|
||||
unfilled={true}
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</ShowUserInfoButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(ShowUserInfoButton)
|
||||
export { ShowUserInfoButtonTooltip }
|
||||
@@ -0,0 +1,80 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import UpdateUserModal from '../../../modals/update-user-modal'
|
||||
import { performUpdateUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type UpdateUserButtonProps = {
|
||||
user: User
|
||||
children: (text: string, handleOpenModal: () => void) => React.ReactElement
|
||||
}
|
||||
|
||||
function UpdateUserButton({ user, children }: UpdateUserButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const text = t('update')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const { toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
const handleUpdateUser = useCallback((user: User, options: any) => {
|
||||
return performUpdateUser(user, postActions, options)
|
||||
}, [postActions])
|
||||
|
||||
if (user.deleted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(text, handleOpenModal)}
|
||||
<UpdateUserModal
|
||||
users={[user]}
|
||||
actionHandler={handleUpdateUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UpdateUserButtonTooltip = memo(function UpdateUserButtonTooltip({
|
||||
user,
|
||||
}: Pick<UpdateUserButtonProps, 'user'>) {
|
||||
return (
|
||||
<UpdateUserButton user={user}>
|
||||
{(text, handleOpenModal) => (
|
||||
<OLTooltip
|
||||
key={`tooltip-update-user-${user.id}`}
|
||||
id={`update-user-${user.id}`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="link"
|
||||
accessibilityLabel={text}
|
||||
className="action-btn"
|
||||
icon="edit"
|
||||
unfilled={true}
|
||||
/>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</UpdateUserButton>
|
||||
)
|
||||
})
|
||||
|
||||
export default memo(UpdateUserButton)
|
||||
export { UpdateUserButtonTooltip }
|
||||
@@ -0,0 +1,34 @@
|
||||
import getMeta from '@/utils/meta'
|
||||
import { DeleteUserButtonTooltip } from './action-buttons/delete-user-button'
|
||||
import { RestoreUserButtonTooltip } from './action-buttons/restore-user-button'
|
||||
import { PurgeUserButtonTooltip } from './action-buttons/purge-user-button'
|
||||
import { FlagUserButtonTooltip } from './action-buttons/flag-user-button'
|
||||
import { UpdateUserButtonTooltip } from './action-buttons/update-user-button'
|
||||
import { ShowUserInfoButtonTooltip } from './action-buttons/show-user-info-button'
|
||||
import { SendRegEmailButtonTooltip } from './action-buttons/send-reg-email-button'
|
||||
import { User } from '../../../../../../types/user/api'
|
||||
|
||||
type ActionsCellProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function ActionsCell({ user }: ActionsCellProps) {
|
||||
const isSelf = getMeta('ol-user_id') === user.id
|
||||
return (
|
||||
<div className="d-flex flex-wrap justify-content-end">
|
||||
<span style={isSelf ? { visibility: 'hidden' } : undefined} >
|
||||
<SendRegEmailButtonTooltip user={user} />
|
||||
</span>
|
||||
<ShowUserInfoButtonTooltip user={user} />
|
||||
<UpdateUserButtonTooltip user={user} />
|
||||
<span style={isSelf ? { visibility: 'hidden' } : undefined} >
|
||||
<FlagUserButtonTooltip user={user} flag="suspended" />
|
||||
</span>
|
||||
<span style={isSelf ? { visibility: 'hidden' } : undefined} >
|
||||
<DeleteUserButtonTooltip user={user} />
|
||||
<RestoreUserButtonTooltip user={user} />
|
||||
<PurgeUserButtonTooltip user={user} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { formatDate, fromNowDate } from '@/utils/dates'
|
||||
import { User } from '../../../../../../types/user/api'
|
||||
|
||||
type DeletedAtProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function deletedAtCell({ user }: deletedAtCellProps) {
|
||||
const deletedAt = user.deletedAt ? fromNowDate(user.deletedAt) : 'Not deleted'
|
||||
const tooltipText = user.deletedAt ? formatDate(user.deletedAt) : 'Not deleted'
|
||||
return (
|
||||
<OLTooltip
|
||||
key={`tooltip-deleted-at-${user.id}`}
|
||||
id={`tooltip-deleted-at-${user.id}`}
|
||||
description={tooltipText}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<span>{deletedAt}</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { User } from '../../../../../../types/user/api'
|
||||
|
||||
type EmailCellProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function EmailCell({ user }: EmailCellProps) {
|
||||
return (
|
||||
<a href={`mailto:${user.email}`} translate="no">
|
||||
{user.email}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { formatDate, fromNowDate } from '@/utils/dates'
|
||||
import { User } from '../../../../../../types/user/api'
|
||||
|
||||
type LastActiveProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function lastActiveCell({ user }: LastActiveCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const lastActiveDate = user.lastActive ? fromNowDate(user.lastActive) : t('never')
|
||||
const tooltipText = user.lastActive ? formatDate(user.lastActive) : t('never')
|
||||
return (
|
||||
<OLTooltip
|
||||
key={`tooltip-last-active-${user.id}`}
|
||||
id={`tooltip-last-active-${user.id}`}
|
||||
description={tooltipText}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<span>{lastActiveDate}</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import { formatDate, fromNowDate } from '@/utils/dates'
|
||||
import { User } from '../../../../../../types/user/api'
|
||||
|
||||
type SignUpDateCellProps = {
|
||||
user: User
|
||||
}
|
||||
|
||||
export default function signUpDateCell({ user }: SignUpDateCellProps) {
|
||||
const signUpDate = fromNowDate(user.signUpDate)
|
||||
const tooltipText = formatDate(user.signUpDate)
|
||||
return (
|
||||
<OLTooltip
|
||||
key={`tooltip-sign-up-date-${user.id}`}
|
||||
id={`tooltip-sign-up-date-${user.id}`}
|
||||
description={tooltipText}
|
||||
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<span>{signUpDate}</span>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChangeEvent, memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useUserListContext } from '../../context/user-list-context'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
|
||||
export const UserCheckbox = memo<{ userId: string; userName: string }>(
|
||||
({ userId, userName }) => {
|
||||
const { t } = useTranslation()
|
||||
const { selectedUserIds, toggleSelectedUser } =
|
||||
useUserListContext()
|
||||
|
||||
const isSelf = useMemo(() => {
|
||||
return getMeta('ol-user_id') === userId
|
||||
}, [userId])
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
toggleSelectedUser(userId, event.target.checked)
|
||||
},
|
||||
[userId, toggleSelectedUser]
|
||||
)
|
||||
|
||||
return (
|
||||
<OLFormCheckbox
|
||||
id={`select_user_${userId}`}
|
||||
autoComplete="off"
|
||||
onChange={handleCheckboxChange}
|
||||
checked={selectedUserIds.has(userId)}
|
||||
disabled={isSelf}
|
||||
aria-label={t('select_user', { user: userName })}
|
||||
data-user-id={userId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
UserCheckbox.displayName = 'UserCheckbox'
|
||||
@@ -0,0 +1,70 @@
|
||||
import { memo, useState } from 'react'
|
||||
import EmailCell from './cells/email-cell'
|
||||
import LastActiveCell from './cells/last-active-cell'
|
||||
import SignUpDateCell from './cells/sign-up-date-cell'
|
||||
import DeletedAtCell from './cells/deleted-at-cell'
|
||||
import ActionsCell from './cells/actions-cell'
|
||||
import ActionsDropdown from '../dropdown/actions-dropdown'
|
||||
import { User } from '../../../../../types/user/api'
|
||||
import { UserCheckbox } from './user-checkbox'
|
||||
import { useUsersPageContext } from '../../../users-page-context'
|
||||
import { getUserName } from '../../../project-list/util/user'
|
||||
|
||||
type UserListTableRowProps = {
|
||||
user: User
|
||||
selected: boolean
|
||||
filter: string
|
||||
}
|
||||
function UserListTableRow({ user, selected, filter }: UserListTableRowProps) {
|
||||
const fullName = getUserName(user)
|
||||
const rowClassName = `${selected ? 'table-active' : ''} ${user.isAdmin ? 'dash-row-admin' : ''}`.trim()
|
||||
const { showProjects } = useUsersPageContext()
|
||||
|
||||
return (
|
||||
<tr className={rowClassName}>
|
||||
<td className="dash-cell-checkbox d-none d-md-table-cell">
|
||||
<UserCheckbox userId={user.id} userName={user.email} />
|
||||
</td>
|
||||
<td className="dash-cell-name">
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
translate="no"
|
||||
onClick={() => {
|
||||
showProjects(user.id)
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{fullName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="dash-cell-email-date pb-0 d-md-none">
|
||||
<span> <EmailCell user={user} /> — <LastActiveCell user={user} /></span>
|
||||
</td>
|
||||
<td className="dash-cell-email d-none d-md-table-cell">
|
||||
<EmailCell user={user} />
|
||||
</td>
|
||||
{filter !== 'deleted' ? (
|
||||
<td className="dash-cell-date-signup d-none d-md-table-cell">
|
||||
<SignUpDateCell user={user} />
|
||||
</td>
|
||||
) : (
|
||||
<td className="dash-cell-date-signup d-none d-md-table-cell">
|
||||
<DeletedAtCell user={user} />
|
||||
</td>
|
||||
)}
|
||||
<td className="dash-cell-date-active d-none d-md-table-cell">
|
||||
<LastActiveCell user={user} />
|
||||
</td>
|
||||
<td className="dash-cell-actions">
|
||||
<div className="d-none d-lg-block">
|
||||
<ActionsCell user={user} />
|
||||
</div>
|
||||
<div className="d-lg-none">
|
||||
<ActionsDropdown user={user} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
export default memo(UserListTableRow)
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UserListTableRow from './user-list-table-row'
|
||||
import { useUserListContext } from '../../context/user-list-context'
|
||||
import useSort from '../../hooks/use-sort'
|
||||
import withContent, { SortBtnProps } from '../sort/with-content'
|
||||
import OLTable from '@/shared/components/ol/ol-table'
|
||||
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
|
||||
return (
|
||||
<button
|
||||
className="table-header-sort-btn d-none d-md-inline-block"
|
||||
onClick={onClick}
|
||||
aria-label={screenReaderText}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{iconType && <MaterialIcon type={iconType} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const SortByButton = withContent(SortBtn)
|
||||
|
||||
function UserListTable() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
visibleUsers,
|
||||
sort,
|
||||
selectedUsers,
|
||||
selectOrUnselectAllUsers,
|
||||
selfVisibleCount,
|
||||
filter,
|
||||
} = useUserListContext()
|
||||
const { handleSort } = useSort()
|
||||
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAllUsersCheckboxChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
selectOrUnselectAllUsers(event.target.checked)
|
||||
},
|
||||
[selectOrUnselectAllUsers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (checkAllRef.current) {
|
||||
checkAllRef.current.indeterminate =
|
||||
selectedUsers.length > 0 &&
|
||||
selectedUsers.length + selfVisibleCount !== visibleUsers.length
|
||||
}
|
||||
}, [selectedUsers, visibleUsers])
|
||||
|
||||
return (
|
||||
<OLTable className="user-dash-table" container={false} hover>
|
||||
<caption className="visually-hidden">{t('users_list')}</caption>
|
||||
<thead className="visually-hidden-max-md">
|
||||
<tr>
|
||||
<th
|
||||
className="dash-cell-checkbox d-none d-md-table-cell"
|
||||
aria-label={t('select_users')}
|
||||
>
|
||||
<OLFormCheckbox
|
||||
name="select_all_users"
|
||||
autoComplete="off"
|
||||
onChange={handleAllUsersCheckboxChange}
|
||||
checked={
|
||||
visibleUsers.length === selectedUsers.length + selfVisibleCount &&
|
||||
visibleUsers.length - selfVisibleCount !== 0
|
||||
}
|
||||
disabled={visibleUsers.length - selfVisibleCount === 0}
|
||||
aria-label={t('select_all_users')}
|
||||
inputRef={checkAllRef}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-name"
|
||||
aria-label={t('title')}
|
||||
aria-sort={
|
||||
sort.by === 'title'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="name"
|
||||
text={t('name')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('name')}
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-email-date d-md-none"
|
||||
aria-label={t('date_and_owner')}
|
||||
>
|
||||
{t('date_and_owner')}
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-email d-none d-md-table-cell"
|
||||
aria-label={t('email')}
|
||||
aria-sort={
|
||||
sort.by === 'email'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="email"
|
||||
text={t('email')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('email')}
|
||||
/>
|
||||
</th>
|
||||
{filter !== 'deleted' ? (
|
||||
<th
|
||||
className="dash-cell-date-signup d-none d-md-table-cell"
|
||||
aria-label={t('signed_up')}
|
||||
aria-sort={
|
||||
sort.by === 'signUpDate'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="signUpDate"
|
||||
text={t('signed_up')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('signUpDate')}
|
||||
/>
|
||||
</th>
|
||||
) : (
|
||||
<th
|
||||
className="dash-cell-date-signup d-none d-md-table-cell"
|
||||
aria-label={t('deleted_at')}
|
||||
aria-sort={
|
||||
sort.by === 'deletedAt'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="deletedAt"
|
||||
text={t('deleted_at')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('deletedAt')}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
className="dash-cell-date-active d-none d-md-table-cell"
|
||||
aria-label={t('last_active')}
|
||||
aria-sort={
|
||||
sort.by === 'lastActive'
|
||||
? sort.order === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SortByButton
|
||||
column="lastActive"
|
||||
text={t('last_active')}
|
||||
sort={sort}
|
||||
onClick={() => handleSort('lastActive')}
|
||||
/>
|
||||
</th>
|
||||
<th className="dash-cell-actions" aria-label={t('actions')}>
|
||||
{t('actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleUsers.length > 0 ? (
|
||||
visibleUsers.map(u => (
|
||||
<UserListTableRow
|
||||
user={u}
|
||||
selected={selectedUsers.some(({ id }) => id === u.id)}
|
||||
key={u.id}
|
||||
filter={filter}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<tr className="no-users">
|
||||
<td className="text-center" colSpan={5}>
|
||||
{t('no_users')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</OLTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserListTable
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import DeleteUserModal from '../../../modals/delete-user-modal'
|
||||
import { performDeleteUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
type DeleteUserResult = {
|
||||
success: boolean
|
||||
message: string
|
||||
user: Partial<User>
|
||||
}
|
||||
|
||||
function DeleteUsersButton() {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: Partial<PostActions> = { toggleSelectedUser, updateUserViewData }
|
||||
|
||||
const handleDeleteUser = (user: User, options: any) => {
|
||||
return performDeleteUser(user, postActions, options)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||
{t('delete')}
|
||||
</OLButton>
|
||||
<DeleteUserModal
|
||||
users={selectedUsers}
|
||||
actionHandler={handleDeleteUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteUsersButton
|
||||
@@ -0,0 +1,84 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import FlagUserModal from '../../../modals/flag-user-modal'
|
||||
import { performUpdateUser, PostActions } from '../../../../util/user-actions'
|
||||
|
||||
function FlagUsersButton({ action }: { action: string }) {
|
||||
const { selectedUsers, toggleSelectedUser, updateUserViewData } =
|
||||
useUserListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t(action)
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
const handleFlagUser = async (user: User, options: any) => {
|
||||
await performUpdateUser(user, postActions, options)
|
||||
}
|
||||
|
||||
let icon
|
||||
let unfilled
|
||||
switch (action) {
|
||||
case 'set_admin':
|
||||
icon = 'add_moderator'
|
||||
unfilled = true
|
||||
break
|
||||
case 'unset_admin':
|
||||
icon = 'remove_moderator'
|
||||
unfilled = true
|
||||
break
|
||||
case 'suspend':
|
||||
icon = 'pause'
|
||||
unfilled = false
|
||||
break
|
||||
case 'resume':
|
||||
icon = 'resume'
|
||||
unfilled = false
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLTooltip
|
||||
id={`tooltip-${action}-users`}
|
||||
description={text}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon={icon}
|
||||
unfilled={unfilled}
|
||||
/>
|
||||
</OLTooltip>
|
||||
<FlagUserModal
|
||||
users={selectedUsers}
|
||||
action={action}
|
||||
actionHandler={handleFlagUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FlagUsersButton)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import PurgeUserModal from '../../../modals/purge-user-modal'
|
||||
import { performPurgeUser, postActions } from '../../../../util/user-actions'
|
||||
|
||||
function PurgeUsersButton() {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { selectedUsers, removeUserFromView } = useUserListContext()
|
||||
const postActions: PostActions = { removeUserFromView }
|
||||
|
||||
const handlePurgeUser = (user: User) => {
|
||||
return performPurgeUser(user, postActions)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||
{t('purge')}
|
||||
</OLButton>
|
||||
|
||||
<PurgeUserModal
|
||||
users={selectedUsers}
|
||||
actionHandler={handlePurgeUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurgeUsersButton
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import RestoreUserModal from '../../../modals/restore-user-modal'
|
||||
import { performRestoreUser, postActions } from '../../../../util/user-actions'
|
||||
|
||||
function RestoreUsersButton() {
|
||||
const { t } = useTranslation()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setShowModal(true)
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext()
|
||||
const postActions: PostActions = { toggleSelectedUser, updateUserViewData }
|
||||
|
||||
const handleRestoreUser = (user: User) => {
|
||||
return performRestoreUser(user, postActions)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLButton variant="primary" onClick={handleOpenModal}>
|
||||
{t('restore')}
|
||||
</OLButton>
|
||||
|
||||
<RestoreUserModal
|
||||
users={selectedUsers}
|
||||
actionHandler={handleRestoreUser}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoreUsersButton
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import useIsMounted from '@/shared/hooks/use-is-mounted'
|
||||
import { useUserListContext } from '../../../../context/user-list-context'
|
||||
import { User } from '../../../../../../../types/user/api'
|
||||
import SendRegEmailModal from '../../../modals/send-reg-email-modal'
|
||||
import { performSendRegEmail } from '../../../../util/user-actions'
|
||||
|
||||
function SendRegEmailsButton({ action }: { action: string }) {
|
||||
const { selectedUsers, toggleSelectedUser } =
|
||||
useUserListContext()
|
||||
const { t } = useTranslation()
|
||||
const text = t(action)
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const handleOpenModal = useCallback(() => {
|
||||
setShowModal(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
if (isMounted.current) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}, [isMounted])
|
||||
|
||||
const postActions: PostActions = { toggleSelectedUser }
|
||||
const handleSendRegEmail = async (user: User) => {
|
||||
await performSendRegEmail(user, postActions)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLTooltip
|
||||
id={`tooltip-resend-users`}
|
||||
description={t('resend')}
|
||||
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleOpenModal}
|
||||
variant="secondary"
|
||||
accessibilityLabel={text}
|
||||
icon={'mail'}
|
||||
unfilled={true}
|
||||
/>
|
||||
</OLTooltip>
|
||||
<SendRegEmailModal
|
||||
users={selectedUsers}
|
||||
actionHandler={handleSendRegEmail}
|
||||
showModal={showModal}
|
||||
handleCloseModal={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SendRegEmailsButton)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useUserListContext } from '../../../context/user-list-context'
|
||||
import DeleteUsersButton from './buttons/delete-users-button'
|
||||
import PurgeUsersButton from './buttons/purge-users-button'
|
||||
import RestoreUsersButton from './buttons/restore-users-button'
|
||||
import FlagUsersButton from './buttons/flag-users-button'
|
||||
import SendRegEmailsButton from './buttons/send-reg-emails-button'
|
||||
import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
|
||||
import OLButtonGroup from '@/shared/components/ol/ol-button-group'
|
||||
|
||||
function UserTools() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, selectedUsers } = useUserListContext()
|
||||
|
||||
return (
|
||||
<OLButtonToolbar aria-label={t('toolbar_selected_users')}>
|
||||
|
||||
{(filter === 'deleted') && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_users_restore')}>
|
||||
<RestoreUsersButton />
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
{(filter !== 'deleted') && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_users_suspend_status')}>
|
||||
{filter !== 'suspended' && <FlagUsersButton action={'suspend'} />}
|
||||
<FlagUsersButton action={'resume'} />
|
||||
{filter !== 'suspended' && <SendRegEmailsButton />}
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
{(filter !== 'deleted') && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_users_admin_status')}>
|
||||
{(filter !== 'admin' && filter !== 'suspended') && <FlagUsersButton action={'set_admin'} />}
|
||||
<FlagUsersButton action={'unset_admin'} />
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
{(filter !== 'deleted') && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_users_remove')}>
|
||||
<DeleteUsersButton />
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
{(filter === 'deleted') && (
|
||||
<OLButtonGroup aria-label={t('toolbar_selected_users_purge')}>
|
||||
<PurgeUsersButton />
|
||||
</OLButtonGroup>
|
||||
)}
|
||||
|
||||
</OLButtonToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UserTools)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { Filter, useUserListContext } from '../../context/user-list-context'
|
||||
|
||||
function UserListTitle({
|
||||
filter,
|
||||
className,
|
||||
}: {
|
||||
filter: Filter
|
||||
className?: string
|
||||
}) {
|
||||
const { filterTranslations } = useUserListContext()
|
||||
|
||||
let message = filterTranslations.get(filter)
|
||||
let extraProps = {}
|
||||
|
||||
return (
|
||||
<h1
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={classnames('user-list-title', className)}
|
||||
{...extraProps}
|
||||
>
|
||||
{message}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserListTitle
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user