/******************************************************************************** * 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. )} ); };