feat: approve users, edit users through manage users page (#1383)

This commit is contained in:
Markos Gogoulos 2025-09-20 15:16:52 +03:00 committed by GitHub
parent 8e8454d8c2
commit cbef629baf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1384 additions and 790 deletions

10
cms/auth_backends.py Normal file
View File

@ -0,0 +1,10 @@
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
class ApprovalBackend(ModelBackend):
def user_can_authenticate(self, user):
can_authenticate = super().user_can_authenticate(user)
if can_authenticate and settings.USERS_NEEDS_TO_BE_APPROVED and not user.is_superuser:
return getattr(user, 'is_approved', False)
return can_authenticate

23
cms/middleware.py Normal file
View File

@ -0,0 +1,23 @@
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
class ApprovalMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if settings.USERS_NEEDS_TO_BE_APPROVED and request.user.is_authenticated and not request.user.is_superuser and not getattr(request.user, 'is_approved', False):
allowed_paths = [
reverse('approval_required'),
reverse('account_logout'),
]
if request.path not in allowed_paths:
if request.path.startswith('/api/'):
return JsonResponse({'detail': 'User account not approved.'}, status=403)
return redirect('approval_required')
response = self.get_response(request)
return response

View File

@ -128,6 +128,10 @@ USERS_CAN_SELF_REGISTER = True
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
# by default users do not need to be approved. If this is set to True, then new users
# will have to be approved before they can login successfully
USERS_NEEDS_TO_BE_APPROVED = False
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
# Empty list disables.
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
@ -501,6 +505,10 @@ ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users
ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page
# valid choices are all, editors, admins
CAN_SEE_MEMBERS_PAGE = "all"
# Maximum number of media a user can upload
NUMBER_OF_MEDIA_USER_CAN_UPLOAD = 100
@ -517,6 +525,9 @@ USER_CAN_TRANSCRIBE_VIDEO = True
# Whisper transcribe options - https://github.com/openai/whisper
WHISPER_MODEL = "base"
# show a custom text in the sidebar footer, otherwise the default will be shown if this is empty
SIDEBAR_FOOTER_TEXT = ""
try:
# keep a local_settings.py file for local overrides
from .local_settings import * # noqa
@ -558,3 +569,12 @@ except ImportError:
if GLOBAL_LOGIN_REQUIRED:
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "django.contrib.auth.middleware.LoginRequiredMiddleware")
if USERS_NEEDS_TO_BE_APPROVED:
AUTHENTICATION_BACKENDS = (
'cms.auth_backends.ApprovalBackend',
'allauth.account.auth_backends.AuthenticationBackend',
)
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")

View File

@ -1 +1 @@
VERSION = "6.5.2"
VERSION = "6.6.0"

View File

@ -519,6 +519,20 @@ ALLOW_ANONYMOUS_USER_LISTING = False
When set to False, only logged-in users will be able to access the user listing API endpoint.
### 5.27 Control who can see the members page
By default `CAN_SEE_MEMBERS_PAGE = "all"` means that all registered users can see the members page. Other valid options are:
- **editors**, only MediaCMS editors can view the page
- **admins**, only MediaCMS admins can view the page
### 5.28 Require user approval on registration
By default, users do not require approval, so they can login immediately after registration (if registration is open). However, if the parameter `USERS_NEEDS_TO_BE_APPROVED` is set to `True`, they will first have to have their accounts approved by an administrator before they can successfully sign in.
Administrators can approve users through the following ways: 1. through Django administration, 2. through the users management page, 3. through editing the profile page directly. In all cases, set 'Is approved' to True.
## 6. Manage pages
to be written

View File

@ -26,10 +26,22 @@ def stuff(request):
ret["UPLOAD_MAX_SIZE"] = settings.UPLOAD_MAX_SIZE
ret["UPLOAD_MAX_FILES_NUMBER"] = settings.UPLOAD_MAX_FILES_NUMBER
ret["PRE_UPLOAD_MEDIA_MESSAGE"] = settings.PRE_UPLOAD_MEDIA_MESSAGE
ret["SIDEBAR_FOOTER_TEXT"] = settings.SIDEBAR_FOOTER_TEXT
ret["POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY"] = settings.POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY
ret["IS_MEDIACMS_ADMIN"] = request.user.is_superuser
ret["IS_MEDIACMS_EDITOR"] = is_mediacms_editor(request.user)
ret["IS_MEDIACMS_MANAGER"] = is_mediacms_manager(request.user)
ret["USERS_NEEDS_TO_BE_APPROVED"] = settings.USERS_NEEDS_TO_BE_APPROVED
can_see_members_page = False
if request.user.is_authenticated:
if settings.CAN_SEE_MEMBERS_PAGE == "all":
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "editors" and is_mediacms_editor(request.user):
can_see_members_page = True
elif settings.CAN_SEE_MEMBERS_PAGE == "admins" and request.user.is_superuser:
can_see_members_page = True
ret["CAN_SEE_MEMBERS_PAGE"] = can_see_members_page
ret["ALLOW_RATINGS"] = settings.ALLOW_RATINGS
ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY
ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE

View File

@ -1,3 +1,5 @@
from django.conf import settings
from django.db.models import Q
from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
@ -219,6 +221,13 @@ class UserList(APIView):
elif role == "editor":
qs = qs.filter(is_editor=True)
if settings.USERS_NEEDS_TO_BE_APPROVED:
is_approved = request.GET.get("is_approved")
if is_approved == "true":
qs = qs.filter(is_approved=True)
elif is_approved == "false":
qs = qs.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
users = qs.order_by(f"{ordering}{sort_by}")
paginator = pagination_class()

View File

@ -110,6 +110,9 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.USERS_NEEDS_TO_BE_APPROVED:
urlpatterns.append(re_path(r"^approval_required", views.approval_required, name="approval_required"))
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))

View File

@ -1,4 +1,5 @@
# Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401
@ -10,6 +11,7 @@ from .media import MediaList # noqa: F401
from .media import MediaSearch # noqa: F401
from .pages import about # noqa: F401
from .pages import add_subtitle # noqa: F401
from .pages import approval_required # noqa: F401
from .pages import categories # noqa: F401
from .pages import contact # noqa: F401
from .pages import edit_chapters # noqa: F401

View File

@ -54,6 +54,11 @@ def about(request):
return render(request, "cms/about.html", context)
def approval_required(request):
"""User needs approval view"""
return render(request, "cms/user_needs_approval.html", {})
def setlanguage(request):
"""Set Language view"""
@ -517,6 +522,12 @@ def manage_comments(request):
def members(request):
"""List members view"""
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/members.html", context)

View File

@ -1,7 +1,8 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { usePopup } from '../../../utils/hooks/';
import { usePopup, useUser } from '../../../utils/hooks/';
import { PageStore } from '../../../utils/stores/';
import { csrfToken } from '../../../utils/helpers/';
import { PopupMain } from '../../_shared';
import { MaterialIcon } from '../../_shared/material-icon/MaterialIcon.jsx';
import { ManageItemDate } from './ManageMediaItem';
@ -38,34 +39,86 @@ function ManageItemUsername(props) {
return <i className="non-available">N/A</i>;
}
function ManageItemCommentActions(props) {
const [popupContentRef, PopupContent, PopupTrigger] = usePopup();
const [isOpenPopup, setIsOpenPopup] = useState(false);
function ManageUsersItemActions(props) {
const { userCan } = useUser();
const [deletePopupRef, DeletePopupContent, DeletePopupTrigger] = usePopup();
const [passwordPopupRef, PasswordPopupContent, PasswordPopupTrigger] = usePopup();
const [approvePopupRef, ApprovePopupContent, ApprovePopupTrigger] = usePopup();
function onPopupShow() {
setIsOpenPopup(true);
}
const [newPassword, setNewPassword] = useState('');
function onPopupHide() {
setIsOpenPopup(false);
}
const [isDeleteOpen, setDeleteOpen] = useState(false);
const [isPasswordOpen, setPasswordOpen] = useState(false);
const [isApproveOpen, setApproveOpen] = useState(false);
function onCancel() {
popupContentRef.current.tryToHide();
if ('function' === typeof props.onCancel) {
props.onCancel();
}
}
function onProceed() {
popupContentRef.current.tryToHide();
function onProceedDelete() {
deletePopupRef.current.tryToHide();
if ('function' === typeof props.onProceed) {
props.onProceed();
}
}
function onCancelDelete() {
deletePopupRef.current.tryToHide();
}
function handlePasswordChangeSubmit(e) {
e.preventDefault();
props.setMessage({ type: '', text: '' });
const formData = new FormData();
formData.append('action', 'change_password');
formData.append('password', newPassword);
fetch(`/api/v1/users/${props.username}`, {
method: 'PUT',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to change password.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'Password changed successfully.' }));
window.location.reload();
})
.catch((err) => {
props.setMessage({ type: 'error', text: err.message });
});
}
function handleApproveUser() {
props.setMessage({ type: '', text: '' });
const formData = new FormData();
formData.append('action', 'approve_user');
fetch(`/api/v1/users/${props.username}`, {
method: 'PUT',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to approve user.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'User approved successfully.' }));
window.location.reload();
})
.catch((err) => {
props.setMessage({ type: 'error', text: err.message });
});
}
const positionState = { updating: false, pending: 0 };
const onWindowResize = useCallback(function () {
if (positionState.updating) {
positionState.pending = positionState.pending + 1;
@ -98,6 +151,8 @@ function ManageItemCommentActions(props) {
}
}, []);
const isOpenPopup = isDeleteOpen || isPasswordOpen || isApproveOpen;
useEffect(() => {
if (isOpenPopup) {
PageStore.on('window_scroll', onWindowResize);
@ -111,11 +166,94 @@ function ManageItemCommentActions(props) {
return (
<div ref={props.containerRef} className="actions">
<PopupTrigger contentRef={popupContentRef}>
<PasswordPopupTrigger contentRef={passwordPopupRef}>
<button>Change password</button>
</PasswordPopupTrigger>
{userCan.usersNeedsToBeApproved && !props.is_approved && (
<>
<span className="seperator">|</span>
<ApprovePopupTrigger contentRef={approvePopupRef}>
<button>Approve</button>
</ApprovePopupTrigger>
</>
)}
<span className="seperator">|</span>
<DeletePopupTrigger contentRef={deletePopupRef}>
<button title={'Delete "' + props.name + '"'}>Delete</button>
</PopupTrigger>
</DeletePopupTrigger>
<PopupContent contentRef={popupContentRef} showCallback={onPopupShow} hideCallback={onPopupHide}>
<PasswordPopupContent
contentRef={passwordPopupRef}
showCallback={() => setPasswordOpen(true)}
hideCallback={() => {
setPasswordOpen(false);
props.setMessage({ type: '', text: '' });
}}
>
<PopupMain>
<form onSubmit={handlePasswordChangeSubmit}>
<div className="popup-message">
<span className="popup-message-title">Change Password for {props.name}</span>
<span className="popup-message-main">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New Password"
required
style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
/>
</span>
</div>
<hr />
<span className="popup-message-bottom">
<button
type="button"
className="button-link cancel-profile-removal"
onClick={() => passwordPopupRef.current.tryToHide()}
>
CANCEL
</button>
<button type="submit" className="button-link proceed-profile-removal">
SUBMIT
</button>
</span>
</form>
</PopupMain>
</PasswordPopupContent>
<ApprovePopupContent
contentRef={approvePopupRef}
showCallback={() => setApproveOpen(true)}
hideCallback={() => {
setApproveOpen(false);
props.setMessage({ type: '', text: '' });
}}
>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Approve User</span>
<span className="popup-message-main">
{'Are you sure you want to approve "' + props.name + '"?'}
</span>
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-profile-removal" onClick={() => approvePopupRef.current.tryToHide()}>
CANCEL
</button>
<button className="button-link proceed-profile-removal" onClick={handleApproveUser}>
PROCEED
</button>
</span>
</PopupMain>
</ApprovePopupContent>
<DeletePopupContent
contentRef={deletePopupRef}
showCallback={() => setDeleteOpen(true)}
hideCallback={() => setDeleteOpen(false)}
>
<PopupMain>
<div className="popup-message">
<span className="popup-message-title">Member removal</span>
@ -123,15 +261,15 @@ function ManageItemCommentActions(props) {
</div>
<hr />
<span className="popup-message-bottom">
<button className="button-link cancel-profile-removal" onClick={onCancel}>
<button className="button-link cancel-profile-removal" onClick={onCancelDelete}>
CANCEL
</button>
<button className="button-link proceed-profile-removal" onClick={onProceed}>
<button className="button-link proceed-profile-removal" onClick={onProceedDelete}>
PROCEED
</button>
</span>
</PopupMain>
</PopupContent>
</DeletePopupContent>
</div>
);
}
@ -168,10 +306,14 @@ export function ManageUsersItem(props) {
</div>
<div className="mi-name">
<ManageItemName name={props.name} url={props.url} />
<ManageItemCommentActions
<ManageUsersItemActions
containerRef={actionsContainerRef}
name={props.name || props.username}
username={props.username}
is_approved={props.is_approved}
onProceed={onClickProceed}
onUserUpdate={props.onUserUpdate}
setMessage={props.setMessage}
/>
</div>
<div className="mi-username">
@ -213,6 +355,17 @@ export function ManageUsersItem(props) {
)}
</div>
) : null}
{props.has_approved ? (
<div className="mi-approved">
{void 0 === props.is_approved || props.is_approved === null ? (
<i className="non-available">N/A</i>
) : props.is_approved ? (
<MaterialIcon type="check_circle" />
) : (
<MaterialIcon type="cancel" />
)}
</div>
) : null}
<div className="mi-featured">
{void 0 === props.is_featured ? (
<i className="non-available">N/A</i>
@ -234,18 +387,25 @@ ManageUsersItem.propTypes = {
add_date: PropTypes.string,
is_featured: PropTypes.bool,
onCheckRow: PropTypes.func,
onUserUpdate: PropTypes.func,
setMessage: PropTypes.func,
selectedRow: PropTypes.bool.isRequired,
hideDeleteAction: PropTypes.bool.isRequired,
has_roles: PropTypes.bool,
has_verified: PropTypes.bool,
has_trusted: PropTypes.bool,
has_approved: PropTypes.bool,
roles: PropTypes.array,
is_verified: PropTypes.bool,
is_trusted: PropTypes.bool,
is_approved: PropTypes.bool,
};
ManageUsersItem.defaultProps = {
has_roles: false,
has_verified: false,
has_trusted: false,
has_approved: false,
onUserUpdate: () => {},
setMessage: () => {},
};

View File

@ -45,6 +45,7 @@ export function ManageUsersItemHeader(props) {
{props.has_roles ? <div className="mi-role">Role</div> : null}
{props.has_verified ? <div className="mi-verified">Verified</div> : null}
{props.has_trusted ? <div className="mi-trusted">Trusted</div> : null}
{props.has_approved ? <div className="mi-approved">Approved</div> : null}
<div className="mi-featured">Featured</div>
</div>
);
@ -59,10 +60,12 @@ ManageUsersItemHeader.propTypes = {
has_roles: PropTypes.bool,
has_verified: PropTypes.bool,
has_trusted: PropTypes.bool,
has_approved: PropTypes.bool,
};
ManageUsersItemHeader.defaultProps = {
has_roles: false,
has_verified: false,
has_trusted: false,
has_approved: false,
};

View File

@ -12,6 +12,89 @@ import { translateString } from '../../../utils/helpers/';
import './ManageItemList.scss';
function AddNewUser({ onUserAdded, setMessage }) {
const [popupRef, PopupContent, PopupTrigger] = usePopup();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
function clearForm() {
setUsername('');
setPassword('');
setEmail('');
setName('');
}
function handleSubmit(e) {
e.preventDefault();
if (setMessage) {
setMessage({ type: '', text: '' });
}
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
formData.append('email', email);
formData.append('name', name);
fetch('/api/v1/users', {
method: 'POST',
body: formData,
headers: { 'X-CSRFToken': csrfToken() },
})
.then((res) => {
if (res.ok) {
return res.json();
}
return res.json().then((data) => {
throw new Error(data.detail || 'Failed to create user.');
});
})
.then(() => {
sessionStorage.setItem('user-management-message', JSON.stringify({ type: 'success', text: 'User created successfully.' }));
window.location.reload();
})
.catch((err) => {
if (setMessage) {
setMessage({ type: 'error', text: err.message });
}
});
}
return (
<div className="add-new-user-container">
<PopupTrigger contentRef={popupRef}>
<button className="add-new-user-btn">Add New User</button>
</PopupTrigger>
<PopupContent contentRef={popupRef} hideCallback={clearForm}>
<PopupMain>
<form onSubmit={handleSubmit}>
<div className="popup-message">
<span className="popup-message-title">Add New User</span>
<div className="popup-message-main">
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" required style={{ width: '100%', padding: '8px', boxSizing: 'border-box', marginBottom: '10px' }} />
</div>
</div>
<hr />
<span className="popup-message-bottom">
<button type="button" className="button-link cancel-profile-removal" onClick={() => popupRef.current.tryToHide()}>CANCEL</button>
<button type="submit" className="button-link proceed-profile-removal">SUBMIT</button>
</span>
</form>
</PopupMain>
</PopupContent>
</div>
);
}
AddNewUser.propTypes = {
onUserAdded: PropTypes.func,
setMessage: PropTypes.func,
};
function useManageItemList(props, itemsListRef) {
let previousItemsLength = 0;
@ -440,6 +523,35 @@ export function ManageItemList(props) {
onItemsLoad,
] = useManageItemListSync(props);
const [message, setMessage] = useState({ type: '', text: '' });
useEffect(() => {
const storedMessage = sessionStorage.getItem('user-management-message');
if (storedMessage) {
setMessage(JSON.parse(storedMessage));
sessionStorage.removeItem('user-management-message');
}
}, []);
useEffect(() => {
if (message.text) {
const timer = setTimeout(() => setMessage({ type: '', text: '' }), 5000);
return () => clearTimeout(timer);
}
}, [message]);
function refreshList() {
if (props.onPageChange && parsedRequestUrl) {
const queryParams = new URLSearchParams(parsedRequestUrlQuery || '');
const currentPage = queryParams.get('page') || '1';
const clickedPageUrl = pageUrl(parsedRequestUrl, pageUrlQuery(parsedRequestUrlQuery, currentPage));
props.onPageChange(clickedPageUrl, currentPage);
} else {
// Fallback for when onPageChange is not available
setListHandler(new ManageItemsListHandler(props.pageItems, props.maxItems, props.requestUrl, onItemsCount, onItemsLoad));
}
}
const [selectedItems, setSelectedItems] = useState([]);
const [selectedAllItems, setSelectedAllItems] = useState(false);
@ -592,50 +704,60 @@ export function ManageItemList(props) {
return () => {
if (listHandler) {
listHandler.cancelAll();
// listHandler.cancelAll();
setListHandler(null);
}
};
}, []);
}, [props.requestUrl]);
return !countedItems ? (
<PendingItemsList className={classname.listOuter} />
) : !items.length ? null : (
) : (
<div className={classname.listOuter}>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
{message.text && (
<div className={`message ${message.type === 'error' ? 'error' : 'success'}`}>{message.text}</div>
)}
{'users' === props.manageType && <AddNewUser onUserAdded={refreshList} setMessage={setMessage} />}
{!items.length ? null : (
<>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
<div ref={itemsListWrapperRef} className="items-list-wrap">
<div ref={itemsListRef} className={classname.list}>
{renderManageItems(items, {
...props,
onAllRowsCheck: onAllRowsCheck,
onRowCheck: onRowCheck,
selectedItems: selectedItems,
selectedAllItems: selectedAllItems,
onDelete: deleteItem,
})}
</div>
</div>
<div ref={itemsListWrapperRef} className="items-list-wrap">
<div ref={itemsListRef} className={classname.list}>
{renderManageItems(items, {
...props,
onAllRowsCheck: onAllRowsCheck,
onRowCheck: onRowCheck,
selectedItems: selectedItems,
selectedAllItems: selectedAllItems,
onDelete: deleteItem,
onUserUpdate: refreshList,
setMessage: setMessage,
})}
</div>
</div>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options popup-on-top"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
<ManageItemsOptions
totalItems={totalItems}
pageItems={props.pageItems}
onPageButtonClick={onPageButtonClick}
query={parsedRequestUrlQuery || ''}
className="manage-items-options popup-on-top"
items={selectedItems}
pagesSize={listHandler.totalPages()}
onProceedRemoval={onBulkItemsRemoval}
/>
</>
)}
</div>
);
}

View File

@ -146,8 +146,13 @@
}
}
> * {
> div {
position: relative;
display: table-cell;
min-width: 98px;
padding-top: 14px;
padding-bottom: 14px;
vertical-align: middle;
border-right: 1px solid #f0f0f0;
.dark_theme & {
@ -160,7 +165,7 @@
}
&:hover {
> * {
> div {
border-color: #eaeaea;
.dark_theme & {
@ -188,17 +193,14 @@
opacity: 0.4;
}
> * {
position: relative;
min-width: 98px;
padding-top: 14px;
padding-bottom: 14px;
vertical-align: middle;
}
.mi-title,
.mi-name {
.actions {
.seperator {
display: inline-block;
margin: 0 4px;
opacity: 0.65;
}
position: relative;
display: block;
padding-top: 4px;
@ -252,7 +254,7 @@
}
&.manage-media-item {
> * {
> div {
width: 10%;
text-align: center;
}
@ -306,8 +308,8 @@
}
&.manage-users-item {
> * {
width: math.div(1,8) * 100%;
> div {
width: math.div(1, 9) * 100%;
}
.mi-added,
@ -315,20 +317,23 @@
.mi-featured,
.mi-verified,
.mi-trusted,
.mi-approved,
.mi-checkbox {
text-align: center;
}
.mi-name,
.mi-username {
min-width: 240px;
min-width: 200px;
width: 50%;
padding-left: 16px;
padding-right: 16px;
font-weight: 500;
}
.mi-name {
width: 100%;
}
.mi-checkbox {
min-width: 48px;
width: 48px;
@ -344,7 +349,7 @@
}
&.manage-comments-item {
> * {
> div {
width: 16%;
}
@ -457,7 +462,7 @@
box-shadow: none;
}
> * {
> div {
padding-top: 20px;
padding-bottom: 20px;
border-right: 0;
@ -469,7 +474,6 @@
position: relative;
display: inline;
vertical-align: top;
background-color: yellow;
.material-icons {
width: auto;
@ -479,7 +483,7 @@
line-height: 1;
}
> * {
> span {
opacity: 0.25;
position: absolute;
@ -501,7 +505,7 @@
text-decoration: underline;
.mi-col-sort-icons {
> * {
> span {
opacity: 0.35;
}
}
@ -509,7 +513,7 @@
&.desc {
.mi-col-sort-icons {
> *:last-child {
> span:last-child {
opacity: 0.8;
}
}
@ -517,7 +521,7 @@
&.asc {
.mi-col-sort-icons {
> *:first-child {
> span:first-child {
opacity: 0.8;
}
}
@ -533,6 +537,66 @@
clear: both;
}
.message {
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
&.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
&.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
}
.add-new-user-container {
display: inline-block;
margin-bottom: 12px;
float: right;
.popup-message-bottom {
display: flex;
justify-content: space-between;
width: 100%;
button {
font-size: 14px;
color: var(--popup-msg-main-text-color);
&.proceed-profile-removal {
color: var(--default-theme-color);
}
&:hover {
color: inherit;
text-decoration: none;
}
}
}
}
.add-new-user-btn {
padding: 0 16px;
height: 36px;
line-height: 36px;
color: var(--default-theme-color);
border: 1px solid var(--default-theme-color);
background: var(--user-action-form-inner-bg-color);
border-radius: 2px;
box-shadow: 0px 1px 4px 0 rgba(17, 17, 17, 0.06);
cursor: pointer;
font-weight: 500;
&:hover {
background: var(--default-theme-color);
color: var(--user-action-form-inner-bg-color);
}
}
.manage-items-bulk-action {
position: relative;
width: auto;

View File

@ -5,6 +5,7 @@ import { ManageCommentsItem } from '../../ManageItem/ManageCommentsItem';
import { ManageMediaItemHeader } from '../../ManageItem/ManageMediaItemHeader';
import { ManageUsersItemHeader } from '../../ManageItem/ManageUsersItemHeader';
import { ManageCommentsItemHeader } from '../../ManageItem/ManageCommentsItemHeader';
import { useUser } from '../../../../utils/hooks/';
function useManageItem(props) {
const itemData = props.item;
@ -15,6 +16,8 @@ function useManageItem(props) {
selectedRow: props.selectedRow,
onProceedRemoval: props.onProceedRemoval,
hideDeleteAction: props.hideDeleteAction,
onUserUpdate: props.onUserUpdate,
setMessage: props.setMessage,
};
return [itemData, itemProps];
@ -44,6 +47,7 @@ function ListManageMediaItem(props) {
}
function ListManageUserItem(props) {
const { userCan } = useUser();
const [itemData, itemProps] = useManageItem(props);
const roles = [];
@ -70,6 +74,8 @@ function ListManageUserItem(props) {
has_roles: void 0 !== itemData.is_editor || void 0 !== itemData.is_manager,
has_verified: void 0 !== itemData.email_is_verified,
has_trusted: void 0 !== itemData.advancedUser,
is_approved: itemData.is_approved,
has_approved: userCan.usersNeedsToBeApproved && void 0 !== itemData.is_approved,
};
return <ManageUsersItem {...args} />;
@ -99,6 +105,8 @@ function ListManageItem(props) {
hideDeleteAction: false,
onCheckRow: props.onCheckRow,
onProceedRemoval: props.onProceedRemoval,
onUserUpdate: props.onUserUpdate,
setMessage: props.setMessage,
};
if ('media' === props.type) {
@ -117,6 +125,7 @@ function ListManageItem(props) {
}
function ListManageItemHeader(props) {
const { userCan } = useUser();
const args = {
sort: props.sort,
order: props.order,
@ -134,6 +143,10 @@ function ListManageItemHeader(props) {
props.items.length && (void 0 !== props.items[0].is_editor || void 0 !== props.items[0].is_manager);
args.has_verified = props.items.length && void 0 !== props.items[0].email_is_verified;
args.has_trusted = props.items.length && void 0 !== props.items[0].advancedUser;
args.has_approved =
userCan.usersNeedsToBeApproved &&
props.items.length &&
void 0 !== props.items[0].is_approved;
return <ManageUsersItemHeader {...args} />;
}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { useUser } from '../../utils/hooks/';
import { FilterOptions } from '../_shared';
import './ManageItemList-filters.scss';
@ -11,12 +12,19 @@ const filters = {
{ id: 'editor', title: 'Editor' },
{ id: 'manager', title: 'Manager' },
],
approved: [
{ id: 'all', title: 'All' },
{ id: 'true', title: 'Yes' },
{ id: 'false', title: 'No' },
],
};
export function ManageUsersFilters(props) {
const { userCan } = useUser();
const [isHidden, setIsHidden] = useState(props.hidden);
const [role, setFilterRole] = useState('all');
const [approved, setFilterApproved] = useState('all');
const containerRef = useRef(null);
const innerContainerRef = useRef(null);
@ -30,6 +38,7 @@ export function ManageUsersFilters(props) {
function onFilterSelect(ev) {
const args = {
role: role,
is_approved: approved,
};
switch (ev.currentTarget.getAttribute('filter')) {
@ -38,6 +47,11 @@ export function ManageUsersFilters(props) {
props.onFiltersUpdate(args);
setFilterRole(args.role);
break;
case 'approved':
args.is_approved = ev.currentTarget.getAttribute('value');
props.onFiltersUpdate(args);
setFilterApproved(args.is_approved);
break;
}
}
@ -60,6 +74,14 @@ export function ManageUsersFilters(props) {
<FilterOptions id={'role'} options={filters.role} selected={role} onSelect={onFilterSelect} />
</div>
</div>
{userCan.usersNeedsToBeApproved ? (
<div className="mi-filter">
<div className="mi-filter-title">APPROVED</div>
<div className="mi-filter-options">
<FilterOptions id={'approved'} options={filters.approved} selected={approved} onSelect={onFilterSelect} />
</div>
</div>
) : null}
</div>
</div>
);

View File

@ -102,7 +102,11 @@ export function SidebarNavigationMenu() {
});
}
if (PageStore.get('config-enabled').pages.members && PageStore.get('config-enabled').pages.members.enabled) {
if (
PageStore.get('config-enabled').pages.members &&
PageStore.get('config-enabled').pages.members.enabled &&
userCan.canSeeMembersPage
) {
items.push({
link: links.members,
icon: 'people',

View File

@ -14,6 +14,8 @@ export function init(user, features) {
register: true,
addMedia: false,
editProfile: false,
canSeeMembersPage: true,
usersNeedsToBeApproved: true,
changePassword: true,
deleteProfile: false,
readComment: true,
@ -91,6 +93,8 @@ export function init(user, features) {
}
}
MEMBER.can.canSeeMembersPage = true === user.can.canSeeMembersPage;
MEMBER.can.usersNeedsToBeApproved = true === user.can.usersNeedsToBeApproved;
MEMBER.can.addMedia = true === user.can.addMedia;
MEMBER.can.editProfile = true === user.can.editProfile;
MEMBER.can.readComment = false === user.can.readComment ? false : true;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block headtitle %} | Account needs approval{% endblock headtitle %}
{% block innercontent %}
<div class="user-action-form-wrap">
<div class="user-action-form-inner">
<h1>Account Pending Approval</h1>
<p>Your account is currently pending approval from an administrator.</p>
<p><a href="{% url 'account_logout' %}">Logout</a></p>
</div>
</div>
{% endblock innercontent %}

View File

@ -16,9 +16,11 @@ MediaCMS.user = {
deleteComment: {% if CAN_DELETE_COMMENTS %}true{% else %}false{% endif %},
editProfile: {% if CAN_EDIT %}true{% else %}false{% endif %},
deleteProfile: {% if CAN_DELETE %}true{% else %}false{% endif %},
canSeeMembersPage: {% if CAN_SEE_MEMBERS_PAGE %}true{% else %}false{% endif %},
manageMedia: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER or IS_MEDIACMS_EDITOR %}true{% else %}false{% endif %},
manageUsers: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER %}true{% else %}false{% endif %},
manageComments: {% if IS_MEDIACMS_ADMIN or IS_MEDIACMS_MANAGER or IS_MEDIACMS_EDITOR %}true{% else %}false{% endif %},
usersNeedsToBeApproved: {% if USERS_NEEDS_TO_BE_APPROVED %}true{% else %}false{% endif %},
},
pages: {
media: '/user/{{request.user.username}}',

View File

@ -17,7 +17,7 @@ MediaCMS.contents = {
}
],
belowNavMenu: null,
footer: 'Powered by <a href="//mediacms.io" title="mediacms.io" target="_blank">mediacms.io</a>',
footer: {% if SIDEBAR_FOOTER_TEXT %}'{{ SIDEBAR_FOOTER_TEXT|escapejs }}'{% else %}'Powered by <a href="//mediacms.io" title="mediacms.io" target="_blank">mediacms.io</a>'{% endif %},
},
uploader: {
belowUploadArea: "{{PRE_UPLOAD_MEDIA_MESSAGE}}",

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin
from .models import User
@ -5,20 +6,7 @@ from .models import User
class UserAdmin(admin.ModelAdmin):
search_fields = ["email", "username", "name"]
exclude = (
"user_permissions",
"title",
"password",
"groups",
"last_login",
"is_featured",
"location",
"first_name",
"last_name",
"media_count",
"date_joined",
"is_active",
)
exclude = ["user_permissions", "title", "password", "groups", "last_login", "is_featured", "location", "first_name", "last_name", "media_count", "date_joined", "is_active", "is_approved"]
list_display = [
"username",
"name",
@ -33,5 +21,10 @@ class UserAdmin(admin.ModelAdmin):
list_filter = ["is_superuser", "is_editor", "is_manager"]
ordering = ("-date_added",)
if settings.USERS_NEEDS_TO_BE_APPROVED:
list_display.append("is_approved")
list_filter.append("is_approved")
exclude.remove("is_approved")
admin.site.register(User, UserAdmin)

View File

@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from files.methods import is_mediacms_manager
@ -25,6 +26,7 @@ class UserForm(forms.ModelForm):
"advancedUser",
"is_manager",
"is_editor",
"is_approved",
# "allow_contact",
)
@ -44,6 +46,11 @@ class UserForm(forms.ModelForm):
self.fields.pop("advancedUser")
self.fields.pop("is_manager")
self.fields.pop("is_editor")
if not settings.USERS_NEEDS_TO_BE_APPROVED or not is_mediacms_manager(user):
if "is_approved" in self.fields:
self.fields.pop("is_approved")
if user.socialaccount_set.exists():
# for Social Accounts do not allow to edit the name
self.fields["name"].widget.attrs['readonly'] = True

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.6 on 2025-09-19 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_approved',
field=models.BooleanField(blank=True, db_index=True, default=False, null=True, verbose_name='Is approved'),
),
]

View File

@ -29,6 +29,7 @@ class User(AbstractUser):
name = models.CharField("full name", max_length=250, db_index=True)
date_added = models.DateTimeField("date added", default=timezone.now, db_index=True)
is_featured = models.BooleanField("Is featured", default=False, db_index=True)
is_approved = models.BooleanField("Is approved", default=False, null=True, blank=True, db_index=True)
title = models.CharField("Title", max_length=250, blank=True)
advancedUser = models.BooleanField("advanced user", default=False, db_index=True)

View File

@ -22,7 +22,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
read_only_fields = (
read_only_fields = [
"date_added",
"is_featured",
"uid",
@ -31,8 +31,8 @@ class UserSerializer(serializers.ModelSerializer):
"is_editor",
"is_manager",
"email_is_verified",
)
fields = (
]
fields = [
"description",
"date_added",
"name",
@ -45,7 +45,11 @@ class UserSerializer(serializers.ModelSerializer):
"is_editor",
"is_manager",
"email_is_verified",
)
]
if settings.USERS_NEEDS_TO_BE_APPROVED:
fields.append("is_approved")
read_only_fields.append("is_approved")
class UserDetailSerializer(serializers.ModelSerializer):

View File

@ -205,6 +205,12 @@ class UserList(APIView):
operation_description='Paginated listing of users',
)
def get(self, request, format=None):
if settings.CAN_SEE_MEMBERS_PAGE == "editors" and not is_mediacms_editor(request.user):
raise PermissionDenied("You do not have permission to view this page.")
if settings.CAN_SEE_MEMBERS_PAGE == "admins" and not request.user.is_superuser:
raise PermissionDenied("You do not have permission to view this page.")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
users = User.objects.filter()
@ -213,11 +219,57 @@ class UserList(APIView):
if name:
users = users.filter(Q(name__icontains=name) | Q(username__icontains=name))
if settings.USERS_NEEDS_TO_BE_APPROVED:
is_approved = request.GET.get("is_approved")
if is_approved == "true":
users = users.filter(is_approved=True)
elif is_approved == "false":
users = users.filter(Q(is_approved=False) | Q(is_approved__isnull=True))
page = paginator.paginate_queryset(users, request)
serializer = UserSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["username", "password", "email", "name"],
properties={
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(type=openapi.TYPE_STRING),
"email": openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_EMAIL),
"name": openapi.Schema(type=openapi.TYPE_STRING),
},
),
tags=["Users"],
operation_summary="Create user",
operation_description="Create a new user. Only for managers.",
responses={201: UserSerializer},
)
def post(self, request, format=None):
if not is_mediacms_manager(request.user):
raise PermissionDenied("You do not have permission to create users.")
username = request.data.get("username")
password = request.data.get("password")
email = request.data.get("email")
name = request.data.get("name")
if not all([username, password, email, name]):
return Response({"detail": "username, password, email, and name are required."}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"detail": "A user with that username already exists."}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(email=email).exists():
return Response({"detail": "A user with that email already exists."}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.create_user(username=username, password=password, email=email, name=name)
serializer = UserSerializer(user, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
class UserDetail(APIView):
""""""
@ -284,27 +336,36 @@ class UserDetail(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
manual_parameters=[
openapi.Parameter(name='action', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=True, description="action to perform ('change_password' or 'approve_user')"),
openapi.Parameter(name='password', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="new password (if action is 'change_password')"),
],
tags=['Users'],
operation_summary='Xto_be_written',
operation_description='to_be_written',
operation_summary='Update user details',
operation_description='Allows a user to change their password. Allows a manager to approve a user.',
)
def put(self, request, uid, format=None):
# ADMIN
user = self.get_user(uid)
def put(self, request, username, format=None):
user = self.get_user(username)
if isinstance(user, Response):
return user
if not request.user.is_superuser:
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("action")
if action == "feature":
user.is_featured = True
if action == "change_password":
# Permission to edit user is already checked by self.get_user -> self.check_object_permissions
password = request.data.get("password")
if not password:
return Response({"detail": "Password is required"}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(password)
user.save()
elif action == "unfeature":
user.is_featured = False
elif action == "approve_user":
if not is_mediacms_manager(request.user):
raise PermissionDenied("You do not have permission to approve users.")
user.is_approved = True
user.save()
else:
return Response({"detail": "Invalid action"}, status=status.HTTP_400_BAD_REQUEST)
serializer = UserDetailSerializer(user, context={"request": request})
return Response(serializer.data)