From f4e641c217f56d52e7de9ef4f434dc3558de48da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Thu, 15 Jan 2026 13:55:11 +0100 Subject: [PATCH 1/3] feat: adding publisher agreement not signed message as page setting component --- webui/src/page-settings.ts | 5 +++++ webui/src/pages/user/user-settings-tokens.tsx | 15 +++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/webui/src/page-settings.ts b/webui/src/page-settings.ts index 93f0d1aef..1447576b8 100644 --- a/webui/src/page-settings.ts +++ b/webui/src/page-settings.ts @@ -43,6 +43,11 @@ export interface PageSettings { }, cookie?: Cookie }; + userSettings?: { + accessTokens?: { + publisherAgreementNotSignedContent?: ComponentType; + } + }, mainHeadTags?: ComponentType<{ pageSettings: PageSettings }>; extensionHeadTags?: ComponentType<{ extension?: Extension, pageSettings: PageSettings }>; namespaceHeadTags?: ComponentType<{ namespaceDetails?: NamespaceDetails, name: string, pageSettings: PageSettings }>; diff --git a/webui/src/pages/user/user-settings-tokens.tsx b/webui/src/pages/user/user-settings-tokens.tsx index ebc39c30a..551485f9d 100644 --- a/webui/src/pages/user/user-settings-tokens.tsx +++ b/webui/src/pages/user/user-settings-tokens.tsx @@ -9,7 +9,7 @@ ********************************************************************************/ import React, { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; -import { Theme, Typography, Box, Paper, Button, Link, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; +import { Theme, Typography, Box, Paper, Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { Timestamp } from '../../components/timestamp'; @@ -27,7 +27,6 @@ const link = ({ theme }: { theme: Theme }) => ({ } }); -const StyledLink = styled(Link)(link); const StyledRouteLink = styled(RouteLink)(link); const EmptyTypography = styled(Typography)(({ theme }: { theme: Theme }) => ({ @@ -43,7 +42,7 @@ const DeleteButton = styled(Button)(({ theme }: { theme: Theme }) => ({ export const UserSettingsTokens: FunctionComponent = () => { - const { service, user, handleError } = useContext(MainContext); + const { service, user, handleError, pageSettings } = useContext(MainContext); const [tokens, setTokens] = useState(new Array()); const [loading, setLoading] = useState(true); @@ -119,14 +118,14 @@ export const UserSettingsTokens: FunctionComponent = () => { }; const agreement = user?.publisherAgreement; + const PublisherAgreementNotSignedContent = pageSettings.elements.userSettings?.accessTokens?.publisherAgreementNotSignedContent; if (agreement && (agreement.status === 'none' || agreement.status === 'outdated')) { - return + return PublisherAgreementNotSignedContent ? : + - Access tokens cannot be created as you currently do not have an Eclipse Foundation Open VSX - Publisher Agreement signed. Please return to + Access tokens cannot be created as you currently do not have a Publisher Agreement signed. Please return to your Profile page - to sign the Publisher Agreement. Should you believe this is in error, please - contact license@eclipse.org. + to sign the Publisher Agreement. ; } From 82f7c37e9a802869358fb1a77001d3923d696947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Mon, 19 Jan 2026 11:43:14 +0100 Subject: [PATCH 2/3] feat: making other references to publisher agreement configurable --- webui/src/default/agreement.tsx | 32 +++++++++++++++++++ webui/src/default/page-settings.tsx | 9 +++++- webui/src/page-settings.ts | 11 ++++--- .../pages/admin-dashboard/publisher-admin.tsx | 2 +- .../admin-dashboard/publisher-details.tsx | 4 ++- .../pages/user/user-publisher-agreement.tsx | 8 ++--- .../src/pages/user/user-settings-profile.tsx | 9 +++--- webui/src/pages/user/user-settings-tokens.tsx | 23 ++----------- webui/src/pages/user/user-settings.tsx | 2 +- 9 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 webui/src/default/agreement.tsx diff --git a/webui/src/default/agreement.tsx b/webui/src/default/agreement.tsx new file mode 100644 index 000000000..7f1432112 --- /dev/null +++ b/webui/src/default/agreement.tsx @@ -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 }) => ( + + + Access tokens cannot be created as you currently do not have a {pageSettings.agreement.name} signed. Please return to + your Profile page + to sign the Publisher Agreement. + + +); \ No newline at end of file diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index d03eba95f..91626379b 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -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): PageSettings { const toolbarContent: FunctionComponent = () => @@ -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', diff --git a/webui/src/page-settings.ts b/webui/src/page-settings.ts index 1447576b8..ccf3d0688 100644 --- a/webui/src/page-settings.ts +++ b/webui/src/page-settings.ts @@ -43,14 +43,15 @@ export interface PageSettings { }, cookie?: Cookie }; - userSettings?: { - accessTokens?: { - publisherAgreementNotSignedContent?: ComponentType; - } - }, 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; diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 7595ab14e..d5c21c762 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -66,7 +66,7 @@ export const PublisherAdmin: FunctionComponent = props => { let listContainer: ReactNode = ''; if (publisher && pageSettings && user) { listContainer = - + ; } else if (notFound) { listContainer = diff --git a/webui/src/pages/admin-dashboard/publisher-details.tsx b/webui/src/pages/admin-dashboard/publisher-details.tsx index 4a0219c56..fd6c0c84f 100644 --- a/webui/src/pages/admin-dashboard/publisher-details.tsx +++ b/webui/src/pages/admin-dashboard/publisher-details.tsx @@ -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 = props => { return - + Access Tokens diff --git a/webui/src/pages/user/user-publisher-agreement.tsx b/webui/src/pages/user/user-publisher-agreement.tsx index 4fb00234b..dc760ee65 100644 --- a/webui/src/pages/user/user-publisher-agreement.tsx +++ b/webui/src/pages/user/user-publisher-agreement.tsx @@ -94,14 +94,14 @@ export const UserPublisherAgreement: FunctionComponent { user.publisherAgreement.timestamp - ? <>You signed the Eclipse Foundation Open VSX Publisher Agreement . - : 'You signed the Eclipse Foundation Open VSX Publisher Agreement.' + ? <>You signed the {pageSettings.agreement.name} . + : <>You signed the {pageSettings.agreement.name}. } ; } else if (user.additionalLogins?.find(login => login.provider === 'eclipse')) { content = <> - You need to sign the Eclipse Foundation Open VSX Publisher Agreement before you can publish + You need to sign the {pageSettings.agreement.name} before you can publish any extension to this registry. @@ -113,7 +113,7 @@ export const UserPublisherAgreement: FunctionComponent - You need to sign the Eclipse Foundation Open VSX Publisher Agreement before you can publish + You need to sign the {pageSettings.agreement.name} before you can publish any extension to this registry. To start the signing process, please log in with an Eclipse Foundation account. diff --git a/webui/src/pages/user/user-settings-profile.tsx b/webui/src/pages/user/user-settings-profile.tsx index b2f242818..7012c27a4 100644 --- a/webui/src/pages/user/user-settings-profile.tsx +++ b/webui/src/pages/user/user-settings-profile.tsx @@ -14,6 +14,7 @@ import { toLocalTime } from '../../utils'; import { UserData } from '../../extension-registry-types'; import { UserPublisherAgreement } from './user-publisher-agreement'; import styled from '@mui/material/styles/styled'; +import { PageSettings } from '../../page-settings'; const ProfileGrid = styled(Grid)(({ theme }: {theme: Theme}) => ({ [theme.breakpoints.up('lg')]: { @@ -37,12 +38,11 @@ const ProfileGrid = styled(Grid)(({ theme }: {theme: Theme}) => ({ marginBottom: theme.spacing(2) })); -export const UserSettingsProfile: FunctionComponent = props => { +export const UserSettingsProfile: FunctionComponent = ({ user, agreement, isAdmin }) => { - const user = props.user; let publisherAgreementPanel: ReactNode = null; if (user.publisherAgreement) { - if (props.isAdmin) { + if (isAdmin) { let statusText = 'has not signed'; if (user.publisherAgreement.status === 'signed') { statusText = 'has signed'; @@ -51,7 +51,7 @@ export const UserSettingsProfile: FunctionComponent = } publisherAgreementPanel = - {user.loginName} {statusText} the Eclipse publisher agreement. + {user.loginName} {statusText} the {agreement.name}. ; } else { publisherAgreementPanel = @@ -88,5 +88,6 @@ export const UserSettingsProfile: FunctionComponent = export interface UserSettingsProfileProps { user: UserData; + agreement: PageSettings['agreement']; isAdmin?: boolean; } \ No newline at end of file diff --git a/webui/src/pages/user/user-settings-tokens.tsx b/webui/src/pages/user/user-settings-tokens.tsx index 551485f9d..9cc24deaa 100644 --- a/webui/src/pages/user/user-settings-tokens.tsx +++ b/webui/src/pages/user/user-settings-tokens.tsx @@ -10,25 +10,13 @@ import React, { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; import { Theme, Typography, Box, Paper, Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; -import { Link as RouteLink } from 'react-router-dom'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { Timestamp } from '../../components/timestamp'; import { PersonalAccessToken } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { GenerateTokenDialog } from './generate-token-dialog'; -import { UserSettingsRoutes } from './user-settings'; import styled from '@mui/material/styles/styled'; -const link = ({ theme }: { theme: Theme }) => ({ - color: theme.palette.secondary.main, - textDecoration: 'none', - '&:hover': { - textDecoration: 'underline' - } -}); - -const StyledRouteLink = styled(RouteLink)(link); - const EmptyTypography = styled(Typography)(({ theme }: { theme: Theme }) => ({ [theme.breakpoints.down('sm')]: { textAlign: 'center' @@ -118,16 +106,9 @@ export const UserSettingsTokens: FunctionComponent = () => { }; const agreement = user?.publisherAgreement; - const PublisherAgreementNotSignedContent = pageSettings.elements.userSettings?.accessTokens?.publisherAgreementNotSignedContent; + const PublisherAgreementNotSignedContent = pageSettings.elements.agreement.notSignedContent; if (agreement && (agreement.status === 'none' || agreement.status === 'outdated')) { - return PublisherAgreementNotSignedContent ? : - - - Access tokens cannot be created as you currently do not have a Publisher Agreement signed. Please return to - your Profile page - to sign the Publisher Agreement. - - ; + return ; } return <> diff --git a/webui/src/pages/user/user-settings.tsx b/webui/src/pages/user/user-settings.tsx index f0ac53e10..9de3c553b 100644 --- a/webui/src/pages/user/user-settings.tsx +++ b/webui/src/pages/user/user-settings.tsx @@ -46,7 +46,7 @@ export const UserSettings: FunctionComponent = props => { switch (tab) { case 'profile': - return ; + return ; case 'tokens': return ; case 'namespaces': From f4b33148ffde23e23cf67bf14d299045ed2c13fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20G=C3=B3mez?= Date: Wed, 21 Jan 2026 12:45:29 +0100 Subject: [PATCH 3/3] feat: adding tier admin view --- server/src/dev/resources/application.yml | 2 +- webui/src/extension-registry-service.ts | 84 +++++- webui/src/extension-registry-types.ts | 14 + .../pages/admin-dashboard/admin-dashboard.tsx | 5 + .../pages/admin-dashboard/publisher-admin.tsx | 8 +- .../tiers/delete-tier-dialog.tsx | 68 +++++ .../tiers/tier-form-dialog.tsx | 240 ++++++++++++++++++ .../src/pages/admin-dashboard/tiers/tiers.tsx | 209 +++++++++++++++ 8 files changed, 620 insertions(+), 10 deletions(-) create mode 100644 webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx create mode 100644 webui/src/pages/admin-dashboard/tiers/tiers.tsx diff --git a/server/src/dev/resources/application.yml b/server/src/dev/resources/application.yml index dcce629a4..4aa55fdf2 100644 --- a/server/src/dev/resources/application.yml +++ b/server/src/dev/resources/application.yml @@ -148,7 +148,7 @@ ovsx: databasesearch: enabled: false elasticsearch: - enabled: true + enabled: false clear-on-start: true redis: enabled: false diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index c90357ba0..625728afb 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -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'; @@ -486,15 +486,47 @@ export interface AdminService { getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> + getAllTiers(): Promise>; + getTierById(id: number): Promise>; + createTier(tier: Omit): Promise>; + updateTier(id: number, tier: Omit): Promise>; + deleteTier(id: number): Promise>; } -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 = 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> { return sendRequest({ @@ -609,6 +641,48 @@ export class AdminServiceImpl implements AdminService { headers }); } + + async getAllTiers(): Promise> { + return Array.from(this.mockTiers.values()); + } + + async getTierById(id: number): Promise> { + const tier = this.mockTiers.get(id); + if (!tier) { + throw new Error(`Tier with ID ${id} not found`); + } + return tier; + } + + async createTier(tier: Omit): Promise> { + const newTier: Tier = { + id: this.tierCounter.value++, + ...tier + }; + this.mockTiers.set(newTier.id, newTier); + return newTier; + } + + async updateTier(id: number, tier: Omit): Promise> { + 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 { + if (!this.mockTiers.has(id)) { + throw new Error(`Tier with ID ${id} not found`); + } + this.mockTiers.delete(id); + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6d50efb48..9d789ff1b 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -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; +} \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 4df98e83f..8f1b2ed3c 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,6 +23,8 @@ 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'; @@ -30,6 +32,7 @@ export namespace AdminDashboardRoutes { 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 }) => { @@ -59,6 +62,7 @@ export const AdminDashboard: FunctionComponent = props => { } route={AdminDashboardRoutes.NAMESPACE_ADMIN} /> } route={AdminDashboardRoutes.EXTENSION_ADMIN} /> } route={AdminDashboardRoutes.PUBLISHER_ADMIN} /> + } route={AdminDashboardRoutes.TIERS} /> @@ -69,6 +73,7 @@ export const AdminDashboard: FunctionComponent = props => { } /> } /> } /> + } /> } /> diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index d5c21c762..d3eabebd7 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -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) { diff --git a/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx new file mode 100644 index 000000000..474216e82 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx @@ -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; +} + +export const DeleteTierDialog: FC = ({ open, tier, onClose, onConfirm }) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + Delete Tier + + {error && {error}} + + + Are you sure you want to delete the tier {tier?.name}? + + + + This action cannot be undone. If this tier is assigned to customers, they will be affected. + + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx new file mode 100644 index 000000000..366e8aae2 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx @@ -0,0 +1,240 @@ +import React, { FC, useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, + Box +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material'; +import { RefillStrategy, type Tier } from "../../../extension-registry-types"; + +type DurationUnit = 'seconds' | 'minutes' | 'hours' | 'days'; + +const DURATION_MULTIPLIERS: Record = { + seconds: 1, + minutes: 60, + hours: 3600, + days: 86400 +}; + +interface TierFormDialogProps { + open: boolean; + tier?: Tier; + onClose: () => void; + onSubmit: (formData: Omit) => Promise; +} + +export const TierFormDialog: FC = ({ open, tier, onClose, onSubmit }) => { + const [formData, setFormData] = useState>({ + name: '', + description: '', + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.INTERVAL + }); + const [durationValue, setDurationValue] = useState(1); + const [durationUnit, setDurationUnit] = useState('hours'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const getDurationInSeconds = (): number => { + return durationValue * DURATION_MULTIPLIERS[durationUnit]; + }; + + useEffect(() => { + if (tier) { + setFormData(prev => ({ + name: tier.name, + description: tier.description || '', + capacity: tier.capacity, + duration: tier.duration, + refillStrategy: tier.refillStrategy as any + })); + // Convert duration seconds to hours for display + setDurationValue(Math.floor(tier.duration / 3600)); + setDurationUnit('hours'); + } else { + setFormData(prev => ({ + ...prev, + name: '', + description: '', + capacity: 100, + duration: 3600, + refillStrategy: RefillStrategy.INTERVAL + })); + setDurationValue(1); + setDurationUnit('hours'); + } + setError(null); + }, [open, tier]); + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target as any; + setFormData((prev: Omit) => ({ + ...prev, + [name]: name === 'capacity' || name === 'duration' ? Number.parseInt(value as string, 10) : value + } as Omit)); + }; + + const handleSubmit = async () => { + try { + setError(null); + setLoading(true); + + // Basic validation + if (!formData.name.trim()) { + setError('Tier name is required'); + setLoading(false); + return; + } + + if (formData.capacity <= 0) { + setError('Capacity must be greater than 0'); + setLoading(false); + return; + } + + if (durationValue <= 0) { + setError('Duration must be greater than 0'); + setLoading(false); + return; + } + + const durationInSeconds = getDurationInSeconds(); + await onSubmit({ + ...formData, + duration: durationInSeconds + }); + onClose(); + } catch (err: any) { + setError(err.message || 'An error occurred while saving the tier'); + setLoading(false); + } + }; + + const isEditMode = !!tier; + const title = isEditMode ? 'Edit Tier' : 'Create New Tier'; + + return ( + + {title} + + + {error && {error}} + + + + + + + + + setDurationValue(Math.max(1, Number.parseInt(e.target.value, 10) || 0))} + inputProps={{ min: '1' }} + disabled={loading} + required={true} + sx={{ flex: 1 }} + /> + + Unit + + + + + = {getDurationInSeconds().toLocaleString()} seconds + + + + Refill Strategy + + + + + + + + + + + + + ); +}; diff --git a/webui/src/pages/admin-dashboard/tiers/tiers.tsx b/webui/src/pages/admin-dashboard/tiers/tiers.tsx new file mode 100644 index 000000000..f5cdc02c1 --- /dev/null +++ b/webui/src/pages/admin-dashboard/tiers/tiers.tsx @@ -0,0 +1,209 @@ +import React, { FC, useState, useEffect } from "react"; +import { + Box, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + CircularProgress, + Alert, + IconButton, + Stack +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import { MainContext } from "../../../context"; +import type { Tier, TierFormData } from "../../../extension-registry-types"; +import { TierFormDialog } from "./tier-form-dialog"; +import { DeleteTierDialog } from "./delete-tier-dialog"; + +export const Tiers: FC = () => { + const { service } = React.useContext(MainContext); + const [tiers, setTiers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formDialogOpen, setFormDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedTier, setSelectedTier] = useState(); + + // Load all tiers + const loadTiers = async () => { + try { + setLoading(true); + setError(null); + const data = await service.admin.getAllTiers(); + setTiers(data as Tier[]); + } catch (err: any) { + setError(err.message || "Failed to load tiers"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadTiers(); + }, []); + + const handleCreateClick = () => { + setSelectedTier(undefined); + setFormDialogOpen(true); + }; + + const handleEditClick = (tier: Tier) => { + setSelectedTier(tier); + setFormDialogOpen(true); + }; + + const handleDeleteClick = (tier: Tier) => { + setSelectedTier(tier); + setDeleteDialogOpen(true); + }; + + const handleFormSubmit = async (formData: TierFormData) => { + try { + if (selectedTier) { + // Update existing tier + await service.admin.updateTier(selectedTier.id, formData); + } else { + // Create new tier + await service.admin.createTier(formData); + } + await loadTiers(); + } catch (err: any) { + throw new Error(err.message || "Failed to save tier"); + } + }; + + const handleDeleteConfirm = async () => { + try { + if (selectedTier) { + await service.admin.deleteTier(selectedTier.id); + await loadTiers(); + } + } catch (err: any) { + throw new Error(err.message || "Failed to delete tier"); + } + }; + + const handleFormDialogClose = () => { + setFormDialogOpen(false); + setSelectedTier(undefined); + }; + + const handleDeleteDialogClose = () => { + setDeleteDialogOpen(false); + setSelectedTier(undefined); + }; + + return ( + + + + Tiers Management + + + + + {error && ( + setError(null)}> + {error} + + )} + + {loading && ( + + + + )} + + {!loading && tiers.length === 0 && ( + + + No tiers found. Create one to get started. + + + )} + + {!loading && tiers.length > 0 && ( + + + + + Name + Description + + Capacity + + + Duration (s) + + Refill Strategy + + Actions + + + + + {tiers.map(tier => ( + + {tier.name} + {tier.description || "-"} + {tier.capacity.toLocaleString()} + {tier.duration.toLocaleString()} + {tier.refillStrategy} + + + handleEditClick(tier)} + title='Edit tier' + color='primary' + > + + + handleDeleteClick(tier)} + title='Delete tier' + color='error' + > + + + + + + ))} + +
+
+ )} + + + + +
+ ); +};