Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions client/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { userHasPermissions } from '@/routing/permissions';
import { gql } from '@apollo/client';
import { Input, notification } from 'antd';
import Link from 'antd/lib/typography/Link';
Expand All @@ -10,6 +11,7 @@ import FormHeader from '../components/FormHeader';
import FormSectionHeader from '../components/FormSectionHeader';

import {
GQLUserPermission,
useGQLAppealSettingsQuery,
useGQLUpdateAppealSettingsMutation,
} from '../../../graphql/generated';
Expand All @@ -23,7 +25,7 @@ gql`
appealsCallbackBody
}
me {
role
permissions
}
}

Expand Down Expand Up @@ -87,6 +89,10 @@ export default function ManualReviewAppealSettings() {
return <FullScreenLoading />;
}

const canManageOrg = userHasPermissions(data?.me?.permissions, [
GQLUserPermission.ManageOrg,
]);

const onUpdateAppealSettings = async () =>
updateAppealSettings({
variables: {
Expand Down Expand Up @@ -169,15 +175,15 @@ export default function ManualReviewAppealSettings() {
<CoopButton
title="Save Settings"
disabled={
data?.me?.role !== 'ADMIN' ||
!canManageOrg ||
!appealsCallbackUrl ||
!validateJSON(appealsCallbackHeaders) ||
!validateJSON(appealsCallbackBody)
}
loading={updateLoading}
disabledTooltipTitle={(() => {
if (data?.me?.role !== 'ADMIN') {
return "To edit these settings, ask your organization's admin to upgrade your role to Admin.";
if (!canManageOrg) {
return 'You do not have permission to edit appeal settings. Ask your organization administrator for access.';
}
if (!appealsCallbackUrl) {
return 'The Callback URL is required.';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
import Sidebar1 from '@/icons/lni/Design/sidebar-1.svg?react';
import AngleDoubleRight from '@/icons/lni/Direction/angle-double-right.svg?react';
import { userHasPermissions } from '@/routing/permissions';
import { __throw } from '@/utils/misc';
import { isNonEmptyString } from '@/utils/string';
import { multilevelListFromFlatList } from '@/utils/tree';
import {
DownOutlined,
EditOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { DownOutlined, EditOutlined, LoadingOutlined } from '@ant-design/icons';
import { gql } from '@apollo/client';
import { Button, Dropdown, Input, Select, Tooltip } from 'antd';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { useNavigate, useParams } from 'react-router-dom';

import ActionParametersModal, {
defaultValuesForParameters,
} from '@/components/ActionParametersModal';
import { type ActionParameterValues } from '@/components/ActionParameterInputs';
import ComponentLoading from '../../../../components/common/ComponentLoading';
import CopyTextComponent from '../../../../components/common/CopyTextComponent';
import CoopModal from '../../components/CoopModal';
import { CoopModalFooterButtonProps } from '../../components/CoopModalFooter';
import PolicyDropdown from '../../components/PolicyDropdown';
import { type ActionParameterValues } from '@/components/ActionParameterInputs';
import ActionParametersModal, {
defaultValuesForParameters,
} from '@/components/ActionParametersModal';
import Drawer from '@/components/common/Drawer';

import {
Expand All @@ -38,6 +35,7 @@ import {
GQLThreadAppealManualReviewJobPayload,
GQLUserManualReviewJobPayload,
GQLUserPenaltySeverity,
GQLUserPermission,
useGQLDequeueManualReviewJobMutation,
useGQLLogSkipMutation,
useGQLManualReviewJobInfoQuery,
Expand All @@ -51,8 +49,8 @@ import { filterNullOrUndefined } from '../../../../utils/collections';
import { getFieldValueForRole } from '../../../../utils/itemUtils';
import { recomputeSelectedRelatedActions } from '../../../../utils/manualReviewTool';
import HTMLRenderer from '../../policies/HTMLRenderer';
import { JOB_FRAGMENT } from './jobFragment';
import { ITEM_TYPE_FRAGMENT } from '../../rules/rule_form/RuleForm';
import { JOB_FRAGMENT } from './jobFragment';
import ManualReviewJobDequeueErrorComponent from './ManualReviewJobDequeueErrorComponent';
import MergedReportsComponent from './MergedReportsComponent';
import ReportInfoComponent from './ReportInfoComponent';
Expand All @@ -66,8 +64,8 @@ import {
} from './v2/ManualReviewJobRelatedActionsStore';
import NCMECReviewUser from './v2/ncmec/NCMECReviewUser';
import ManualReviewJobEnqueuedRelatedActions from './v2/related_actions/ManualReviewJobEnqueuedRelatedActions';
import { useEnqueueActionGate } from './v2/useEnqueueActionGate';
import ManualReviewJobListOfThreadsComponent from './v2/threads/ManualReviewJobListOfThreadsComponent';
import { useEnqueueActionGate } from './v2/useEnqueueActionGate';
import ManualReviewJobPrimaryUserComponent from './v2/user/ManualReviewJobPrimaryUserComponent';

const { Option } = Select;
Expand All @@ -76,7 +74,9 @@ const { TextArea } = Input;
// Narrows the GraphQL union of action types to "this one declares parameter
// inputs". Only `CustomAction` carries `parameters`; everything else is
// treated as no-parameter.
function actionHasParameters(action: { __typename?: string } | undefined): boolean {
function actionHasParameters(
action: { __typename?: string } | undefined,
): boolean {
if (!action || !('parameters' in action)) return false;
const params = (action as { parameters?: readonly unknown[] | null })
.parameters;
Expand Down Expand Up @@ -161,7 +161,7 @@ gql`
...JobFields
}
}
role
permissions
}
}

Expand Down Expand Up @@ -630,16 +630,16 @@ function ManualReviewJobReviewImpl(props: {
const job = closedJob
? closedJob
: jobData
? jobData.dequeueManualReviewJob?.job
: data?.me?.reviewableQueues
.find((queue) => queue.id === queueId)
?.jobs.find((job) => job.id === jobId);
? jobData.dequeueManualReviewJob?.job
: data?.me?.reviewableQueues
.find((queue) => queue.id === queueId)
?.jobs.find((job) => job.id === jobId);
const pendingJobCount = jobData?.dequeueManualReviewJob
? jobData.dequeueManualReviewJob.numPendingJobs
: data?.me?.reviewableQueues
? data?.me?.reviewableQueues.find((queue) => queue.id === queueId)
?.pendingJobCount
: undefined;
? data?.me?.reviewableQueues.find((queue) => queue.id === queueId)
?.pendingJobCount
: undefined;

const [logSkip] = useGQLLogSkipMutation({
// This is safe because we check it before calling logSkip
Expand Down Expand Up @@ -712,14 +712,17 @@ function ManualReviewJobReviewImpl(props: {
if (!closedJob && !queue) {
throw Error(`Queue not found for ID ${queueId}`);
}
const userIsAdmin = data.me?.role === 'ADMIN';
const userCanBypassSkipRestriction = userHasPermissions(
data.me?.permissions,
[GQLUserPermission.ManageOrg],
);

const filteredActions = org.actions.filter(
({ id }) => !queue?.hiddenActionIds?.includes(id),
);
const { payload, policyIds } = job;
const reportHistory =
'reportHistory' in job.payload ? job.payload.reportHistory ?? [] : [];
'reportHistory' in job.payload ? (job.payload.reportHistory ?? []) : [];

const modal = (
<CoopModal
Expand Down Expand Up @@ -797,7 +800,8 @@ function ManualReviewJobReviewImpl(props: {
const threadItems =
payload.__typename === 'UserManualReviewJobPayload' ||
payload.__typename === 'ContentManualReviewJobPayload'
? (payload.itemThreadContentItems as ReadonlyArray<GQLContentItem>) ?? []
? ((payload.itemThreadContentItems as ReadonlyArray<GQLContentItem>) ??
[])
: [];
const policiesFromIds = (policyIds: readonly string[]) =>
policyIds.map((policyId) => {
Expand Down Expand Up @@ -860,17 +864,18 @@ function ManualReviewJobReviewImpl(props: {

// Builds the standard target descriptor for the reported item. Hoisted so
// both the direct-add path and the modal Save handler use the same shape.
const reportedItemTarget = (): ManualReviewJobEnqueuedPrimaryActionData['target'] => ({
identifier: {
itemId: reportedItem.id,
itemTypeId: reportedItem.type.id,
},
displayName:
getFieldValueForRole<GQLSchemaFieldRoles, keyof GQLSchemaFieldRoles>(
reportedItem,
'displayName',
) ?? reportedItem.id,
});
const reportedItemTarget =
(): ManualReviewJobEnqueuedPrimaryActionData['target'] => ({
identifier: {
itemId: reportedItem.id,
itemTypeId: reportedItem.type.id,
},
displayName:
getFieldValueForRole<GQLSchemaFieldRoles, keyof GQLSchemaFieldRoles>(
reportedItem,
'displayName',
) ?? reportedItem.id,
});

// Returns the parameter spec for a CustomAction by id, or `[]` for
// built-ins / actions without a spec. Looks up against `decisionActions`
Expand Down Expand Up @@ -939,7 +944,8 @@ function ManualReviewJobReviewImpl(props: {
className="sticky flex flex-col border border-gray-200 border-solid rounded-md shrink-0"
data-testid="manual-review-decision-action-list"
>
{decisionActions.map((action) => {
{decisionActions
.map((action) => {
const { key, selected, label } = (() => {
if ('type' in action) {
return {
Expand Down Expand Up @@ -1201,7 +1207,8 @@ function ManualReviewJobReviewImpl(props: {
);

const skipToNextJobButton =
org.hideSkipButtonForNonAdmins && !userIsAdmin ? undefined : (
org.hideSkipButtonForNonAdmins &&
!userCanBypassSkipRestriction ? undefined : (
<Button
className="bottom-0 w-1/3 !px-2 mb-2 overflow-hidden !border-slate-200 !hover:fill-[#40a9ff] !focus:fill-[#40a9ff]"
onClick={skipToNextJob}
Expand Down Expand Up @@ -1340,8 +1347,8 @@ function ManualReviewJobReviewImpl(props: {
payload.__typename === 'UserManualReviewJobPayload'
? filterNullOrUndefined(payload.reportedItems ?? [])
: payload.__typename === 'ContentManualReviewJobPayload'
? [{ id: payload.item.id, typeId: payload.item.type.id }]
: []
? [{ id: payload.item.id, typeId: payload.item.type.id }]
: []
}
otherItems={payload.itemThreadContentItems as readonly GQLContentItem[]}
allActions={filteredActions}
Expand Down Expand Up @@ -1598,8 +1605,7 @@ function ManualReviewJobReviewImpl(props: {
onSave={(values) => {
if (paramsModal.mode === 'create') {
const action = decisionActions.find(
(a) =>
!('type' in a) && a.id === paramsModal.actionId,
(a) => !('type' in a) && a.id === paramsModal.actionId,
);
if (action) {
addPrimaryAction(action, { ...values });
Expand Down
1 change: 1 addition & 0 deletions server/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions server/graphql/modules/apiKey.resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { UserPermission } from '../../services/userManagementService/index.js';
import { Query } from './apiKey.js';

describe('apiKey resolvers', () => {
function makeCtx(permissions: readonly UserPermission[]) {
const getActiveApiKeyForOrg = jest.fn(async () => ({ key: 'secret' }));
const ctx = {
getUser: () => ({
id: 'user-1',
orgId: 'org-1',
getPermissions: () => permissions,
}),
services: { ApiKeyService: { getActiveApiKeyForOrg } },
};
return { ctx, getActiveApiKeyForOrg };
}

describe('Query.apiKey', () => {
it('throws unauthenticatedError when caller is not authenticated', async () => {
const { ctx, getActiveApiKeyForOrg } = makeCtx([
UserPermission.MANAGE_ORG,
]);
const unauthenticatedCtx = { ...ctx, getUser: () => null };
await expect(Query.apiKey({}, {}, unauthenticatedCtx)).rejects.toThrow(
'Authenticated user required',
);
expect(getActiveApiKeyForOrg).not.toHaveBeenCalled();
});

it('throws forbiddenError when caller lacks MANAGE_ORG', async () => {
const { ctx, getActiveApiKeyForOrg } = makeCtx([UserPermission.VIEW_MRT]);
await expect(Query.apiKey({}, {}, ctx)).rejects.toThrow(
'User does not have permission to view the org API key',
);
expect(getActiveApiKeyForOrg).not.toHaveBeenCalled();
});

it('returns the existence indicator when caller has MANAGE_ORG', async () => {
const { ctx, getActiveApiKeyForOrg } = makeCtx([
UserPermission.MANAGE_ORG,
]);
await expect(Query.apiKey({}, {}, ctx)).resolves.toBe(
'API key exists (hidden for security)',
);
expect(getActiveApiKeyForOrg).toHaveBeenCalledWith('org-1');
});
});
});
Loading
Loading