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
2 changes: 1 addition & 1 deletion server/src/dev/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ ovsx:
databasesearch:
enabled: false
elasticsearch:
enabled: true
enabled: false
clear-on-start: true
redis:
enabled: false
Expand Down
32 changes: 32 additions & 0 deletions webui/src/default/agreement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Box, styled, Theme, Typography } from "@mui/material";
import { Link as RouteLink } from 'react-router-dom';
import React, { FC } from "react";
import { PageSettings } from "../page-settings";
import { UserSettingsRoutes } from "../pages/user/user-settings";

const link = ({ theme }: { theme: Theme }) => ({
color: theme.palette.secondary.main,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline'
}
});

const EmptyTypography = styled(Typography)(({ theme }: { theme: Theme }) => ({
[theme.breakpoints.down('sm')]: {
textAlign: 'center'
}
}));

const StyledRouteLink = styled(RouteLink)(link);


export const DefaultAgreementNotSignedContent: FC<{ pageSettings: PageSettings }> = ({ pageSettings }) => (
<Box>
<EmptyTypography variant='body1'>
Access tokens cannot be created as you currently do not have a {pageSettings.agreement.name} signed. Please return to
your <StyledRouteLink to={UserSettingsRoutes.PROFILE}>Profile</StyledRouteLink> page
to sign the Publisher Agreement.
</EmptyTypography>
</Box>
);
9 changes: 8 additions & 1 deletion webui/src/default/page-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { DefaultMenuContent, MobileMenuContent } from './menu-content';
import OpenVSXLogo from './openvsx-registry-logo';
import About from './about';
import { createAbsoluteURL } from '../utils';
import { DefaultAgreementNotSignedContent } from './agreement';

export default function createPageSettings(prefersDarkMode: boolean, serverUrl: string, serverVersionPromise: Promise<string>): PageSettings {
const toolbarContent: FunctionComponent = () =>
Expand Down Expand Up @@ -124,7 +125,13 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl:
additionalRoutes,
mainHeadTags,
extensionHeadTags,
namespaceHeadTags
namespaceHeadTags,
agreement: {
notSignedContent: DefaultAgreementNotSignedContent,
}
},
agreement: {
name: 'Eclipse Foundation Open VSX Publisher Agreement',
},
urls: {
extensionDefaultIcon: '/default-icon.png',
Expand Down
84 changes: 79 additions & 5 deletions webui/src/extension-registry-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview,
SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy,
SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion,
LoginProviders
LoginProviders, Tier, RefillStrategy
} from './extension-registry-types';
import { createAbsoluteURL, addQuery } from './utils';
import { sendRequest, ErrorResponse } from './server-request';
Expand Down Expand Up @@ -486,15 +486,47 @@ export interface AdminService {
getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise<Readonly<PublisherInfo>>
revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise<Readonly<SuccessResult | ErrorResult>>
revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise<Readonly<SuccessResult | ErrorResult>>
getAllTiers(): Promise<Readonly<Tier[]>>;
getTierById(id: number): Promise<Readonly<Tier>>;
createTier(tier: Omit<Tier, 'id'>): Promise<Readonly<Tier>>;
updateTier(id: number, tier: Omit<Tier, 'id'>): Promise<Readonly<Tier>>;
deleteTier(id: number): Promise<Readonly<void>>;
}

export interface AdminServiceConstructor {
new (registry: ExtensionRegistryService): AdminService
}
export type AdminServiceConstructor = new (registry: ExtensionRegistryService) => AdminService;

export class AdminServiceImpl implements AdminService {

constructor(readonly registry: ExtensionRegistryService) {}
constructor(readonly registry: ExtensionRegistryService) {
this.initializeMockTiers();
}

private readonly tierCounter = { value: 1 };
private readonly mockTiers: Map<number, Tier> = new Map();

private initializeMockTiers(): void {
if (this.mockTiers.size === 0) {
const sampleTiers: Tier[] = [
{
id: this.tierCounter.value++,
name: 'Free',
description: 'Free tier with basic rate limiting',
capacity: 100,
duration: 3600,
refillStrategy: RefillStrategy.GREEDY
},
{
id: this.tierCounter.value++,
name: 'Professional',
description: 'Professional tier with higher rate limits',
capacity: 1000,
duration: 3600,
refillStrategy: RefillStrategy.GREEDY
}
];
sampleTiers.forEach(tier => this.mockTiers.set(tier.id, tier));
}
}

getExtension(abortController: AbortController, namespace: string, extension: string): Promise<Readonly<Extension>> {
return sendRequest({
Expand Down Expand Up @@ -609,6 +641,48 @@ export class AdminServiceImpl implements AdminService {
headers
});
}

async getAllTiers(): Promise<Readonly<Tier[]>> {
return Array.from(this.mockTiers.values());
}

async getTierById(id: number): Promise<Readonly<Tier>> {
const tier = this.mockTiers.get(id);
if (!tier) {
throw new Error(`Tier with ID ${id} not found`);
}
return tier;
}

async createTier(tier: Omit<Tier, 'id'>): Promise<Readonly<Tier>> {
const newTier: Tier = {
id: this.tierCounter.value++,
...tier
};
this.mockTiers.set(newTier.id, newTier);
return newTier;
}

async updateTier(id: number, tier: Omit<Tier, 'id'>): Promise<Readonly<Tier>> {
const existingTier = this.mockTiers.get(id);
if (!existingTier) {
throw new Error(`Tier with ID ${id} not found`);
}

const updatedTier: Tier = {
...existingTier,
...tier
};
this.mockTiers.set(id, updatedTier);
return updatedTier;
}

async deleteTier(id: number): Promise<void> {
if (!this.mockTiers.has(id)) {
throw new Error(`Tier with ID ${id} not found`);
}
this.mockTiers.delete(id);
}
}

export interface ExtensionFilter {
Expand Down
14 changes: 14 additions & 0 deletions webui/src/extension-registry-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,17 @@ export interface LoginProviders {
export type MembershipRole = 'contributor' | 'owner';
export type SortBy = 'relevance' | 'timestamp' | 'rating' | 'downloadCount';
export type SortOrder = 'asc' | 'desc';

export enum RefillStrategy {
GREEDY = 'GREEDY',
INTERVAL = 'INTERVAL',
}

export interface Tier {
id: number;
name: string;
description?: string;
capacity: number;
duration: number;
refillStrategy: RefillStrategy;
}
6 changes: 6 additions & 0 deletions webui/src/page-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface PageSettings {
mainHeadTags?: ComponentType<{ pageSettings: PageSettings }>;
extensionHeadTags?: ComponentType<{ extension?: Extension, pageSettings: PageSettings }>;
namespaceHeadTags?: ComponentType<{ namespaceDetails?: NamespaceDetails, name: string, pageSettings: PageSettings }>;
agreement: {
notSignedContent: ComponentType<{ pageSettings: PageSettings }>;
}
};
agreement: {
name: string;
};
urls: {
extensionDefaultIcon: string;
Expand Down
5 changes: 5 additions & 0 deletions webui/src/pages/admin-dashboard/admin-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff';
import { Welcome } from './welcome';
import { PublisherAdmin } from './publisher-admin';
import PersonIcon from '@mui/icons-material/Person';
import StarIcon from '@mui/icons-material/Star';
import { Tiers } from './tiers/tiers';

export namespace AdminDashboardRoutes {
export const ROOT = 'admin-dashboard';
export const MAIN = createRoute([ROOT]);
export const NAMESPACE_ADMIN = createRoute([ROOT, 'namespaces']);
export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']);
export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']);
export const TIERS = createRoute([ROOT, 'tiers']);
}

const Message: FunctionComponent<{message: string}> = ({ message }) => {
Expand Down Expand Up @@ -59,6 +62,7 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.NAMESPACE_ADMIN} label='Namespaces' icon={<AssignmentIndIcon />} route={AdminDashboardRoutes.NAMESPACE_ADMIN} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.EXTENSION_ADMIN} label='Extensions' icon={<ExtensionSharpIcon />} route={AdminDashboardRoutes.EXTENSION_ADMIN} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.PUBLISHER_ADMIN} label='Publishers' icon={<PersonIcon />} route={AdminDashboardRoutes.PUBLISHER_ADMIN} />
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.TIERS} label='Tiers' icon={<StarIcon />} route={AdminDashboardRoutes.TIERS} />
</Sidepanel>
<Box overflow='auto' flex={1}>
<IconButton onClick={toMainPage} sx={{ float: 'right', mt: 1, mr: 1 }}>
Expand All @@ -69,6 +73,7 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
<Route path='/namespaces' element={<NamespaceAdmin/>} />
<Route path='/extensions' element={<ExtensionAdmin/>} />
<Route path='/publisher' element={<PublisherAdmin/>} />
<Route path='/tiers' element={<Tiers/>} />
<Route path='*' element={<Welcome/>} />
</Routes>
</Container>
Expand Down
10 changes: 5 additions & 5 deletions webui/src/pages/admin-dashboard/publisher-admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ export const PublisherAdmin: FunctionComponent = props => {
const publisherName = inputValue;
try {
setLoading(true);
if (publisherName !== '') {
const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName);
if (publisherName === '') {
setNotFound('');
setPublisher(publisher);
setPublisher(undefined);
} else {
const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName);
setNotFound('');
setPublisher(undefined);
setPublisher(publisher);
}
setLoading(false);
} catch (err) {
Expand All @@ -66,7 +66,7 @@ export const PublisherAdmin: FunctionComponent = props => {
let listContainer: ReactNode = '';
if (publisher && pageSettings && user) {
listContainer = <UpdateContext.Provider value={{ handleUpdate }}>
<PublisherDetails publisherInfo={publisher} />
<PublisherDetails publisherInfo={publisher} agreement={pageSettings.agreement} />
</UpdateContext.Provider>;
} else if (notFound) {
listContainer = <Box display='flex' flexDirection='column'>
Expand Down
4 changes: 3 additions & 1 deletion webui/src/pages/admin-dashboard/publisher-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ import { UserExtensionList } from '../user/user-extension-list';
import { Box, Typography } from '@mui/material';
import { PublisherRevokeDialog } from './publisher-revoke-dialog';
import { PublisherRevokeTokensButton } from './publisher-revoke-tokens-button';
import { PageSettings } from '../../page-settings';

interface PublisherDetailsProps {
publisherInfo: PublisherInfo;
agreement: PageSettings['agreement'];
}

export const PublisherDetails: FunctionComponent<PublisherDetailsProps> = props => {
return <Box mt={2}>
<UserSettingsProfile user={props.publisherInfo.user} isAdmin={true} />
<UserSettingsProfile user={props.publisherInfo.user} agreement={props.agreement} isAdmin={true} />
<Box mt={2}>
<Typography variant='h5'>Access Tokens</Typography>
<Typography variant='body1'>
Expand Down
68 changes: 68 additions & 0 deletions webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { FC, useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
CircularProgress,
Alert
} from '@mui/material';
import type { Tier } from '../../../extension-registry-types';

interface DeleteTierDialogProps {
open: boolean;
tier?: Tier;
onClose: () => void;
onConfirm: () => Promise<void>;
}

export const DeleteTierDialog: FC<DeleteTierDialogProps> = ({ open, tier, onClose, onConfirm }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleConfirm = async () => {
try {
setError(null);
setLoading(true);
await onConfirm();
onClose();
} catch (err: any) {
setError(err.message || 'An error occurred while deleting the tier');
setLoading(false);
}
};

return (
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
<DialogTitle>Delete Tier</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
{error && <Alert severity='error'>{error}</Alert>}

<Typography>
Are you sure you want to delete the tier <strong>{tier?.name}</strong>?
</Typography>

<Typography variant='body2' color='warning.main'>
This action cannot be undone. If this tier is assigned to customers, they will be affected.
</Typography>
</DialogContent>

<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={handleConfirm}
variant='contained'
color='error'
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : undefined}
>
Delete
</Button>
</DialogActions>
</Dialog>
);
};
Loading
Loading