diff --git a/api/cmd/main.go b/api/cmd/main.go index d50d0656..72940c69 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -266,6 +266,7 @@ func main() { securityHandler := handlers.NewSecurityHandler(database) templateVersioningHandler := handlers.NewTemplateVersioningHandler(database) setupHandler := handlers.NewSetupHandler(database) + applicationHandler := handlers.NewApplicationHandler(database) // NOTE: Billing is now handled by the streamspace-billing plugin // SECURITY: Initialize webhook authentication @@ -276,7 +277,7 @@ func main() { } // Setup routes - setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, nodeHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) + setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, nodeHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, applicationHandler, jwtManager, userDB, redisCache, webhookSecret) // Create HTTP server with security timeouts srv := &http.Server{ @@ -357,7 +358,7 @@ func main() { log.Println("Graceful shutdown completed") } -func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, nodeHandler *handlers.NodeHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { +func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, nodeHandler *handlers.NodeHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, applicationHandler *handlers.ApplicationHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { // SECURITY: Create authentication middleware authMiddleware := auth.Middleware(jwtManager, userDB) adminMiddleware := auth.RequireRole("admin") @@ -688,6 +689,9 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH // Plugin system - using dedicated handler pluginHandler.RegisterRoutes(protected) + // Installed applications management - using dedicated handler (admin only for management) + applicationHandler.RegisterRoutes(protected) + // Team-based RBAC - using dedicated handler teamHandler.RegisterRoutes(protected) diff --git a/api/internal/db/applications.go b/api/internal/db/applications.go new file mode 100644 index 00000000..9a193ee4 --- /dev/null +++ b/api/internal/db/applications.go @@ -0,0 +1,454 @@ +// Package db provides PostgreSQL database access and management for StreamSpace. +// +// This file implements installed application management and access control. +// +// Purpose: +// - CRUD operations for installed applications +// - Application configuration management +// - Group-based access control for applications +// - Application enable/disable functionality +// +// Features: +// - Install applications from catalog templates +// - Custom display names for user dashboard +// - Configuration storage in JSONB +// - Group access permissions +// - Enable/disable applications +// +// Database Schema: +// - installed_applications table: Installed application instances +// - id (varchar): Primary key (UUID) +// - catalog_template_id (int): Foreign key to catalog_templates +// - name (varchar): Internal name with GUID suffix +// - display_name (varchar): Custom display name for dashboard +// - folder_path (varchar): Path to configuration folder +// - enabled (boolean): Whether application is active +// - configuration (jsonb): Application-specific settings +// - created_by (varchar): User who installed the application +// - created_at, updated_at: Timestamps +// +// - application_group_access table: Group permissions for applications +// - id (varchar): Primary key (UUID) +// - application_id (varchar): Foreign key to installed_applications +// - group_id (varchar): Foreign key to groups +// - access_level (varchar): Permission level (view, launch, admin) +// - created_at: When access was granted +// +// Thread Safety: +// - All database operations are thread-safe via database/sql pool +// +// Example Usage: +// +// appDB := db.NewApplicationDB(database.DB()) +// +// // Install application +// app, err := appDB.InstallApplication(ctx, &models.InstallApplicationRequest{ +// CatalogTemplateID: 1, +// DisplayName: "Firefox Browser", +// }) +// +// // Grant group access +// err := appDB.AddGroupAccess(ctx, appID, groupID, "launch") +// +// // Enable/disable application +// err := appDB.SetApplicationEnabled(ctx, appID, true) +package db + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/streamspace/streamspace/api/internal/models" +) + +// ApplicationDB handles database operations for installed applications +type ApplicationDB struct { + db *sql.DB +} + +// NewApplicationDB creates a new ApplicationDB instance +func NewApplicationDB(db *sql.DB) *ApplicationDB { + return &ApplicationDB{db: db} +} + +// InstallApplication installs a new application from the catalog +func (a *ApplicationDB) InstallApplication(ctx context.Context, req *models.InstallApplicationRequest, userID string) (*models.InstalledApplication, error) { + appID := uuid.New().String() + guidSuffix := uuid.New().String()[:8] + + // Get template info for default name + var templateName, templateDisplayName string + err := a.db.QueryRowContext(ctx, ` + SELECT name, display_name FROM catalog_templates WHERE id = $1 + `, req.CatalogTemplateID).Scan(&templateName, &templateDisplayName) + if err != nil { + return nil, fmt.Errorf("failed to get template: %w", err) + } + + // Set default display name if not provided + displayName := req.DisplayName + if displayName == "" { + displayName = templateDisplayName + } + + // Create internal name with GUID suffix + name := fmt.Sprintf("%s-%s", templateName, guidSuffix) + folderPath := fmt.Sprintf("apps/%s", name) + + // Serialize configuration + configJSON := []byte("{}") + if req.Configuration != nil { + configJSON, err = json.Marshal(req.Configuration) + if err != nil { + return nil, fmt.Errorf("failed to marshal configuration: %w", err) + } + } + + app := &models.InstalledApplication{ + ID: appID, + CatalogTemplateID: req.CatalogTemplateID, + Name: name, + DisplayName: displayName, + FolderPath: folderPath, + Enabled: true, + Configuration: req.Configuration, + CreatedBy: userID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO installed_applications ( + id, catalog_template_id, name, display_name, folder_path, + enabled, configuration, created_by, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ` + + _, err = a.db.ExecContext(ctx, query, + app.ID, app.CatalogTemplateID, app.Name, app.DisplayName, app.FolderPath, + app.Enabled, configJSON, app.CreatedBy, app.CreatedAt, app.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to install application: %w", err) + } + + return app, nil +} + +// GetApplication retrieves an installed application by ID +func (a *ApplicationDB) GetApplication(ctx context.Context, appID string) (*models.InstalledApplication, error) { + app := &models.InstalledApplication{} + var configJSON []byte + + query := ` + SELECT + ia.id, ia.catalog_template_id, ia.name, ia.display_name, ia.folder_path, + ia.enabled, ia.configuration, ia.created_by, ia.created_at, ia.updated_at, + ct.name as template_name, ct.display_name as template_display_name, + ct.description, ct.category, ct.app_type, ct.icon_url, ct.manifest + FROM installed_applications ia + JOIN catalog_templates ct ON ia.catalog_template_id = ct.id + WHERE ia.id = $1 + ` + + err := a.db.QueryRowContext(ctx, query, appID).Scan( + &app.ID, &app.CatalogTemplateID, &app.Name, &app.DisplayName, &app.FolderPath, + &app.Enabled, &configJSON, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt, + &app.TemplateName, &app.TemplateDisplayName, &app.Description, + &app.Category, &app.AppType, &app.IconURL, &app.Manifest, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("application not found") + } + return nil, err + } + + // Unmarshal configuration + if len(configJSON) > 0 { + json.Unmarshal(configJSON, &app.Configuration) + } + + return app, nil +} + +// ListApplications retrieves all installed applications with optional filtering +func (a *ApplicationDB) ListApplications(ctx context.Context, enabledOnly bool) ([]*models.InstalledApplication, error) { + query := ` + SELECT + ia.id, ia.catalog_template_id, ia.name, ia.display_name, ia.folder_path, + ia.enabled, ia.configuration, ia.created_by, ia.created_at, ia.updated_at, + ct.name as template_name, ct.display_name as template_display_name, + ct.description, ct.category, ct.app_type, ct.icon_url + FROM installed_applications ia + JOIN catalog_templates ct ON ia.catalog_template_id = ct.id + WHERE 1=1 + ` + + if enabledOnly { + query += " AND ia.enabled = true" + } + + query += " ORDER BY ia.display_name ASC" + + rows, err := a.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + apps := []*models.InstalledApplication{} + for rows.Next() { + app := &models.InstalledApplication{} + var configJSON []byte + + err := rows.Scan( + &app.ID, &app.CatalogTemplateID, &app.Name, &app.DisplayName, &app.FolderPath, + &app.Enabled, &configJSON, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt, + &app.TemplateName, &app.TemplateDisplayName, &app.Description, + &app.Category, &app.AppType, &app.IconURL, + ) + if err != nil { + continue + } + + // Unmarshal configuration + if len(configJSON) > 0 { + json.Unmarshal(configJSON, &app.Configuration) + } + + apps = append(apps, app) + } + + return apps, nil +} + +// UpdateApplication updates an installed application +func (a *ApplicationDB) UpdateApplication(ctx context.Context, appID string, req *models.UpdateApplicationRequest) error { + updates := []string{} + args := []interface{}{} + argIdx := 1 + + if req.DisplayName != nil { + updates = append(updates, fmt.Sprintf("display_name = $%d", argIdx)) + args = append(args, *req.DisplayName) + argIdx++ + } + + if req.Enabled != nil { + updates = append(updates, fmt.Sprintf("enabled = $%d", argIdx)) + args = append(args, *req.Enabled) + argIdx++ + } + + if req.Configuration != nil { + configJSON, err := json.Marshal(req.Configuration) + if err != nil { + return fmt.Errorf("failed to marshal configuration: %w", err) + } + updates = append(updates, fmt.Sprintf("configuration = $%d", argIdx)) + args = append(args, configJSON) + argIdx++ + } + + if len(updates) == 0 { + return nil // Nothing to update + } + + updates = append(updates, fmt.Sprintf("updated_at = $%d", argIdx)) + args = append(args, time.Now()) + argIdx++ + + args = append(args, appID) + + query := fmt.Sprintf("UPDATE installed_applications SET %s WHERE id = $%d", + joinStrings(updates, ", "), argIdx) + + _, err := a.db.ExecContext(ctx, query, args...) + return err +} + +// DeleteApplication deletes an installed application +func (a *ApplicationDB) DeleteApplication(ctx context.Context, appID string) error { + // Delete group access first + _, err := a.db.ExecContext(ctx, "DELETE FROM application_group_access WHERE application_id = $1", appID) + if err != nil { + return err + } + + // Delete application + _, err = a.db.ExecContext(ctx, "DELETE FROM installed_applications WHERE id = $1", appID) + return err +} + +// SetApplicationEnabled enables or disables an application +func (a *ApplicationDB) SetApplicationEnabled(ctx context.Context, appID string, enabled bool) error { + _, err := a.db.ExecContext(ctx, ` + UPDATE installed_applications + SET enabled = $1, updated_at = $2 + WHERE id = $3 + `, enabled, time.Now(), appID) + + return err +} + +// === Group Access Operations === + +// AddGroupAccess grants a group access to an application +func (a *ApplicationDB) AddGroupAccess(ctx context.Context, appID, groupID, accessLevel string) error { + if accessLevel == "" { + accessLevel = "launch" + } + + id := uuid.New().String() + + query := ` + INSERT INTO application_group_access (id, application_id, group_id, access_level, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (application_id, group_id) DO UPDATE + SET access_level = $4 + ` + + _, err := a.db.ExecContext(ctx, query, id, appID, groupID, accessLevel, time.Now()) + return err +} + +// RemoveGroupAccess removes a group's access to an application +func (a *ApplicationDB) RemoveGroupAccess(ctx context.Context, appID, groupID string) error { + _, err := a.db.ExecContext(ctx, ` + DELETE FROM application_group_access + WHERE application_id = $1 AND group_id = $2 + `, appID, groupID) + + return err +} + +// GetApplicationGroups retrieves all groups with access to an application +func (a *ApplicationDB) GetApplicationGroups(ctx context.Context, appID string) ([]*models.ApplicationGroupAccess, error) { + query := ` + SELECT + aga.id, aga.application_id, aga.group_id, aga.access_level, aga.created_at, + g.name, g.display_name + FROM application_group_access aga + JOIN groups g ON aga.group_id = g.id + WHERE aga.application_id = $1 + ORDER BY g.display_name ASC + ` + + rows, err := a.db.QueryContext(ctx, query, appID) + if err != nil { + return nil, err + } + defer rows.Close() + + accessList := []*models.ApplicationGroupAccess{} + for rows.Next() { + access := &models.ApplicationGroupAccess{} + err := rows.Scan( + &access.ID, &access.ApplicationID, &access.GroupID, + &access.AccessLevel, &access.CreatedAt, + &access.GroupName, &access.GroupDisplayName, + ) + if err != nil { + continue + } + accessList = append(accessList, access) + } + + return accessList, nil +} + +// UpdateGroupAccessLevel updates a group's access level for an application +func (a *ApplicationDB) UpdateGroupAccessLevel(ctx context.Context, appID, groupID, accessLevel string) error { + _, err := a.db.ExecContext(ctx, ` + UPDATE application_group_access + SET access_level = $1 + WHERE application_id = $2 AND group_id = $3 + `, accessLevel, appID, groupID) + + return err +} + +// HasGroupAccess checks if a group has access to an application +func (a *ApplicationDB) HasGroupAccess(ctx context.Context, appID, groupID string) (bool, error) { + var exists bool + err := a.db.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM application_group_access WHERE application_id = $1 AND group_id = $2) + `, appID, groupID).Scan(&exists) + + return exists, err +} + +// GetUserAccessibleApplications retrieves applications accessible to a user (via their groups) +func (a *ApplicationDB) GetUserAccessibleApplications(ctx context.Context, userID string) ([]*models.InstalledApplication, error) { + query := ` + SELECT DISTINCT + ia.id, ia.catalog_template_id, ia.name, ia.display_name, ia.folder_path, + ia.enabled, ia.configuration, ia.created_by, ia.created_at, ia.updated_at, + ct.name as template_name, ct.display_name as template_display_name, + ct.description, ct.category, ct.app_type, ct.icon_url + FROM installed_applications ia + JOIN catalog_templates ct ON ia.catalog_template_id = ct.id + JOIN application_group_access aga ON ia.id = aga.application_id + JOIN group_memberships gm ON aga.group_id = gm.group_id + WHERE gm.user_id = $1 AND ia.enabled = true + ORDER BY ia.display_name ASC + ` + + rows, err := a.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + apps := []*models.InstalledApplication{} + for rows.Next() { + app := &models.InstalledApplication{} + var configJSON []byte + + err := rows.Scan( + &app.ID, &app.CatalogTemplateID, &app.Name, &app.DisplayName, &app.FolderPath, + &app.Enabled, &configJSON, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt, + &app.TemplateName, &app.TemplateDisplayName, &app.Description, + &app.Category, &app.AppType, &app.IconURL, + ) + if err != nil { + continue + } + + // Unmarshal configuration + if len(configJSON) > 0 { + json.Unmarshal(configJSON, &app.Configuration) + } + + apps = append(apps, app) + } + + return apps, nil +} + +// GetApplicationTemplateConfig retrieves the template's configurable options +func (a *ApplicationDB) GetApplicationTemplateConfig(ctx context.Context, appID string) (map[string]interface{}, error) { + var manifest string + err := a.db.QueryRowContext(ctx, ` + SELECT ct.manifest + FROM installed_applications ia + JOIN catalog_templates ct ON ia.catalog_template_id = ct.id + WHERE ia.id = $1 + `, appID).Scan(&manifest) + if err != nil { + return nil, err + } + + var config map[string]interface{} + if manifest != "" { + json.Unmarshal([]byte(manifest), &config) + } + + return config, nil +} diff --git a/api/internal/handlers/applications.go b/api/internal/handlers/applications.go new file mode 100644 index 00000000..a15c3344 --- /dev/null +++ b/api/internal/handlers/applications.go @@ -0,0 +1,533 @@ +// Package handlers provides HTTP handlers for the StreamSpace API. +// This file implements installed application management endpoints. +// +// APPLICATION FEATURES: +// - Install applications from catalog templates +// - Custom display names for user dashboards +// - Application configuration management +// - Enable/disable applications +// - Group-based access control +// +// ACCESS CONTROL: +// - Grant/revoke group access to applications +// - Multiple access levels (view, launch, admin) +// - Filter applications by user's group membership +// +// API Endpoints: +// - GET /api/v1/applications - List all installed applications +// - POST /api/v1/applications - Install a new application +// - GET /api/v1/applications/:id - Get application details +// - PUT /api/v1/applications/:id - Update application +// - DELETE /api/v1/applications/:id - Delete application +// - PUT /api/v1/applications/:id/enabled - Enable/disable application +// - GET /api/v1/applications/:id/groups - Get groups with access +// - POST /api/v1/applications/:id/groups - Add group access +// - PUT /api/v1/applications/:id/groups/:groupId - Update group access level +// - DELETE /api/v1/applications/:id/groups/:groupId - Remove group access +// - GET /api/v1/applications/:id/config - Get template config options +// - GET /api/v1/applications/user - Get applications accessible to current user +// +// Thread Safety: +// - All database operations are thread-safe via connection pooling +// +// Dependencies: +// - Database: installed_applications, application_group_access tables +// +// Example Usage: +// +// handler := NewApplicationHandler(database) +// handler.RegisterRoutes(router.Group("/api/v1")) +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/streamspace/streamspace/api/internal/db" + "github.com/streamspace/streamspace/api/internal/models" +) + +// ApplicationHandler handles installed application endpoints +type ApplicationHandler struct { + db *db.Database + appDB *db.ApplicationDB +} + +// NewApplicationHandler creates a new application handler +func NewApplicationHandler(database *db.Database) *ApplicationHandler { + return &ApplicationHandler{ + db: database, + appDB: db.NewApplicationDB(database.DB()), + } +} + +// RegisterRoutes registers application-related routes +func (h *ApplicationHandler) RegisterRoutes(router *gin.RouterGroup) { + apps := router.Group("/applications") + { + apps.GET("", h.ListApplications) + apps.POST("", h.InstallApplication) + apps.GET("/user", h.GetUserApplications) + apps.GET("/:id", h.GetApplication) + apps.PUT("/:id", h.UpdateApplication) + apps.DELETE("/:id", h.DeleteApplication) + apps.PUT("/:id/enabled", h.SetApplicationEnabled) + apps.GET("/:id/groups", h.GetApplicationGroups) + apps.POST("/:id/groups", h.AddGroupAccess) + apps.PUT("/:id/groups/:groupId", h.UpdateGroupAccess) + apps.DELETE("/:id/groups/:groupId", h.RemoveGroupAccess) + apps.GET("/:id/config", h.GetTemplateConfig) + } +} + +// ListApplications godoc +// @Summary List all installed applications +// @Description Get all installed applications with optional filtering +// @Tags applications +// @Accept json +// @Produce json +// @Param enabled query boolean false "Filter by enabled status" +// @Success 200 {object} models.ApplicationListResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications [get] +func (h *ApplicationHandler) ListApplications(c *gin.Context) { + enabledOnly := c.Query("enabled") == "true" + + apps, err := h.appDB.ListApplications(c.Request.Context(), enabledOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Database error", + Message: err.Error(), + }) + return + } + + // Get group access for each application + for _, app := range apps { + groups, err := h.appDB.GetApplicationGroups(c.Request.Context(), app.ID) + if err == nil { + app.Groups = groups + } + } + + c.JSON(http.StatusOK, models.ApplicationListResponse{ + Applications: apps, + Total: len(apps), + }) +} + +// InstallApplication godoc +// @Summary Install a new application +// @Description Install an application from the catalog +// @Tags applications +// @Accept json +// @Produce json +// @Param request body models.InstallApplicationRequest true "Installation request" +// @Success 201 {object} models.InstalledApplication +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications [post] +func (h *ApplicationHandler) InstallApplication(c *gin.Context) { + var req models.InstallApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request", + Message: err.Error(), + }) + return + } + + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{ + Error: "Unauthorized", + Message: "User not authenticated", + }) + return + } + + app, err := h.appDB.InstallApplication(c.Request.Context(), &req, userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Installation failed", + Message: err.Error(), + }) + return + } + + // Grant initial group access if specified + for _, groupID := range req.GroupIDs { + h.appDB.AddGroupAccess(c.Request.Context(), app.ID, groupID, "launch") + } + + // Get full application with template info + fullApp, err := h.appDB.GetApplication(c.Request.Context(), app.ID) + if err == nil { + app = fullApp + } + + // Get group access + groups, err := h.appDB.GetApplicationGroups(c.Request.Context(), app.ID) + if err == nil { + app.Groups = groups + } + + c.JSON(http.StatusCreated, app) +} + +// GetApplication godoc +// @Summary Get application details +// @Description Get detailed information about an installed application +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Success 200 {object} models.InstalledApplication +// @Failure 404 {object} ErrorResponse +// @Router /api/v1/applications/{id} [get] +func (h *ApplicationHandler) GetApplication(c *gin.Context) { + appID := c.Param("id") + + app, err := h.appDB.GetApplication(c.Request.Context(), appID) + if err != nil { + c.JSON(http.StatusNotFound, ErrorResponse{ + Error: "Application not found", + Message: err.Error(), + }) + return + } + + // Get group access + groups, err := h.appDB.GetApplicationGroups(c.Request.Context(), appID) + if err == nil { + app.Groups = groups + } + + c.JSON(http.StatusOK, app) +} + +// UpdateApplication godoc +// @Summary Update an application +// @Description Update display name, configuration, or enabled status +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Param request body models.UpdateApplicationRequest true "Update request" +// @Success 200 {object} models.InstalledApplication +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /api/v1/applications/{id} [put] +func (h *ApplicationHandler) UpdateApplication(c *gin.Context) { + appID := c.Param("id") + + var req models.UpdateApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request", + Message: err.Error(), + }) + return + } + + err := h.appDB.UpdateApplication(c.Request.Context(), appID, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Update failed", + Message: err.Error(), + }) + return + } + + // Return updated application + app, err := h.appDB.GetApplication(c.Request.Context(), appID) + if err != nil { + c.JSON(http.StatusNotFound, ErrorResponse{ + Error: "Application not found", + Message: err.Error(), + }) + return + } + + // Get group access + groups, err := h.appDB.GetApplicationGroups(c.Request.Context(), appID) + if err == nil { + app.Groups = groups + } + + c.JSON(http.StatusOK, app) +} + +// DeleteApplication godoc +// @Summary Delete an application +// @Description Remove an installed application and all its access rules +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications/{id} [delete] +func (h *ApplicationHandler) DeleteApplication(c *gin.Context) { + appID := c.Param("id") + + err := h.appDB.DeleteApplication(c.Request.Context(), appID) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Delete failed", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Application deleted successfully", + }) +} + +// SetApplicationEnabled godoc +// @Summary Enable or disable an application +// @Description Toggle the application's enabled status +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Param request body object true "Enabled status" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} ErrorResponse +// @Router /api/v1/applications/{id}/enabled [put] +func (h *ApplicationHandler) SetApplicationEnabled(c *gin.Context) { + appID := c.Param("id") + + var req struct { + Enabled bool `json:"enabled"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request", + Message: err.Error(), + }) + return + } + + err := h.appDB.SetApplicationEnabled(c.Request.Context(), appID, req.Enabled) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Update failed", + Message: err.Error(), + }) + return + } + + status := "disabled" + if req.Enabled { + status = "enabled" + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Application " + status + " successfully", + "enabled": req.Enabled, + }) +} + +// GetApplicationGroups godoc +// @Summary Get groups with access to an application +// @Description List all groups that have access to this application +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications/{id}/groups [get] +func (h *ApplicationHandler) GetApplicationGroups(c *gin.Context) { + appID := c.Param("id") + + groups, err := h.appDB.GetApplicationGroups(c.Request.Context(), appID) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Database error", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "groups": groups, + "total": len(groups), + }) +} + +// AddGroupAccess godoc +// @Summary Grant group access to an application +// @Description Add a group with specified access level +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Param request body models.AddGroupAccessRequest true "Access request" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} ErrorResponse +// @Router /api/v1/applications/{id}/groups [post] +func (h *ApplicationHandler) AddGroupAccess(c *gin.Context) { + appID := c.Param("id") + + var req models.AddGroupAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request", + Message: err.Error(), + }) + return + } + + accessLevel := req.AccessLevel + if accessLevel == "" { + accessLevel = "launch" + } + + err := h.appDB.AddGroupAccess(c.Request.Context(), appID, req.GroupID, accessLevel) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to add access", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Group access granted successfully", + }) +} + +// UpdateGroupAccess godoc +// @Summary Update group access level +// @Description Change a group's access level for an application +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Param groupId path string true "Group ID" +// @Param request body models.UpdateGroupAccessRequest true "Access level" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} ErrorResponse +// @Router /api/v1/applications/{id}/groups/{groupId} [put] +func (h *ApplicationHandler) UpdateGroupAccess(c *gin.Context) { + appID := c.Param("id") + groupID := c.Param("groupId") + + var req models.UpdateGroupAccessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request", + Message: err.Error(), + }) + return + } + + err := h.appDB.UpdateGroupAccessLevel(c.Request.Context(), appID, groupID, req.AccessLevel) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to update access", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Group access updated successfully", + }) +} + +// RemoveGroupAccess godoc +// @Summary Remove group access from an application +// @Description Revoke a group's access to an application +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Param groupId path string true "Group ID" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications/{id}/groups/{groupId} [delete] +func (h *ApplicationHandler) RemoveGroupAccess(c *gin.Context) { + appID := c.Param("id") + groupID := c.Param("groupId") + + err := h.appDB.RemoveGroupAccess(c.Request.Context(), appID, groupID) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to remove access", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Group access removed successfully", + }) +} + +// GetTemplateConfig godoc +// @Summary Get application template configuration options +// @Description Get the configurable options from the template manifest +// @Tags applications +// @Accept json +// @Produce json +// @Param id path string true "Application ID" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications/{id}/config [get] +func (h *ApplicationHandler) GetTemplateConfig(c *gin.Context) { + appID := c.Param("id") + + config, err := h.appDB.GetApplicationTemplateConfig(c.Request.Context(), appID) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to get config", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "config": config, + }) +} + +// GetUserApplications godoc +// @Summary Get applications accessible to current user +// @Description Get all applications the user can access via their groups +// @Tags applications +// @Accept json +// @Produce json +// @Success 200 {object} models.ApplicationListResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/applications/user [get] +func (h *ApplicationHandler) GetUserApplications(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{ + Error: "Unauthorized", + Message: "User not authenticated", + }) + return + } + + apps, err := h.appDB.GetUserAccessibleApplications(c.Request.Context(), userID.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Database error", + Message: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.ApplicationListResponse{ + Applications: apps, + Total: len(apps), + }) +} diff --git a/api/internal/models/application.go b/api/internal/models/application.go new file mode 100644 index 00000000..ec740cd7 --- /dev/null +++ b/api/internal/models/application.go @@ -0,0 +1,169 @@ +// Package models defines the core data structures for the StreamSpace API. +// +// This file contains models for installed applications and access control. +// +// Features: +// - Installed application representation +// - Group access control models +// - Request/response types for application API +package models + +import ( + "time" +) + +// InstalledApplication represents an installed application instance. +// +// Applications are installed from catalog templates and can be: +// - Customized with display names +// - Configured with application-specific settings +// - Enabled/disabled +// - Granted to specific groups +// +// Example: +// +// { +// "id": "550e8400-e29b-41d4-a716-446655440000", +// "catalogTemplateId": 1, +// "name": "firefox-abc12345", +// "displayName": "Firefox Browser", +// "folderPath": "apps/firefox-abc12345", +// "enabled": true, +// "configuration": {"homepage": "https://example.com"} +// } +type InstalledApplication struct { + // ID is a unique identifier for this installed application (UUID v4). + ID string `json:"id" db:"id"` + + // CatalogTemplateID is the ID of the source catalog template. + CatalogTemplateID int `json:"catalogTemplateId" db:"catalog_template_id"` + + // Name is the internal name with GUID suffix for uniqueness. + // Example: "firefox-abc12345" + Name string `json:"name" db:"name"` + + // DisplayName is the custom name shown on user dashboards. + // Can be changed by admins to customize the user experience. + // Example: "Firefox Browser", "Development Firefox" + DisplayName string `json:"displayName" db:"display_name"` + + // FolderPath is the path to the application configuration folder. + // Example: "apps/firefox-abc12345" + FolderPath string `json:"folderPath" db:"folder_path"` + + // Enabled indicates whether the application is active. + // Disabled applications are not shown to users. + Enabled bool `json:"enabled" db:"enabled"` + + // Configuration contains application-specific settings as JSONB. + // Schema depends on the template's configurable options. + Configuration map[string]interface{} `json:"configuration,omitempty"` + + // CreatedBy is the user ID who installed the application. + CreatedBy string `json:"createdBy" db:"created_by"` + + // CreatedAt is when the application was installed. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + + // UpdatedAt is when the application was last modified. + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + + // Template information (populated from JOIN) + TemplateName string `json:"templateName,omitempty"` + TemplateDisplayName string `json:"templateDisplayName,omitempty"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + AppType string `json:"appType,omitempty"` + IconURL string `json:"icon,omitempty"` + Manifest string `json:"manifest,omitempty"` + + // Groups with access to this application (populated separately) + Groups []*ApplicationGroupAccess `json:"groups,omitempty"` +} + +// ApplicationGroupAccess represents a group's access to an application. +// +// Access levels: +// - "view": Can see the application in the catalog +// - "launch": Can launch sessions with this application +// - "admin": Can modify application settings +type ApplicationGroupAccess struct { + // ID is a unique identifier for this access record. + ID string `json:"id" db:"id"` + + // ApplicationID is the installed application. + ApplicationID string `json:"applicationId" db:"application_id"` + + // GroupID is the group with access. + GroupID string `json:"groupId" db:"group_id"` + + // AccessLevel is the permission level. + // Valid values: "view", "launch", "admin" + AccessLevel string `json:"accessLevel" db:"access_level"` + + // CreatedAt is when access was granted. + CreatedAt time.Time `json:"createdAt" db:"created_at"` + + // Group information (populated from JOIN) + GroupName string `json:"groupName,omitempty"` + GroupDisplayName string `json:"groupDisplayName,omitempty"` +} + +// InstallApplicationRequest is the request to install a new application. +type InstallApplicationRequest struct { + // CatalogTemplateID is the source template to install from. + CatalogTemplateID int `json:"catalogTemplateId" binding:"required"` + + // DisplayName is the custom name for this installation (optional). + // If not provided, uses the template's default display name. + DisplayName string `json:"displayName"` + + // Configuration is the initial application settings (optional). + Configuration map[string]interface{} `json:"configuration"` + + // GroupIDs is the list of groups to grant access (optional). + // If not provided, no groups will have access initially. + GroupIDs []string `json:"groupIds"` +} + +// UpdateApplicationRequest is the request to update an installed application. +type UpdateApplicationRequest struct { + // DisplayName updates the custom display name. + DisplayName *string `json:"displayName,omitempty"` + + // Enabled updates the active status. + Enabled *bool `json:"enabled,omitempty"` + + // Configuration updates the application settings. + Configuration map[string]interface{} `json:"configuration,omitempty"` +} + +// AddGroupAccessRequest is the request to grant group access to an application. +type AddGroupAccessRequest struct { + // GroupID is the group to grant access. + GroupID string `json:"groupId" binding:"required"` + + // AccessLevel is the permission level. + // Valid values: "view", "launch", "admin" + // Default: "launch" + AccessLevel string `json:"accessLevel"` +} + +// UpdateGroupAccessRequest is the request to update a group's access level. +type UpdateGroupAccessRequest struct { + // AccessLevel is the new permission level. + // Valid values: "view", "launch", "admin" + AccessLevel string `json:"accessLevel" binding:"required"` +} + +// ApplicationListResponse is the response for listing applications. +type ApplicationListResponse struct { + Applications []*InstalledApplication `json:"applications"` + Total int `json:"total"` +} + +// ApplicationWithGroups is an application with its group access list. +type ApplicationWithGroups struct { + *InstalledApplication + Groups []*ApplicationGroupAccess `json:"groups"` +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a9e21bc9..c5f8e86a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,7 +34,7 @@ const SessionViewer = lazy(() => import('./pages/SessionViewer')); const UserSettings = lazy(() => import('./pages/UserSettings')); // Admin Content Management Pages (moved from user pages) -const EnhancedCatalog = lazy(() => import('./pages/EnhancedCatalog')); +const Applications = lazy(() => import('./pages/Applications')); const Scheduling = lazy(() => import('./pages/Scheduling')); const SecuritySettings = lazy(() => import('./pages/SecuritySettings')); const EnhancedRepositories = lazy(() => import('./pages/EnhancedRepositories')); @@ -337,10 +337,10 @@ function App() { {/* Admin Content Management Routes */} - + } /> diff --git a/ui/src/components/AdminPortalLayout.tsx b/ui/src/components/AdminPortalLayout.tsx index bf62095f..d42a1aad 100644 --- a/ui/src/components/AdminPortalLayout.tsx +++ b/ui/src/components/AdminPortalLayout.tsx @@ -122,7 +122,7 @@ function AdminPortalLayout({ children }: AdminPortalLayoutProps) { { title: 'Content Management', items: [ - { text: 'Template Catalog', icon: , path: '/admin/templates' }, + { text: 'Applications', icon: , path: '/admin/applications' }, { text: 'Plugins', icon: , path: '/admin/plugins' }, { text: 'Repositories', icon: , path: '/admin/repositories' }, ], diff --git a/ui/src/hooks/useApi.ts b/ui/src/hooks/useApi.ts index 7f7d8309..b9ed02ce 100644 --- a/ui/src/hooks/useApi.ts +++ b/ui/src/hooks/useApi.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import api, { CreateSessionRequest, CatalogFilters } from '../lib/api'; +import api, { CreateSessionRequest, CatalogFilters, InstallApplicationRequest, UpdateApplicationRequest, AddGroupAccessRequest } from '../lib/api'; // ============================================================================ // Session Hooks @@ -296,6 +296,116 @@ export function useBrowsePlugins(filters?: CatalogFilters) { }); } +// ============================================================================ +// Installed Applications Hooks +// ============================================================================ + +export function useApplications(enabledOnly?: boolean) { + return useQuery({ + queryKey: ['applications', enabledOnly], + queryFn: () => api.listApplications(enabledOnly), + select: (data) => data.applications, + }); +} + +export function useApplication(id: string) { + return useQuery({ + queryKey: ['application', id], + queryFn: () => api.getApplication(id), + enabled: !!id, + }); +} + +export function useUserApplications() { + return useQuery({ + queryKey: ['user-applications'], + queryFn: () => api.getUserApplications(), + select: (data) => data.applications, + }); +} + +export function useInstallApplication() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: InstallApplicationRequest) => api.installApplication(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['applications'] }); + }, + }); +} + +export function useUpdateApplication() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateApplicationRequest }) => + api.updateApplication(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: ['application', variables.id] }); + }, + }); +} + +export function useDeleteApplication() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => api.deleteApplication(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['applications'] }); + }, + }); +} + +export function useSetApplicationEnabled() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + api.setApplicationEnabled(id, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['applications'] }); + }, + }); +} + +export function useApplicationGroups(id: string) { + return useQuery({ + queryKey: ['application-groups', id], + queryFn: () => api.getApplicationGroups(id), + select: (data) => data.groups, + enabled: !!id, + }); +} + +export function useAddApplicationGroupAccess() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: AddGroupAccessRequest }) => + api.addApplicationGroupAccess(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['application-groups', variables.id] }); + queryClient.invalidateQueries({ queryKey: ['application', variables.id] }); + }, + }); +} + +export function useRemoveApplicationGroupAccess() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, groupId }: { id: string; groupId: string }) => + api.removeApplicationGroupAccess(id, groupId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['application-groups', variables.id] }); + queryClient.invalidateQueries({ queryKey: ['application', variables.id] }); + }, + }); +} + // ============================================================================ // Shared Sessions Hooks // ============================================================================ diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index c6790021..c0cdeded 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -227,6 +227,56 @@ export interface PluginRating { updatedAt: string; } +// Installed Application Types +export interface InstalledApplication { + id: string; + catalogTemplateId: number; + name: string; + displayName: string; + folderPath: string; + enabled: boolean; + configuration?: Record; + createdBy: string; + createdAt: string; + updatedAt: string; + templateName?: string; + templateDisplayName?: string; + description?: string; + category?: string; + appType?: string; + icon?: string; + manifest?: string; + groups?: ApplicationGroupAccess[]; +} + +export interface ApplicationGroupAccess { + id: string; + applicationId: string; + groupId: string; + accessLevel: 'view' | 'launch' | 'admin'; + createdAt: string; + groupName?: string; + groupDisplayName?: string; +} + +export interface InstallApplicationRequest { + catalogTemplateId: number; + displayName?: string; + configuration?: Record; + groupIds?: string[]; +} + +export interface UpdateApplicationRequest { + displayName?: string; + enabled?: boolean; + configuration?: Record; +} + +export interface AddGroupAccessRequest { + groupId: string; + accessLevel?: 'view' | 'launch' | 'admin'; +} + export interface CreateSessionRequest { user: string; template: string; @@ -1076,6 +1126,67 @@ class APIClient { await this.client.post(`/catalog/templates/${id}/install`); } + // ============================================================================ + // Installed Applications Management + // ============================================================================ + + async listApplications(enabledOnly?: boolean): Promise<{ applications: InstalledApplication[]; total: number }> { + const params: Record = {}; + if (enabledOnly) params.enabled = 'true'; + const response = await this.client.get<{ applications: InstalledApplication[]; total: number }>('/applications', { params }); + return response.data; + } + + async installApplication(request: InstallApplicationRequest): Promise { + const response = await this.client.post('/applications', request); + return response.data; + } + + async getApplication(id: string): Promise { + const response = await this.client.get(`/applications/${id}`); + return response.data; + } + + async updateApplication(id: string, request: UpdateApplicationRequest): Promise { + const response = await this.client.put(`/applications/${id}`, request); + return response.data; + } + + async deleteApplication(id: string): Promise { + await this.client.delete(`/applications/${id}`); + } + + async setApplicationEnabled(id: string, enabled: boolean): Promise { + await this.client.put(`/applications/${id}/enabled`, { enabled }); + } + + async getApplicationGroups(id: string): Promise<{ groups: ApplicationGroupAccess[]; total: number }> { + const response = await this.client.get<{ groups: ApplicationGroupAccess[]; total: number }>(`/applications/${id}/groups`); + return response.data; + } + + async addApplicationGroupAccess(id: string, request: AddGroupAccessRequest): Promise { + await this.client.post(`/applications/${id}/groups`, request); + } + + async updateApplicationGroupAccess(id: string, groupId: string, accessLevel: string): Promise { + await this.client.put(`/applications/${id}/groups/${groupId}`, { accessLevel }); + } + + async removeApplicationGroupAccess(id: string, groupId: string): Promise { + await this.client.delete(`/applications/${id}/groups/${groupId}`); + } + + async getApplicationTemplateConfig(id: string): Promise<{ config: any }> { + const response = await this.client.get<{ config: any }>(`/applications/${id}/config`); + return response.data; + } + + async getUserApplications(): Promise<{ applications: InstalledApplication[]; total: number }> { + const response = await this.client.get<{ applications: InstalledApplication[]; total: number }>('/applications/user'); + return response.data; + } + // ============================================================================ // Repository Management // ============================================================================ diff --git a/ui/src/pages/Applications.tsx b/ui/src/pages/Applications.tsx new file mode 100644 index 00000000..0222824f --- /dev/null +++ b/ui/src/pages/Applications.tsx @@ -0,0 +1,683 @@ +import { useState, useEffect } from 'react'; +import { + Box, + Typography, + Button, + Grid, + Card, + CardContent, + CardActions, + IconButton, + Chip, + Switch, + CircularProgress, + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + MenuItem, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Avatar, + Divider, + Tabs, + Tab, + FormControl, + InputLabel, + Select, +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Settings as SettingsIcon, + Group as GroupIcon, + Refresh as RefreshIcon, + Search as SearchIcon, +} from '@mui/icons-material'; +import AdminPortalLayout from '../components/AdminPortalLayout'; +import { + api, + type InstalledApplication, + type CatalogTemplate, + type Group, + type ApplicationGroupAccess, + type InstallApplicationRequest, +} from '../lib/api'; +import { useNotificationQueue } from '../components/NotificationQueue'; +import WebSocketErrorBoundary from '../components/WebSocketErrorBoundary'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + return ( + + ); +} + +function ApplicationsContent() { + const [loading, setLoading] = useState(true); + const [applications, setApplications] = useState([]); + const [selectedApp, setSelectedApp] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [catalogTemplates, setCatalogTemplates] = useState([]); + const [groups, setGroups] = useState([]); + const [editTab, setEditTab] = useState(0); + + // Add dialog state + const [selectedTemplate, setSelectedTemplate] = useState(''); + const [newDisplayName, setNewDisplayName] = useState(''); + const [selectedGroups, setSelectedGroups] = useState([]); + + // Edit dialog state + const [editDisplayName, setEditDisplayName] = useState(''); + const [editConfiguration, setEditConfiguration] = useState>({}); + const [appGroups, setAppGroups] = useState([]); + const [newGroupId, setNewGroupId] = useState(''); + const [newGroupAccessLevel, setNewGroupAccessLevel] = useState<'view' | 'launch' | 'admin'>('launch'); + + const { addNotification } = useNotificationQueue(); + + useEffect(() => { + loadApplications(); + loadCatalogTemplates(); + loadGroups(); + }, []); + + const loadApplications = async () => { + setLoading(true); + try { + const data = await api.listApplications(); + setApplications(data.applications || []); + } catch (error) { + console.error('Failed to load applications:', error); + addNotification({ + message: 'Failed to load applications', + severity: 'error', + priority: 'high', + }); + } finally { + setLoading(false); + } + }; + + const loadCatalogTemplates = async () => { + try { + const data = await api.listCatalogTemplates({ limit: 100 }); + setCatalogTemplates(data.templates || []); + } catch (error) { + console.error('Failed to load catalog templates:', error); + } + }; + + const loadGroups = async () => { + try { + const data = await api.listGroups(); + setGroups(data || []); + } catch (error) { + console.error('Failed to load groups:', error); + } + }; + + const handleAddApplication = async () => { + if (!selectedTemplate) return; + + try { + const request: InstallApplicationRequest = { + catalogTemplateId: selectedTemplate, + displayName: newDisplayName || undefined, + groupIds: selectedGroups.length > 0 ? selectedGroups : undefined, + }; + + await api.installApplication(request); + addNotification({ + message: 'Application installed successfully', + severity: 'success', + priority: 'medium', + }); + setAddDialogOpen(false); + resetAddDialog(); + loadApplications(); + } catch (error) { + console.error('Failed to install application:', error); + addNotification({ + message: 'Failed to install application', + severity: 'error', + priority: 'high', + }); + } + }; + + const handleEditApplication = async () => { + if (!selectedApp) return; + + try { + await api.updateApplication(selectedApp.id, { + displayName: editDisplayName, + configuration: editConfiguration, + }); + addNotification({ + message: 'Application updated successfully', + severity: 'success', + priority: 'medium', + }); + setEditDialogOpen(false); + loadApplications(); + } catch (error) { + console.error('Failed to update application:', error); + addNotification({ + message: 'Failed to update application', + severity: 'error', + priority: 'high', + }); + } + }; + + const handleDeleteApplication = async () => { + if (!selectedApp) return; + + try { + await api.deleteApplication(selectedApp.id); + addNotification({ + message: 'Application deleted successfully', + severity: 'success', + priority: 'medium', + }); + setDeleteDialogOpen(false); + setSelectedApp(null); + loadApplications(); + } catch (error) { + console.error('Failed to delete application:', error); + addNotification({ + message: 'Failed to delete application', + severity: 'error', + priority: 'high', + }); + } + }; + + const handleToggleEnabled = async (app: InstalledApplication) => { + try { + await api.setApplicationEnabled(app.id, !app.enabled); + addNotification({ + message: `Application ${app.enabled ? 'disabled' : 'enabled'} successfully`, + severity: 'success', + priority: 'low', + }); + loadApplications(); + } catch (error) { + console.error('Failed to toggle application:', error); + addNotification({ + message: 'Failed to update application status', + severity: 'error', + priority: 'high', + }); + } + }; + + const handleOpenEdit = async (app: InstalledApplication) => { + setSelectedApp(app); + setEditDisplayName(app.displayName); + setEditConfiguration(app.configuration || {}); + setEditTab(0); + + // Load group access for this application + try { + const data = await api.getApplicationGroups(app.id); + setAppGroups(data.groups || []); + } catch (error) { + console.error('Failed to load application groups:', error); + setAppGroups([]); + } + + setEditDialogOpen(true); + }; + + const handleAddGroupAccess = async () => { + if (!selectedApp || !newGroupId) return; + + try { + await api.addApplicationGroupAccess(selectedApp.id, { + groupId: newGroupId, + accessLevel: newGroupAccessLevel, + }); + + // Reload groups + const data = await api.getApplicationGroups(selectedApp.id); + setAppGroups(data.groups || []); + setNewGroupId(''); + + addNotification({ + message: 'Group access added successfully', + severity: 'success', + priority: 'low', + }); + } catch (error) { + console.error('Failed to add group access:', error); + addNotification({ + message: 'Failed to add group access', + severity: 'error', + priority: 'high', + }); + } + }; + + const handleRemoveGroupAccess = async (groupId: string) => { + if (!selectedApp) return; + + try { + await api.removeApplicationGroupAccess(selectedApp.id, groupId); + + // Reload groups + const data = await api.getApplicationGroups(selectedApp.id); + setAppGroups(data.groups || []); + + addNotification({ + message: 'Group access removed', + severity: 'success', + priority: 'low', + }); + } catch (error) { + console.error('Failed to remove group access:', error); + addNotification({ + message: 'Failed to remove group access', + severity: 'error', + priority: 'high', + }); + } + }; + + const resetAddDialog = () => { + setSelectedTemplate(''); + setNewDisplayName(''); + setSelectedGroups([]); + }; + + const getSelectedTemplateName = () => { + if (!selectedTemplate) return ''; + const template = catalogTemplates.find(t => t.id === selectedTemplate); + return template?.displayName || ''; + }; + + return ( + + + + + Applications + + + + + + + + {loading ? ( + + + + ) : applications.length === 0 ? ( + + No applications installed. Click "Add Application" to install your first application. + + ) : ( + + {applications.map((app) => ( + + + + + + {app.displayName?.charAt(0) || 'A'} + + + + {app.displayName} + + + {app.category} + + + handleToggleEnabled(app)} + size="small" + /> + + + + {app.description || 'No description available'} + + + + {app.enabled ? ( + + ) : ( + + )} + {app.groups && app.groups.length > 0 && ( + } + label={`${app.groups.length} group${app.groups.length !== 1 ? 's' : ''}`} + size="small" + variant="outlined" + /> + )} + + + + + handleOpenEdit(app)} + title="Edit" + > + + + { + setSelectedApp(app); + setDeleteDialogOpen(true); + }} + title="Delete" + color="error" + > + + + + + + ))} + + )} + + {/* Add Application Dialog */} + setAddDialogOpen(false)} maxWidth="sm" fullWidth> + Add Application + + + + Select Application + + + + setNewDisplayName(e.target.value)} + placeholder={getSelectedTemplateName() || 'Custom display name'} + helperText="Name shown on user dashboard. Leave blank to use default." + /> + + + Grant Access to Groups + + + + + + + + + + + {/* Edit Application Dialog */} + setEditDialogOpen(false)} maxWidth="md" fullWidth> + + Edit Application: {selectedApp?.displayName} + + + setEditTab(v)} sx={{ borderBottom: 1, borderColor: 'divider' }}> + + + + + + + + setEditDisplayName(e.target.value)} + fullWidth + helperText="Name shown on user dashboard" + /> + + Template: {selectedApp?.templateDisplayName || selectedApp?.templateName} + + + Folder Path: {selectedApp?.folderPath} + + + + + + + + Groups with Access + + {appGroups.length === 0 ? ( + + No groups have access to this application + + ) : ( + + {appGroups.map((access) => ( + + + + handleRemoveGroupAccess(access.groupId)} + > + + + + + ))} + + )} + + + + + + Add Group Access + + + + Group + + + + Access Level + + + + + + + + + Application-specific configuration. Edit the JSON below to customize settings. + + { + try { + setEditConfiguration(JSON.parse(e.target.value)); + } catch { + // Invalid JSON, ignore + } + }} + sx={{ fontFamily: 'monospace' }} + /> + + + + + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)}> + Delete Application + + + Are you sure you want to delete "{selectedApp?.displayName}"? This action cannot be undone. + + + + + + + + + + ); +} + +/** + * Applications - Installed applications management page + * + * This page allows administrators to: + * - View all installed applications + * - Install new applications from the catalog + * - Enable/disable applications + * - Configure application settings + * - Manage group access to applications + * - Edit display names for user dashboards + * + * @page + * @route /admin/applications + * @access admin - Only administrators can access this page + */ +export default function Applications() { + return ( + + + + ); +}