/********************************************************************************
* Copyright (c) 2020 TypeFox and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/
import { FunctionComponent, ReactNode, useContext, useState } from 'react';
import {
Alert,
Avatar,
Box,
Chip,
Divider,
LinearProgress,
Paper,
Stack,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography
} from '@mui/material';
import { useIsMutating } from '@tanstack/react-query';
import { Link as RouterLink } from 'react-router';
import GitHubIcon from '@mui/icons-material/GitHub';
import PersonIcon from '@mui/icons-material/Person';
import FolderSharedIcon from '@mui/icons-material/FolderShared';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import ExtensionIcon from '@mui/icons-material/Extension';
import GavelIcon from '@mui/icons-material/Gavel';
import { UserRelationships, SuccessResult } from '../../extension-registry-types';
import { ErrorResponse } from '../../server-request';
import { MainContext } from '../../context';
import { ExtensionCardList } from '../../components/extension/extension-card-list';
import { handleError as formatError, toLocalTime } from '../../utils';
import { AdminDashboardRoutes } from './admin-dashboard-routes';
import { PublisherRevokeContributionsButton } from './publisher-revoke-dialog';
import { PublisherRevokeTokensButton } from './publisher-revoke-tokens-button';
import {
type PublisherRole,
publisherMutationKey,
usePublisherInfo,
useUpdatePublisherRole
} from './use-publisher-admin';
// Ordered as an escalating permission scale, low → high.
const ROLE_OPTIONS: { value: PublisherRole; label: string }[] = [
{ value: 'none', label: 'No role' },
{ value: 'privileged', label: 'Privileged' },
{ value: 'admin', label: 'Admin' }
];
const AGREEMENT_META = {
signed: { label: 'Signed', color: 'success' as const },
outdated: { label: 'Outdated', color: 'warning' as const },
none: { label: 'Not signed', color: 'default' as const }
};
const DetailSection: FunctionComponent<{ icon: ReactNode; title: string; count?: number; children: ReactNode }> = ({
icon,
title,
count,
children
}) => (
{icon}
{title}
{count !== undefined && (
({count})
)}
{children}
);
/**
* The details card for the publisher selected in the search. Identity and role are
* available immediately from the search result; the account info (agreement, tokens,
* extensions) loads on demand. A top progress bar reflects any write in flight.
*/
export const PublisherDetails: FunctionComponent<{ entry: UserRelationships }> = ({ entry }) => {
const { user } = entry;
const { user: currentUser } = useContext(MainContext);
const isCurrentUser = currentUser?.loginName === user.loginName && currentUser?.provider === user.provider;
const [selectedRole, setSelectedRole] = useState(() => (user.role as PublisherRole) ?? 'none');
const updateRole = useUpdatePublisherRole();
const busy = useIsMutating({ mutationKey: publisherMutationKey }) > 0;
const { data: publisherInfo, error } = usePublisherInfo(user.loginName, user.provider ?? 'github', true);
const handleRoleChange = (role: PublisherRole) => {
if (role === selectedRole || !user.provider) {
return;
}
// Optimistic: reflect the choice immediately, revert if the save fails.
setSelectedRole(role);
updateRole.mutate(
{ provider: user.provider, login: user.loginName, role },
{
onSuccess() {
setTimeout(() => {
updateRole.reset();
}, 3000);
},
onError: () => setSelectedRole((user.role as PublisherRole) ?? 'none')
}
);
};
const agreementStatus = publisherInfo?.user.publisherAgreement?.status ?? 'none';
const agreement = AGREEMENT_META[agreementStatus];
return (
{busy && }
{user.loginName}
) : (
)
}
label={user.provider ?? '—'}
size='small'
variant='outlined'
/>
{isCurrentUser && (
)}
{user.fullName || '—'}
Role
value && handleRoleChange(value)}>
{ROLE_OPTIONS.map(o => (
{o.label}
))}
{updateRole.isError && (
updateRole.reset()}>
{formatError(updateRole.error as Error | Partial)}
)}
{updateRole.isSuccess && (
{(updateRole.data as Partial).success ?? ''}
)}
}
title='Namespaces'
count={entry.namespaces.length}>
{entry.namespaces.length > 0 ? (
{entry.namespaces.map(ns => (
))}
) : (
None
)}
{error && {formatError(error as Error | Partial)}}
{publisherInfo && (
<>
{publisherInfo.user.publisherAgreement && (
}
label={`Publisher agreement: ${agreement.label}`}
size='small'
color={agreement.color}
variant={agreementStatus === 'none' ? 'outlined' : 'filled'}
/>
)}
}
label={`${publisherInfo.activeAccessTokenNum} active access token${
publisherInfo.activeAccessTokenNum === 1 ? '' : 's'
}`}
size='small'
variant='outlined'
/>
}
title='Published extensions'
count={publisherInfo.extensions.length}>
{publisherInfo.extensions.length > 0 ? (
) : (
This user has not published any extensions.
)}
Danger Zone
{publisherInfo.activeAccessTokenNum > 0 && (
<>
Revoke access tokens
Deactivate {publisherInfo.activeAccessTokenNum} active access token
{publisherInfo.activeAccessTokenNum === 1 ? '' : 's'} for{' '}
{user.loginName}. This cannot be undone.
>
)}
Revoke publisher contributions
Deactivate all extensions, access tokens, and revoke the publisher agreement
for {user.loginName}. This cannot be undone.
>
)}
);
};