Admin Tools: Manage users and Manage projects pages

This commit is contained in:
yu-i-i
2026-01-26 03:06:40 +01:00
parent b1db82ab0a
commit 103adeb820
132 changed files with 10128 additions and 18 deletions

View File

@@ -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
)
},
}

View File

@@ -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),
}

View 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),
}

View File

@@ -0,0 +1 @@
{ "extends": "../../../../tsconfig.backend.json" }

View 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')}…

View File

@@ -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

View File

@@ -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

View File

@@ -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 />
))

View File

@@ -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 />
))

View File

@@ -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 />)
}

View File

@@ -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 />)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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} />
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 } })
}

View File

@@ -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)
}

View File

@@ -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]'
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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