🔥(frontend) remove the sharing management UI

Sharing is managed in Drive now: keeping the share modal, invitations
and role management in Docs would offer two competing places to do the
same thing on the same items. The whole doc-share feature goes away;
only read-only visibility badges remain so users still see at a glance
that a document is public or shared. The 403 page points users to the
document owner instead of an in-app access request, and the shared
button in the grid becomes a passive indicator.
This commit is contained in:
Nathan Vasse
2026-07-30 15:49:29 +02:00
parent 5f5bf1efa2
commit 0a6cfa3a33
43 changed files with 50 additions and 3683 deletions
@@ -1,7 +1,6 @@
import { Box } from '@/components';
import { CardFloatingBar, FloatingBar } from '@/components/FloatingBar';
import { useDocStore } from '@/docs/doc-management/stores/useDocStore';
import { DocShareButton } from '@/features/docs/doc-share/components/DocShareButton';
import { RightPanelCollapseButton } from '@/features/right-panel/components/RightPanelCollapseButton';
import { DocLeftPanelCollapseButton } from './DocLeftPanelCollapseButton';
@@ -15,7 +14,6 @@ export const DocFloatingBar = () => {
<FloatingBar>
<DocLeftPanelCollapseButton />
<Box $direction="row" $align="center" $gap="2xs">
{!isDeletedDoc && currentDoc && <DocShareButton doc={currentDoc} />}
<CardFloatingBar>
<RightPanelCollapseButton />
{!isDeletedDoc && currentDoc && <DocToolBox doc={currentDoc} />}
@@ -14,11 +14,9 @@ import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
import DownloadSVG from '@/assets/icons/ui-kit/download.svg';
import SharedSVG from '@/assets/icons/ui-kit/group.svg';
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import LeaveSVG from '@/assets/icons/ui-kit/leave.svg';
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
import {
@@ -29,11 +27,9 @@ import {
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
useDocUtils,
useDuplicateDoc,
} from '@/docs/doc-management';
import { usePresenterStore } from '@/docs/doc-presenter/stores';
import { useAuth } from '@/features/auth';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -54,24 +50,6 @@ const ModalSelectVersion = dynamic(
{ ssr: false },
);
const DocShareModal = dynamic(
() =>
import('@/docs/doc-share/components/DocShareModal').then((mod) => ({
default: mod.DocShareModal,
})),
{ ssr: false },
);
const ConfirmationLeaveModal = dynamic(
() =>
import('@/docs/doc-share/components/ConfirmationLeaveModal').then(
(mod) => ({
default: mod.ConfirmationLeaveModal,
}),
),
{ ssr: false },
);
const ModalExport =
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? dynamic(
@@ -91,16 +69,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const treeContext = useTreeContext<Doc>();
const router = useRouter();
const { isTopRoot } = useDocUtils(doc);
const isTopParent = doc.id === treeContext?.root?.id; // it can be a child but not for the current user
const { authenticated } = useAuth();
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const [openDropdown, setOpenDropdown] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const { restoreFocus, addLastFocus } = useFocusStore();
const { isMobile } = useResponsiveStore();
@@ -153,14 +127,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon: <AddLinkSVG width={24} height={24} aria-hidden="true" />,
callback: copyDocLink,
},
{
label: t('Share'),
icon: <SharedSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalShareOpen(true);
},
isHidden: !isTopRoot || !authenticated,
},
{
label: t('Download'),
icon: <DownloadSVG width={24} height={24} aria-hidden="true" />,
@@ -201,19 +167,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
isHidden: !doc.abilities.duplicate,
showSeparator: true,
},
{
label: t('Leave'),
icon: <LeaveSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalLeaveOpen(true);
},
/**
* A user can only leave a top parent because we cannot
* leave a child if the parent is not left.
* ⚠️ This doc can still be a child for other users.
*/
isHidden: !isTopParent,
},
{
label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
@@ -289,25 +242,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
doc={doc}
/>
)}
{isModalShareOpen && (
<DocShareModal
onClose={() => {
setIsModalShareOpen(false);
restoreFocus();
}}
doc={doc}
isRootDoc={isTopParent}
/>
)}
{isModalLeaveOpen && (
<ConfirmationLeaveModal
onClose={() => {
setIsModalLeaveOpen(false);
restoreFocus();
}}
doc={doc}
/>
)}
</>
);
};
@@ -6,9 +6,7 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import img403 from '@/assets/icons/icon-403.png';
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
import { ButtonAccessRequest } from '@/docs/doc-share';
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useSkeletonStore } from '@/features/skeletons';
const StyledButton = styled(Button)`
@@ -19,6 +17,7 @@ interface DocProps {
id: string;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const DocPage403 = ({ id }: DocProps) => {
const { t } = useTranslation();
const { setIsSkeletonVisible } = useSkeletonStore();
@@ -28,23 +27,6 @@ export const DocPage403 = ({ id }: DocProps) => {
setIsSkeletonVisible(false);
}, [setIsSkeletonVisible]);
const {
data: requests,
isLoading: isLoadingRequest,
error: docAccessError,
} = useDocAccessRequests({
docId: id,
page: 1,
});
const hasRequested = !!requests?.results.find(
(request) => request.document === id,
);
if (isLoadingRequest) {
return <Loading />;
}
return (
<>
<Head>
@@ -77,24 +59,19 @@ export const DocPage403 = ({ id }: DocProps) => {
<Box $align="center" $gap="0.8rem">
<Text as="p" $textAlign="center" $maxWidth="350px" $theme="brand">
{hasRequested
? t('Your access request for this document is pending.')
: t('Insufficient access rights to view the document.')}
{t('Insufficient access rights to view the document.')}
</Text>
{docAccessError?.status === 404 && (
<Text
as="p"
$maxWidth="320px"
$textAlign="center"
$size="sm"
$margin={{ top: '0' }}
>
{t(
"You're currently viewing a sub-document. To gain access, please request permission from the main document.",
)}
</Text>
)}
{/* Sharing is managed in Drive: ask for access from the Drive app. */}
<Text
as="p"
$maxWidth="320px"
$textAlign="center"
$size="sm"
$margin={{ top: '0' }}
>
{t('Ask the document owner to share it with you from Drive.')}
</Text>
<Box $direction="row" $gap="0.7rem">
<StyledLink href="/">
@@ -106,9 +83,6 @@ export const DocPage403 = ({ id }: DocProps) => {
{t('Home')}
</StyledButton>
</StyledLink>
{docAccessError?.status !== 404 && (
<ButtonAccessRequest docId={id} />
)}
</Box>
</Box>
</Box>
@@ -1,10 +0,0 @@
export * from './useCreateDocAccess';
export * from './useCreateDocInvitation';
export * from './useDeleteDocAccess';
export * from './useDeleteDocInvitation';
export * from './useDocAccesses';
export * from './useDocAccessRequest';
export * from './useDocInvitations';
export * from './useUpdateDocAccess';
export * from './useUpdateDocInvitation';
export * from './useUsers';
@@ -1,71 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import {
Access,
Doc,
KEY_DOC,
KEY_LIST_DOC,
Role,
} from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { OptionType } from '../types';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
import { KEY_LIST_USER } from './useUsers';
interface CreateDocAccessParams {
role: Role;
docId: Doc['id'];
memberId: User['id'];
}
export const createDocAccess = async ({
memberId,
role,
docId,
}: CreateDocAccessParams): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
body: JSON.stringify({
user_id: memberId,
role,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to add the member in the doc.`,
await errorCauses(response, {
type: OptionType.NEW_MEMBER,
}),
);
}
return response.json() as Promise<Access>;
};
export function useCreateDocAccess() {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: (_data, variable) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
void queryClient.resetQueries({
queryKey: [KEY_LIST_USER],
});
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variable.docId}`);
},
});
}
@@ -1,53 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { Invitation, OptionType } from '../types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
interface CreateDocInvitationParams {
email: User['email'];
role: Role;
docId: Doc['id'];
}
export const createDocInvitation = async ({
email,
role,
docId,
}: CreateDocInvitationParams): Promise<Invitation> => {
const response = await fetchAPI(`documents/${docId}/invitations/`, {
method: 'POST',
body: JSON.stringify({
email,
role,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to create the invitation for ${email}`,
await errorCauses(response, {
value: email,
type: OptionType.INVITATION,
}),
);
}
return response.json() as Promise<Invitation>;
};
export function useCreateDocInvitation() {
const queryClient = useQueryClient();
return useMutation<Invitation, APIError, CreateDocInvitationParams>({
mutationFn: createDocInvitation,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
},
});
}
@@ -1,70 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
import { KEY_LIST_USER } from './useUsers';
interface DeleteDocAccessProps {
docId: string;
accessId: string;
}
export const deleteDocAccess = async ({
docId,
accessId,
}: DeleteDocAccessProps): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new APIError(
'Failed to delete the member',
await errorCauses(response),
);
}
};
type UseDeleteDocAccessOptions = UseMutationOptions<
void,
APIError,
DeleteDocAccessProps
>;
export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
const queryClient = useQueryClient();
const { broadcast } = useBroadcastStore();
return useMutation<void, APIError, DeleteDocAccessProps>({
mutationFn: deleteDocAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${variables.docId}`);
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_USER],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
@@ -1,70 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
interface DeleteDocInvitationProps {
docId: string;
invitationId: string;
}
type RemoveDocInvitationError = {
role?: string[];
};
export const deleteDocInvitation = async ({
docId,
invitationId,
}: DeleteDocInvitationProps): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/invitations/${invitationId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the invitation',
await errorCauses(response),
);
}
};
type UseDeleteDocInvitationOptions = UseMutationOptions<
void,
APIError<RemoveDocInvitationError>,
DeleteDocInvitationProps
>;
export const useDeleteDocInvitation = (
options?: UseDeleteDocInvitationOptions,
) => {
const queryClient = useQueryClient();
return useMutation<
void,
APIError<RemoveDocInvitationError>,
DeleteDocInvitationProps
>({
mutationFn: deleteDocInvitation,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, onMutateResult, context);
}
},
});
};
@@ -1,236 +0,0 @@
import {
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { AccessRequest, Doc, Role } from '@/docs/doc-management';
import { OptionType } from '../types';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
interface CreateDocAccessRequestParams {
docId: Doc['id'];
role?: Role;
}
export const createDocAccessRequest = async ({
docId,
role,
}: CreateDocAccessRequestParams): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/ask-for-access/`, {
method: 'POST',
body: JSON.stringify({
role,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to create a request to access to the doc.`,
await errorCauses(response, {
type: OptionType.NEW_MEMBER,
}),
);
}
};
type UseCreateDocAccessRequestOptions = UseMutationOptions<
void,
APIError,
CreateDocAccessRequestParams
>;
export function useCreateDocAccessRequest(
options?: UseCreateDocAccessRequestOptions,
) {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateDocAccessRequestParams>({
mutationFn: createDocAccessRequest,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
void options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
type AccessRequestResponse = APIList<AccessRequest>;
interface DocAccessRequestsParams {
docId: Doc['id'];
}
export type DocAccessRequestsAPIParams = DocAccessRequestsParams & {
page: number;
};
export const getDocAccessRequests = async ({
docId,
page,
}: DocAccessRequestsAPIParams): Promise<AccessRequestResponse> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/?page=${page}`,
);
if (!response.ok) {
throw new APIError(
'Failed to get the doc access requests',
await errorCauses(response),
);
}
return response.json() as Promise<AccessRequestResponse>;
};
export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests';
export function useDocAccessRequests(
params: DocAccessRequestsAPIParams,
queryConfig?: UseQueryOptions<
AccessRequestResponse,
APIError,
AccessRequestResponse
>,
) {
return useQuery<AccessRequestResponse, APIError, AccessRequestResponse>({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS, params],
queryFn: () => getDocAccessRequests(params),
...queryConfig,
});
}
export const useDocAccessRequestsInfinite = (
params: DocAccessRequestsParams,
) => {
return useAPIInfiniteQuery(
KEY_LIST_DOC_ACCESS_REQUESTS,
getDocAccessRequests,
params,
);
};
interface acceptDocAccessRequestsParams {
docId: string;
accessRequestId: string;
role: Role;
}
export const acceptDocAccessRequests = async ({
docId,
accessRequestId,
role,
}: acceptDocAccessRequestsParams): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/${accessRequestId}/accept/`,
{
method: 'POST',
body: JSON.stringify({
role,
}),
},
);
if (!response.ok) {
throw new APIError(
'Failed to accept the access request',
await errorCauses(response),
);
}
};
type UseAcceptDocAccessRequests = Partial<AccessRequest>;
type UseAcceptDocAccessRequestsOptions = UseMutationOptions<
void,
APIError,
UseAcceptDocAccessRequests
>;
export const useAcceptDocAccessRequest = (
options?: UseAcceptDocAccessRequestsOptions,
) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, acceptDocAccessRequestsParams>({
mutationFn: acceptDocAccessRequests,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
interface DeleteDocAccessRequestParams {
docId: string;
accessRequestId: string;
}
export const deleteDocAccessRequest = async ({
docId,
accessRequestId,
}: DeleteDocAccessRequestParams): Promise<void> => {
const response = await fetchAPI(
`documents/${docId}/ask-for-access/${accessRequestId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the access request',
await errorCauses(response),
);
}
};
type UseDeleteDocAccessRequestOptions = UseMutationOptions<
void,
APIError,
DeleteDocAccessRequestParams
>;
export const useDeleteDocAccessRequest = (
options?: UseDeleteDocAccessRequestOptions,
) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteDocAccessRequestParams>({
mutationFn: deleteDocAccessRequest,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
@@ -1,44 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Access } from '@/docs/doc-management';
export type DocAccessesParams = {
docId: string;
ordering?: string;
};
export const getDocAccesses = async ({
docId,
ordering,
}: DocAccessesParams): Promise<Access[]> => {
let url = `documents/${docId}/accesses/`;
if (ordering) {
url += '&ordering=' + ordering;
}
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
'Failed to get the doc accesses',
await errorCauses(response),
);
}
return (await response.json()) as Access[];
};
export const KEY_LIST_DOC_ACCESSES = 'docs-accesses';
export function useDocAccesses(
params: DocAccessesParams,
queryConfig?: UseQueryOptions<Access[], APIError, Access[]>,
) {
return useQuery<Access[], APIError, Access[]>({
queryKey: [KEY_LIST_DOC_ACCESSES, params],
queryFn: () => getDocAccesses(params),
...queryConfig,
});
}
@@ -1,75 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Invitation } from '../types';
export type DocInvitationsParams = {
docId: string;
ordering?: string;
};
export type DocInvitationsAPIParams = DocInvitationsParams & {
page: number;
};
type DocInvitationsResponse = APIList<Invitation>;
export const getDocInvitations = async ({
page,
docId,
ordering,
}: DocInvitationsAPIParams): Promise<DocInvitationsResponse> => {
let url = `documents/${docId}/invitations/?page=${page}`;
if (ordering) {
url += '&ordering=' + ordering;
}
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
'Failed to get the doc accesses',
await errorCauses(response),
);
}
return response.json() as Promise<DocInvitationsResponse>;
};
export const KEY_LIST_DOC_INVITATIONS = 'docs-invitations';
export function useDocInvitations(
params: DocInvitationsAPIParams,
queryConfig?: UseQueryOptions<
DocInvitationsResponse,
APIError,
DocInvitationsResponse
>,
) {
return useQuery<DocInvitationsResponse, APIError, DocInvitationsResponse>({
queryKey: [KEY_LIST_DOC_INVITATIONS, params],
queryFn: () => getDocInvitations(params),
...queryConfig,
});
}
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
*/
export function useDocInvitationsInfinite(params: DocInvitationsParams) {
return useAPIInfiniteQuery(
KEY_LIST_DOC_INVITATIONS,
getDocInvitations,
params,
);
}
@@ -1,45 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_DOC } from '@/docs/doc-management/api';
interface LeaveDocProps {
docId: string;
}
export const leaveDoc = async ({ docId }: LeaveDocProps): Promise<void> => {
const response = await fetchAPI(`documents/${docId}/leave/`, {
method: 'POST',
});
if (!response.ok) {
throw new APIError(
'Failed to leave the document',
await errorCauses(response),
);
}
};
type UseLeaveDocOptions = UseMutationOptions<void, APIError, LeaveDocProps>;
export const useLeaveDoc = (options?: UseLeaveDocOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, LeaveDocProps>({
mutationFn: leaveDoc,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
@@ -1,68 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Access, KEY_DOC, KEY_LIST_DOC, Role } from '@/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
interface UpdateDocAccessProps {
docId: string;
accessId: string;
role: Role;
}
export const updateDocAccess = async ({
docId,
accessId,
role,
}: UpdateDocAccessProps): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, {
method: 'PATCH',
body: JSON.stringify({
role,
}),
});
if (!response.ok) {
throw new APIError('Failed to update role', await errorCauses(response));
}
return response.json() as Promise<Access>;
};
type UseUpdateDocAccess = Partial<Access>;
type UseUpdateDocAccessOptions = UseMutationOptions<
Access,
APIError,
UseUpdateDocAccess
>;
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
return useMutation<Access, APIError, UpdateDocAccessProps>({
mutationFn: updateDocAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
});
};
@@ -1,79 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Role } from '@/docs/doc-management';
import { Invitation } from '../types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
interface UpdateDocInvitationProps {
docId: string;
invitationId: string;
role: Role;
}
type UpdateDocInvitationError = {
role?: string[];
};
export const updateDocInvitation = async ({
docId,
invitationId,
role,
}: UpdateDocInvitationProps): Promise<Invitation> => {
const response = await fetchAPI(
`documents/${docId}/invitations/${invitationId}/`,
{
method: 'PATCH',
body: JSON.stringify({
role,
}),
},
);
if (!response.ok) {
throw new APIError('Failed to update role', await errorCauses(response));
}
return response.json() as Promise<Invitation>;
};
type UseUpdateDocInvitation = Partial<Invitation>;
type UseUpdateDocInvitationOptions = UseMutationOptions<
Invitation,
APIError<UpdateDocInvitationError>,
UseUpdateDocInvitation
>;
export const useUpdateDocInvitation = (
options?: UseUpdateDocInvitationOptions,
) => {
const queryClient = useQueryClient();
return useMutation<
Invitation,
APIError<UpdateDocInvitationError>,
UpdateDocInvitationProps
>({
mutationFn: updateDocInvitation,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC_INVITATIONS],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, onMutateResult, context);
}
},
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
void options.onError(error, variables, onMutateResult, context);
}
},
});
};
@@ -1,60 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, LinkReach, LinkRole } from '@/docs/doc-management';
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
Partial<Pick<Doc, 'link_role'>>;
type UpdateDocLinkResponse = { link_role: LinkRole; link_reach: LinkReach };
export const updateDocLink = async ({
id,
...params
}: UpdateDocLinkParams): Promise<UpdateDocLinkResponse> => {
const response = await fetchAPI(`documents/${id}/link-configuration/`, {
method: 'PUT',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the doc link',
await errorCauses(response),
);
}
return response.json() as Promise<UpdateDocLinkResponse>;
};
type UseUpdateDocLinkOptions = UseMutationOptions<
UpdateDocLinkResponse,
APIError,
UpdateDocLinkParams
> & {
listInvalidQueries?: string[];
};
export function useUpdateDocLink(options?: UseUpdateDocLinkOptions) {
const queryClient = useQueryClient();
return useMutation<UpdateDocLinkResponse, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
options?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}
@@ -1,43 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
export type UsersParams = {
query: string;
docId: Doc['id'];
};
type UsersResponse = User[];
export const getUsers = async ({
query,
docId,
}: UsersParams): Promise<UsersResponse> => {
const queriesParams = [];
queriesParams.push(query ? `q=${encodeURIComponent(query)}` : '');
queriesParams.push(docId ? `document_id=${docId}` : '');
const queryParams = queriesParams.filter(Boolean).join('&');
const response = await fetchAPI(`users/?${queryParams}`);
if (!response.ok) {
throw new APIError('Failed to get the users', await errorCauses(response));
}
return response.json() as Promise<UsersResponse>;
};
export const KEY_LIST_USER = 'users';
export function useUsers(
param: UsersParams,
queryConfig?: UseQueryOptions<UsersResponse, APIError, UsersResponse>,
) {
return useQuery<UsersResponse, APIError, UsersResponse>({
queryKey: [KEY_LIST_USER, param],
queryFn: () => getUsers(param),
...queryConfig,
});
}
@@ -1,15 +0,0 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="desynchro">
<path
id="&#244;&#128;&#153;&#160;"
d="M5.99986 12.2256C5.99986 11.7946 6.25238 11.5791 6.75741 11.5791H10.4145C10.802 11.5791 11.2156 11.4811 11.6553 11.2852C12.0994 11.0893 12.5413 10.8302 12.981 10.508C13.4208 10.1902 13.8235 9.84192 14.1892 9.46314C14.5593 9.08437 14.864 8.70778 15.1035 8.33336L15.4496 7.80438C15.5802 7.59105 15.7718 7.48438 16.0243 7.48438C16.1897 7.48438 16.3334 7.5388 16.4553 7.64765C16.5816 7.76084 16.6447 7.91758 16.6447 8.11785C16.6447 8.30506 16.5837 8.48791 16.4618 8.66641L16.2398 9.01254C15.996 9.37389 15.7086 9.7309 15.3778 10.0835C15.0469 10.4362 14.6964 10.7606 14.3263 11.0566C13.9606 11.357 13.6058 11.6073 13.2619 11.8076C12.9179 12.0122 12.6153 12.1472 12.3541 12.2125V12.2386C12.6153 12.2996 12.9179 12.4324 13.2619 12.637C13.6058 12.8416 13.9606 13.0941 14.3263 13.3945C14.6921 13.695 15.0404 14.0193 15.3712 14.3676C15.7065 14.7203 15.996 15.0794 16.2398 15.4451L16.4618 15.7847C16.5837 15.9632 16.6447 16.1461 16.6447 16.3333C16.6447 16.5292 16.5837 16.6838 16.4618 16.797C16.3443 16.9102 16.1963 16.9668 16.0178 16.9668C15.7696 16.9668 15.5802 16.8601 15.4496 16.6468L15.1035 16.1243C14.864 15.7499 14.5593 15.3711 14.1892 14.988C13.8235 14.6092 13.4208 14.2588 12.981 13.9366C12.5413 13.6188 12.0994 13.3619 11.6553 13.166C11.2156 12.9701 10.802 12.8721 10.4145 12.8721H6.75741C6.25238 12.8721 5.99986 12.6566 5.99986 12.2256ZM14.3068 7.64112C14.1065 7.48438 14.0303 7.32329 14.0782 7.15785C14.1261 6.99677 14.2697 6.88139 14.5092 6.81173L17.0822 6.03459C17.2782 5.97364 17.4392 5.99323 17.5655 6.09337C17.6918 6.1935 17.7505 6.34371 17.7418 6.54398L17.6308 9.23457C17.6221 9.48274 17.5437 9.64818 17.3957 9.7309C17.252 9.81362 17.0822 9.78097 16.8863 9.63294L14.3068 7.64112ZM14.3198 16.7251L16.9516 14.8117C17.1519 14.6637 17.3239 14.6354 17.4675 14.7268C17.6112 14.8182 17.6831 14.9858 17.6831 15.2296L17.7157 17.9268C17.7157 18.127 17.6504 18.2729 17.5198 18.3643C17.3935 18.4601 17.2324 18.4753 17.0365 18.41L14.4896 17.5545C14.2545 17.4805 14.1152 17.3608 14.0717 17.1953C14.0281 17.0299 14.1108 16.8732 14.3198 16.7251Z"
fill="currentColor"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,15 +0,0 @@
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g id="undo">
<path
id="v"
d="M4.6665 12.667V11.3337H9.39984C10.0998 11.3337 10.7082 11.1114 11.2248 10.667C11.7415 10.2225 11.9998 9.66699 11.9998 9.00033C11.9998 8.33366 11.7415 7.7781 11.2248 7.33366C10.7082 6.88921 10.0998 6.66699 9.39984 6.66699H5.19984L6.93317 8.40033L5.99984 9.33366L2.6665 6.00033L5.99984 2.66699L6.93317 3.60033L5.19984 5.33366H9.39984C10.4776 5.33366 11.4026 5.68366 12.1748 6.38366C12.9471 7.08366 13.3332 7.95588 13.3332 9.00033C13.3332 10.0448 12.9471 10.917 12.1748 11.617C11.4026 12.317 10.4776 12.667 9.39984 12.667H4.6665Z"
fill="currentColor"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 730 B

@@ -1,89 +0,0 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Trans, useTranslation } from 'react-i18next';
import { AlertModal, Box, Icon, Text } from '@/components';
import { useDocAccessRequests } from '../api/useDocAccessRequest';
import { ButtonAccessRequest } from './DocShareAccessRequest';
interface AlertModalRequestAccessProps {
docId: string;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
targetDocumentTitle: string;
title: string;
}
export const AlertModalRequestAccess = ({
docId,
isOpen,
onClose,
onConfirm,
targetDocumentTitle,
title,
}: AlertModalRequestAccessProps) => {
const { t } = useTranslation();
const { data: requests } = useDocAccessRequests({
docId,
page: 1,
});
const hasRequested = !!(
requests && requests?.results.find((request) => request.document === docId)
);
return (
<AlertModal
onClose={onClose}
isOpen={isOpen}
title={title}
aria-label={t('Request access modal')}
description={
<>
<Text $display="inline">
<Trans
i18nKey="You don't have permission to move this document to <strong>{{targetDocumentTitle}}</strong>. You need edit access to the destination. Request access, then try again."
values={{
targetDocumentTitle,
}}
components={{ strong: <strong /> }}
/>
</Text>
{hasRequested && (
<Text
$weight="bold"
$margin={{ top: 'sm' }}
$direction="row"
$align="center"
>
<Icon
iconName="person_check"
$margin={{ right: 'xxs' }}
variant="symbols-outlined"
/>
{t('You have already requested access to this document.')}
</Text>
)}
</>
}
confirmLabel={t('Request access')}
onConfirm={onConfirm}
rightActions={
<Box $direction="row" $gap="small">
<Button
aria-label={t('Cancel')}
variant="secondary"
fullWidth
onClick={onClose}
autoFocus
>
{t('Cancel')}
</Button>
<ButtonAccessRequest docId={docId} onClick={onConfirm} />
</Box>
}
/>
);
};
@@ -1,257 +0,0 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useAuth } from '@/features/auth';
import { Doc } from '../../doc-management';
import { useDeleteDocAccess, useDocAccesses } from '../api';
import { useLeaveDoc } from '../api/useLeaveDoc';
import { useWhoAmI } from '../hooks/useWhoAmI';
const ModalStyle = createGlobalStyle`
.c__modal__footer {
margin-top: 0;
}
`;
interface ConfirmationLeaveModalProps {
doc: Doc;
onClose: () => void;
}
export const ConfirmationLeaveModal = ({
doc,
onClose,
}: ConfirmationLeaveModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const { mutate: leaveDoc, isPending: isLeavePending } = useLeaveDoc({
onSuccess: () => {
if (router.pathname !== `/`) {
void router.push('/');
} else {
onClose();
}
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={onClose}
// TODO: add a fix on the Modal component on Cunningham side
// If aria-label is not set the modal add by default [object Object]
aria-label={t('Confirmation to leave the document')}
aria-labelledby="modal-leave-doc-title"
aria-describedby="modal-leave-doc-desc"
rightActions={
<>
{doc.abilities.leave ? (
<ButtonsLeaveDoc
leave={() => leaveDoc({ docId: doc.id })}
isPending={isLeavePending}
onClose={onClose}
/>
) : (
<ButtonsLeaveMemberDoc
doc={doc}
leave={() => leaveDoc({ docId: doc.id })}
isLeavePending={isLeavePending}
onClose={onClose}
/>
)}
</>
}
size={ModalSize.MEDIUM}
title={
<>
<Text
$size="h6"
as="h2"
id="modal-leave-doc-title"
$margin="0"
$align="flex-start"
>
{t('Leave a doc')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the leave modal')}
onClick={onClose}
/>
</Box>
</>
}
>
<ModalStyle />
<Text
id="modal-leave-doc-desc"
className="--docs--modal-leave-doc"
$size="sm"
$variation="secondary"
$display="inline-block"
as="p"
$css={css`
line-height: 1.6;
`}
>
{doc.abilities.leave ? (
<TextModal />
) : (
<TextModalMember docId={doc.id} />
)}
</Text>
</Modal>
);
};
const TextModal = () => {
const { t } = useTranslation();
return (
<Trans t={t}>
This document and <strong>all the sub-documents</strong> will no longer be
visible in your document list and in your search results. The rights that
were given to you on this document will be removed.
</Trans>
);
};
const TextModalMember = ({ docId }: { docId: string }) => {
const { t } = useTranslation();
const { access, isLoading, isError } = useGetMyAccess(docId);
const { isLastOwner } = useWhoAmI(access);
if (isLoading) {
return null;
}
if (isError) {
return (
<Trans t={t}>
Unable to verify your permissions on this document.{' '}
<strong>Leaving has been disabled</strong> until your access level can
be confirmed. Please try again later.
</Trans>
);
}
if (isLastOwner) {
return (
<Trans t={t}>
You cannot leave this document{' '}
<strong>because you are the unique owner</strong>. Add another user with
the owner role to ensure you can transfer ownership before leaving the
document.
</Trans>
);
}
return <TextModal />;
};
/**
* Simple button to leave a doc
* The user is not a member of the doc, he can just leave the doc
*/
const ButtonsLeaveDoc = ({
leave,
isPending,
onClose,
}: {
leave: () => void;
isPending: boolean;
onClose: () => void;
}) => {
const { t } = useTranslation();
return (
<>
<Button
aria-label={t('Cancel leaving the document')}
variant="secondary"
fullWidth
autoFocus
onClick={onClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Confirm leaving the document')}
color="error"
fullWidth
onClick={leave}
disabled={isPending}
>
{t('Leave')}
</Button>
</>
);
};
/**
* The user is a member of the doc, he can leave the doc but
* if he is the last owner, he need to transfer the ownership
* before leaving the doc
*/
const ButtonsLeaveMemberDoc = ({
doc,
leave,
isLeavePending,
onClose,
}: {
doc: Doc;
leave: () => void;
isLeavePending: boolean;
onClose: () => void;
}) => {
const { mutateAsync: deleteDocAccess, isPending: isDeletePending } =
useDeleteDocAccess();
const { access, isLoading, isError } = useGetMyAccess(doc.id);
const { isLastOwner } = useWhoAmI(access);
/**
* If the user is the last owner, or ownership cannot be verified (loading or
* error), we don't display the leave button to avoid failing open.
*/
if (isLastOwner || isLoading || isError) {
return null;
}
return (
<ButtonsLeaveDoc
leave={async () => {
if (access) {
await deleteDocAccess({ docId: doc.id, accessId: access.id });
leave();
} else {
leave();
}
}}
isPending={isDeletePending || isLeavePending}
onClose={onClose}
/>
);
};
const useGetMyAccess = (docId: string) => {
const { user } = useAuth();
const {
data: accesses,
isLoading,
isError,
} = useDocAccesses({
docId,
});
const access = useMemo(() => {
return accesses?.find((access) => access.user.id === user?.id);
}, [accesses, user]);
return { access, isLoading, isError };
};
@@ -1,74 +0,0 @@
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
import Desync from './../assets/desynchro.svg';
import Undo from './../assets/undo.svg';
interface DocDesynchronizedProps {
doc: Doc;
}
export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
onSuccess: () => {
toast(t('The document visibility restored.'), VariantType.SUCCESS, {
duration: 2000,
});
},
});
return (
<Card
$padding="3xs"
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['4xs']}
$theme="brand"
>
<Box
$withThemeInherited
$direction="row"
$align="center"
$gap={spacingsTokens['3xs']}
>
<Desync />
<Text $size="xs" $withThemeInherited $weight="400">
{t('The link sharing rules differ from the parent document')}
</Text>
</Box>
{doc.abilities.accesses_manage && (
<Button
onClick={() =>
updateDocLink({
id: doc.id,
link_reach: doc.ancestors_link_reach,
link_role: doc?.ancestors_link_role || undefined,
})
}
size="small"
color="brand"
variant="tertiary"
icon={<Undo />}
>
{t('Restore')}
</Button>
)}
</Card>
);
};
@@ -1,66 +0,0 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, useDocStore } from '../../doc-management';
import { DocShareMemberItem } from './DocShareMember';
type DocInheritedShareContentProps = {
rawAccesses: Access[];
};
export const DocInheritedShareContent = ({
rawAccesses,
}: DocInheritedShareContentProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { currentDoc } = useDocStore();
// Check if accesses map is empty
const hasAccesses = rawAccesses.length > 0;
if (!hasAccesses) {
return null;
}
return (
<Box
$gap={spacingsTokens.sm}
$padding={{ top: spacingsTokens.sm }}
className="--docs--doc-inherited-share-content"
>
<HorizontalSeparator $margin="none" />
<Box
$gap={spacingsTokens.sm}
$padding={{
horizontal: spacingsTokens.base,
}}
>
<Box $direction="row" $align="center" $gap={spacingsTokens['4xs']}>
<Text $weight="bold" $size="sm">
{t('People with access via the parent document')}
</Text>
<Box>
<StyledLink href={`/docs/${rawAccesses[0].document.id}`}>
<Button
size="small"
icon={<Icon iconName="open_in_new" />}
color="neutral"
variant="tertiary"
/>
</StyledLink>
</Box>
</Box>
{rawAccesses.map((access) => (
<Fragment key={access.id}>
<DocShareMemberItem doc={currentDoc} access={access} isInherited />
</Fragment>
))}
</Box>
</Box>
);
};
@@ -1,156 +0,0 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
import { Access, Doc, Role, useTrans } from '@/docs/doc-management/';
import { useDeleteDocAccess, useDeleteDocInvitation } from '../api';
import { Invitation, isInvitation } from '../types';
type DocRoleDropdownProps = {
doc?: Doc;
access?: Access | Invitation;
canUpdate?: boolean;
currentRole: Role;
message?: string;
onSelectRole: (role: Role) => void;
rolesAllowed?: Role[];
isLastOwner?: boolean;
ariaLabel?: string;
};
export const DocRoleDropdown = ({
canUpdate = true,
currentRole,
message,
onSelectRole,
doc,
rolesAllowed,
access,
isLastOwner = false,
ariaLabel,
}: DocRoleDropdownProps) => {
const { t } = useTranslation();
const { transRole, translatedRoles } = useTrans();
const { toast } = useToastProvider();
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),
VariantType.ERROR,
{
duration: 4000,
},
);
},
});
const { mutate: removeDocAccess } = useDeleteDocAccess({
onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, {
duration: 4000,
});
},
});
const onRemove = () => {
const invitation = isInvitation(access);
if (!doc || !access) {
return;
}
if (invitation) {
removeDocInvitation({ invitationId: access.id, docId: doc.id });
} else {
removeDocAccess({ accessId: access.id, docId: doc.id });
}
};
/**
* When there is a higher role, the rolesAllowed are truncated
* We display a message to indicate that there is a higher role
*/
const topMessage = useMemo(() => {
if (!canUpdate || !rolesAllowed || rolesAllowed.length === 0) {
return message;
}
const allRoles = Object.keys(translatedRoles);
if (rolesAllowed.length < allRoles.length) {
let result = message ? `${message}\n\n` : '';
result += t('This user has access inherited from a parent page.');
return result;
}
return message;
}, [canUpdate, rolesAllowed, translatedRoles, message, t]);
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key, index) => {
const isLast = index === Object.keys(translatedRoles).length - 1;
const isRoleAllowed = rolesAllowed?.includes(key as Role) ?? true;
return {
label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role),
isSelected: currentRole === (key as Role),
showSeparator: isLast,
disabled: (isLastOwner && key !== 'owner') || !isRoleAllowed,
};
},
);
if (!canUpdate) {
return (
<Text
$variation="tertiary"
aria-label={t('Document role text')}
$weight={500}
$size="s"
>
{transRole(currentRole)}
</Text>
);
}
return (
<DropdownMenu
topMessage={topMessage}
label={t('{{action}}, current role: {{role}}', {
action: ariaLabel,
role: transRole(currentRole),
})}
showArrow={true}
arrowCss={css`
color: var(--c--contextuals--content--semantic--brand--tertiary);
font-size: 16px;
`}
testId="doc-role-dropdown"
options={[
...roles,
{
label: t('Remove access'),
disabled: !access?.abilities.destroy,
callback: onRemove,
},
]}
buttonCss={css`
&:hover {
background: none;
}
font-size: 12px;
`}
>
<Text $theme="brand" $variation="tertiary">
{transRole(currentRole)}
</Text>
</DropdownMenu>
);
};
@@ -1,255 +0,0 @@
import {
Button,
ButtonProps,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { MouseEventHandler, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import {
Box,
BoxButton,
HorizontalSeparator,
Icon,
LoadMoreText,
Loading,
Text,
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { AccessRequest, Doc, Role } from '@/docs/doc-management/';
import { useAuth } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import {
useAcceptDocAccessRequest,
useCreateDocAccessRequest,
useDeleteDocAccessRequest,
useDocAccessRequests,
useDocAccessRequestsInfinite,
} from '../api/useDocAccessRequest';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
const QuickSearchGroupAccessRequestStyle = createGlobalStyle`
.quick-search-container .--docs--share-access-request [cmdk-item]:hover,
.quick-search-container .--docs--share-access-request [cmdk-item][data-selected='true'] {
background: inherit;
}
.--docs--doc-share-access-request-item:hover {
background: var(--c--contextuals--background--semantic--contextual--primary);
}
`;
type Props = {
doc: Doc;
accessRequest: AccessRequest;
};
const DocShareAccessRequestItem = ({ doc, accessRequest }: Props) => {
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
const { toast } = useToastProvider();
const { spacingsTokens } = useCunninghamTheme();
const { mutate: acceptDocAccessRequests } = useAcceptDocAccessRequest();
const [role, setRole] = useState(accessRequest.role);
const { mutate: removeDocAccess } = useDeleteDocAccessRequest({
onError: () => {
toast(t('Error while removing the request.'), VariantType.ERROR, {
duration: 4000,
});
},
});
if (!doc.abilities.accesses_view) {
return null;
}
return (
<Box
$width="100%"
data-testid={`doc-share-access-request-row-${accessRequest.user.email}`}
className="--docs--doc-share-access-request-item"
$css={css`
& .--docs--quick-search-item-content {
flex-wrap: wrap;
.--docs--quick-search-item-content-right {
margin-left: auto;
}
}
`}
>
<SearchUserRow
alwaysShowRight={true}
user={accessRequest.user}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['sm']}>
<DocRoleDropdown
currentRole={role}
onSelectRole={setRole}
canUpdate={doc.abilities.accesses_manage}
rolesAllowed={accessRequest.abilities.set_role_to}
ariaLabel={t('Change role for {{name}}', {
name: accessRequest.user.full_name || accessRequest.user.email,
})}
/>
<Button
color="brand"
variant="secondary"
onClick={() =>
acceptDocAccessRequests({
docId: doc.id,
accessRequestId: accessRequest.id,
role,
})
}
size={isSmallMobile ? 'nano' : 'small'}
>
{t('Approve')}
</Button>
{doc.abilities.accesses_manage && (
<BoxButton
onClick={() =>
removeDocAccess({
accessRequestId: accessRequest.id,
docId: doc.id,
})
}
aria-label={t('Close the access request modal')}
>
<Icon iconName="close" $size="16px" />
</BoxButton>
)}
</Box>
}
/>
</Box>
);
};
interface QuickSearchGroupAccessRequestProps {
doc: Doc;
}
export const QuickSearchGroupAccessRequest = ({
doc,
}: QuickSearchGroupAccessRequestProps) => {
const { t } = useTranslation();
const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id });
const accessRequestsData: QuickSearchData<AccessRequest> = useMemo(() => {
const accessRequests =
accessRequestQuery.data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Access Requests'),
elements: accessRequests,
endActions: accessRequestQuery.hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-requests" />,
onSelect: () => void accessRequestQuery.fetchNextPage(),
},
]
: undefined,
};
}, [accessRequestQuery, t]);
if (!accessRequestsData.elements.length) {
return null;
}
return (
<>
<Box
aria-label={t('List request access card')}
className="--docs--share-access-request"
$padding={{ horizontal: 'base' }}
>
<QuickSearchGroupAccessRequestStyle />
<QuickSearchGroup
group={accessRequestsData}
renderElement={(accessRequest) => (
<DocShareAccessRequestItem
doc={doc}
accessRequest={accessRequest}
/>
)}
/>
</Box>
<HorizontalSeparator $margin={{ vertical: 'sm' }} />
</>
);
};
type ButtonAccessRequestProps = {
docId: Doc['id'];
} & Omit<ButtonProps, 'onClick'> & {
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
};
export const ButtonAccessRequest = ({
docId,
onClick,
...buttonProps
}: ButtonAccessRequestProps) => {
const { authenticated } = useAuth();
const {
data: requests,
error: docAccessError,
isLoading,
} = useDocAccessRequests({
docId,
page: 1,
});
const { t } = useTranslation();
const { toast } = useToastProvider();
const { mutate: createRequest } = useCreateDocAccessRequest({
onSuccess: () => {
toast(t('Access request sent successfully.'), VariantType.SUCCESS, {
duration: 3000,
});
},
});
if (!authenticated) {
return null;
}
if (docAccessError?.status === 404) {
return (
<Text $maxWidth="320px" $textAlign="center" $size="sm">
{t(
'As this is a sub-document, please request access to the parent document to enable these features.',
)}
</Text>
);
}
if (isLoading) {
return <Loading $height="auto" />;
}
const hasRequested = !!(
requests && requests?.results.find((request) => request.document === docId)
);
return (
<Button
onClick={(e) => {
createRequest({ docId, role: Role.EDITOR });
onClick?.(e);
}}
disabled={hasRequested}
{...buttonProps}
>
{buttonProps.children || t('Request access')}
</Button>
);
};
@@ -1,171 +0,0 @@
import {
Button,
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
import { DocRoleDropdown } from './DocRoleDropdown';
import { DocShareAddMemberListItem } from './DocShareAddMemberListItem';
type APIErrorUser = APIError<{
value: string;
type: OptionType;
}>;
type Props = {
doc: Doc;
selectedUsers: User[];
onRemoveUser?: (user: User) => void;
onSubmit?: (selectedUsers: User[], role: Role) => void;
afterInvite?: () => void;
};
export const DocShareAddMemberList = ({
doc,
selectedUsers,
onRemoveUser,
afterInvite,
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { isSmallMobile } = useResponsiveStore();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens } = useCunninghamTheme();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
const canShare = doc.abilities.accesses_manage;
const { mutateAsync: createInvitation } = useCreateDocInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
const onError = (dataError: APIErrorUser) => {
let messageError =
dataError['data']?.type === OptionType.INVITATION
? t(`Failed to create the invitation for {{email}}.`, {
email: dataError['data']?.value,
})
: t(`Failed to add the member in the document.`);
if (
dataError.cause?.[0] ===
'Document invitation with this Email address and Document already exists.'
) {
messageError = t('"{{email}}" is already invited to the document.', {
email: dataError['data']?.value,
});
}
if (
dataError.cause?.[0] ===
'This email is already associated to a registered user.'
) {
messageError = t('"{{email}}" is already member of the document.', {
email: dataError['data']?.value,
});
}
toast(messageError, VariantType.ERROR, {
duration: 4000,
});
};
const onInvite = async () => {
setIsLoading(true);
const promises = selectedUsers.map((user) => {
const isInvitationMode = user.id === user.email;
const payload = {
role: invitationRole,
docId: doc.id,
};
return isInvitationMode
? createInvitation({
...payload,
email: user.email.toLowerCase(),
})
: createDocAccess({
...payload,
memberId: user.id,
});
});
const settledPromises = await Promise.allSettled(promises);
settledPromises.forEach((settledPromise) => {
if (settledPromise.status === 'rejected') {
onError(settledPromise.reason as APIErrorUser);
}
});
afterInvite?.();
setIsLoading(false);
};
const inviteLabel =
selectedUsers.length === 1
? t('Invite {{name}}', {
name: selectedUsers[0].full_name || selectedUsers[0].email,
})
: t('Invite {{count}} members', { count: selectedUsers.length });
return (
<Card
className="--docs--doc-share-add-member-list"
data-testid="doc-share-add-member-list"
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'stretch' : 'center'}
$padding={spacingsTokens.sm}
$scope="surface"
$theme="tertiary"
$variation=""
$border="1px solid var(--c--contextuals--border--surface--primary)"
$margin={{ bottom: 'sm' }}
$gap={spacingsTokens.xs}
>
<Box
$direction="row"
$align="center"
$wrap="wrap"
$flex={1}
$gap={spacingsTokens.xs}
>
{selectedUsers.map((user) => (
<DocShareAddMemberListItem
key={user.id}
user={user}
onRemoveUser={onRemoveUser}
/>
))}
</Box>
<Box
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$margin={{ left: isSmallMobile ? 'auto' : '' }}
>
<DocRoleDropdown
canUpdate={canShare}
currentRole={invitationRole}
onSelectRole={setInvitationRole}
ariaLabel={t('Invite new members')}
/>
<Button
onClick={() => void onInvite()}
disabled={isLoading}
aria-label={inviteLabel}
data-testid="doc-share-invite-button"
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Invite')}
</Button>
</Box>
</Card>
);
};
@@ -1,55 +0,0 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
type Props = {
user: User;
onRemoveUser?: (user: User) => void;
};
export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
className="--docs--doc-share-add-member-list-item"
data-testid={`doc-share-add-member-${user.email}`}
$radius={spacingsTokens['3xs']}
$direction="row"
$height="fit-content"
$justify="center"
$align="center"
$gap={spacingsTokens['3xs']}
$padding={{
left: spacingsTokens['xs'],
right: spacingsTokens['4xs'],
vertical: spacingsTokens['4xs'],
}}
$withThemeBG
$theme="neutral"
$variation="secondary"
>
<Text
$withThemeInherited
$size="xs"
$css={css`
line-break: anywhere;
`}
>
{user.full_name || user.email}
</Text>
<BoxButton
onClick={() => onRemoveUser?.(user)}
aria-label={t('Remove {{name}} from the invite list', {
name: user.full_name || user.email,
})}
$withThemeInherited
>
<Icon $withThemeInherited $size="sm" iconName="close" />
</BoxButton>
</Box>
);
};
@@ -1,89 +0,0 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import dynamic from 'next/dynamic';
import { useTranslation } from 'react-i18next';
import SharedSVG from '@/assets/icons/ui-kit/shared.svg';
import { CardFloatingBar } from '@/components/FloatingBar';
import { Doc } from '@/docs/doc-management/types';
import { useAuth } from '@/features/auth';
import { useFocusStore } from '@/stores/useFocusStore';
import { KEY_LIST_DOC_ACCESSES, useDocAccesses } from '../api';
const DocShareModal = dynamic(
() =>
import('./DocShareModal').then((mod) => ({
default: mod.DocShareModal,
})),
{ ssr: false },
);
interface DocShareButtonProps {
doc: Doc;
isDisabled?: boolean;
isHidden?: boolean;
}
export const DocShareButton = ({
doc,
isDisabled,
isHidden,
}: DocShareButtonProps) => {
const { t } = useTranslation();
const { addLastFocus, restoreFocus } = useFocusStore();
const treeContext = useTreeContext<Doc>();
const modalShare = useModal();
const { data: accesses } = useDocAccesses(
{
docId: doc.id,
},
{
enabled: doc.abilities.accesses_view,
queryKey: [KEY_LIST_DOC_ACCESSES, doc.id],
},
);
const { authenticated } = useAuth();
const hasAccesses = !!accesses && accesses.length > 1; // more than the current user
if (isHidden || !authenticated) {
return null;
}
return (
<>
<CardFloatingBar className="--docs--card--share">
<Button
color={hasAccesses ? 'brand' : 'neutral'}
size="small"
variant={hasAccesses ? 'secondary' : 'tertiary'}
onClick={(e) => {
addLastFocus(e.currentTarget);
modalShare.open();
}}
disabled={isDisabled}
icon={
hasAccesses ? (
<SharedSVG width={24} height={24} aria-hidden="true" />
) : undefined
}
aria-label={hasAccesses ? t('Shared') : t('Share')}
data-test="share-button"
>
{hasAccesses ? t('Shared') : t('Share')}
</Button>
</CardFloatingBar>
{modalShare.isOpen && (
<DocShareModal
onClose={() => {
modalShare.close();
restoreFocus();
}}
doc={doc}
isRootDoc={treeContext?.root?.id === doc.id}
/>
)}
</>
);
};
@@ -1,187 +0,0 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
BoxButton,
HorizontalSeparator,
Icon,
LoadMoreText,
Text,
} from '@/components';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useDocInvitationsInfinite, useUpdateDocInvitation } from '../api';
import { Invitation } from '../types';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
type DocShareInvitationItemProps = {
doc: Doc;
invitation: Invitation;
};
export const DocShareInvitationItem = ({
doc,
invitation,
}: DocShareInvitationItemProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const invitedUser: User = {
id: invitation.email,
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
language: 'en-us',
is_first_connection: false,
};
const { toast } = useToastProvider();
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during update invitation'),
VariantType.ERROR,
{
duration: 4000,
},
);
},
});
const onUpdate = (newRole: Role) => {
updateDocInvitation({
docId: doc.id,
role: newRole,
invitationId: invitation.id,
});
};
return (
<Box
$width="100%"
data-testid={`doc-share-invitation-row-${invitation.email}`}
className="--docs--doc-share-invitation-item"
>
<SearchUserRow
isInvitation={true}
alwaysShowRight={true}
user={invitedUser}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
currentRole={invitation.role}
onSelectRole={onUpdate}
canUpdate={canUpdate}
doc={doc}
access={invitation}
ariaLabel={t('Change role for {{email}}', {
email: invitation.email,
})}
/>
</Box>
}
/>
</Box>
);
};
type DocShareModalInviteUserRowProps = {
user: User;
};
export const DocShareModalInviteUserRow = ({
user,
}: DocShareModalInviteUserRowProps) => {
const { t } = useTranslation();
return (
<Box
$width="100%"
data-testid={`search-user-row-${user.email}`}
className="--docs--doc-share-modal-invite-user-row"
>
<SearchUserRow
user={user}
right={
<BoxButton
className="right-hover"
$direction="row"
$align="center"
$css={css`
contain: content;
`}
$theme="brand"
$variation="tertiary"
$gap="var(--c--globals--spacings--xxxs)"
>
<Text $withThemeInherited $size="sm">
{t('Add')}
</Text>
<Icon $withThemeInherited iconName="add" />
</BoxButton>
}
/>
</Box>
);
};
interface QuickSearchGroupInvitationProps {
doc: Doc;
}
export const QuickSearchGroupInvitation = ({
doc,
}: QuickSearchGroupInvitationProps) => {
const { t } = useTranslation();
const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({
docId: doc.id,
});
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
const invitations = data?.pages.flatMap((page) => page.results) || [];
return {
groupName: t('Pending invitations'),
elements: invitations,
endActions: hasNextPage
? [
{
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void fetchNextPage(),
},
]
: undefined,
};
}, [data?.pages, fetchNextPage, hasNextPage, t]);
if (!invitationsData.elements.length) {
return null;
}
return (
<>
<Box
aria-label={t('List invitation card')}
$padding={{ horizontal: 'base' }}
>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem doc={doc} invitation={invitation} />
)}
/>
</Box>
<HorizontalSeparator $margin={{ vertical: 'sm' }} />
</>
);
};
@@ -1,135 +0,0 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { QuickSearchData } from '@/components/quick-search';
import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { useDocAccesses, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/';
import { DocRoleDropdown } from './DocRoleDropdown';
import { SearchUserRow } from './SearchUserRow';
type Props = {
doc?: Doc;
access: Access;
isInherited?: boolean;
};
export const DocShareMemberItem = ({
doc,
access,
isInherited = false,
}: Props) => {
const { t } = useTranslation();
const { isLastOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
const { spacingsTokens } = useCunninghamTheme();
const message = isLastOwner
? t(
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
)
: undefined;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onError: () => {
toast(t('Error while updating the member role.'), VariantType.ERROR, {
duration: 4000,
});
},
});
const onUpdate = (newRole: Role) => {
if (!doc) {
return;
}
updateDocAccess({
docId: doc.id,
role: newRole,
accessId: access.id,
});
};
const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage;
return (
<Box
$width="100%"
data-testid={`doc-share-member-row-${access.user.email}`}
className="--docs--doc-share-member-item"
>
<SearchUserRow
alwaysShowRight={true}
user={access.user}
right={
<Box $direction="row" $align="center" $gap={spacingsTokens['2xs']}>
<DocRoleDropdown
currentRole={isInherited ? access.max_role : access.role}
onSelectRole={onUpdate}
isLastOwner={isLastOwner}
canUpdate={canUpdate}
message={message}
rolesAllowed={access.abilities.set_role_to}
access={access}
doc={doc}
ariaLabel={t('Change role for {{name}}', {
name: access.user.full_name || access.user.email,
})}
/>
</Box>
}
/>
</Box>
);
};
interface QuickSearchGroupMemberProps {
doc: Doc;
}
export const QuickSearchGroupMember = ({
doc,
}: QuickSearchGroupMemberProps) => {
const { t } = useTranslation();
const membersQuery = useDocAccesses({
docId: doc.id,
});
const membersData: QuickSearchData<Access> = useMemo(() => {
const members = membersQuery.data || [];
const count = members.length;
return {
groupName:
count === 1
? t('Document owner')
: t('Share with {{count}} users', {
count: count,
}),
elements: members,
endActions: undefined,
};
}, [membersQuery.data, t]);
return (
<Box
aria-label={t('List members card')}
$padding={{ horizontal: 'base', bottom: '3xs' }}
>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
);
};
@@ -1,398 +0,0 @@
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { announce } from '@react-aria/live-announcer';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
import {
QuickSearch,
QuickSearchData,
QuickSearchGroup,
} from '@/components/quick-search/';
import { useConfig } from '@/core';
import { Doc } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import { isValidEmail } from '@/utils';
import {
KEY_LIST_DOC_ACCESSES,
KEY_LIST_DOC_ACCESS_REQUESTS,
KEY_LIST_DOC_INVITATIONS,
KEY_LIST_USER,
useDocAccesses,
useUsers,
} from '../api';
import { DocInheritedShareContent } from './DocInheritedShareContent';
import {
ButtonAccessRequest,
QuickSearchGroupAccessRequest,
} from './DocShareAccessRequest';
import { DocShareAddMemberList } from './DocShareAddMemberList';
import {
DocShareModalInviteUserRow,
QuickSearchGroupInvitation,
} from './DocShareInvitation';
import { QuickSearchGroupMember } from './DocShareMember';
import { DocShareModalFooter } from './DocShareModalFooter';
const ShareModalStyle = createGlobalStyle`
.--docs--doc-share-modal [cmdk-item] {
cursor: auto;
}
.c__modal__title {
padding-bottom: 0 !important;
}
`;
type Props = {
doc: Doc;
isRootDoc?: boolean;
onClose: () => void;
};
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const { t } = useTranslation();
const selectedUsersRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: config } = useConfig();
const API_USERS_SEARCH_QUERY_MIN_LENGTH =
config?.API_USERS_SEARCH_QUERY_MIN_LENGTH || 5;
const { isLargeScreen } = useResponsiveStore();
/**
* The modal content height is calculated based on the viewport height.
* The formula is:
* 100dvh - 2em - 12px - 34px
* - 34px is the height of the modal title in mobile
* - 2em is the padding of the modal content
* - 12px is the padding of the modal footer
* - 690px is the height of the content in desktop
* This ensures that the modal content is always visible and does not overflow.
*/
const modalContentHeight = isLargeScreen
? 'min(690px, calc(100dvh - 2em - 12px - 34px))'
: `calc(100dvh - 34px)`;
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState('');
const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage && isRootDoc;
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue;
const onSelect = (user: User) => {
setSelectedUsers((prev) => [...prev, user]);
setUserQuery('');
setInputValue('');
const userName = user.full_name || user.email;
announce(
t(
'{{name}} added to invite list. Add more members or press Tab to select role and invite.',
{
name: userName,
},
),
'polite',
);
};
const { data: membersQuery } = useDocAccesses({
docId: doc.id,
});
const searchUsersQuery = useUsers(
{ query: userQuery, docId: doc.id },
{
enabled: userQuery?.length >= API_USERS_SEARCH_QUERY_MIN_LENGTH,
queryKey: [KEY_LIST_USER, { query: userQuery }],
},
);
const onFilter = useDebouncedCallback((str: string) => {
setUserQuery(str);
}, 300);
const onRemoveUser = (row: User) => {
setSelectedUsers((prevState) => {
const index = prevState.findIndex((value) => value.id === row.id);
if (index < 0) {
return prevState;
}
const newArray = [...prevState];
newArray.splice(index, 1);
const userName = row.full_name || row.email;
announce(
t('{{name}} removed from invite list', {
name: userName,
}),
'polite',
);
return newArray;
});
};
const handleRef = (node: HTMLDivElement) => {
const inputHeight = canShare ? 70 : 0;
const marginTop = 11;
const footerHeight = node?.clientHeight ?? 0;
const selectedUsersHeight = selectedUsersRef.current?.clientHeight ?? 0;
const height = `calc(${modalContentHeight} - ${footerHeight}px - ${selectedUsersHeight}px - ${inputHeight}px - ${marginTop}px)`;
setListHeight(height);
};
const inheritedAccesses = useMemo(() => {
return (
membersQuery?.filter((access) => access.document.id !== doc.id) ?? []
);
}, [membersQuery, doc.id]);
const showInheritedShareContent =
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
// Invalidate relevant queries to ensure fresh data on modal open
useEffect(() => {
[
KEY_LIST_DOC_INVITATIONS,
KEY_LIST_DOC_ACCESS_REQUESTS,
KEY_LIST_DOC_ACCESSES,
].forEach((key) => {
void queryClient.invalidateQueries({
queryKey: [key],
});
});
}, [queryClient]);
return (
<>
<Modal
isOpen
closeOnClickOutside
data-testid="doc-share-modal"
aria-label={t('Share the document')}
size={isLargeScreen ? ModalSize.LARGE : ModalSize.FULL}
aria-modal="true"
onClose={onClose}
title={
<Box $direction="row" $justify="space-between" $align="center">
<Text
as="h1"
id="doc-share-modal-title"
$align="flex-start"
$size="small"
$weight="600"
$margin="0"
>
{t('Share the document')}
</Text>
<ButtonCloseModal
aria-label={t('Close the share modal')}
onClick={onClose}
/>
</Box>
}
hideCloseButton
>
<ShareModalStyle />
<Box
$height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
$overflow="hidden"
className="--docs--doc-share-modal noPadding "
$justify="space-between"
>
<Box
$flex={1}
$css={css`
[cmdk-list] {
overflow-y: auto;
height: ${listHeight};
}
`}
>
<Box ref={selectedUsersRef}>
{canShare && selectedUsers.length > 0 && (
<Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
<DocShareAddMemberList
doc={doc}
selectedUsers={selectedUsers}
onRemoveUser={onRemoveUser}
afterInvite={() => {
setUserQuery('');
setInputValue('');
setSelectedUsers([]);
}}
/>
</Box>
)}
{!canViewAccesses && (
<HorizontalSeparator $margin={{ vertical: 'sm' }} />
)}
</Box>
<Box data-testid="doc-share-quick-search">
{!canViewAccesses && (
<Box
$height={listHeight}
$align="center"
$justify="center"
$gap="1rem"
>
<Text
$maxWidth="320px"
$textAlign="center"
$variation="secondary"
$size="sm"
as="p"
>
{t(
'You can view this document but need additional access to see its members or modify settings.',
)}
</Text>
<ButtonAccessRequest
docId={doc.id}
variant="secondary"
size="small"
/>
</Box>
)}
{canViewAccesses && (
<QuickSearch
label={t('Search results')}
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')}
>
{showInheritedShareContent && (
<DocInheritedShareContent
rawAccesses={
membersQuery?.filter(
(access) => access.document.id !== doc.id,
) ?? []
}
/>
)}
{showMemberSection && isRootDoc && (
<Box $padding={{ top: 'base' }}>
<QuickSearchGroupAccessRequest doc={doc} />
<QuickSearchGroupInvitation doc={doc} />
<QuickSearchGroupMember doc={doc} />
</Box>
)}
{!showMemberSection && canShare && (
<QuickSearchInviteInputSection
searchUsersRawData={searchUsersQuery.data}
onSelect={onSelect}
userQuery={userQuery}
minLength={API_USERS_SEARCH_QUERY_MIN_LENGTH}
/>
)}
</QuickSearch>
)}
</Box>
</Box>
<Box ref={handleRef}>
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />}
</Box>
</Box>
</Modal>
</>
);
};
interface QuickSearchInviteInputSectionProps {
onSelect: (usr: User) => void;
searchUsersRawData: User[] | undefined;
userQuery: string;
minLength: number;
}
const QuickSearchInviteInputSection = ({
onSelect,
searchUsersRawData,
userQuery,
minLength,
}: QuickSearchInviteInputSectionProps) => {
const { t } = useTranslation();
const hint = useMemo(() => {
if (userQuery.length < minLength) {
return t('Type at least {{minLength}} characters to display user names', {
minLength,
});
}
if (isValidEmail(userQuery)) {
return t('Choose the email');
}
if (!searchUsersRawData?.length) {
return t('No results. Type a full email address to invite someone.');
}
return t('Choose a user');
}, [minLength, searchUsersRawData?.length, t, userQuery]);
useEffect(() => {
announce(hint, 'polite');
}, [hint]);
const searchUserData: QuickSearchData<User> = useMemo(() => {
const users = searchUsersRawData || [];
const isEmail = isValidEmail(userQuery);
const newUser: User = {
id: userQuery,
full_name: '',
email: userQuery,
short_name: '',
language: '',
is_first_connection: false,
};
const hasEmailInUsers = users.some(
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
);
return {
groupName: hint,
elements: users,
endActions:
isEmail && !hasEmailInUsers
? [
{
content: <DocShareModalInviteUserRow user={newUser} />,
onSelect: () => void onSelect(newUser),
},
]
: undefined,
};
}, [searchUsersRawData, userQuery, hint, onSelect]);
return (
<Box
aria-label={t('List search user result card')}
$padding={{ horizontal: 'base', bottom: '3xs', top: 'base' }}
>
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
/>
</Box>
);
};
@@ -1,50 +0,0 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator, Icon } from '@/components';
import { Doc, useCopyDocLink } from '@/docs/doc-management';
import { DocVisibility } from './DocVisibility';
type DocShareModalFooterProps = {
doc: Doc;
onClose: () => void;
};
export const DocShareModalFooter = ({
doc,
onClose,
}: DocShareModalFooterProps) => {
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation();
return (
<Box
$css={css`
flex-shrink: 0;
`}
className="--docs--doc-share-modal-footer"
>
<HorizontalSeparator $margin={{ vertical: 'sm' }} />
<DocVisibility doc={doc} />
<HorizontalSeparator $margin={{ vertical: 'sm' }} />
<Box
$direction="row"
$justify="space-between"
$padding={{ horizontal: 'base', bottom: 'base' }}
>
<Button
fullWidth={false}
onClick={copyDocLink}
variant="secondary"
icon={<Icon iconName="add_link" $withThemeInherited />}
>
{t('Copy link')}
</Button>
<Button onClick={onClose}>{t('OK')}</Button>
</Box>
</Box>
);
};
@@ -1,250 +0,0 @@
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
Text,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
LinkReach,
LinkRole,
getDocLinkReach,
getDocLinkRole,
useDocUtils,
} from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useUpdateDocLink } from '../api/useUpdateDocLink';
import { useTranslatedShareSettings } from '../hooks/';
import { DocDesynchronized } from './DocDesynchronized';
interface DocVisibilityProps {
doc: Doc;
}
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
const canManage = doc.abilities.accesses_manage;
const docLinkReach = getDocLinkReach(doc);
const docLinkRole = getDocLinkRole(doc);
const { isDesynchronized } = useDocUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const { toast } = useToastProvider();
const description =
docLinkRole === LinkRole.READER
? linkReachChoices[docLinkReach].descriptionReadOnly
: linkReachChoices[docLinkReach].descriptionEdit;
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
onSuccess: () => {
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 2000,
},
);
},
});
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {
return Object.values(LinkReach).map((key) => {
const isDisabled = doc.abilities.link_select_options[key] === undefined;
let linkRole = undefined;
if (key !== LinkReach.RESTRICTED) {
linkRole = docLinkRole;
}
return {
label: linkReachTranslations[key],
callback: () =>
updateDocLink({
id: doc.id,
link_reach: key,
link_role: linkRole,
}),
isSelected: docLinkReach === key,
disabled: isDisabled,
};
});
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
docLinkRole,
linkReachTranslations,
updateDocLink,
]);
const haveDisabledOptions = linkReachOptions.some(
(option) => option.disabled,
);
const showLinkRoleOptions =
docLinkReach !== LinkReach.RESTRICTED && docLinkRole;
const linkRoleOptions: DropdownMenuOption[] = useMemo(() => {
const options = doc.abilities.link_select_options[docLinkReach] ?? [];
return Object.values(LinkRole).map((key) => {
const isDisabled = !options.includes(key);
return {
label: linkModeTranslations[key],
callback: () =>
updateDocLink({
id: doc.id,
link_role: key,
link_reach: docLinkReach,
}),
isSelected: docLinkRole === key,
disabled: isDisabled,
};
});
}, [
doc.abilities.link_select_options,
doc.id,
docLinkReach,
docLinkRole,
linkModeTranslations,
updateDocLink,
]);
const haveDisabledLinkRoleOptions = linkRoleOptions.some(
(option) => option.disabled,
);
return (
<Box
$padding={{ horizontal: 'base' }}
aria-label={t('Doc visibility card')}
$gap={spacingsTokens['base']}
className="--docs--doc-visibility"
>
<Text as="h2" $weight="700" $size="sm" $margin="none">
{t('Link settings')}
</Text>
{isDesynchronized && <DocDesynchronized doc={doc} />}
<Box
$direction="row"
$align="center"
$justify="space-between"
$gap={spacingsTokens['xs']}
$width="100%"
$wrap="nowrap"
>
<Box
$direction="row"
$align={isDesktop ? 'center' : undefined}
$padding={{ horizontal: '2xs' }}
$gap={canManage ? spacingsTokens['3xs'] : spacingsTokens['base']}
>
<DropdownMenu
testId="doc-visibility"
label={t('Document visibility')}
arrowCss={css`
color: var(
--c--contextuals--content--semantic--brand--tertiary
) !important;
`}
buttonCss={css`
&:hover {
background-color: unset;
}
`}
disabled={!canManage}
showArrow={true}
topMessage={
haveDisabledOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
options={linkReachOptions}
>
<Box
$theme={canManage ? 'brand' : 'neutral'}
$variation="tertiary"
$direction="row"
$align="center"
$gap={spacingsTokens['3xs']}
>
<Icon
iconName={linkReachChoices[docLinkReach].icon}
$withThemeInherited
/>
<Text $weight="500" $size="md" $withThemeInherited>
{linkReachChoices[docLinkReach].label}
</Text>
</Box>
</DropdownMenu>
{isDesktop && (
<Text $size="xs" $variation="secondary" $weight="400">
{description}
</Text>
)}
</Box>
{showLinkRoleOptions && (
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
<DropdownMenu
testId="doc-access-mode"
buttonCss={css`
&:hover {
background-color: unset;
}
font-size: 12px;
`}
arrowCss={css`
color: var(
--c--contextuals--content--semantic--brand--tertiary
) !important;
font-size: 16px;
`}
disabled={!canManage}
showArrow={true}
options={linkRoleOptions}
topMessage={
haveDisabledLinkRoleOptions
? t(
'You cannot restrict access to a subpage relative to its parent page.',
)
: undefined
}
label={t('Document access mode')}
>
<Text
$weight="initial"
$theme={!canManage ? 'neutral' : 'brand'}
$variation={!canManage ? 'secondary' : 'tertiary'}
>
{linkModeTranslations[docLinkRole]}
</Text>
</DropdownMenu>
</Box>
)}
</Box>
{!isDesktop && (
<Text $size="xs" $variation="secondary">
{description}
</Text>
)}
</Box>
);
};
@@ -1,69 +0,0 @@
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import {
QuickSearchItemContent,
QuickSearchItemContentProps,
} from '@/components/quick-search';
import { useCunninghamTheme } from '@/cunningham';
import { User, UserAvatar } from '@/features/auth';
type Props = {
user: User;
alwaysShowRight?: boolean;
right?: QuickSearchItemContentProps['right'];
isInvitation?: boolean;
};
export const SearchUserRow = ({
user,
right,
alwaysShowRight = false,
isInvitation = false,
}: Props) => {
const hasFullName = !!user.full_name;
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
return (
<QuickSearchItemContent
right={right}
alwaysShowRight={alwaysShowRight}
left={
<Box
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
className="--docs--search-user-row"
>
<UserAvatar
fullName={user.full_name || user.email}
background={isInvitation ? colorsTokens['gray-400'] : undefined}
/>
<Box $direction="column">
<Text
$size="sm"
$weight="500"
$css={css`
line-break: anywhere;
`}
>
{hasFullName ? user.full_name : user.email}
</Text>
{hasFullName && (
<Text
$size="xs"
$margin={{ top: '-2px' }}
$variation="secondary"
$css={css`
line-break: anywhere;
`}
>
{user.email}
</Text>
)}
</Box>
</Box>
}
/>
);
};
@@ -1,3 +0,0 @@
export * from './AlertModalRequestAccess';
export * from './DocShareModal';
export * from './DocShareAccessRequest';
@@ -1,2 +0,0 @@
export * from './useTranslatedShareSettings';
export * from './useWhoAmI';
@@ -1,52 +0,0 @@
import { useTranslation } from 'react-i18next';
import { LinkReach, LinkRole } from '@/docs/doc-management/types';
export const useTranslatedShareSettings = () => {
const { t } = useTranslation();
const linkReachTranslations = {
[LinkReach.RESTRICTED]: t('Private'),
[LinkReach.AUTHENTICATED]: t('Connected'),
[LinkReach.PUBLIC]: t('Public'),
};
const linkModeTranslations = {
[LinkRole.READER]: t('Reading'),
[LinkRole.EDITOR]: t('Editing'),
};
const linkReachChoices = {
[LinkReach.RESTRICTED]: {
label: linkReachTranslations[LinkReach.RESTRICTED],
icon: 'lock',
value: LinkReach.RESTRICTED,
descriptionReadOnly: t('Only invited people can access'),
descriptionEdit: t('Only invited people can access'),
},
[LinkReach.AUTHENTICATED]: {
label: linkReachTranslations[LinkReach.AUTHENTICATED],
icon: 'vpn_lock',
value: LinkReach.AUTHENTICATED,
descriptionReadOnly: t(
'Anyone with the link can view the document if they are logged in',
),
descriptionEdit: t(
'Anyone with the link can edit the document if they are logged in',
),
},
[LinkReach.PUBLIC]: {
label: linkReachTranslations[LinkReach.PUBLIC],
icon: 'public',
value: LinkReach.PUBLIC,
descriptionReadOnly: t('Anyone with the link can see the document'),
descriptionEdit: t('Anyone with the link can edit the document'),
},
};
return {
linkReachTranslations,
linkModeTranslations,
linkReachChoices,
};
};
@@ -1,28 +0,0 @@
import { Access, Role } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
export const useWhoAmI = (access?: Access) => {
const { user } = useAuth();
if (!access) {
return {
isLastOwner: false,
isOtherOwner: false,
isMyself: false,
};
}
const isMyself = user?.id === access.user.id;
const rolesAllowed = access.abilities.set_role_to;
const isLastOwner =
!rolesAllowed.length && access.role === Role.OWNER && isMyself;
const isOtherOwner = access.role === Role.OWNER && user?.id && !isMyself;
return {
isLastOwner,
isOtherOwner,
isMyself,
};
};
@@ -1,4 +0,0 @@
export * from './api';
export * from './components';
export * from './hooks';
export * from './types';
@@ -1,51 +0,0 @@
import { Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
export interface Invitation {
id: string;
role: Role;
document: string;
created_at: string;
is_expired: boolean;
issuer: string;
email: string;
abilities: {
destroy: boolean;
retrieve: boolean;
partial_update: boolean;
update: boolean;
};
}
/**
* Type guard to check if an object is an Invitation
* Invitation has unique properties: email, issuer, is_expired, and document as a string
*/
export const isInvitation = (obj: unknown): obj is Invitation => {
return obj !== null && typeof obj === 'object' && 'issuer' in obj;
};
export enum OptionType {
INVITATION = 'invitation',
NEW_MEMBER = 'new_member',
}
export const isOptionNewMember = (
data: OptionSelect,
): data is OptionNewMember => {
return 'id' in data.value;
};
export interface OptionInvitation {
value: { email: string };
label: string;
type: OptionType.INVITATION;
}
export interface OptionNewMember {
value: User;
label: string;
type: OptionType.NEW_MEMBER;
}
export type OptionSelect = OptionNewMember | OptionInvitation;
@@ -21,14 +21,6 @@ import { useResponsiveStore } from '@/stores';
import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem';
const AlertModalRequestAccess = dynamic(
() =>
import('@/docs/doc-share/components/AlertModalRequestAccess').then(
(mod) => ({ default: mod.AlertModalRequestAccess }),
),
{ ssr: false },
);
const ModalConfirmationMoveDoc = dynamic(
() =>
import('./ModalConfimationMoveDoc').then((mod) => ({
@@ -96,7 +88,6 @@ export const DocMoveModal = ({
const { untitledDocument } = useTrans();
const docTargetTitle = docSelected?.title || untitledDocument;
const modalConfirmation = useModal();
const modalRequest = useModal();
const { mutateAsync: moveDoc } = useMoveDoc();
const [search, setSearch] = useState('');
const { isDesktop, isTablet, isMobile } = useResponsiveStore();
@@ -155,7 +146,6 @@ export const DocMoveModal = ({
fullWidth
onClick={() => {
if (!docSelected?.abilities.move) {
modalRequest.open();
return;
}
@@ -292,19 +282,6 @@ export const DocMoveModal = ({
targetDocumentTitle={docTargetTitle}
/>
)}
{modalRequest.isOpen && docSelected?.id && (
<AlertModalRequestAccess
docId={docSelected.id}
isOpen={modalRequest.isOpen}
onClose={modalRequest.onClose}
onConfirm={() => {
modalRequest.onClose();
onClose();
}}
targetDocumentTitle={docTargetTitle}
title={t('Move document')}
/>
)}
</>
);
};
@@ -7,10 +7,8 @@ import { useTranslation } from 'react-i18next';
import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg';
import DeleteSVG from '@/assets/icons/ui-kit/delete.svg';
import DocMoveInSVG from '@/assets/icons/ui-kit/doc-move-in.svg';
import GroupSVG from '@/assets/icons/ui-kit/group.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import LeaveSVG from '@/assets/icons/ui-kit/leave.svg';
import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg';
import {
Doc,
@@ -26,14 +24,6 @@ import { useFocusStore } from '@/stores';
import { DocMoveModal } from './DocMoveModal';
const DocShareModal = dynamic(
() =>
import('@/docs/doc-share/components/DocShareModal').then((mod) => ({
default: mod.DocShareModal,
})),
{ ssr: false },
);
const ModalRemoveDoc = dynamic(
() =>
import('@/docs/doc-management/components/ModalRemoveDoc').then((mod) => ({
@@ -42,16 +32,6 @@ const ModalRemoveDoc = dynamic(
{ ssr: false },
);
const ConfirmationLeaveModal = dynamic(
() =>
import('@/docs/doc-share/components/ConfirmationLeaveModal').then(
(mod) => ({
default: mod.ConfirmationLeaveModal,
}),
),
{ ssr: false },
);
interface DocsGridActionsProps {
doc: Doc;
}
@@ -61,8 +41,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { restoreFocus, addLastFocus } = useFocusStore();
const [openDropdown, setOpenDropdown] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false);
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalMoveOpen, setIsModalMoveOpen] = useState(false);
const { untitledDocument } = useTrans();
@@ -99,15 +77,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
showSeparator: true,
},
{
label: t('Share'),
icon: <GroupSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalShareOpen(true);
},
testId: `docs-grid-actions-share-${doc.id}`,
},
{
label: t('Move into a doc'),
icon: <DocMoveInSVG width={24} height={24} aria-hidden="true" />,
@@ -130,13 +99,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
},
showSeparator: true,
},
{
label: t('Leave'),
icon: <LeaveSVG width={24} height={24} aria-hidden="true" />,
callback: () => {
setIsModalLeaveOpen(true);
},
},
{
label: t('Delete'),
icon: <DeleteSVG width={24} height={24} aria-hidden="true" />,
@@ -186,24 +148,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
doc={doc}
/>
)}
{isModalShareOpen && (
<DocShareModal
doc={doc}
onClose={() => {
setIsModalShareOpen(false);
restoreFocus();
}}
/>
)}
{isModalLeaveOpen && (
<ConfirmationLeaveModal
onClose={() => {
setIsModalLeaveOpen(false);
restoreFocus();
}}
doc={doc}
/>
)}
{isModalMoveOpen && (
<DocMoveModal
doc={doc}
@@ -1,84 +1,57 @@
import { Button, Tooltip, useModal } from '@gouvfr-lasuite/cunningham-react';
import dynamic from 'next/dynamic';
import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { Doc } from '@/docs/doc-management';
import { useFocusStore } from '@/stores';
const DocShareModal = dynamic(
() =>
import('@/docs/doc-share/components/DocShareModal').then((mod) => ({
default: mod.DocShareModal,
})),
{ ssr: false },
);
type Props = {
doc: Doc;
disabled: boolean;
};
/**
* Read-only sharing indicator: sharing is managed in Drive, so this badge
* only displays the number of accesses known by Drive.
*/
export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
const { t } = useTranslation();
const sharedCount = doc.nb_accesses_direct;
const isShared = sharedCount - 1 > 0;
const shareModal = useModal();
const { addLastFocus, restoreFocus } = useFocusStore();
if (!isShared) {
return <Box $minWidth="50px">&nbsp;</Box>;
}
return (
<>
<Tooltip
content={
<Text $textAlign="center">
{t('Shared with {{count}} users', { count: sharedCount })}
</Text>
}
placement="top"
className="--docs--doc-tooltip-grid-item-shared-button"
<Tooltip
content={
<Text $textAlign="center">
{t('Shared with {{count}} users', { count: sharedCount })}
</Text>
}
placement="top"
className="--docs--doc-tooltip-grid-item-shared-button"
>
<Box
className="--docs--doc-grid-item-shared-button"
$direction="row"
$align="center"
$gap="2px"
style={{
padding: `0 var(--c--globals--spacings--xxxs)`,
}}
>
<Button
className="--docs--doc-grid-item-shared-button"
aria-label={t('Open the sharing settings for the document')}
data-testid={`docs-grid-item-shared-button-${doc.id}`}
style={{
padding: `0 var(--c--globals--spacings--xxxs) 0 var(--c--globals--spacings--xxxs)`,
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addLastFocus(event.currentTarget);
shareModal.open();
}}
color="brand"
variant="secondary"
size="nano"
icon={
<Icon
$theme="brand"
$variation="secondary"
iconName="group"
disabled={disabled}
variant="filled"
/>
}
<Icon
$theme="brand"
$variation="secondary"
iconName="group"
disabled={disabled}
>
{sharedCount}
</Button>
</Tooltip>
{shareModal.isOpen && (
<DocShareModal
doc={doc}
onClose={() => {
shareModal.close();
restoreFocus();
}}
variant="filled"
/>
)}
</>
<Text $theme="brand" $variation="secondary" $size="xs">
{sharedCount}
</Text>
</Box>
</Tooltip>
);
};
@@ -7,12 +7,7 @@ import { useTranslation } from 'react-i18next';
import { Loading } from '@/components';
import { LOGIN_URL, setAuthUrl, useAuth } from '@/features/auth';
import {
LinkReach,
LinkRole,
useCreateDoc,
} from '@/features/docs/doc-management';
import { useUpdateDocLink } from '@/features/docs/doc-share/api/useUpdateDocLink';
import { useCreateDoc } from '@/features/docs/doc-management';
import { useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
@@ -22,15 +17,11 @@ const Page: NextPageWithLayout = () => {
const { setIsSkeletonVisible } = useSkeletonStore();
const router = useRouter();
const searchParams = useSearchParams();
const linkReach = searchParams.get('link-reach');
const linkRole = searchParams.get('link-role');
const title = searchParams.get('title');
const { authenticated } = useAuth();
const { mutateAsync: createDocAsync, data: doc } = useCreateDoc();
const { mutateAsync: updateDocLinkAsync } = useUpdateDocLink();
const redirectToDoc = useCallback(
(docId: string) => {
void router.push(`/docs/${docId}`);
@@ -52,32 +43,12 @@ const Page: NextPageWithLayout = () => {
return;
}
// Link configuration is managed in Drive: the legacy link-reach/link-role
// query params are ignored.
createDocAsync({
title: title || undefined,
})
.then((createdDoc) => {
if ((linkReach && linkRole) || linkReach) {
updateDocLinkAsync({
id: createdDoc.id,
link_reach: linkReach as LinkReach,
link_role: (linkRole as LinkRole | undefined) || undefined,
})
.catch((error) => {
captureException(error, {
extra: {
docId: createdDoc.id,
linkReach,
linkRole,
},
});
})
.finally(() => {
redirectToDoc(createdDoc.id);
});
return;
}
redirectToDoc(createdDoc.id);
})
.catch((error) => {
@@ -87,16 +58,7 @@ const Page: NextPageWithLayout = () => {
},
});
});
}, [
authenticated,
createDocAsync,
doc,
linkReach,
linkRole,
redirectToDoc,
title,
updateDocLinkAsync,
]);
}, [authenticated, createDocAsync, doc, redirectToDoc, title]);
return (
<>