mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
🐛(global) allow thread viewers to post internal comments (#632)
Writing an internal comment is a personal authoring act that should not require thread edit rights: support teammates invited as thread viewers must still be able to comment and mention colleagues, as long as they have edit rights on the mailbox. ThreadEvent IM writes and ThreadUser listing are relaxed accordingly, while every other thread mutation keeps the full edit-rights check. The message composer is now gated by the thread edit ability so that read-only users cannot bypass the check through reply or forward, and the thread-panel selection separator is hidden when no bulk action is available. A few unrelated UI polish fixes (disabled link button style, combobox placeholder visibility) ship alongside.
This commit is contained in:
committed by
GitHub
parent
efbae5f517
commit
8f8798cba3
@@ -545,6 +545,98 @@ class IsGlobalChannelMixin:
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
def _user_can_comment_on_thread(user, thread_id):
|
||||
"""True if ``user`` can post/read internal comments on ``thread_id``.
|
||||
|
||||
Requires any ``ThreadAccess`` (viewer or editor) on a mailbox where the
|
||||
user has ``MAILBOX_ROLES_CAN_EDIT``. Writing an internal comment is a
|
||||
personal authoring act that does not mutate the thread's shared state,
|
||||
so VIEWER ThreadAccess is enough as long as the user is at least editor
|
||||
on the mailbox.
|
||||
"""
|
||||
return models.ThreadAccess.objects.filter(
|
||||
thread_id=thread_id,
|
||||
mailbox__accesses__user=user,
|
||||
mailbox__accesses__role__in=enums.MAILBOX_ROLES_CAN_EDIT,
|
||||
).exists()
|
||||
|
||||
|
||||
class HasThreadCommentAccess(IsAuthenticated):
|
||||
"""Allows users who can author internal comments on the thread.
|
||||
|
||||
Used for endpoints that serve the comment authoring flow (posting an
|
||||
``im`` ThreadEvent, listing thread users to pick mention targets): both
|
||||
must stay accessible to Thread VIEWERs as long as they are at least
|
||||
editor on one of the mailboxes sharing the thread.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
thread_id = view.kwargs.get("thread_id")
|
||||
if not thread_id:
|
||||
return False
|
||||
|
||||
return _user_can_comment_on_thread(request.user, thread_id)
|
||||
|
||||
|
||||
class HasThreadEventWriteAccess(IsAuthenticated):
|
||||
"""Gates write actions on ThreadEvent — rules depend on the event type.
|
||||
|
||||
Only ``im`` events (internal comments) relax the thread-level role: a
|
||||
Thread VIEWER with mailbox edit rights can create/update/destroy their
|
||||
own ``im`` events. Any other event type is considered a shared-state
|
||||
mutation (system event, status change, …) and keeps the stricter
|
||||
full-edit-rights policy (``MailboxAccess`` in ``MAILBOX_ROLES_CAN_EDIT``
|
||||
AND ``ThreadAccess.role == EDITOR``).
|
||||
|
||||
Author check for update/destroy is enforced here regardless of type —
|
||||
no user can edit or delete another user's event.
|
||||
"""
|
||||
|
||||
message = "You do not have permission to perform this action on this thread."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not super().has_permission(request, view):
|
||||
return False
|
||||
|
||||
thread_id = view.kwargs.get("thread_id")
|
||||
if thread_id is None:
|
||||
return True
|
||||
|
||||
if view.action == "create":
|
||||
event_type = request.data.get("type") if hasattr(request, "data") else None
|
||||
if event_type == enums.ThreadEventTypeChoices.IM:
|
||||
return _user_can_comment_on_thread(request.user, thread_id)
|
||||
# Non-IM (or missing) types require full edit rights. Using the
|
||||
# stricter path as the default keeps unknown types safe.
|
||||
return (
|
||||
models.ThreadAccess.objects.editable_by(request.user)
|
||||
.filter(thread_id=thread_id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# update / partial_update / destroy: defer to object-level check
|
||||
# (type is resolved from the stored instance, not the payload).
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not isinstance(obj, models.ThreadEvent):
|
||||
return False
|
||||
|
||||
if (
|
||||
view.action in ("update", "partial_update", "destroy")
|
||||
and obj.author_id != request.user.id
|
||||
):
|
||||
return False
|
||||
|
||||
if obj.type == enums.ThreadEventTypeChoices.IM:
|
||||
return _user_can_comment_on_thread(request.user, obj.thread_id)
|
||||
|
||||
return obj.thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT]
|
||||
|
||||
|
||||
class HasThreadEditAccess(IsAuthenticated):
|
||||
"""Allows access only to users with full edit rights on the thread.
|
||||
|
||||
|
||||
@@ -36,16 +36,18 @@ class ThreadEventViewSet(
|
||||
lookup_url_kwarg = "id"
|
||||
|
||||
def get_permissions(self):
|
||||
"""Use HasThreadEditAccess for write actions.
|
||||
"""Route write actions through the type-aware permission class.
|
||||
|
||||
ThreadEvent write operations require editor access, except
|
||||
``read_mention`` which is a personal acknowledgement and only
|
||||
requires read access on the thread.
|
||||
Reads (``list``, ``retrieve``) and the personal ``read_mention``
|
||||
acknowledgement only need read access on the thread. Writes are
|
||||
gated by :class:`HasThreadEventWriteAccess`, which relaxes the
|
||||
ThreadAccess role for ``im`` (comment) events while keeping the
|
||||
stricter full-edit-rights rule for every other event type.
|
||||
"""
|
||||
if self.action in ["list", "retrieve", "read_mention"]:
|
||||
return [permissions.IsAuthenticated(), permissions.IsAllowedToAccess()]
|
||||
|
||||
return [permissions.HasThreadEditAccess()]
|
||||
return [permissions.HasThreadEventWriteAccess()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict results to events for the specified thread."""
|
||||
|
||||
@@ -19,7 +19,7 @@ class ThreadUserViewSet(
|
||||
pagination_class = None
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
permissions.IsAllowedToManageThreadAccess,
|
||||
permissions.HasThreadCommentAccess,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -280,22 +280,27 @@ class TestNonAuthorEventManipulation:
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
class TestViewerCannotCreateEvents:
|
||||
"""Test that VIEWER-role users cannot create events (only EDITOR+)."""
|
||||
class TestImEventCreationPermissions:
|
||||
"""Permission matrix for creating ``im`` ThreadEvents (internal comments).
|
||||
|
||||
def test_viewer_cannot_create_event(self, api_client):
|
||||
"""Users with only VIEWER role on a thread should not create events."""
|
||||
``im`` events are personal authoring acts (not shared-state mutations),
|
||||
so Thread VIEWERs may post them as long as they are at least editor on
|
||||
the mailbox. The mailbox role is the hard floor.
|
||||
"""
|
||||
|
||||
def test_thread_viewer_mailbox_editor_can_create_im(self, api_client):
|
||||
"""Thread VIEWER + mailbox editor: must be allowed to comment."""
|
||||
user, _mailbox, thread = setup_user_with_thread_access(
|
||||
role=enums.ThreadAccessRoleChoices.VIEWER
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
data = {"type": "im", "data": {"content": "from a viewer"}}
|
||||
data = {"type": "im", "data": {"content": "from a thread viewer"}}
|
||||
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_editor_can_create_event(self, api_client):
|
||||
"""Users with EDITOR role should be able to create events (sanity check)."""
|
||||
def test_thread_editor_can_create_im(self, api_client):
|
||||
"""Thread EDITOR + mailbox admin: sanity check."""
|
||||
user, _mailbox, thread = setup_user_with_thread_access(
|
||||
role=enums.ThreadAccessRoleChoices.EDITOR
|
||||
)
|
||||
@@ -305,20 +310,18 @@ class TestViewerCannotCreateEvents:
|
||||
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
def test_viewer_mailbox_with_editor_thread_access_cannot_create_event(
|
||||
self, api_client
|
||||
):
|
||||
"""Previously-missed scenario: a user with only VIEWER MailboxAccess
|
||||
cannot post events even when the mailbox has EDITOR ThreadAccess.
|
||||
Creating a ThreadEvent mutates shared state, so both role checks
|
||||
must pass.
|
||||
def test_viewer_mailbox_cannot_create_im(self, api_client):
|
||||
"""Mailbox VIEWER cannot post comments even with EDITOR thread access.
|
||||
|
||||
Authoring a comment requires at least editor-level mailbox role,
|
||||
otherwise a read-only teammate could inject content into threads.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
factories.MailboxAccessFactory(
|
||||
mailbox=mailbox,
|
||||
user=user,
|
||||
role=enums.MailboxRoleChoices.VIEWER, # VIEWER mailbox role
|
||||
role=enums.MailboxRoleChoices.VIEWER,
|
||||
)
|
||||
thread = factories.ThreadFactory()
|
||||
factories.ThreadAccessFactory(
|
||||
@@ -332,6 +335,87 @@ class TestViewerCannotCreateEvents:
|
||||
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_no_thread_access_cannot_create_im(self, api_client):
|
||||
"""A user without any ThreadAccess on the thread is denied."""
|
||||
user = factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
factories.MailboxAccessFactory(
|
||||
mailbox=mailbox,
|
||||
user=user,
|
||||
role=enums.MailboxRoleChoices.ADMIN,
|
||||
)
|
||||
thread = factories.ThreadFactory() # no ThreadAccess for user's mailbox
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
data = {"type": "im", "data": {"content": "from outsider"}}
|
||||
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
class TestImEventAuthorEditsOwnComment:
|
||||
"""Authors of ``im`` events must be able to edit/delete their own
|
||||
comment even if they are only VIEWER on the thread.
|
||||
|
||||
If we allowed them to create the comment, we must let them take it
|
||||
back — no orphan comments the author can't retract.
|
||||
"""
|
||||
|
||||
def test_thread_viewer_author_can_update_own_im(self, api_client):
|
||||
"""
|
||||
A user with VIEWER access to a thread can update their own ``im`` event.
|
||||
"""
|
||||
user, _mailbox, thread = setup_user_with_thread_access(
|
||||
role=enums.ThreadAccessRoleChoices.VIEWER
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
|
||||
|
||||
response = api_client.patch(
|
||||
get_thread_event_url(thread.id, event.id),
|
||||
{"data": {"content": "edited from viewer"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
event.refresh_from_db()
|
||||
assert event.data["content"] == "edited from viewer"
|
||||
|
||||
def test_thread_viewer_author_can_destroy_own_im(self, api_client):
|
||||
"""
|
||||
A user with VIEWER access to a thread can destroy their own ``im`` event.
|
||||
"""
|
||||
user, _mailbox, thread = setup_user_with_thread_access(
|
||||
role=enums.ThreadAccessRoleChoices.VIEWER
|
||||
)
|
||||
api_client.force_authenticate(user=user)
|
||||
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
|
||||
|
||||
response = api_client.delete(get_thread_event_url(thread.id, event.id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not models.ThreadEvent.objects.filter(id=event.id).exists()
|
||||
|
||||
def test_mailbox_viewer_author_cannot_update_own_im(self, api_client):
|
||||
"""Mailbox role dropped to VIEWER after the event was created:
|
||||
the author has lost comment rights and can no longer edit.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
mailbox = factories.MailboxFactory()
|
||||
factories.MailboxAccessFactory(
|
||||
mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.VIEWER
|
||||
)
|
||||
thread = factories.ThreadFactory()
|
||||
factories.ThreadAccessFactory(
|
||||
mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
|
||||
)
|
||||
event = factories.ThreadEventFactory(thread=thread, author=user, type="im")
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
response = api_client.patch(
|
||||
get_thread_event_url(thread.id, event.id),
|
||||
{"data": {"content": "nope"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
class TestParameterConfusionAttack:
|
||||
"""Test that conflicting thread_id in URL path vs query params can't bypass permissions."""
|
||||
|
||||
@@ -62,9 +62,10 @@ class TestThreadUserListAuthentication:
|
||||
class TestThreadUserListPermissions:
|
||||
"""Test permission matrix for GET /threads/{thread_id}/users/.
|
||||
|
||||
The endpoint uses IsAllowedToManageThreadAccess which requires:
|
||||
- ThreadAccess role = EDITOR on the thread
|
||||
- MailboxAccess role in MAILBOX_ROLES_CAN_EDIT (EDITOR, SENDER, ADMIN)
|
||||
The endpoint backs the mention picker in the comment composer, so it
|
||||
must stay reachable to anyone who can post an ``im`` comment: mailbox
|
||||
role in MAILBOX_ROLES_CAN_EDIT (EDITOR, SENDER, ADMIN) + any
|
||||
ThreadAccess (VIEWER or EDITOR).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -73,12 +74,17 @@ class TestThreadUserListPermissions:
|
||||
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN),
|
||||
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR),
|
||||
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.SENDER),
|
||||
# Thread VIEWER with editor-level mailbox role: allowed because
|
||||
# the user can still author comments and needs to pick mentions.
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
|
||||
],
|
||||
)
|
||||
def test_list_thread_users_allowed(
|
||||
self, api_client, thread_access_role, mailbox_access_role
|
||||
):
|
||||
"""Users with EDITOR thread access + EDITOR/SENDER/ADMIN mailbox role can list."""
|
||||
"""Any ThreadAccess + editor-level MailboxAccess grants list access."""
|
||||
user, _mailbox, thread = setup_user_with_thread_access(
|
||||
thread_role=thread_access_role,
|
||||
mailbox_role=mailbox_access_role,
|
||||
@@ -91,12 +97,9 @@ class TestThreadUserListPermissions:
|
||||
@pytest.mark.parametrize(
|
||||
"thread_access_role, mailbox_access_role",
|
||||
[
|
||||
# VIEWER on thread — regardless of mailbox role
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN),
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR),
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER),
|
||||
# EDITOR on thread but only VIEWER on mailbox
|
||||
# Mailbox VIEWER: mailbox role too low regardless of thread role.
|
||||
(enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER),
|
||||
(enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.VIEWER),
|
||||
],
|
||||
)
|
||||
def test_list_thread_users_forbidden(
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
&::after {
|
||||
content: attr(data-value) ' ';
|
||||
white-space: pre-wrap;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const AttachmentUploader = ({
|
||||
>
|
||||
{t("Add attachments")}
|
||||
</Button>
|
||||
<DriveAttachmentPicker onPick={onDriveAttachmentPick} />
|
||||
<DriveAttachmentPicker onPick={onDriveAttachmentPick} disabled={disabled} />
|
||||
<p className="attachment-uploader__input__helper-text">
|
||||
{t("or drag and drop some files")}
|
||||
</p>
|
||||
|
||||
@@ -97,7 +97,7 @@ export const MessageForm = ({
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const saveDraftRef = useRef<() => void>(() => {});
|
||||
const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined;
|
||||
const { selectedMailbox, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
|
||||
const { selectedMailbox, selectedThread, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
|
||||
const hideSubjectField = Boolean(draftMessage?.parent_id ?? parentMessage);
|
||||
const defaultSenderId = mailboxes?.find((mailbox) => {
|
||||
if (draft?.sender) return draft.sender.email === mailbox.email;
|
||||
@@ -221,8 +221,10 @@ export const MessageForm = ({
|
||||
name: "from",
|
||||
});
|
||||
const currentSender = mailboxes?.find((mailbox) => mailbox.id === currentSenderId);
|
||||
const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, currentSender!);
|
||||
const canWriteMessages = useAbility(Abilities.CAN_WRITE_MESSAGES, currentSender!);
|
||||
const canEditCurrentThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread);
|
||||
const canEditThread = mode === "new" ? true : canEditCurrentThread;
|
||||
const canSendMessages = useAbility(Abilities.CAN_SEND_MESSAGES, currentSender!) && canEditThread;
|
||||
const canWriteMessages = useAbility(Abilities.CAN_WRITE_MESSAGES, currentSender!) && canEditThread;
|
||||
const canChangeSender = !draft || canWriteMessages;
|
||||
|
||||
const initialAttachments = useMemo((): (Attachment | DriveFile)[] => {
|
||||
|
||||
@@ -66,4 +66,3 @@ export const MailboxPanelActions = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ThreadPanelFilter } from "./thread-panel-filter";
|
||||
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
|
||||
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
|
||||
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
|
||||
type ThreadPanelTitleProps = {
|
||||
selectedThreadIds: Set<string>;
|
||||
@@ -101,6 +102,14 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
|
||||
const starLabel = t('Star');
|
||||
const unstarLabel = t('Unstar');
|
||||
|
||||
const canArchive = canEditSelection && !isSpamView && !isTrashedView && !isDraftsView;
|
||||
const canReportSpam = canEditSelection && !isTrashedView && !isSentView && !isDraftsView;
|
||||
const canTrash = canEditSelection && !isDraftsView;
|
||||
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
|
||||
const canAssignLabel = canManageLabels && !isSpamView && !isTrashedView && !isDraftsView;
|
||||
const hasSelectionActions = canArchive || canReportSpam || canTrash || canAssignLabel;
|
||||
|
||||
const countLabel = useMemo(() => {
|
||||
if (isSearch) {
|
||||
if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) {
|
||||
@@ -192,8 +201,8 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
</Tooltip>
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<VerticalSeparator withPadding={false} />
|
||||
{canEditSelection && !isSpamView && !isTrashedView && !isDraftsView && (
|
||||
{hasSelectionActions && <VerticalSeparator withPadding={false} />}
|
||||
{canArchive && (
|
||||
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -213,7 +222,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEditSelection && !isTrashedView && !isSentView && !isDraftsView && (
|
||||
{canReportSpam && (
|
||||
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -233,7 +242,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEditSelection && !isDraftsView && (
|
||||
{canTrash && (
|
||||
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -253,7 +262,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isSpamView && !isTrashedView && !isDraftsView && (
|
||||
{canAssignLabel && (
|
||||
<LabelsWidget
|
||||
threadIds={Array.from(selectedThreadIds)}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import useRead from "@/features/message/use-read"
|
||||
import useMentionRead from "@/features/message/use-mention-read"
|
||||
import { useDebounceCallback } from "@/hooks/use-debounce-callback"
|
||||
import { useVisibilityObserver } from "@/hooks/use-visibility-observer"
|
||||
import { Message, Thread, ThreadAccessRoleChoices, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models"
|
||||
import { MailboxRoleChoices, Message, Thread, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models"
|
||||
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"
|
||||
import { Banner } from "@/features/ui/components/banner"
|
||||
import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link"
|
||||
@@ -363,15 +363,18 @@ export const ThreadView = () => {
|
||||
archived: messagesWithDraftChildren?.filter((m) => m.is_archived).length || 0,
|
||||
total: messagesWithDraftChildren?.length || 0,
|
||||
}), [messagesWithDraftChildren]);
|
||||
// Show IM input for shared mailboxes, or when the current mailbox
|
||||
// has editor access on a thread shared with other mailboxes.
|
||||
const hasEditorAccess = selectedThread?.accesses?.some(
|
||||
access => access.mailbox.id === selectedMailbox?.id
|
||||
&& access.role === ThreadAccessRoleChoices.editor
|
||||
// Show IM input when the user has at least edit rights on the mailbox
|
||||
// (editor/sender/admin), regardless of the thread-level role.
|
||||
// Still gated to shared contexts: either a shared mailbox or a thread
|
||||
// shared across multiple mailboxes.
|
||||
const hasMailboxEditAccess = !!selectedMailbox && (
|
||||
selectedMailbox.role === MailboxRoleChoices.editor
|
||||
|| selectedMailbox.role === MailboxRoleChoices.sender
|
||||
|| selectedMailbox.role === MailboxRoleChoices.admin
|
||||
);
|
||||
const hasMultipleAccesses = (selectedThread?.accesses?.length ?? 0) > 1;
|
||||
const isSharedMailbox = selectedMailbox?.is_identity === false;
|
||||
const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasEditorAccess);
|
||||
const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasMailboxEditAccess);
|
||||
|
||||
// Build filtered timeline items: enrich messages with draft children,
|
||||
// apply trash filtering, and keep all events.
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
--c--globals--font--weights--medium: 500;
|
||||
--c--globals--font--weights--bold: 600;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--weights--black: 800;
|
||||
--c--globals--font--families--base: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--accent: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -556,6 +556,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -936,7 +948,7 @@
|
||||
--c--globals--font--weights--medium: 500;
|
||||
--c--globals--font--weights--bold: 600;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--weights--black: 800;
|
||||
--c--globals--font--families--base: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--accent: Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -1131,6 +1143,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -1500,7 +1524,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -1682,6 +1705,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -2051,7 +2086,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -2233,6 +2267,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -2605,7 +2651,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -2787,6 +2832,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -3159,7 +3216,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--font--families--base: Marianne, Inter, Roboto Flex Variable, sans-serif;
|
||||
--c--globals--spacings--0: 0;
|
||||
@@ -3341,6 +3397,18 @@
|
||||
--c--components--datagrid--header--weight: 500;
|
||||
--c--components--datagrid--body--background-color-hover: var(--c--contextuals--background--semantic--neutral--tertiary);
|
||||
--c--components--forms-checkbox--font-size: var(--c--globals--font--sizes--sm);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
|
||||
@@ -371,7 +371,7 @@ $themes: (
|
||||
'medium': 500,
|
||||
'bold': 600,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'black': 800
|
||||
),
|
||||
'families': (
|
||||
'base': #{Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -706,6 +706,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
@@ -1110,7 +1130,7 @@ $themes: (
|
||||
'medium': 500,
|
||||
'bold': 600,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'black': 800
|
||||
),
|
||||
'families': (
|
||||
'base': #{Hanken Grotesk, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -1445,6 +1465,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
@@ -1835,8 +1875,7 @@ $themes: (
|
||||
),
|
||||
'weights': (
|
||||
'thin': 100,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'extrabold': 800
|
||||
),
|
||||
'families': (
|
||||
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -2158,6 +2197,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
@@ -2548,8 +2607,7 @@ $themes: (
|
||||
),
|
||||
'weights': (
|
||||
'thin': 100,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'extrabold': 800
|
||||
),
|
||||
'families': (
|
||||
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -2871,6 +2929,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
@@ -3264,8 +3342,7 @@ $themes: (
|
||||
),
|
||||
'weights': (
|
||||
'thin': 100,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'extrabold': 800
|
||||
),
|
||||
'families': (
|
||||
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -3587,6 +3664,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
@@ -3980,8 +4077,7 @@ $themes: (
|
||||
),
|
||||
'weights': (
|
||||
'thin': 100,
|
||||
'extrabold': 800,
|
||||
'black': 900
|
||||
'extrabold': 800
|
||||
),
|
||||
'families': (
|
||||
'accent': #{Marianne, Inter, Roboto Flex Variable, sans-serif},
|
||||
@@ -4303,6 +4399,26 @@ $themes: (
|
||||
'forms-checkbox': (
|
||||
'font-size': 0.875rem
|
||||
),
|
||||
'forms-input': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-select': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-textarea': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'forms-datepicker': (
|
||||
'border-radius': 4px,
|
||||
'border-radius--hover': 4px,
|
||||
'border-radius--focus': 4px
|
||||
),
|
||||
'badge': (
|
||||
'font-size': 0.75rem,
|
||||
'border-radius': 12px,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -76,6 +76,11 @@ body {
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
}
|
||||
|
||||
a.c__button.c__button--brand--primary.c__button--disabled {
|
||||
background-color: var(--c--contextuals--background--semantic--disabled--primary);
|
||||
color: var(--c--contextuals--content--semantic--disabled--primary);
|
||||
}
|
||||
|
||||
// Cunningham: force label up for datetime-local/time inputs
|
||||
// The browser always renders native placeholder text for these input types,
|
||||
// which overlaps with Cunningham's floating label in "placeholder" position.
|
||||
|
||||
Reference in New Issue
Block a user