diff --git a/CleanVSCache.ps1 b/CleanVSCache.ps1
new file mode 100644
index 0000000..cb12c19
--- /dev/null
+++ b/CleanVSCache.ps1
@@ -0,0 +1,78 @@
+# Script para limpiar caché de Visual Studio 2026 y resolver problemas de implementación
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "Limpieza de caché de Visual Studio 2026" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+
+# Verificar si Visual Studio está ejecutándose
+$vsProcesses = Get-Process devenv -ErrorAction SilentlyContinue
+if ($vsProcesses) {
+ Write-Host "ERROR: Visual Studio está ejecutándose." -ForegroundColor Red
+ Write-Host "Por favor, cierra Visual Studio antes de ejecutar este script." -ForegroundColor Yellow
+ Write-Host ""
+ Read-Host "Presiona Enter para salir"
+ exit 1
+}
+
+Write-Host "✓ Visual Studio no está en ejecución" -ForegroundColor Green
+Write-Host ""
+
+# Limpiar carpeta .vs del proyecto
+$vsFolder = Join-Path $PSScriptRoot ".vs"
+if (Test-Path $vsFolder) {
+ Write-Host "Eliminando carpeta .vs del proyecto..." -ForegroundColor Yellow
+ Remove-Item $vsFolder -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "✓ Carpeta .vs eliminada" -ForegroundColor Green
+} else {
+ Write-Host "✓ Carpeta .vs no existe" -ForegroundColor Green
+}
+
+# Limpiar carpeta bin y obj de web
+$webBin = Join-Path $PSScriptRoot "web\bin"
+$webObj = Join-Path $PSScriptRoot "web\obj"
+
+if (Test-Path $webBin) {
+ Write-Host "Eliminando web\bin..." -ForegroundColor Yellow
+ Remove-Item $webBin -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "✓ web\bin eliminada" -ForegroundColor Green
+}
+
+if (Test-Path $webObj) {
+ Write-Host "Eliminando web\obj..." -ForegroundColor Yellow
+ Remove-Item $webObj -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "✓ web\obj eliminada" -ForegroundColor Green
+}
+
+# Limpiar caché de componentes de Visual Studio
+$vsComponentCache = "$env:LOCALAPPDATA\Microsoft\VisualStudio\18.0_*"
+$cacheFolders = @(
+ "ComponentModelCache",
+ "ApplicationPrivateSettings.xml"
+)
+
+Write-Host ""
+Write-Host "Limpiando caché de componentes de VS..." -ForegroundColor Yellow
+
+Get-Item $vsComponentCache -ErrorAction SilentlyContinue | ForEach-Object {
+ $vsPath = $_.FullName
+ foreach ($cache in $cacheFolders) {
+ $cachePath = Join-Path $vsPath $cache
+ if (Test-Path $cachePath) {
+ Remove-Item $cachePath -Recurse -Force -ErrorAction SilentlyContinue
+ Write-Host "✓ Eliminado: $cache" -ForegroundColor Green
+ }
+ }
+}
+
+Write-Host ""
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "Limpieza completada" -ForegroundColor Green
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+Write-Host "Pasos siguientes:" -ForegroundColor Yellow
+Write-Host "1. Abre Visual Studio 2026" -ForegroundColor White
+Write-Host "2. Abre la solución NexusCad.sln" -ForegroundColor White
+Write-Host "3. Reconstruye la solución (Ctrl+Shift+B)" -ForegroundColor White
+Write-Host "4. Intenta implementar el proyecto web nuevamente" -ForegroundColor White
+Write-Host ""
+Read-Host "Presiona Enter para salir"
diff --git a/INSTRUCCIONES-URGENTE.md b/INSTRUCCIONES-URGENTE.md
new file mode 100644
index 0000000..e60b73a
--- /dev/null
+++ b/INSTRUCCIONES-URGENTE.md
@@ -0,0 +1,102 @@
+# ⚠️ INSTRUCCIONES EXACTAS - Visual Studio 2026
+
+## 🛑 LO QUE ESTÁS HACIENDO MAL:
+
+Estás usando una de estas opciones **INCORRECTAS**:
+- ❌ Clic derecho en proyecto → "Implementar" / "Deploy"
+- ❌ Menú: Compilar → Implementar NexusCad.Web
+- ❌ Menú: Compilar → Implementar solución
+- ❌ Cualquier opción que diga "IMPLEMENTAR" / "DEPLOY"
+
+## ✅ LO QUE DEBES HACER:
+
+### OPCIÓN 1: Usar Teclado (MÁS FÁCIL)
+
+1. Cierra cualquier ventana de navegador anterior
+2. En Visual Studio, asegúrate que estás en el proyecto NexusCad.Web
+3. Presiona una de estas teclas:
+ - **Ctrl + F5** (recomendado - ejecutar sin depuración)
+ - **F5** (ejecutar con depuración)
+
+### OPCIÓN 2: Usar Menú
+
+1. Menú superior → **Depurar**
+2. Selecciona:
+ - **"Iniciar sin depurar"** (Ctrl+F5)
+ - O **"Iniciar depuración"** (F5)
+
+### OPCIÓN 3: Usar Barra de Herramientas
+
+1. Busca en la barra de herramientas superior el botón de **play verde ▶️**
+2. Asegúrate que al lado diga "NexusCad.Web"
+3. Haz clic en el botón **▶️ verde**
+
+### OPCIÓN 4: Usar Terminal (100% Garantizado)
+
+Si nada de lo anterior funciona:
+
+1. En Visual Studio: **Ver → Terminal** (o Ctrl + `)
+2. Ejecuta:
+```powershell
+cd web
+pnpm dev
+```
+3. Abre tu navegador en: http://localhost:3000
+
+## 🔴 NUNCA HAGAS ESTO CON PROYECTOS WEB:
+
+```
+❌ "Compilar e implementar"
+❌ "Build and Deploy"
+❌ Botón "Implementar"
+❌ "Deploy Project"
+❌ Clic derecho → Implementar
+```
+
+## ✅ SIEMPRE HAZ ESTO CON PROYECTOS WEB:
+
+```
+✅ F5 o Ctrl+F5
+✅ "Depurar" → "Iniciar"
+✅ Botón play verde ▶️
+✅ Terminal: pnpm dev
+```
+
+## 📸 Verificación Visual
+
+Cuando lo hagas correctamente, verás en la salida:
+
+```
+Iniciando la depuración del proyecto...
+> pnpm dev
+
+ VITE v8.0.14 ready in XXX ms
+
+ ➜ Local: http://localhost:3000/
+ ➜ Network: use --host to expose
+```
+
+**NO debe decir**: "Operación Implementar iniciada"
+
+## 🔧 Si Ya Recargaste el Proyecto y Sigue Fallando:
+
+Visual Studio tiene caché. Haz lo siguiente:
+
+1. **CIERRA Visual Studio completamente**
+2. Ejecuta el script de limpieza:
+```powershell
+cd E:\NexusCad
+.\CleanVSCache.ps1
+```
+3. **REABRE Visual Studio**
+4. Abre SOLO el archivo `NexusCad.Web.esproj`
+5. Presiona **Ctrl + F5**
+
+## 💡 Resumen de 3 Segundos:
+
+**NO uses "Implementar". USA Ctrl+F5 o pnpm dev en terminal.**
+
+---
+
+Si después de seguir **exactamente** estas instrucciones sigue sin funcionar,
+dime qué opción exacta estás usando (nombre del menú o botón) y qué ves en pantalla.
diff --git a/NexusCad.slnx b/NexusCad.slnx
index 5fbe4bd..53650d1 100644
--- a/NexusCad.slnx
+++ b/NexusCad.slnx
@@ -14,8 +14,7 @@
-
-
+
diff --git a/RunWeb.ps1 b/RunWeb.ps1
new file mode 100644
index 0000000..4e4de7e
--- /dev/null
+++ b/RunWeb.ps1
@@ -0,0 +1,19 @@
+# Script para ejecutar el proyecto web directamente
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "Ejecutando NexusCad.Web" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+
+Set-Location -Path "$PSScriptRoot\web"
+
+Write-Host "Directorio actual: $(Get-Location)" -ForegroundColor Yellow
+Write-Host ""
+Write-Host "Iniciando servidor de desarrollo Vite..." -ForegroundColor Green
+Write-Host "Presiona Ctrl+C para detener el servidor" -ForegroundColor Yellow
+Write-Host ""
+Write-Host "Una vez iniciado, abre tu navegador en:" -ForegroundColor Cyan
+Write-Host " http://localhost:3000" -ForegroundColor White
+Write-Host ""
+
+# Ejecutar pnpm dev
+pnpm dev
diff --git a/docs/SOLUCION-VS2026-ESPROJ.md b/docs/SOLUCION-VS2026-ESPROJ.md
new file mode 100644
index 0000000..2aa582f
--- /dev/null
+++ b/docs/SOLUCION-VS2026-ESPROJ.md
@@ -0,0 +1,107 @@
+# ¡SOLUCIÓN ENCONTRADA! - Error de Implementación VS2026
+
+## 🎯 EL PROBLEMA REAL
+
+**Visual Studio 2026 NO soporta "Implementar" (Deploy) para proyectos esproj de la misma manera que proyectos .NET tradicionales.**
+
+El error "El valor no puede ser nulo. Nombre del parámetro: source" ocurre porque:
+- VS2026 intenta ejecutar un proceso de "implementación" que no aplica a proyectos Vite/Node
+- Los proyectos JavaScript/TypeScript se deben **ejecutar**, no **implementar**
+
+## ✅ SOLUCIÓN CORRECTA
+
+### Método 1: Ejecutar sin Depuración (F5 o Ctrl+F5)
+
+En lugar de usar "Implementar":
+
+1. **Establece `NexusCad.Web` como proyecto de inicio:**
+ - Clic derecho en el proyecto → "Establecer como proyecto de inicio"
+
+2. **Ejecuta el proyecto:**
+ - Presiona **F5** (con depuración) o **Ctrl+F5** (sin depuración)
+ - O usa el menú: **Depurar → Iniciar sin depurar**
+
+3. Visual Studio ejecutará automáticamente:
+ ```
+ pnpm dev
+ ```
+
+### Método 2: Usar la Terminal de Visual Studio
+
+1. Abre la terminal en VS: **Ver → Terminal**
+2. Navega al proyecto web:
+ ```powershell
+ cd web
+ ```
+3. Ejecuta el servidor de desarrollo:
+ ```powershell
+ pnpm dev
+ ```
+
+### Método 3: Terminal Externa
+
+Abre PowerShell o Terminal y ejecuta:
+```powershell
+cd E:\NexusCad\web
+pnpm dev
+```
+
+## 🚫 NO USES "IMPLEMENTAR" (Deploy)
+
+La opción **"Implementar proyecto"** o **"Deploy"** NO funciona con proyectos esproj porque:
+- Es para aplicaciones .NET que necesitan ser publicadas
+- Los proyectos Node/Vite se ejecutan directamente con el servidor de desarrollo
+- No hay un proceso de "implementación" en desarrollo local
+
+## 📋 Configuración Correcta del Proyecto
+
+El archivo `NexusCad.Web.esproj` debe tener:
+- ✅ `StartupCommand` = `pnpm dev`
+- ✅ `BuildCommand` = `pnpm build`
+- ❌ NO necesita `LaunchCommand` ni `LaunchUrl` (causan el error)
+- ❌ NO necesita `.vscode/launch.json` para esproj
+
+## 🎬 Flujo de Trabajo Correcto
+
+### Para Desarrollo:
+1. Abre el proyecto en VS2026
+2. Presiona **Ctrl+F5** (ejecutar sin depuración)
+3. VS ejecuta `pnpm dev` automáticamente
+4. El navegador se abre en http://localhost:3000
+
+### Para Compilar (Build):
+1. Clic derecho en proyecto → **Compilar**
+2. VS ejecuta `pnpm build`
+3. Los archivos se generan en `web/dist/`
+
+### Para Producción:
+- Usa Docker: `docker build -f web/Dockerfile .`
+- O compila y sirve: `pnpm build && pnpm preview`
+
+## 🔍 Por Qué Funcionaba en Otro PC
+
+En el otro PC probablemente:
+- Usabas **F5/Ctrl+F5** en lugar de "Implementar"
+- O tenías una versión diferente de VS con mejor manejo de esproj
+- O el proyecto web se ejecutaba desde terminal directamente
+
+## ⚠️ IMPORTANTE
+
+**Nunca uses "Compilar e Implementar" o "Deploy" con proyectos JavaScript/TypeScript en VS2026.**
+
+Usa siempre:
+- **Compilar** (Build) → `pnpm build`
+- **Ejecutar** (F5/Ctrl+F5) → `pnpm dev`
+- **Terminal** → Control manual completo
+
+## 📝 Resumen
+
+| ❌ NO HAGAS ESTO | ✅ HAZ ESTO |
+|------------------|-------------|
+| Clic derecho → Implementar | Presiona **Ctrl+F5** |
+| Build → Deploy | Build → Debug (F5) |
+| Buscar "Deploy/Implementar" | Buscar "Run/Ejecutar" |
+
+---
+
+**Después de seguir esta guía, el proyecto debería ejecutarse sin errores.**
diff --git a/docs/TROUBLESHOOTING-VS2026.md b/docs/TROUBLESHOOTING-VS2026.md
new file mode 100644
index 0000000..fbbf831
--- /dev/null
+++ b/docs/TROUBLESHOOTING-VS2026.md
@@ -0,0 +1,104 @@
+# Solución al Error de Implementación en Visual Studio 2026
+
+## Problema
+Error: "El valor no puede ser nulo. Nombre del parámetro: source" al intentar implementar el proyecto NexusCad.Web.esproj
+
+## Causa
+Este error es específico de Visual Studio 2026 (18.6.2) y ocurre cuando:
+1. La caché de Visual Studio tiene configuraciones corruptas
+2. El proyecto esproj no tiene las propiedades de lanzamiento correctamente configuradas
+3. Los archivos de configuración de lanzamiento están incompletos
+
+## Soluciones Aplicadas
+
+### 1. Actualización de NexusCad.Web.esproj
+Se agregaron las propiedades `LaunchCommand` y `LaunchUrl` al archivo del proyecto:
+```xml
+pnpm dev
+http://localhost:3000
+```
+
+### 2. Configuración de launchSettings.json
+Se creó/actualizó `web/Properties/launchSettings.json` con:
+- Schema JSON para validación
+- Perfil de lanzamiento con nombre del proyecto
+- URL de aplicación configurada
+- Variables de entorno
+
+### 3. Configuración de launch.json
+Se creó `.vscode/launch.json` con configuraciones para:
+- Chrome
+- Edge
+- Node (pnpm dev)
+
+## Solución Recomendada
+
+### Opción A: Limpiar Caché de Visual Studio (RECOMENDADO)
+
+1. **Cierra Visual Studio 2026 completamente**
+
+2. **Ejecuta el script de limpieza:**
+ ```powershell
+ .\CleanVSCache.ps1
+ ```
+ Este script eliminará:
+ - Carpeta `.vs` del proyecto
+ - Carpetas `bin` y `obj` del proyecto web
+ - Caché de componentes de Visual Studio
+
+3. **Reabre Visual Studio y la solución**
+
+4. **Reconstruye la solución** (Ctrl+Shift+B)
+
+5. **Intenta implementar nuevamente**
+
+### Opción B: Limpieza Manual (si el script falla)
+
+Si prefieres hacerlo manualmente:
+
+1. Cierra Visual Studio
+2. Elimina estas carpetas:
+ - `E:\NexusCad\.vs`
+ - `E:\NexusCad\web\bin`
+ - `E:\NexusCad\web\obj`
+3. Elimina caché de VS en: `%LOCALAPPDATA%\Microsoft\VisualStudio\18.0_*\ComponentModelCache`
+4. Reabre VS y reconstruye
+
+### Opción C: Usar Terminal Directamente (WORKAROUND)
+
+Si el problema persiste, puedes ejecutar el proyecto directamente desde terminal:
+
+```powershell
+cd E:\NexusCad\web
+pnpm dev
+```
+
+Esto iniciará Vite en http://localhost:3000 sin necesidad de la implementación de Visual Studio.
+
+## Verificación
+
+Después de aplicar la solución, deberías ver:
+- ✅ Compilación exitosa
+- ✅ Implementación sin errores
+- ✅ Navegador abriéndose en http://localhost:3000
+
+## Notas Adicionales
+
+- Las advertencias de SignalR sobre `/*#__PURE__*/` son solo advertencias y no afectan la funcionalidad
+- La advertencia sobre el tamaño del chunk (758KB) es informativa y puede optimizarse más adelante con code-splitting
+- En otros equipos el proyecto funciona correctamente, lo que confirma que es un problema local de caché de VS
+
+## Si el Problema Persiste
+
+Si después de limpiar la caché el problema continúa:
+
+1. Verifica que Node.js esté correctamente instalado en `C:\Program Files\nodejs\node.exe`
+2. Verifica que pnpm esté instalado globalmente: `pnpm --version`
+3. Reinstala las dependencias: `cd web && pnpm install`
+4. Considera reparar la instalación de Visual Studio 2026
+
+## Archivos Modificados
+- `web/NexusCad.Web.esproj` - Agregadas propiedades de lanzamiento
+- `web/Properties/launchSettings.json` - Configuración de perfiles
+- `.vscode/launch.json` - Configuraciones de depuración
+- `CleanVSCache.ps1` - Script de limpieza (nuevo)
diff --git a/nexuscad.db.bak-pre-uninstall-20260529-131623 b/nexuscad.db.bak-pre-uninstall-20260529-131623
new file mode 100644
index 0000000..2b7c4f6
Binary files /dev/null and b/nexuscad.db.bak-pre-uninstall-20260529-131623 differ
diff --git a/src/NexusCad.Admin/App.xaml.cs b/src/NexusCad.Admin/App.xaml.cs
index 8c0ee60..d1793b9 100644
--- a/src/NexusCad.Admin/App.xaml.cs
+++ b/src/NexusCad.Admin/App.xaml.cs
@@ -77,7 +77,7 @@ protected override void OnStartup(StartupEventArgs e)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
// ViewModels
services.AddTransient();
@@ -88,8 +88,8 @@ protected override void OnStartup(StartupEventArgs e)
services.AddTransient();
services.AddTransient();
services.AddTransient();
- services.AddTransient();
- services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
services.AddTransient();
// Model Rules
diff --git a/src/NexusCad.Admin/MainWindow.xaml b/src/NexusCad.Admin/MainWindow.xaml
index 9d00ab6..67e724a 100644
--- a/src/NexusCad.Admin/MainWindow.xaml
+++ b/src/NexusCad.Admin/MainWindow.xaml
@@ -89,18 +89,18 @@
-
-
+
+
-
+
-
+
@@ -173,8 +173,8 @@
Foreground="{StaticResource NexusIconMuted}"
VerticalAlignment="Center" Margin="0,0,4,0" />
-
-
+
+
@@ -322,7 +322,7 @@
-
+
@@ -336,12 +336,12 @@
-
+
-
-
-
@@ -375,7 +375,7 @@
-
+
-
+
-
+ /// Ruta personalizada de modelos SolidWorks master para este proyecto.
+ ///
+ public string? ModelsPath { get; set; }
public int Version { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
@@ -57,6 +61,7 @@ public class CreateProjectRequest
public string? Description { get; set; }
public string FormSchemaJson { get; set; } = "{}";
public string? IconUrl { get; set; }
+ public string? ModelsPath { get; set; }
}
public class UpdateProjectRequest
@@ -65,4 +70,5 @@ public class UpdateProjectRequest
public string? Description { get; set; }
public string FormSchemaJson { get; set; } = "{}";
public string? IconUrl { get; set; }
+ public string? ModelsPath { get; set; }
}
diff --git a/src/NexusCad.Admin/Models/RegisterRequest.cs b/src/NexusCad.Admin/Models/RegisterRequest.cs
index 2dccebf..2e3e9bf 100644
--- a/src/NexusCad.Admin/Models/RegisterRequest.cs
+++ b/src/NexusCad.Admin/Models/RegisterRequest.cs
@@ -21,7 +21,4 @@ public class RegisterRequest
[Required(ErrorMessage = "Full name is required")]
[StringLength(200, ErrorMessage = "Full name cannot exceed 200 characters")]
public string FullName { get; set; } = string.Empty;
-
- [Required(ErrorMessage = "Role is required")]
- public string Role { get; set; } = string.Empty;
}
diff --git a/src/NexusCad.Admin/Models/UpdateUserRequest.cs b/src/NexusCad.Admin/Models/UpdateUserRequest.cs
index 1c0f093..922dbd5 100644
--- a/src/NexusCad.Admin/Models/UpdateUserRequest.cs
+++ b/src/NexusCad.Admin/Models/UpdateUserRequest.cs
@@ -16,8 +16,6 @@ public class UpdateUserRequest
public bool IsActive { get; set; } = true;
- public List Roles { get; set; } = new();
-
[MinLength(6, ErrorMessage = "Password must be at least 6 characters")]
public string? NewPassword { get; set; }
}
diff --git a/src/NexusCad.Admin/Models/UserGroupDtos.cs b/src/NexusCad.Admin/Models/UserGroupDtos.cs
new file mode 100644
index 0000000..00a2fe8
--- /dev/null
+++ b/src/NexusCad.Admin/Models/UserGroupDtos.cs
@@ -0,0 +1,80 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Admin.Models;
+
+///
+/// Grupo personalizado de usuarios. Distinto de Workspace (contenedor de proyectos)
+/// y de los roles globales (Admin/Autor/Comercial/Cliente).
+///
+public class UserGroupDto
+{
+ public Guid Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string? IconUrl { get; set; }
+ public string CreatedBy { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+ public int MemberCount { get; set; }
+}
+
+public class UserGroupListItemDto
+{
+ public Guid Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string? IconUrl { get; set; }
+ public int MemberCount { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public class CreateUserGroupRequest
+{
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? IconUrl { get; set; }
+}
+
+public class UpdateUserGroupRequest
+{
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? IconUrl { get; set; }
+}
+
+public class UserGroupMemberDto
+{
+ public string UserId { get; set; } = string.Empty;
+ public string? UserName { get; set; }
+ public string? Email { get; set; }
+ public string? FullName { get; set; }
+ public DateTime AddedAt { get; set; }
+}
+
+public class AddUserGroupMemberRequest
+{
+ public string UserId { get; set; } = string.Empty;
+}
+
+public class SetUserGroupMembersRequest
+{
+ public List UserIds { get; set; } = new();
+}
+
+public class UserGroupProjectDto
+{
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+}
+
+public class SetUserGroupProjectsRequest
+{
+ public List ProjectIds { get; set; } = new();
+}
diff --git a/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs b/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs
new file mode 100644
index 0000000..56f330e
--- /dev/null
+++ b/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs
@@ -0,0 +1,31 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Admin.Models;
+
+///
+/// Membresía de un usuario en un grupo, devuelta por GET /api/auth/users/{id}/memberships
+///
+public class UserGroupMembershipDto
+{
+ public Guid GroupId { get; set; }
+ public string GroupCode { get; set; } = string.Empty;
+ public string GroupName { get; set; } = string.Empty;
+ public string? GroupIconUrl { get; set; }
+ public int Role { get; set; } // 0=Owner,1=Author,2=Commercial,3=Client
+ public DateTime CreatedAt { get; set; }
+}
+
+///
+/// Request para reemplazar todas las membresías de un usuario.
+///
+public class SetUserMembershipsRequest
+{
+ public List Memberships { get; set; } = new();
+}
+
+public class MembershipItem
+{
+ public Guid GroupId { get; set; }
+ public int Role { get; set; }
+}
diff --git a/src/NexusCad.Admin/Models/GroupDto.cs b/src/NexusCad.Admin/Models/WorkspaceDto.cs
similarity index 65%
rename from src/NexusCad.Admin/Models/GroupDto.cs
rename to src/NexusCad.Admin/Models/WorkspaceDto.cs
index 1a05856..a0c1e82 100644
--- a/src/NexusCad.Admin/Models/GroupDto.cs
+++ b/src/NexusCad.Admin/Models/WorkspaceDto.cs
@@ -3,7 +3,7 @@
//
namespace NexusCad.Admin.Models;
-public class GroupDto
+public class WorkspaceDto
{
public Guid Id { get; set; }
public string Code { get; set; } = string.Empty;
@@ -18,7 +18,7 @@ public class GroupDto
public DateTime UpdatedAt { get; set; }
}
-public class GroupListItemDto
+public class WorkspaceListItemDto
{
public Guid Id { get; set; }
public string Code { get; set; } = string.Empty;
@@ -29,7 +29,7 @@ public class GroupListItemDto
public DateTime UpdatedAt { get; set; }
}
-public class CreateGroupRequest
+public class CreateWorkspaceRequest
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
@@ -38,10 +38,33 @@ public class CreateGroupRequest
public string? IconUrl { get; set; }
}
-public class UpdateGroupRequest
+public class UpdateWorkspaceRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? WorkspacePath { get; set; }
public string? IconUrl { get; set; }
}
+
+/// Acceso de un workspace a un proyecto concreto.
+public class WorkspaceProjectAccessDto
+{
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+
+ /// 0 = Ver, 1 = Configurar
+ public int AccessLevel { get; set; } = 1;
+}
+
+public class SetWorkspaceProjectAccessRequest
+{
+ public List Items { get; set; } = new();
+}
+
+public class WorkspaceProjectAccessEntry
+{
+ public Guid ProjectId { get; set; }
+ public int AccessLevel { get; set; } = 1;
+}
+
diff --git a/src/NexusCad.Admin/Services/CurrentGroupService.cs b/src/NexusCad.Admin/Services/CurrentGroupService.cs
deleted file mode 100644
index b60e76a..0000000
--- a/src/NexusCad.Admin/Services/CurrentGroupService.cs
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2026 JaviFRx
-// Licensed under AGPL v3
-//
-using System.IO;
-using System.Text.Json;
-using NexusCad.Admin.Models;
-
-namespace NexusCad.Admin.Services;
-
-public interface ICurrentGroupService
-{
- event EventHandler? CurrentGroupChanged;
-
- GroupDto? Current { get; }
-
- bool HasActiveGroup { get; }
-
- Task SetCurrentAsync(GroupDto? group);
-
- Task RestoreLastAsync(INexusCadApi api);
-}
-
-///
-/// Mantiene en memoria el Group activo del Admin y persiste su Id en
-/// %LocalAppData%/NexusCad/active-group.json para restaurarlo en el siguiente arranque.
-///
-public sealed class CurrentGroupService : ICurrentGroupService
-{
- private static readonly string StatePath = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "NexusCad",
- "active-group.json");
-
- public event EventHandler? CurrentGroupChanged;
-
- public GroupDto? Current { get; private set; }
-
- public bool HasActiveGroup => Current != null;
-
- public async Task SetCurrentAsync(GroupDto? group)
- {
- Current = group;
- await PersistAsync(group?.Id);
- CurrentGroupChanged?.Invoke(this, EventArgs.Empty);
- }
-
- public async Task RestoreLastAsync(INexusCadApi api)
- {
- var storedId = await LoadStoredIdAsync();
- if (storedId == null)
- return null;
-
- try
- {
- var response = await api.GetGroupByIdAsync(storedId.Value);
- if (response.Success && response.Data != null)
- {
- Current = response.Data;
- CurrentGroupChanged?.Invoke(this, EventArgs.Empty);
- return Current;
- }
- }
- catch
- {
- // Si el grupo ya no existe o la API no responde, ignoramos —
- // el usuario tendrá que reabrir desde Recent.
- }
- return null;
- }
-
- private static async Task PersistAsync(Guid? id)
- {
- try
- {
- var dir = Path.GetDirectoryName(StatePath);
- if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
- Directory.CreateDirectory(dir);
-
- if (id == null)
- {
- if (File.Exists(StatePath))
- File.Delete(StatePath);
- return;
- }
-
- var json = JsonSerializer.Serialize(new { ActiveGroupId = id });
- await File.WriteAllTextAsync(StatePath, json);
- }
- catch
- {
- }
- }
-
- private static async Task LoadStoredIdAsync()
- {
- try
- {
- if (!File.Exists(StatePath))
- return null;
- var json = await File.ReadAllTextAsync(StatePath);
- using var doc = JsonDocument.Parse(json);
- if (doc.RootElement.TryGetProperty("ActiveGroupId", out var el)
- && el.TryGetGuid(out var id))
- return id;
- }
- catch
- {
- }
- return null;
- }
-}
diff --git a/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs
new file mode 100644
index 0000000..f43fcc7
--- /dev/null
+++ b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs
@@ -0,0 +1,152 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.IO;
+using System.Text.Json;
+using NexusCad.Admin.Models;
+
+namespace NexusCad.Admin.Services;
+
+public interface ICurrentWorkspaceService
+{
+ event EventHandler? CurrentWorkspaceChanged;
+
+ ///
+ /// Disparado cuando la lista de workspaces conocida cambia (CRUD desde
+ /// cualquier vista). Los consumidores deben recargar su listado.
+ ///
+ event EventHandler? WorkspacesListChanged;
+
+ WorkspaceDto? Current { get; }
+
+ bool HasActiveWorkspace { get; }
+
+ Task SetCurrentAsync(WorkspaceDto? workspace);
+
+ Task RestoreLastAsync(INexusCadApi api);
+
+ ///
+ /// Notifica a todos los consumidores que la lista de workspaces ha cambiado.
+ /// Llamar tras create/update/delete de un workspace.
+ ///
+ void NotifyWorkspacesListChanged();
+}
+
+///
+/// Mantiene en memoria el Workspace activo del Admin y persiste su Id en
+/// %LocalAppData%/NexusCad/active-workspace.json para restaurarlo en el siguiente arranque.
+///
+public sealed class CurrentWorkspaceService : ICurrentWorkspaceService
+{
+ private static readonly string StatePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NexusCad",
+ "active-workspace.json");
+
+ // Ruta del archivo legacy (renombrado de Group → Workspace en v1.x)
+ private static readonly string LegacyStatePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "NexusCad",
+ "active-group.json");
+
+ public event EventHandler? CurrentWorkspaceChanged;
+
+ public event EventHandler? WorkspacesListChanged;
+
+ public WorkspaceDto? Current { get; private set; }
+
+ public bool HasActiveWorkspace => Current != null;
+
+ public async Task SetCurrentAsync(WorkspaceDto? workspace)
+ {
+ Current = workspace;
+ await PersistAsync(workspace?.Id);
+ CurrentWorkspaceChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void NotifyWorkspacesListChanged()
+ {
+ WorkspacesListChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public async Task RestoreLastAsync(INexusCadApi api)
+ {
+ var storedId = await LoadStoredIdAsync();
+ if (storedId == null)
+ return null;
+
+ try
+ {
+ var response = await api.GetWorkspaceByIdAsync(storedId.Value);
+ if (response.Success && response.Data != null)
+ {
+ Current = response.Data;
+ CurrentWorkspaceChanged?.Invoke(this, EventArgs.Empty);
+ return Current;
+ }
+ }
+ catch
+ {
+ // Si el workspace ya no existe o la API no responde, ignoramos —
+ // el usuario tendrá que reabrir desde Recent.
+ }
+ return null;
+ }
+
+ private static async Task PersistAsync(Guid? id)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(StatePath);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ if (id == null)
+ {
+ if (File.Exists(StatePath))
+ File.Delete(StatePath);
+ return;
+ }
+
+ var json = JsonSerializer.Serialize(new { ActiveWorkspaceId = id });
+ await File.WriteAllTextAsync(StatePath, json);
+ }
+ catch
+ {
+ }
+ }
+
+ private static async Task LoadStoredIdAsync()
+ {
+ try
+ {
+ // Migración: si existe el archivo legacy (active-group.json), lo leemos
+ // y lo migramos al nuevo formato (active-workspace.json).
+ if (!File.Exists(StatePath) && File.Exists(LegacyStatePath))
+ {
+ var legacyJson = await File.ReadAllTextAsync(LegacyStatePath);
+ using var legacyDoc = JsonDocument.Parse(legacyJson);
+ if (legacyDoc.RootElement.TryGetProperty("ActiveGroupId", out var legacyEl)
+ && legacyEl.TryGetGuid(out var legacyId))
+ {
+ // Escribe al nuevo archivo y elimina el legacy
+ await PersistAsync(legacyId);
+ try { File.Delete(LegacyStatePath); } catch { }
+ return legacyId;
+ }
+ }
+
+ if (!File.Exists(StatePath))
+ return null;
+ var json = await File.ReadAllTextAsync(StatePath);
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("ActiveWorkspaceId", out var el)
+ && el.TryGetGuid(out var id))
+ return id;
+ }
+ catch
+ {
+ }
+ return null;
+ }
+}
diff --git a/src/NexusCad.Admin/Services/INexusCadApi.cs b/src/NexusCad.Admin/Services/INexusCadApi.cs
index 81ef25f..986e861 100644
--- a/src/NexusCad.Admin/Services/INexusCadApi.cs
+++ b/src/NexusCad.Admin/Services/INexusCadApi.cs
@@ -39,6 +39,14 @@ public interface INexusCadApi
[Headers("Authorization: Bearer")]
Task> DeleteUserAsync(string id);
+ [Get("/api/auth/users/{id}/memberships")]
+ [Headers("Authorization: Bearer")]
+ Task>> GetUserMembershipsAsync(string id);
+
+ [Put("/api/auth/users/{id}/memberships")]
+ [Headers("Authorization: Bearer")]
+ Task>> SetUserMembershipsAsync(string id, [Body] SetUserMembershipsRequest request);
+
// Projects endpoints
[Get("/api/projects")]
[Headers("Authorization: Bearer")]
@@ -71,30 +79,75 @@ Task> GetProjectsAsync(
[Headers("Authorization: Bearer")]
Task> DeleteProjectAsync(Guid id);
- // Groups endpoints
+ // Workspaces endpoints
[Get("/api/groups")]
[Headers("Authorization: Bearer")]
- Task>> GetGroupsAsync([Query] bool recent = false);
+ Task>> GetWorkspacesAsync([Query] bool recent = false);
[Get("/api/groups/{id}")]
[Headers("Authorization: Bearer")]
- Task> GetGroupByIdAsync(Guid id);
+ Task> GetWorkspaceByIdAsync(Guid id);
[Post("/api/groups")]
[Headers("Authorization: Bearer")]
- Task> CreateGroupAsync([Body] CreateGroupRequest request);
+ Task> CreateWorkspaceAsync([Body] CreateWorkspaceRequest request);
[Put("/api/groups/{id}")]
[Headers("Authorization: Bearer")]
- Task> UpdateGroupAsync(Guid id, [Body] UpdateGroupRequest request);
+ Task> UpdateWorkspaceAsync(Guid id, [Body] UpdateWorkspaceRequest request);
// El endpoint devuelve 204 NoContent — Refit no puede deserializarlo a un
// NexusCadApiResponse, así que el método retorna Task vacío. Refit lanza
- // ApiException si el status no es 2xx (p. ej. 409 si el grupo tiene proyectos
+ // ApiException si el status no es 2xx (p. ej. 409 si el workspace tiene proyectos
// y no se pasó force=true).
[Delete("/api/groups/{id}")]
[Headers("Authorization: Bearer")]
- Task DeleteGroupAsync(Guid id, [Query] bool force = false);
+ Task DeleteWorkspaceAsync(Guid id, [Query] bool force = false);
+
+ [Get("/api/groups/{id}/project-access")]
+ [Headers("Authorization: Bearer")]
+ Task>> GetWorkspaceProjectAccessAsync(Guid id);
+
+ [Put("/api/groups/{id}/project-access")]
+ [Headers("Authorization: Bearer")]
+ Task>> SetWorkspaceProjectAccessAsync(Guid id, [Body] SetWorkspaceProjectAccessRequest request);
+
+ // UserGroups endpoints (grupos personalizados de usuarios — distintos de Workspace y de roles)
+ [Get("/api/usergroups")]
+ [Headers("Authorization: Bearer")]
+ Task>> GetUserGroupsAsync();
+
+ [Get("/api/usergroups/{id}")]
+ [Headers("Authorization: Bearer")]
+ Task> GetUserGroupByIdAsync(Guid id);
+
+ [Post("/api/usergroups")]
+ [Headers("Authorization: Bearer")]
+ Task> CreateUserGroupAsync([Body] CreateUserGroupRequest request);
+
+ [Put("/api/usergroups/{id}")]
+ [Headers("Authorization: Bearer")]
+ Task> UpdateUserGroupAsync(Guid id, [Body] UpdateUserGroupRequest request);
+
+ [Delete("/api/usergroups/{id}")]
+ [Headers("Authorization: Bearer")]
+ Task DeleteUserGroupAsync(Guid id);
+
+ [Get("/api/usergroups/{id}/members")]
+ [Headers("Authorization: Bearer")]
+ Task>> GetUserGroupMembersAsync(Guid id);
+
+ [Put("/api/usergroups/{id}/members")]
+ [Headers("Authorization: Bearer")]
+ Task>> SetUserGroupMembersAsync(Guid id, [Body] SetUserGroupMembersRequest request);
+
+ [Get("/api/usergroups/{id}/projects")]
+ [Headers("Authorization: Bearer")]
+ Task>> GetUserGroupProjectsAsync(Guid id);
+
+ [Put("/api/usergroups/{id}/projects")]
+ [Headers("Authorization: Bearer")]
+ Task>> SetUserGroupProjectsAsync(Guid id, [Body] SetUserGroupProjectsRequest request);
// LookupTables endpoints
[Get("/api/lookuptables")]
diff --git a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs
new file mode 100644
index 0000000..5f0b7b6
--- /dev/null
+++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs
@@ -0,0 +1,118 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace NexusCad.Admin.ViewModels;
+
+///
+/// Item de usuario en el dialog de Group: el usuario completo + checkbox de pertenencia.
+///
+public class GroupUserItem : INotifyPropertyChanged
+{
+ private bool _isMember;
+
+ public string UserId { get; set; } = string.Empty;
+ public string Email { get; set; } = string.Empty;
+ public string FullName { get; set; } = string.Empty;
+
+ public bool IsMember
+ {
+ get => _isMember;
+ set { _isMember = value; OnPropertyChanged(); }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
+
+///
+/// Item de proyecto en el dialog de Group: el proyecto + checkbox de acceso.
+///
+public class GroupProjectItem : INotifyPropertyChanged
+{
+ private bool _hasAccess;
+
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+
+ public bool HasAccess
+ {
+ get => _hasAccess;
+ set { _hasAccess = value; OnPropertyChanged(); }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
+
+public class GroupEditDialogViewModel : INotifyPropertyChanged
+{
+ private string _name = string.Empty;
+ private string _description = string.Empty;
+ private string _code = string.Empty;
+ private bool _isEditMode;
+
+ public bool IsEditMode
+ {
+ get => _isEditMode;
+ set
+ {
+ _isEditMode = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Title));
+ OnPropertyChanged(nameof(IsCodeReadOnly));
+ }
+ }
+
+ public bool IsCodeReadOnly => IsEditMode;
+
+ public string Title => IsEditMode ? $"Editar grupo · {Code}" : "Nuevo grupo";
+
+ public string Code
+ {
+ get => _code;
+ set
+ {
+ _code = (value ?? string.Empty).ToLowerInvariant().Replace(" ", "-");
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Title));
+ }
+ }
+
+ public string Name
+ {
+ get => _name;
+ set { _name = value; OnPropertyChanged(); }
+ }
+
+ public string Description
+ {
+ get => _description;
+ set { _description = value; OnPropertyChanged(); }
+ }
+
+ /// Todos los usuarios del sistema con su estado de pertenencia al grupo.
+ public ObservableCollection UserItems { get; } = new();
+
+ /// Todos los proyectos del sistema con su estado de acceso para este grupo.
+ public ObservableCollection ProjectItems { get; } = new();
+
+ public bool IsValid()
+ {
+ if (string.IsNullOrWhiteSpace(Name))
+ return false;
+ if (!IsEditMode && string.IsNullOrWhiteSpace(Code))
+ return false;
+ return true;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs b/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs
index e2ff90a..2e62aa3 100644
--- a/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs
@@ -15,7 +15,7 @@ public partial class MainWindowViewModel : ObservableObject
{
private readonly INavigationService _navigationService;
private readonly IServiceProvider _serviceProvider;
- private readonly ICurrentGroupService _currentGroupService;
+ private readonly ICurrentWorkspaceService _currentWorkspaceService;
private readonly INexusCadApi _api;
[ObservableProperty]
@@ -28,7 +28,7 @@ public partial class MainWindowViewModel : ObservableObject
private string version = "v0.1.0-phase5";
[ObservableProperty]
- private string currentGroup = "(none)";
+ private string currentWorkspace = "(none)";
[ObservableProperty]
private string currentProject = "(none)";
@@ -57,49 +57,49 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CreateProjectCommand))]
[NotifyCanExecuteChangedFor(nameof(OpenProjectCommand))]
- [NotifyCanExecuteChangedFor(nameof(CloseGroupCommand))]
+ [NotifyCanExecuteChangedFor(nameof(CloseWorkspaceCommand))]
private bool isTreeEnabled;
///
- /// Grupo seleccionado en el panel central. Al asignarse, lo activa como
- /// CurrentGroup y carga sus proyectos en el panel Projects.
+ /// Workspace seleccionado en el panel central. Al asignarse, lo activa como
+ /// CurrentWorkspace y carga sus proyectos en el panel Projects.
///
[ObservableProperty]
- private GroupListItemDto? selectedGroup;
+ private WorkspaceListItemDto? selectedWorkspace;
[ObservableProperty]
private ProjectListItemDto? selectedProject;
public ObservableCollection Stages { get; }
- public ObservableCollection Groups { get; } = new();
+ public ObservableCollection Workspaces { get; } = new();
public ObservableCollection Projects { get; } = new();
public MainWindowViewModel(
INavigationService navigationService,
IServiceProvider serviceProvider,
- ICurrentGroupService currentGroupService,
+ ICurrentWorkspaceService currentWorkspaceService,
INexusCadApi api)
{
_navigationService = navigationService;
_serviceProvider = serviceProvider;
- _currentGroupService = currentGroupService;
+ _currentWorkspaceService = currentWorkspaceService;
_api = api;
_navigationService.CurrentViewChanged += OnNavigationViewChanged;
- _currentGroupService.CurrentGroupChanged += OnCurrentGroupChanged;
+ _currentWorkspaceService.CurrentWorkspaceChanged += OnCurrentWorkspaceChanged;
+ _currentWorkspaceService.WorkspacesListChanged += OnWorkspacesListChanged;
Stages = new ObservableCollection
{
new("Home", "house"),
new("Help", "help"),
- new(
- "Stage 1: Group Setup",
+ new("Stage 1: Workspace Setup",
"group",
new[]
{
new StageNode("Security Settings", "shield"),
- new StageNode("Group Tables", "table"),
+ new StageNode("Workspace Tables", "table"),
new StageNode("Autopilot Settings", "cog"),
new StageNode("Published Apps", "application_xp_terminal"),
},
@@ -133,10 +133,11 @@ public MainWindowViewModel(
///
public async Task InitializeAsync()
{
- await _currentGroupService.RestoreLastAsync(_api);
- ApplyCurrentGroup();
- await RefreshGroupsAsync();
+ await _currentWorkspaceService.RestoreLastAsync(_api);
+ ApplyCurrentWorkspace();
+ await RefreshWorkspacesAsync();
await RefreshProjectsAsync();
+ NavigateToNode("Home");
}
private void OnNavigationViewChanged(object? sender, object? newView)
@@ -144,15 +145,22 @@ private void OnNavigationViewChanged(object? sender, object? newView)
CurrentView = newView;
}
- private void OnCurrentGroupChanged(object? sender, EventArgs e)
+ private void OnCurrentWorkspaceChanged(object? sender, EventArgs e)
+ {
+ ApplyCurrentWorkspace();
+ }
+
+ private async void OnWorkspacesListChanged(object? sender, EventArgs e)
{
- ApplyCurrentGroup();
+ // Otro VM (SecuritySettings, etc.) ha creado/editado/borrado un workspace.
+ // Refrescamos la lista del sidebar para mantenerla coherente.
+ await RefreshWorkspacesAsync();
}
- private void ApplyCurrentGroup()
+ private void ApplyCurrentWorkspace()
{
- var g = _currentGroupService.Current;
- CurrentGroup = g?.Name ?? "(none)";
+ var g = _currentWorkspaceService.Current;
+ CurrentWorkspace = g?.Name ?? "(none)";
IsTreeEnabled = g != null;
if (g == null)
{
@@ -160,72 +168,72 @@ private void ApplyCurrentGroup()
SelectedNode = null;
Projects.Clear();
}
- // Mantener la selección visual en la lista de grupos coherente con
- // el grupo activo real.
- var match = g == null ? null : Groups.FirstOrDefault(x => x.Id == g.Id);
- if (!ReferenceEquals(SelectedGroup, match))
+ // Mantener la selección visual en la lista de workspaces coherente con
+ // el workspace activo real.
+ var match = g == null ? null : Workspaces.FirstOrDefault(x => x.Id == g.Id);
+ if (!ReferenceEquals(SelectedWorkspace, match))
{
- _suppressGroupSelectionEvent = true;
- SelectedGroup = match;
- _suppressGroupSelectionEvent = false;
+ _suppressWorkspaceSelectionEvent = true;
+ SelectedWorkspace = match;
+ _suppressWorkspaceSelectionEvent = false;
}
System.Windows.Input.CommandManager.InvalidateRequerySuggested();
}
- private bool _suppressGroupSelectionEvent;
+ private bool _suppressWorkspaceSelectionEvent;
- partial void OnSelectedGroupChanged(GroupListItemDto? value)
+ partial void OnSelectedWorkspaceChanged(WorkspaceListItemDto? value)
{
- if (_suppressGroupSelectionEvent)
+ if (_suppressWorkspaceSelectionEvent)
return;
- _ = ActivateGroupAsync(value);
+ _ = ActivateWorkspaceAsync(value);
}
- private async Task ActivateGroupAsync(GroupListItemDto? listItem)
+ private async Task ActivateWorkspaceAsync(WorkspaceListItemDto? listItem)
{
if (listItem == null)
{
- await _currentGroupService.SetCurrentAsync(null);
- ApplyCurrentGroup();
+ await _currentWorkspaceService.SetCurrentAsync(null);
+ ApplyCurrentWorkspace();
CurrentProject = "(none)";
return;
}
try
{
- var response = await _api.GetGroupByIdAsync(listItem.Id);
+ var response = await _api.GetWorkspaceByIdAsync(listItem.Id);
if (response.Success && response.Data != null)
{
- await _currentGroupService.SetCurrentAsync(response.Data);
- ApplyCurrentGroup();
+ await _currentWorkspaceService.SetCurrentAsync(response.Data);
+ ApplyCurrentWorkspace();
await RefreshProjectsAsync();
}
}
catch (Refit.ApiException apiEx)
{
MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}",
- "Open Group", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Open Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
- private async Task RefreshGroupsAsync()
+ private async Task RefreshWorkspacesAsync()
{
try
{
- var response = await _api.GetGroupsAsync(recent: false);
+ var response = await _api.GetWorkspacesAsync(recent: false);
if (!response.Success || response.Data == null)
return;
- Groups.Clear();
+ Workspaces.Clear();
foreach (var g in response.Data.OrderBy(g => g.Name))
- Groups.Add(g);
+ Workspaces.Add(g);
- // Resincroniza la selección visual con el grupo activo tras recargar.
- var active = _currentGroupService.Current;
+ // Resincroniza la selección visual con el workspace activo tras recargar.
+ var active = _currentWorkspaceService.Current;
if (active != null)
{
- _suppressGroupSelectionEvent = true;
- SelectedGroup = Groups.FirstOrDefault(x => x.Id == active.Id);
- _suppressGroupSelectionEvent = false;
+ _suppressWorkspaceSelectionEvent = true;
+ SelectedWorkspace = Workspaces.FirstOrDefault(x => x.Id == active.Id);
+ _suppressWorkspaceSelectionEvent = false;
}
}
catch
@@ -237,7 +245,7 @@ private async Task RefreshGroupsAsync()
private async Task RefreshProjectsAsync()
{
Projects.Clear();
- var active = _currentGroupService.Current;
+ var active = _currentWorkspaceService.Current;
if (active == null)
return;
@@ -270,13 +278,17 @@ private void NavigateToNode(string nodeName)
{
switch (nodeName)
{
+ case "Home":
+ _navigationService.NavigateTo(new Views.HomeView());
+ break;
+
case "Security Settings":
var securityVm = _serviceProvider.GetRequiredService();
var securityView = new Views.SecuritySettingsView { DataContext = securityVm };
_navigationService.NavigateTo(securityView);
break;
- case "Group Tables":
+ case "Workspace Tables":
var lookupVm = _serviceProvider.GetRequiredService();
var lookupView = new Views.LookupTablesView { DataContext = lookupVm };
_navigationService.NavigateTo(lookupView);
@@ -343,142 +355,145 @@ private void NavigateToNode(string nodeName)
}
[RelayCommand]
- private async Task NewGroup()
+ private async Task NewWorkspace()
{
- var dialogVm = _serviceProvider.GetRequiredService();
+ var dialogVm = _serviceProvider.GetRequiredService();
var owner = Application.Current.MainWindow;
- var created = Views.NewGroupDialog.ShowDialog(owner, dialogVm);
+ var created = Views.NewWorkspaceDialog.ShowDialog(owner, dialogVm);
if (created != null)
{
- await _currentGroupService.SetCurrentAsync(created);
- await RefreshGroupsAsync();
- ApplyCurrentGroup();
+ await _currentWorkspaceService.SetCurrentAsync(created);
+ await RefreshWorkspacesAsync();
+ ApplyCurrentWorkspace();
await RefreshProjectsAsync();
+ _currentWorkspaceService.NotifyWorkspacesListChanged();
}
}
[RelayCommand]
- private async Task OpenGroup()
+ private async Task OpenWorkspace()
{
- var dialogVm = _serviceProvider.GetRequiredService();
+ var dialogVm = _serviceProvider.GetRequiredService();
var owner = Application.Current.MainWindow;
- var opened = Views.OpenGroupDialog.ShowDialog(owner, dialogVm);
+ var opened = Views.OpenWorkspaceDialog.ShowDialog(owner, dialogVm);
if (opened != null)
{
- await _currentGroupService.SetCurrentAsync(opened);
- await RefreshGroupsAsync();
- ApplyCurrentGroup();
+ await _currentWorkspaceService.SetCurrentAsync(opened);
+ await RefreshWorkspacesAsync();
+ ApplyCurrentWorkspace();
await RefreshProjectsAsync();
}
}
- [RelayCommand(CanExecute = nameof(CanCloseGroup))]
- private async Task CloseGroup()
+ [RelayCommand(CanExecute = nameof(CanCloseWorkspace))]
+ private async Task CloseWorkspace()
{
- await _currentGroupService.SetCurrentAsync(null);
- ApplyCurrentGroup();
+ await _currentWorkspaceService.SetCurrentAsync(null);
+ ApplyCurrentWorkspace();
CurrentProject = "(none)";
}
- private bool CanCloseGroup() => IsTreeEnabled;
+ private bool CanCloseWorkspace() => IsTreeEnabled;
[RelayCommand]
- private async Task DeleteGroup(GroupListItemDto? item)
+ private async Task DeleteWorkspace(WorkspaceListItemDto? item)
{
if (item == null)
return;
var confirm = MessageBox.Show(
- $"Delete group '{item.Name}'? This cannot be undone.\n\nThe API will reject the delete if the group still contains projects.",
- "Delete Group",
+ $"Delete workspace '{item.Name}'? This cannot be undone.\n\nThe API will reject the delete if the workspace still contains projects.",
+ "Delete Workspace",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes)
return;
- await TryDeleteGroupAsync(item.Id, item.Name, force: false);
+ await TryDeleteWorkspaceAsync(item.Id, item.Name, force: false);
}
[RelayCommand]
- private async Task RenameGroup(GroupListItemDto? item)
+ private async Task RenameWorkspace(WorkspaceListItemDto? item)
{
if (item == null)
return;
- var input = Views.RenameDialog.Show("Rename Group", $"New name for group '{item.Name}':", item.Name);
+ var input = Views.RenameDialog.Show("Rename Workspace", $"New name for workspace '{item.Name}':", item.Name);
if (string.IsNullOrWhiteSpace(input) || input == item.Name)
return;
try
{
- var request = new Models.UpdateGroupRequest { Name = input.Trim(), Description = item.Description };
- await _api.UpdateGroupAsync(item.Id, request);
+ var request = new Models.UpdateWorkspaceRequest { Name = input.Trim(), Description = item.Description };
+ await _api.UpdateWorkspaceAsync(item.Id, request);
- if (_currentGroupService.Current?.Id == item.Id)
+ if (_currentWorkspaceService.Current?.Id == item.Id)
{
- _currentGroupService.Current.Name = input.Trim();
- ApplyCurrentGroup();
+ _currentWorkspaceService.Current.Name = input.Trim();
+ ApplyCurrentWorkspace();
}
- await RefreshGroupsAsync();
+ await RefreshWorkspacesAsync();
+ _currentWorkspaceService.NotifyWorkspacesListChanged();
}
catch (Refit.ApiException apiEx)
{
MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}",
- "Rename Group", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Rename Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch (Exception ex)
{
- MessageBox.Show(ex.Message, "Rename Group", MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBox.Show(ex.Message, "Rename Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
- private async Task TryDeleteGroupAsync(Guid groupId, string label, bool force)
+ private async Task TryDeleteWorkspaceAsync(Guid workspaceId, string label, bool force)
{
try
{
- await _api.DeleteGroupAsync(groupId, force);
+ await _api.DeleteWorkspaceAsync(workspaceId, force);
- if (_currentGroupService.Current?.Id == groupId)
+ if (_currentWorkspaceService.Current?.Id == workspaceId)
{
- await _currentGroupService.SetCurrentAsync(null);
+ await _currentWorkspaceService.SetCurrentAsync(null);
CurrentProject = "(none)";
}
- await RefreshGroupsAsync();
- ApplyCurrentGroup();
+ await RefreshWorkspacesAsync();
+ ApplyCurrentWorkspace();
await RefreshProjectsAsync();
+ _currentWorkspaceService.NotifyWorkspacesListChanged();
}
catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode == 409 && !force)
{
var confirm = MessageBox.Show(
- $"{apiEx.Content}\n\nForce-delete the group and all its projects, specifications and generated documents? This cannot be undone.",
- "Delete Group — Force",
+ $"{apiEx.Content}\n\nForce-delete the workspace and all its projects, specifications and generated documents? This cannot be undone.",
+ "Delete Workspace — Force",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm == MessageBoxResult.Yes)
{
- await TryDeleteGroupAsync(groupId, label, force: true);
+ await TryDeleteWorkspaceAsync(workspaceId, label, force: true);
}
}
catch (Refit.ApiException apiEx)
{
MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}",
- "Delete Group", MessageBoxButton.OK, MessageBoxImage.Error);
+ "Delete Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch (Exception ex)
{
- MessageBox.Show(ex.Message, "Delete Group", MessageBoxButton.OK, MessageBoxImage.Error);
+ MessageBox.Show(ex.Message, "Delete Workspace", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
[RelayCommand(CanExecute = nameof(CanCreateProject))]
private async Task CreateProject()
{
- var current = _currentGroupService.Current;
+ var current = _currentWorkspaceService.Current;
if (current == null)
{
- MessageBox.Show("Open or create a group first.", "Create Project",
+ MessageBox.Show("Open or create a workspace first.", "Create Project",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
@@ -542,10 +557,10 @@ private async Task CreateProject()
[RelayCommand(CanExecute = nameof(CanCreateProject))]
private async Task OpenProject()
{
- var current = _currentGroupService.Current;
+ var current = _currentWorkspaceService.Current;
if (current == null)
{
- MessageBox.Show("Open or create a group first.", "Open Project",
+ MessageBox.Show("Open or create a workspace first.", "Open Project",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
diff --git a/src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs
similarity index 72%
rename from src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs
rename to src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs
index 149bbc0..5b418a6 100644
--- a/src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs
@@ -12,7 +12,7 @@
namespace NexusCad.Admin.ViewModels;
-public partial class NewGroupDialogViewModel : ObservableValidator
+public partial class NewWorkspaceDialogViewModel : ObservableValidator
{
private readonly INexusCadApi _api;
@@ -22,19 +22,19 @@ public partial class NewGroupDialogViewModel : ObservableValidator
ErrorMessage = "Lowercase letters, digits and hyphens only (e.g. configurador-itecam)")]
[NotifyDataErrorInfo]
[NotifyCanExecuteChangedFor(nameof(CreateCommand))]
- private string _groupCode = string.Empty;
+ private string _workspaceCode = string.Empty;
[ObservableProperty]
[Required(ErrorMessage = "Name is required")]
[StringLength(200, MinimumLength = 3)]
[NotifyDataErrorInfo]
[NotifyCanExecuteChangedFor(nameof(CreateCommand))]
- private string _groupName = string.Empty;
+ private string _workspaceName = string.Empty;
[ObservableProperty]
[StringLength(1000)]
[NotifyDataErrorInfo]
- private string? _groupDescription;
+ private string? _workspaceDescription;
[ObservableProperty]
[StringLength(500)]
@@ -50,18 +50,18 @@ public partial class NewGroupDialogViewModel : ObservableValidator
[ObservableProperty]
private bool _isBusy;
- public GroupDto? CreatedGroup { get; private set; }
+ public WorkspaceDto? CreatedWorkspace { get; private set; }
public bool DialogResult { get; private set; }
- public NewGroupDialogViewModel(INexusCadApi api)
+ public NewWorkspaceDialogViewModel(INexusCadApi api)
{
_api = api;
PropertyChanged += (_, e) =>
{
- if (e.PropertyName == nameof(GroupCode)
- || e.PropertyName == nameof(GroupName)
- || e.PropertyName == nameof(GroupDescription))
+ if (e.PropertyName == nameof(WorkspaceCode)
+ || e.PropertyName == nameof(WorkspaceName)
+ || e.PropertyName == nameof(WorkspaceDescription))
{
ValidateAllProperties();
}
@@ -71,11 +71,9 @@ public NewGroupDialogViewModel(INexusCadApi api)
[RelayCommand]
private void BrowseWorkspace()
{
- // OpenFileDialog en modo "selección de carpeta" — usamos un truco común:
- // pedir un archivo dummy y quedarnos con su carpeta.
var dlg = new OpenFolderDialog
{
- Title = "Select the workspace folder for this group",
+ Title = "Select the workspace folder for this workspace",
};
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.FolderName))
{
@@ -94,25 +92,25 @@ private async Task CreateAsync()
ErrorMessage = null;
try
{
- var request = new CreateGroupRequest
+ var request = new CreateWorkspaceRequest
{
- Code = GroupCode.Trim().ToLowerInvariant(),
- Name = GroupName.Trim(),
- Description = string.IsNullOrWhiteSpace(GroupDescription) ? null : GroupDescription.Trim(),
+ Code = WorkspaceCode.Trim().ToLowerInvariant(),
+ Name = WorkspaceName.Trim(),
+ Description = string.IsNullOrWhiteSpace(WorkspaceDescription) ? null : WorkspaceDescription.Trim(),
WorkspacePath = string.IsNullOrWhiteSpace(WorkspacePath) ? null : WorkspacePath.Trim(),
IconUrl = string.IsNullOrWhiteSpace(IconUrl) ? null : IconUrl.Trim(),
};
- var response = await _api.CreateGroupAsync(request);
+ var response = await _api.CreateWorkspaceAsync(request);
if (response.Success && response.Data != null)
{
- CreatedGroup = response.Data;
+ CreatedWorkspace = response.Data;
DialogResult = true;
OnPropertyChanged(nameof(DialogResult));
}
else
{
- ErrorMessage = response.Message ?? "Failed to create group";
+ ErrorMessage = response.Message ?? "Failed to create workspace";
}
}
catch (ApiException apiEx)
@@ -131,8 +129,8 @@ private async Task CreateAsync()
private bool CanCreate() =>
!IsBusy
- && !string.IsNullOrWhiteSpace(GroupCode)
- && !string.IsNullOrWhiteSpace(GroupName)
+ && !string.IsNullOrWhiteSpace(WorkspaceCode)
+ && !string.IsNullOrWhiteSpace(WorkspaceName)
&& !HasErrors;
[RelayCommand]
diff --git a/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs
index 0342c5b..5abfae8 100644
--- a/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs
@@ -14,7 +14,7 @@ namespace NexusCad.Admin.ViewModels;
public partial class OpenProjectDialogViewModel : ObservableObject
{
private readonly INexusCadApi _api;
- private readonly ICurrentGroupService _currentGroupService;
+ private readonly ICurrentWorkspaceService _currentWorkspaceService;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(OpenCommand))]
@@ -32,10 +32,10 @@ public partial class OpenProjectDialogViewModel : ObservableObject
public bool DialogResult { get; private set; }
- public OpenProjectDialogViewModel(INexusCadApi api, ICurrentGroupService currentGroupService)
+ public OpenProjectDialogViewModel(INexusCadApi api, ICurrentWorkspaceService currentWorkspaceService)
{
_api = api;
- _currentGroupService = currentGroupService;
+ _currentWorkspaceService = currentWorkspaceService;
}
public async Task LoadAsync()
@@ -44,14 +44,14 @@ public async Task LoadAsync()
ErrorMessage = null;
try
{
- var currentGroup = _currentGroupService.Current;
- if (currentGroup == null)
+ var currentWorkspace = _currentWorkspaceService.Current;
+ if (currentWorkspace == null)
{
- ErrorMessage = "No group is currently active. Please open a group first.";
+ ErrorMessage = "No workspace is currently active. Please open a workspace first.";
return;
}
- var response = await _api.GetProjectsAsync(groupId: currentGroup.Id, pageSize: 200);
+ var response = await _api.GetProjectsAsync(groupId: currentWorkspace.Id, pageSize: 200);
if (!response.Success || response.Data?.Items == null)
{
ErrorMessage = response.Message ?? "Failed to load projects";
diff --git a/src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs
similarity index 64%
rename from src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs
rename to src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs
index 7d11a00..94517ef 100644
--- a/src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs
@@ -11,13 +11,13 @@
namespace NexusCad.Admin.ViewModels;
-public partial class OpenGroupDialogViewModel : ObservableObject
+public partial class OpenWorkspaceDialogViewModel : ObservableObject
{
private readonly INexusCadApi _api;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(OpenCommand))]
- private GroupListItemDto? selectedGroup;
+ private WorkspaceListItemDto? selectedWorkspace;
[ObservableProperty]
private bool isLoading;
@@ -25,13 +25,13 @@ public partial class OpenGroupDialogViewModel : ObservableObject
[ObservableProperty]
private string? errorMessage;
- public ObservableCollection Groups { get; } = new();
+ public ObservableCollection Workspaces { get; } = new();
- public GroupDto? OpenedGroup { get; private set; }
+ public WorkspaceDto? OpenedWorkspace { get; private set; }
public bool DialogResult { get; private set; }
- public OpenGroupDialogViewModel(INexusCadApi api)
+ public OpenWorkspaceDialogViewModel(INexusCadApi api)
{
_api = api;
}
@@ -42,15 +42,15 @@ public async Task LoadAsync()
ErrorMessage = null;
try
{
- var response = await _api.GetGroupsAsync(recent: false);
+ var response = await _api.GetWorkspacesAsync(recent: false);
if (!response.Success || response.Data == null)
{
- ErrorMessage = response.Message ?? "Failed to load groups";
+ ErrorMessage = response.Message ?? "Failed to load workspaces";
return;
}
- Groups.Clear();
- foreach (var g in response.Data)
- Groups.Add(g);
+ Workspaces.Clear();
+ foreach (var w in response.Data)
+ Workspaces.Add(w);
}
catch (Exception ex)
{
@@ -65,22 +65,22 @@ public async Task LoadAsync()
[RelayCommand(CanExecute = nameof(CanOpen))]
private async Task OpenAsync()
{
- if (SelectedGroup == null)
+ if (SelectedWorkspace == null)
return;
IsLoading = true;
ErrorMessage = null;
try
{
- var response = await _api.GetGroupByIdAsync(SelectedGroup.Id);
+ var response = await _api.GetWorkspaceByIdAsync(SelectedWorkspace.Id);
if (response.Success && response.Data != null)
{
- OpenedGroup = response.Data;
+ OpenedWorkspace = response.Data;
DialogResult = true;
OnPropertyChanged(nameof(DialogResult));
}
else
{
- ErrorMessage = response.Message ?? "Failed to open group";
+ ErrorMessage = response.Message ?? "Failed to open workspace";
}
}
catch (Exception ex)
@@ -93,47 +93,47 @@ private async Task OpenAsync()
}
}
- private bool CanOpen() => SelectedGroup != null && !IsLoading;
+ private bool CanOpen() => SelectedWorkspace != null && !IsLoading;
[RelayCommand]
- private async Task DeleteAsync(GroupListItemDto? group)
+ private async Task DeleteAsync(WorkspaceListItemDto? workspace)
{
- if (group == null)
+ if (workspace == null)
return;
var confirm = MessageBox.Show(
- $"Delete group '{group.Name}'? This cannot be undone.\n\nThe API will reject the delete if the group still contains projects.",
- "Delete Group",
+ $"Delete workspace '{workspace.Name}'? This cannot be undone.\n\nThe API will reject the delete if the workspace still contains projects.",
+ "Delete Workspace",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes)
return;
- await TryDeleteAsync(group, force: false);
+ await TryDeleteAsync(workspace, force: false);
}
- private async Task TryDeleteAsync(GroupListItemDto group, bool force)
+ private async Task TryDeleteAsync(WorkspaceListItemDto workspace, bool force)
{
IsLoading = true;
ErrorMessage = null;
try
{
- await _api.DeleteGroupAsync(group.Id, force);
- Groups.Remove(group);
- if (SelectedGroup?.Id == group.Id)
- SelectedGroup = null;
+ await _api.DeleteWorkspaceAsync(workspace.Id, force);
+ Workspaces.Remove(workspace);
+ if (SelectedWorkspace?.Id == workspace.Id)
+ SelectedWorkspace = null;
}
catch (ApiException apiEx) when ((int)apiEx.StatusCode == 409 && !force)
{
IsLoading = false;
var confirm = MessageBox.Show(
- $"{apiEx.Content}\n\nForce-delete the group and all its projects?",
- "Delete Group — Force",
+ $"{apiEx.Content}\n\nForce-delete the workspace and all its projects?",
+ "Delete Workspace — Force",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm == MessageBoxResult.Yes)
{
- await TryDeleteAsync(group, force: true);
+ await TryDeleteAsync(workspace, force: true);
}
return;
}
diff --git a/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs b/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs
index 3cb644a..de6eb9b 100644
--- a/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs
@@ -43,6 +43,19 @@ public string ProjectDescription
}
}
+ private string? _modelsPath;
+ public string? ModelsPath
+ {
+ get => _modelsPath;
+ set
+ {
+ if (SetProperty(ref _modelsPath, value))
+ {
+ HasUnsavedChanges = true;
+ }
+ }
+ }
+
public ObservableCollection Variables { get; set; } = new();
private ProjectVariableDto? _selectedVariable;
@@ -127,6 +140,7 @@ public bool IsPublished
public ICommand DeleteVariableCommand { get; }
public ICommand OpenRulesEditorCommand { get; }
public ICommand OpenFormDesignerCommand { get; }
+ public ICommand BrowseModelsPathCommand { get; }
public ProjectEditorViewModel(Services.INexusCadApi api)
{
@@ -139,6 +153,7 @@ public ProjectEditorViewModel(Services.INexusCadApi api)
DeleteVariableCommand = new RelayCommand(DeleteVariable, CanDeleteVariable);
OpenRulesEditorCommand = new RelayCommand(OpenRulesEditor);
OpenFormDesignerCommand = new RelayCommand(OpenFormDesigner);
+ BrowseModelsPathCommand = new RelayCommand(BrowseModelsPath);
}
private async Task LoadProject()
@@ -205,7 +220,8 @@ private async Task SaveProject()
{
Name = ProjectName,
Description = ProjectDescription,
- FormSchemaJson = FormSchemaJson
+ FormSchemaJson = FormSchemaJson,
+ ModelsPath = string.IsNullOrWhiteSpace(ModelsPath) ? null : ModelsPath
};
var response = await _api.UpdateProjectAsync(_projectId.Value, request);
@@ -300,6 +316,20 @@ private void OpenRulesEditor()
ErrorMessage = "Rules editor will be implemented in a future update";
}
+ private void BrowseModelsPath()
+ {
+ var dialog = new Microsoft.Win32.OpenFolderDialog
+ {
+ Title = "Seleccionar carpeta de modelos SolidWorks del proyecto",
+ InitialDirectory = ModelsPath ?? string.Empty
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ ModelsPath = dialog.FolderName;
+ }
+ }
+
private void OpenFormDesigner()
{
var formDesignerVm = new FormDesignerViewModel();
@@ -364,6 +394,7 @@ public void LoadExistingProject(Models.ProjectDto project)
ProjectDescription = project.Description;
FormSchemaJson = project.FormSchemaJson ?? "{}";
IsPublished = project.Status == Models.ProjectStatus.Published;
+ ModelsPath = project.ModelsPath;
// TODO: Cargar las variables del proyecto desde la API
Variables.Clear();
diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs
index 12521fc..c95bb24 100644
--- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs
@@ -13,12 +13,15 @@
namespace NexusCad.Admin.ViewModels;
///
-/// ViewModel for Security Settings view (User management)
+/// ViewModel for Security Settings view (User and Group management)
///
public partial class SecuritySettingsViewModel : ObservableObject
{
private readonly INexusCadApi _api;
private readonly IAuthService _authService;
+ private readonly ICurrentWorkspaceService _currentWorkspaceService;
+
+ // ── Users ──────────────────────────────────────────────────────────────
[ObservableProperty]
private ObservableCollection users = new();
@@ -40,10 +43,64 @@ public partial class SecuritySettingsViewModel : ObservableObject
// Available roles
public List AvailableRoles { get; } = new() { "Admin", "Autor", "Comercial", "Cliente" };
- public SecuritySettingsViewModel(INexusCadApi api, IAuthService authService)
+ // ── Workspaces ─────────────────────────────────────────────────────────────
+
+ [ObservableProperty]
+ private ObservableCollection workspaces = new();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(EditWorkspaceCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DeleteWorkspaceCommand))]
+ private WorkspaceListItemDto? selectedWorkspace;
+
+ [ObservableProperty]
+ private bool isWorkspacesLoading;
+
+ [ObservableProperty]
+ private string? workspacesErrorMessage;
+
+ [ObservableProperty]
+ private string? workspacesSuccessMessage;
+
+ // ── UserGroups (grupos personalizados de usuarios) ──────────────────────
+
+ [ObservableProperty]
+ private ObservableCollection userGroups = new();
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(EditUserGroupCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DeleteUserGroupCommand))]
+ private UserGroupListItemDto? selectedUserGroup;
+
+ [ObservableProperty]
+ private bool isUserGroupsLoading;
+
+ [ObservableProperty]
+ private string? userGroupsErrorMessage;
+
+ [ObservableProperty]
+ private string? userGroupsSuccessMessage;
+
+ public SecuritySettingsViewModel(
+ INexusCadApi api,
+ IAuthService authService,
+ ICurrentWorkspaceService currentWorkspaceService)
{
_api = api;
_authService = authService;
+ _currentWorkspaceService = currentWorkspaceService;
+ _currentWorkspaceService.WorkspacesListChanged += OnWorkspacesListChanged;
+ }
+
+ private bool _suppressExternalReload;
+
+ private async void OnWorkspacesListChanged(object? sender, EventArgs e)
+ {
+ // Evita re-cargar si el cambio lo originó esta misma vista (LoadWorkspacesAsync
+ // ya se llamó justo antes de NotifyWorkspacesListChanged).
+ if (_suppressExternalReload)
+ return;
+ await LoadWorkspacesAsync();
}
[RelayCommand]
@@ -92,14 +149,49 @@ private async Task EditUser()
if (SelectedUser == null)
return;
- var dialogVm = new UserEditDialogViewModel(AvailableRoles)
+ var dialogVm = new UserEditDialogViewModel
{
IsEditMode = true,
FullName = SelectedUser.FullName,
IsActive = SelectedUser.IsActive,
- SelectedRoles = new ObservableCollection(SelectedUser.Roles)
};
+ // Load all user-groups and current memberships to populate the groups tab
+ var allUserGroups = new List();
+ var currentMemberships = new List();
+ string? groupsLoadError = null;
+ try
+ {
+ var groupsResponse = await _api.GetUserGroupsAsync();
+ if (groupsResponse.Success && groupsResponse.Data != null)
+ allUserGroups = groupsResponse.Data;
+ else
+ groupsLoadError = $"No se pudieron cargar los grupos: {groupsResponse.Message}";
+
+ var membResponse = await _api.GetUserMembershipsAsync(SelectedUser.Id);
+ if (membResponse.Success && membResponse.Data != null)
+ currentMemberships = membResponse.Data;
+ }
+ catch (Exception ex)
+ {
+ groupsLoadError = $"Error cargando grupos: {ex.Message}";
+ }
+
+ foreach (var g in allUserGroups)
+ {
+ var existing = currentMemberships.FirstOrDefault(m => m.GroupId == g.Id);
+ dialogVm.GroupItems.Add(new GroupMembershipItem
+ {
+ GroupId = g.Id,
+ GroupCode = g.Code,
+ GroupName = g.Name,
+ HasAccess = existing != null,
+ });
+ }
+
+ if (groupsLoadError != null)
+ ErrorMessage = groupsLoadError;
+
var owner = Application.Current.MainWindow;
var confirmed = UserEditDialog.ShowEditDialog(owner!, dialogVm);
if (!confirmed)
@@ -111,11 +203,11 @@ private async Task EditUser()
ErrorMessage = null;
SuccessMessage = null;
+ // Update basic user data
var request = new UpdateUserRequest
{
FullName = dialogVm.FullName,
IsActive = dialogVm.IsActive,
- Roles = dialogVm.SelectedRoles.ToList(),
NewPassword = string.IsNullOrWhiteSpace(dialogVm.Password) ? null : dialogVm.Password
};
@@ -125,7 +217,26 @@ private async Task EditUser()
var idx = Users.IndexOf(SelectedUser);
Users[idx] = response.Data;
SelectedUser = response.Data;
- SuccessMessage = "User updated successfully";
+
+ // Save group memberships
+ var membRequest = new SetUserMembershipsRequest
+ {
+ Memberships = dialogVm.GroupItems
+ .Where(g => g.HasAccess)
+ .Select(g => new MembershipItem { GroupId = g.GroupId })
+ .ToList()
+ };
+ try
+ {
+ await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest);
+ }
+ catch (Exception membEx)
+ {
+ ErrorMessage = $"Usuario actualizado pero error al guardar acceso a grupos: {membEx.Message}";
+ return;
+ }
+
+ SuccessMessage = "Usuario actualizado correctamente";
}
else
{
@@ -154,7 +265,15 @@ private async Task DeleteUser()
if (SelectedUser == null)
return;
- // TODO: Show confirmation dialog
+ var result = MessageBox.Show(
+ $"¿Estás seguro de que quieres eliminar el usuario '{SelectedUser.FullName}' ({SelectedUser.Email})?\n\nEsta acción no se puede deshacer.",
+ "Eliminar usuario",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes)
+ return;
+
try
{
IsLoading = true;
@@ -214,14 +333,270 @@ private static string ExtractApiError(Refit.ApiException apiEx, string fallback)
return $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}";
}
+ // ── Workspace commands ─────────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task LoadWorkspaces()
+ {
+ WorkspacesErrorMessage = null;
+ WorkspacesSuccessMessage = null;
+ await LoadWorkspacesAsync();
+ }
+
+ public async Task LoadWorkspacesAsync()
+ {
+ try
+ {
+ IsWorkspacesLoading = true;
+ var response = await _api.GetWorkspacesAsync();
+ if (response.Success && response.Data != null)
+ {
+ Workspaces = new ObservableCollection(response.Data);
+ if (Workspaces.Count == 0)
+ WorkspacesErrorMessage = "No workspaces found.";
+ }
+ else
+ {
+ WorkspacesErrorMessage = $"API error: {response.Message ?? "Unknown error"}";
+ }
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ WorkspacesErrorMessage = $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {apiEx.Content}";
+ }
+ catch (Exception ex)
+ {
+ WorkspacesErrorMessage = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ finally
+ {
+ IsWorkspacesLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CreateWorkspace()
+ {
+ var dialogVm = new NewWorkspaceDialogViewModel(_api);
+ var owner = Application.Current.MainWindow;
+ var created = NewWorkspaceDialog.ShowDialog(owner!, dialogVm);
+ if (created == null)
+ return;
+
+ WorkspacesSuccessMessage = $"Workspace '{created.Name}' created successfully";
+ WorkspacesErrorMessage = null;
+ await LoadWorkspacesAsync();
+ SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == created.Id);
+ _suppressExternalReload = true;
+ try { _currentWorkspaceService.NotifyWorkspacesListChanged(); }
+ finally { _suppressExternalReload = false; }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanEditWorkspace))]
+ private async Task EditWorkspace()
+ {
+ if (SelectedWorkspace == null)
+ return;
+
+ var dialogVm = new WorkspaceEditDialogViewModel
+ {
+ Code = SelectedWorkspace.Code,
+ Name = SelectedWorkspace.Name,
+ Description = SelectedWorkspace.Description,
+ };
+
+ // Load full workspace to get WorkspacePath and IconUrl
+ try
+ {
+ var full = await _api.GetWorkspaceByIdAsync(SelectedWorkspace.Id);
+ if (full.Success && full.Data != null)
+ {
+ dialogVm.WorkspacePath = full.Data.WorkspacePath;
+ dialogVm.IconUrl = full.Data.IconUrl;
+ }
+ }
+ catch { /* non-critical */ }
+
+ // Load all projects + current access for this workspace
+ try
+ {
+ var allProjectsResp = await _api.GetProjectsAsync(pageSize: 1000);
+ var currentAccessResp = await _api.GetWorkspaceProjectAccessAsync(SelectedWorkspace.Id);
+
+ var currentAccess = (currentAccessResp.Success ? currentAccessResp.Data : null)
+ ?? new List();
+
+ if (allProjectsResp.Success && allProjectsResp.Data?.Items != null)
+ {
+ foreach (var proj in allProjectsResp.Data.Items)
+ {
+ var existing = currentAccess.FirstOrDefault(a => a.ProjectId == proj.Id);
+ dialogVm.ProjectItems.Add(new WorkspaceProjectAccessItem
+ {
+ ProjectId = proj.Id,
+ ProjectCode = proj.Code,
+ ProjectName = proj.Name,
+ HasAccess = existing != null,
+ AccessLevel = existing?.AccessLevel ?? 1,
+ });
+ }
+ }
+ }
+ catch { /* non-critical — proceed without project tab */ }
+
+ var owner = Application.Current.MainWindow;
+ var confirmed = WorkspaceEditDialog.ShowDialog(owner!, dialogVm);
+ if (!confirmed)
+ return;
+
+ try
+ {
+ IsWorkspacesLoading = true;
+ WorkspacesErrorMessage = null;
+ WorkspacesSuccessMessage = null;
+
+ var request = new UpdateWorkspaceRequest
+ {
+ Name = dialogVm.Name,
+ Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description,
+ WorkspacePath = string.IsNullOrWhiteSpace(dialogVm.WorkspacePath) ? null : dialogVm.WorkspacePath,
+ IconUrl = string.IsNullOrWhiteSpace(dialogVm.IconUrl) ? null : dialogVm.IconUrl,
+ };
+
+ var response = await _api.UpdateWorkspaceAsync(SelectedWorkspace.Id, request);
+ if (response.Success && response.Data != null)
+ {
+ // Save project access
+ var accessRequest = new SetWorkspaceProjectAccessRequest
+ {
+ Items = dialogVm.ProjectItems
+ .Where(p => p.HasAccess)
+ .Select(p => new WorkspaceProjectAccessEntry
+ {
+ ProjectId = p.ProjectId,
+ AccessLevel = p.AccessLevel,
+ })
+ .ToList()
+ };
+ try { await _api.SetWorkspaceProjectAccessAsync(SelectedWorkspace.Id, accessRequest); }
+ catch { /* non-critical */ }
+
+ WorkspacesSuccessMessage = "Workspace updated successfully";
+ await LoadWorkspacesAsync();
+ SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == response.Data.Id);
+ _suppressExternalReload = true;
+ try { _currentWorkspaceService.NotifyWorkspacesListChanged(); }
+ finally { _suppressExternalReload = false; }
+ }
+ else
+ {
+ WorkspacesErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to update workspace");
+ }
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ WorkspacesErrorMessage = ExtractApiError(apiEx, "Failed to update workspace");
+ }
+ catch (Exception ex)
+ {
+ WorkspacesErrorMessage = $"Error updating workspace: {ex.Message}";
+ }
+ finally
+ {
+ IsWorkspacesLoading = false;
+ }
+ }
+
+ private bool CanEditWorkspace() => SelectedWorkspace != null;
+
+ [RelayCommand(CanExecute = nameof(CanDeleteWorkspace))]
+ private async Task DeleteWorkspace()
+ {
+ if (SelectedWorkspace == null)
+ return;
+
+ var result = MessageBox.Show(
+ $"Delete workspace '{SelectedWorkspace.Name}'?\n\nThis will fail if the workspace still has projects.",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes)
+ return;
+
+ try
+ {
+ IsWorkspacesLoading = true;
+ WorkspacesErrorMessage = null;
+ WorkspacesSuccessMessage = null;
+
+ await _api.DeleteWorkspaceAsync(SelectedWorkspace.Id);
+ Workspaces.Remove(SelectedWorkspace);
+ SelectedWorkspace = null;
+ WorkspacesSuccessMessage = "Workspace deleted successfully";
+ _suppressExternalReload = true;
+ try { _currentWorkspaceService.NotifyWorkspacesListChanged(); }
+ finally { _suppressExternalReload = false; }
+ }
+ catch (Refit.ApiException apiEx) when (apiEx.StatusCode == System.Net.HttpStatusCode.Conflict)
+ {
+ WorkspacesErrorMessage = "Cannot delete: the workspace still has projects. Delete or move them first.";
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ WorkspacesErrorMessage = ExtractApiError(apiEx, "Failed to delete workspace");
+ }
+ catch (Exception ex)
+ {
+ WorkspacesErrorMessage = $"Error deleting workspace: {ex.Message}";
+ }
+ finally
+ {
+ IsWorkspacesLoading = false;
+ }
+ }
+
+ private bool CanDeleteWorkspace() => SelectedWorkspace != null;
+
[RelayCommand]
private async Task CreateUser()
{
- var dialogVm = new UserEditDialogViewModel(AvailableRoles)
+ var dialogVm = new UserEditDialogViewModel
{
IsEditMode = false
};
+ // Load user-groups so the groups tab is populated on creation
+ string? groupsLoadError = null;
+ try
+ {
+ var groupsResponse = await _api.GetUserGroupsAsync();
+ if (groupsResponse.Success && groupsResponse.Data != null)
+ {
+ foreach (var g in groupsResponse.Data)
+ {
+ dialogVm.GroupItems.Add(new GroupMembershipItem
+ {
+ GroupId = g.Id,
+ GroupCode = g.Code,
+ GroupName = g.Name,
+ HasAccess = false,
+ });
+ }
+ }
+ else
+ {
+ groupsLoadError = $"No se pudieron cargar los grupos: {groupsResponse.Message}";
+ }
+ }
+ catch (Exception ex)
+ {
+ groupsLoadError = $"Error cargando grupos: {ex.Message}";
+ }
+
+ if (groupsLoadError != null)
+ ErrorMessage = groupsLoadError;
+
var owner = Application.Current.MainWindow;
var confirmed = UserEditDialog.ShowCreateDialog(owner!, dialogVm);
if (!confirmed)
@@ -238,12 +613,28 @@ private async Task CreateUser()
Email = dialogVm.Email,
Password = dialogVm.Password,
FullName = dialogVm.FullName,
- Role = dialogVm.SelectedRoles.FirstOrDefault() ?? string.Empty
};
var response = await _api.RegisterUserAsync(request);
if (response.Success && response.Data != null)
{
+ // Save group memberships for the newly created user
+ var selectedGroups = dialogVm.GroupItems.Where(g => g.HasAccess).ToList();
+ if (selectedGroups.Count > 0)
+ {
+ try
+ {
+ var membRequest = new SetUserMembershipsRequest
+ {
+ Memberships = selectedGroups
+ .Select(g => new MembershipItem { GroupId = g.GroupId })
+ .ToList()
+ };
+ await _api.SetUserMembershipsAsync(response.Data.Id, membRequest);
+ }
+ catch { /* non-critical */ }
+ }
+
SuccessMessage = "User created successfully";
await LoadUsersAsync();
SelectedUser = Users.FirstOrDefault(u => u.Email == request.Email);
@@ -259,6 +650,25 @@ private async Task CreateUser()
SuccessMessage = "User created successfully";
await LoadUsersAsync();
SelectedUser = Users.FirstOrDefault(u => u.Email == dialogVm.Email);
+ // Save group memberships
+ if (SelectedUser != null)
+ {
+ var selectedGroups = dialogVm.GroupItems.Where(g => g.HasAccess).ToList();
+ if (selectedGroups.Count > 0)
+ {
+ try
+ {
+ var membRequest = new SetUserMembershipsRequest
+ {
+ Memberships = selectedGroups
+ .Select(g => new MembershipItem { GroupId = g.GroupId })
+ .ToList()
+ };
+ await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest);
+ }
+ catch { /* non-critical */ }
+ }
+ }
}
catch (Refit.ApiException apiEx)
{
@@ -273,4 +683,337 @@ private async Task CreateUser()
IsLoading = false;
}
}
+
+ // ── UserGroup commands ───────────────────────────────────────────────────
+
+ [RelayCommand]
+ private async Task LoadUserGroups()
+ {
+ UserGroupsErrorMessage = null;
+ UserGroupsSuccessMessage = null;
+ await LoadUserGroupsAsync();
+ }
+
+ public async Task LoadUserGroupsAsync()
+ {
+ try
+ {
+ IsUserGroupsLoading = true;
+ var response = await _api.GetUserGroupsAsync();
+ if (response.Success && response.Data != null)
+ {
+ UserGroups = new ObservableCollection(response.Data);
+ if (UserGroups.Count == 0)
+ UserGroupsErrorMessage = "No groups defined yet. Create one with 'New Group'.";
+ }
+ else
+ {
+ UserGroupsErrorMessage = $"API error: {response.Message ?? "Unknown error"}";
+ }
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ UserGroupsErrorMessage = $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {apiEx.Content}";
+ }
+ catch (Exception ex)
+ {
+ UserGroupsErrorMessage = $"{ex.GetType().Name}: {ex.Message}";
+ }
+ finally
+ {
+ IsUserGroupsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CreateUserGroup()
+ {
+ var dialogVm = new GroupEditDialogViewModel { IsEditMode = false };
+
+ // Load all users so the dialog can offer membership checkboxes
+ await PopulateUserItemsAsync(dialogVm, currentMemberIds: new HashSet());
+ await PopulateProjectItemsAsync(dialogVm, currentProjectIds: new HashSet());
+
+ var owner = Application.Current.MainWindow;
+ var confirmed = GroupEditDialog.ShowDialog(owner!, dialogVm);
+ if (!confirmed)
+ return;
+
+ try
+ {
+ IsUserGroupsLoading = true;
+ UserGroupsErrorMessage = null;
+ UserGroupsSuccessMessage = null;
+
+ var request = new CreateUserGroupRequest
+ {
+ Code = dialogVm.Code.Trim().ToLowerInvariant().Replace(" ", "-"),
+ Name = dialogVm.Name.Trim(),
+ Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description,
+ };
+
+ var response = await _api.CreateUserGroupAsync(request);
+ if (response.Success && response.Data != null)
+ {
+ // Save members if any were checked
+ var members = dialogVm.UserItems.Where(u => u.IsMember).Select(u => u.UserId).ToList();
+ if (members.Count > 0)
+ {
+ try
+ {
+ await _api.SetUserGroupMembersAsync(response.Data.Id,
+ new SetUserGroupMembersRequest { UserIds = members });
+ }
+ catch (Exception membEx)
+ {
+ UserGroupsErrorMessage = $"Grupo creado pero error al asignar miembros: {membEx.Message}";
+ }
+ }
+
+ // Save project access
+ var projectIds = dialogVm.ProjectItems.Where(p => p.HasAccess).Select(p => p.ProjectId).ToList();
+ try
+ {
+ await _api.SetUserGroupProjectsAsync(response.Data.Id,
+ new SetUserGroupProjectsRequest { ProjectIds = projectIds });
+ }
+ catch (Exception projEx)
+ {
+ UserGroupsErrorMessage = $"Grupo creado pero error al asignar proyectos: {projEx.Message}";
+ }
+
+ UserGroupsSuccessMessage = $"Group '{response.Data.Name}' created";
+ await LoadUserGroupsAsync();
+ SelectedUserGroup = UserGroups.FirstOrDefault(g => g.Id == response.Data.Id);
+ }
+ else
+ {
+ UserGroupsErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to create group");
+ }
+ }
+ catch (Refit.ApiException apiEx) when (apiEx.StatusCode == System.Net.HttpStatusCode.Created)
+ {
+ UserGroupsSuccessMessage = "Group created";
+ await LoadUserGroupsAsync();
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to create group");
+ }
+ catch (Exception ex)
+ {
+ UserGroupsErrorMessage = $"Error creating group: {ex.Message}";
+ }
+ finally
+ {
+ IsUserGroupsLoading = false;
+ }
+ }
+
+ [RelayCommand(CanExecute = nameof(CanEditUserGroup))]
+ private async Task EditUserGroup()
+ {
+ if (SelectedUserGroup == null)
+ return;
+
+ var dialogVm = new GroupEditDialogViewModel
+ {
+ IsEditMode = true,
+ Code = SelectedUserGroup.Code,
+ Name = SelectedUserGroup.Name,
+ Description = SelectedUserGroup.Description,
+ };
+
+ // Load current members and populate user list
+ var currentMemberIds = new HashSet();
+ try
+ {
+ var membersResp = await _api.GetUserGroupMembersAsync(SelectedUserGroup.Id);
+ if (membersResp.Success && membersResp.Data != null)
+ {
+ foreach (var m in membersResp.Data)
+ currentMemberIds.Add(m.UserId);
+ }
+ }
+ catch { /* non-critical */ }
+
+ await PopulateUserItemsAsync(dialogVm, currentMemberIds);
+
+ // Load current project access for this group
+ var currentProjectIds = new HashSet();
+ try
+ {
+ var projectsResp = await _api.GetUserGroupProjectsAsync(SelectedUserGroup.Id);
+ if (projectsResp.Success && projectsResp.Data != null)
+ {
+ foreach (var p in projectsResp.Data)
+ currentProjectIds.Add(p.ProjectId);
+ }
+ }
+ catch { /* non-critical */ }
+
+ await PopulateProjectItemsAsync(dialogVm, currentProjectIds);
+
+ var owner = Application.Current.MainWindow;
+ var confirmed = GroupEditDialog.ShowDialog(owner!, dialogVm);
+ if (!confirmed)
+ return;
+
+ try
+ {
+ IsUserGroupsLoading = true;
+ UserGroupsErrorMessage = null;
+ UserGroupsSuccessMessage = null;
+
+ var request = new UpdateUserGroupRequest
+ {
+ Name = dialogVm.Name.Trim(),
+ Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description,
+ };
+
+ var response = await _api.UpdateUserGroupAsync(SelectedUserGroup.Id, request);
+ if (response.Success && response.Data != null)
+ {
+ // Save members
+ var members = dialogVm.UserItems.Where(u => u.IsMember).Select(u => u.UserId).ToList();
+ try
+ {
+ await _api.SetUserGroupMembersAsync(SelectedUserGroup.Id,
+ new SetUserGroupMembersRequest { UserIds = members });
+ }
+ catch (Exception membEx)
+ {
+ UserGroupsErrorMessage = $"Grupo actualizado pero error al guardar miembros: {membEx.Message}";
+ return;
+ }
+
+ // Save project access
+ var projectIds = dialogVm.ProjectItems.Where(p => p.HasAccess).Select(p => p.ProjectId).ToList();
+ try
+ {
+ await _api.SetUserGroupProjectsAsync(SelectedUserGroup.Id,
+ new SetUserGroupProjectsRequest { ProjectIds = projectIds });
+ }
+ catch (Exception projEx)
+ {
+ UserGroupsErrorMessage = $"Grupo actualizado pero error al guardar proyectos: {projEx.Message}";
+ return;
+ }
+
+ UserGroupsSuccessMessage = "Group updated";
+ await LoadUserGroupsAsync();
+ SelectedUserGroup = UserGroups.FirstOrDefault(g => g.Id == response.Data.Id);
+ }
+ else
+ {
+ UserGroupsErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to update group");
+ }
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to update group");
+ }
+ catch (Exception ex)
+ {
+ UserGroupsErrorMessage = $"Error updating group: {ex.Message}";
+ }
+ finally
+ {
+ IsUserGroupsLoading = false;
+ }
+ }
+
+ private bool CanEditUserGroup() => SelectedUserGroup != null;
+
+ [RelayCommand(CanExecute = nameof(CanDeleteUserGroup))]
+ private async Task DeleteUserGroup()
+ {
+ if (SelectedUserGroup == null)
+ return;
+
+ var result = MessageBox.Show(
+ $"Delete group '{SelectedUserGroup.Name}'?\n\nMembers will lose membership but their user accounts remain.",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ if (result != MessageBoxResult.Yes)
+ return;
+
+ try
+ {
+ IsUserGroupsLoading = true;
+ UserGroupsErrorMessage = null;
+ UserGroupsSuccessMessage = null;
+
+ await _api.DeleteUserGroupAsync(SelectedUserGroup.Id);
+ UserGroups.Remove(SelectedUserGroup);
+ SelectedUserGroup = null;
+ UserGroupsSuccessMessage = "Group deleted";
+ }
+ catch (Refit.ApiException apiEx)
+ {
+ UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to delete group");
+ }
+ catch (Exception ex)
+ {
+ UserGroupsErrorMessage = $"Error deleting group: {ex.Message}";
+ }
+ finally
+ {
+ IsUserGroupsLoading = false;
+ }
+ }
+
+ private bool CanDeleteUserGroup() => SelectedUserGroup != null;
+
+ private async Task PopulateUserItemsAsync(GroupEditDialogViewModel dialogVm, HashSet currentMemberIds)
+ {
+ try
+ {
+ var usersResp = await _api.GetAllUsersAsync();
+ if (usersResp.Success && usersResp.Data != null)
+ {
+ foreach (var u in usersResp.Data.OrderBy(u => u.FullName ?? u.Email))
+ {
+ dialogVm.UserItems.Add(new GroupUserItem
+ {
+ UserId = u.Id,
+ Email = u.Email,
+ FullName = string.IsNullOrWhiteSpace(u.FullName) ? u.Email : u.FullName,
+ IsMember = currentMemberIds.Contains(u.Id),
+ });
+ }
+ }
+ }
+ catch
+ {
+ // dialog can still proceed without users list
+ }
+ }
+
+ private async Task PopulateProjectItemsAsync(GroupEditDialogViewModel dialogVm, HashSet currentProjectIds)
+ {
+ try
+ {
+ var projectsResp = await _api.GetProjectsAsync(pageSize: 1000);
+ if (projectsResp.Success && projectsResp.Data?.Items != null)
+ {
+ foreach (var p in projectsResp.Data.Items.OrderBy(p => p.Name))
+ {
+ dialogVm.ProjectItems.Add(new GroupProjectItem
+ {
+ ProjectId = p.Id,
+ ProjectCode = p.Code,
+ ProjectName = p.Name,
+ HasAccess = currentProjectIds.Contains(p.Id),
+ });
+ }
+ }
+ }
+ catch
+ {
+ // dialog can still proceed without projects list
+ }
+ }
}
diff --git a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs
index 3785a6d..0d2a21d 100644
--- a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs
+++ b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs
@@ -3,9 +3,23 @@
//
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using NexusCad.Admin.Models;
namespace NexusCad.Admin.ViewModels;
+///
+/// Item observable que representa la membresía de un usuario en un grupo concreto.
+///
+public partial class GroupMembershipItem : ObservableObject
+{
+ public Guid GroupId { get; set; }
+ public string GroupCode { get; set; } = string.Empty;
+ public string GroupName { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ private bool hasAccess;
+}
+
public partial class UserEditDialogViewModel : ObservableObject
{
[ObservableProperty]
@@ -20,20 +34,17 @@ public partial class UserEditDialogViewModel : ObservableObject
[ObservableProperty]
private bool isActive = true;
- [ObservableProperty]
- private ObservableCollection selectedRoles = new();
-
[ObservableProperty]
private bool isEditMode;
- public string Title => IsEditMode ? "Edit User" : "New User";
+ public string Title => IsEditMode ? "Editar usuario" : "Nuevo usuario";
- public List AvailableRoles { get; }
+ ///
+ /// Grupos a los que puede pertenecer el usuario. Se rellena antes de abrir el diálogo.
+ ///
+ public ObservableCollection GroupItems { get; } = new();
- public UserEditDialogViewModel(List availableRoles)
- {
- AvailableRoles = availableRoles;
- }
+ public UserEditDialogViewModel() { }
public bool IsValid()
{
@@ -52,9 +63,6 @@ public bool IsValid()
return false;
}
- if (!SelectedRoles.Any())
- return false;
-
return true;
}
@@ -66,3 +74,4 @@ public static bool IsPasswordStrong(string password) =>
&& password.Any(char.IsLower)
&& password.Any(char.IsDigit);
}
+
diff --git a/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs
new file mode 100644
index 0000000..456bdd4
--- /dev/null
+++ b/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace NexusCad.Admin.ViewModels;
+
+///
+/// Item observable para una fila de proyecto en la pestaña "Proyectos" del diálogo de workspace.
+///
+public partial class WorkspaceProjectAccessItem : ObservableObject
+{
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ private bool hasAccess;
+
+ /// 0 = Ver, 1 = Configurar
+ [ObservableProperty]
+ private int accessLevel = 1;
+}
+
+public partial class WorkspaceEditDialogViewModel : ObservableObject
+{
+ [ObservableProperty]
+ private string name = string.Empty;
+
+ [ObservableProperty]
+ private string? description;
+
+ [ObservableProperty]
+ private string? workspacePath;
+
+ [ObservableProperty]
+ private string? iconUrl;
+
+ public string Code { get; init; } = string.Empty;
+
+ /// Proyectos disponibles con su estado de acceso. Cargado por SecuritySettingsViewModel antes de abrir el diálogo.
+ public ObservableCollection ProjectItems { get; } = new();
+
+ public bool IsValid() => !string.IsNullOrWhiteSpace(Name);
+}
+
diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml
new file mode 100644
index 0000000..2a7aa5f
--- /dev/null
+++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml
@@ -0,0 +1,293 @@
+
+
+
+
+ #0078D7
+ #212529
+ #6C757D
+ #DEE2E6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs
new file mode 100644
index 0000000..65772e0
--- /dev/null
+++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs
@@ -0,0 +1,67 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Windows;
+using NexusCad.Admin.ViewModels;
+
+namespace NexusCad.Admin.Views;
+
+public partial class GroupEditDialog : Window
+{
+ public GroupEditDialogViewModel ViewModel { get; }
+
+ public GroupEditDialog(GroupEditDialogViewModel viewModel)
+ {
+ InitializeComponent();
+ ViewModel = viewModel;
+ DataContext = viewModel;
+ Loaded += (_, _) =>
+ {
+ if (ViewModel.IsEditMode)
+ NameBox.Focus();
+ else
+ CodeBox.Focus();
+ RefreshNoUsersText();
+ RefreshNoProjectsText();
+ };
+ viewModel.UserItems.CollectionChanged += (_, _) => RefreshNoUsersText();
+ viewModel.ProjectItems.CollectionChanged += (_, _) => RefreshNoProjectsText();
+ }
+
+ private void RefreshNoUsersText()
+ {
+ NoUsersText.Visibility = ViewModel.UserItems.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+
+ private void RefreshNoProjectsText()
+ {
+ NoProjectsText.Visibility = ViewModel.ProjectItems.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+
+ private void OkButton_Click(object sender, RoutedEventArgs e)
+ {
+ ErrorText.Visibility = Visibility.Collapsed;
+
+ if (!ViewModel.IsValid())
+ {
+ ErrorText.Text = ViewModel.IsEditMode
+ ? "El nombre es obligatorio."
+ : "Código y nombre son obligatorios.";
+ ErrorText.Visibility = Visibility.Visible;
+ return;
+ }
+
+ DialogResult = true;
+ Close();
+ }
+
+ public static bool ShowDialog(Window owner, GroupEditDialogViewModel viewModel)
+ {
+ var dlg = new GroupEditDialog(viewModel) { Owner = owner };
+ return dlg.ShowDialog() == true;
+ }
+}
diff --git a/src/NexusCad.Admin/Views/HomeView.xaml b/src/NexusCad.Admin/Views/HomeView.xaml
new file mode 100644
index 0000000..93c7438
--- /dev/null
+++ b/src/NexusCad.Admin/Views/HomeView.xaml
@@ -0,0 +1,638 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Captured Models
+ Cotas paramétricas (mm/deg)
+ Features (supresión)
+ Custom Properties
+ Drawings
+ File Formats
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Model Dimensions
+ Variables (entrada / derivada)
+ Model Rules (Roslyn C#)
+ Publicar proyecto
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login
+ Catálogo de proyectos
+ Formulario dinámico
+ Enviar especificación
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Evalúa Model Rules
+ Aplica cotas en SolidWorks
+ Exporta STEP / DXF / PDF
+ Archivos listos para descarga
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Instalación:
+ ejecuta .\dev-scripts\register-swaddin.ps1 (PowerShell elevado). En SolidWorks: Herramientas → Complementos → ☑ NexusCad Capture.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SolidWorks no es thread-safe.
+ El número de procesos worker en paralelo está limitado al número de licencias disponibles.
+
+
+
+
+
+
+
+ Renovación de token:
+ el worker renueva el JWT automáticamente. Mensajes IDX10223 en logs se recuperan solos sin intervención.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusCad.Admin/Views/HomeView.xaml.cs b/src/NexusCad.Admin/Views/HomeView.xaml.cs
new file mode 100644
index 0000000..16b831e
--- /dev/null
+++ b/src/NexusCad.Admin/Views/HomeView.xaml.cs
@@ -0,0 +1,14 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Windows.Controls;
+
+namespace NexusCad.Admin.Views;
+
+public partial class HomeView : UserControl
+{
+ public HomeView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/NexusCad.Admin/Views/NewGroupDialog.xaml b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml
similarity index 89%
rename from src/NexusCad.Admin/Views/NewGroupDialog.xaml
rename to src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml
index d9403a8..15da0b8 100644
--- a/src/NexusCad.Admin/Views/NewGroupDialog.xaml
+++ b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml
@@ -2,7 +2,7 @@
Copyright (C) 2026 JaviFRx
Licensed under AGPL v3
-->
-
-
-
@@ -95,23 +95,23 @@
Padding="24,20">
-
+
-
+
-
+ Text="Optional. What this workspace is for (max 1000 chars)." />
@@ -128,7 +128,7 @@
Margin="8,0,0,0" />
+ Text="Optional folder where the workspace stores artifacts, temp and templates." />
@@ -181,7 +181,7 @@
Width="16" Height="16"
VerticalAlignment="Center"
Margin="0,0,8,0" />
-
+
diff --git a/src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs
similarity index 55%
rename from src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs
rename to src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs
index c43820e..8518cf0 100644
--- a/src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs
+++ b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs
@@ -8,11 +8,11 @@
namespace NexusCad.Admin.Views;
-public partial class NewGroupDialog : Window
+public partial class NewWorkspaceDialog : Window
{
- public NewGroupDialogViewModel ViewModel { get; }
+ public NewWorkspaceDialogViewModel ViewModel { get; }
- public NewGroupDialog(NewGroupDialogViewModel viewModel)
+ public NewWorkspaceDialog(NewWorkspaceDialogViewModel viewModel)
{
InitializeComponent();
ViewModel = viewModel;
@@ -23,16 +23,16 @@ public NewGroupDialog(NewGroupDialogViewModel viewModel)
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName != nameof(NewGroupDialogViewModel.DialogResult))
+ if (e.PropertyName != nameof(NewWorkspaceDialogViewModel.DialogResult))
return;
DialogResult = ViewModel.DialogResult;
Close();
}
- public static GroupDto? ShowDialog(Window owner, NewGroupDialogViewModel viewModel)
+ public static WorkspaceDto? ShowDialog(Window owner, NewWorkspaceDialogViewModel viewModel)
{
- var dialog = new NewGroupDialog(viewModel) { Owner = owner };
+ var dialog = new NewWorkspaceDialog(viewModel) { Owner = owner };
var result = dialog.ShowDialog();
- return result == true ? dialog.ViewModel.CreatedGroup : null;
+ return result == true ? dialog.ViewModel.CreatedWorkspace : null;
}
}
diff --git a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml
similarity index 93%
rename from src/NexusCad.Admin/Views/OpenGroupDialog.xaml
rename to src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml
index 29ea27e..efc9737 100644
--- a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml
+++ b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml
@@ -2,7 +2,7 @@
Copyright (C) 2026 JaviFRx
Licensed under AGPL v3
-->
-
-
-
@@ -79,8 +79,8 @@
Foreground="#7A5800" />
-
@@ -91,7 +91,7 @@
-
@@ -104,7 +104,7 @@
-
+
@@ -178,7 +178,7 @@
Width="16" Height="16"
VerticalAlignment="Center"
Margin="0,0,8,0" />
-
+
diff --git a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs
similarity index 57%
rename from src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs
rename to src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs
index 792fd10..8eb397c 100644
--- a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs
+++ b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs
@@ -8,11 +8,11 @@
namespace NexusCad.Admin.Views;
-public partial class OpenGroupDialog : Window
+public partial class OpenWorkspaceDialog : Window
{
- public OpenGroupDialogViewModel ViewModel { get; }
+ public OpenWorkspaceDialogViewModel ViewModel { get; }
- public OpenGroupDialog(OpenGroupDialogViewModel viewModel)
+ public OpenWorkspaceDialog(OpenWorkspaceDialogViewModel viewModel)
{
InitializeComponent();
ViewModel = viewModel;
@@ -23,16 +23,16 @@ public OpenGroupDialog(OpenGroupDialogViewModel viewModel)
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName != nameof(OpenGroupDialogViewModel.DialogResult))
+ if (e.PropertyName != nameof(OpenWorkspaceDialogViewModel.DialogResult))
return;
DialogResult = ViewModel.DialogResult;
Close();
}
- public static GroupDto? ShowDialog(Window owner, OpenGroupDialogViewModel viewModel)
+ public static WorkspaceDto? ShowDialog(Window owner, OpenWorkspaceDialogViewModel viewModel)
{
- var dialog = new OpenGroupDialog(viewModel) { Owner = owner };
+ var dialog = new OpenWorkspaceDialog(viewModel) { Owner = owner };
var result = dialog.ShowDialog();
- return result == true ? dialog.ViewModel.OpenedGroup : null;
+ return result == true ? dialog.ViewModel.OpenedWorkspace : null;
}
}
diff --git a/src/NexusCad.Admin/Views/ProjectEditorView.xaml b/src/NexusCad.Admin/Views/ProjectEditorView.xaml
index ca2c94e..492b294 100644
--- a/src/NexusCad.Admin/Views/ProjectEditorView.xaml
+++ b/src/NexusCad.Admin/Views/ProjectEditorView.xaml
@@ -183,6 +183,42 @@
TextWrapping="Wrap"
AcceptsReturn="True"
MinHeight="80"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml
index aab528f..6bc648c 100644
--- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml
+++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml
@@ -20,7 +20,6 @@
-
@@ -43,222 +42,575 @@
Foreground="{StaticResource NexusText}"
VerticalAlignment="Center"/>
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs
index e37df43..b45bcee 100644
--- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs
+++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs
@@ -23,6 +23,8 @@ private async void OnDataContextChanged(object sender, DependencyPropertyChanged
if (e.NewValue is SecuritySettingsViewModel viewModel)
{
await viewModel.LoadUsersCommand.ExecuteAsync(null);
+ await viewModel.LoadWorkspacesCommand.ExecuteAsync(null);
+ await viewModel.LoadUserGroupsCommand.ExecuteAsync(null);
}
}
diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml
index 6b8eb18..c363422 100644
--- a/src/NexusCad.Admin/Views/UserEditDialog.xaml
+++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml
@@ -12,8 +12,8 @@
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:UserEditDialogViewModel}"
Title="User"
- Height="540"
- Width="480"
+ Height="620"
+ Width="540"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
WindowStyle="SingleBorderWindow"
@@ -25,6 +25,7 @@
#212529
#6C757D
#DEE2E6
+ #198754
@@ -71,7 +72,7 @@
FontSize="18"
FontWeight="SemiBold"
Foreground="White" />
-
@@ -79,88 +80,161 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
(container);
- if (cb?.Tag is string role)
- cb.IsChecked = ViewModel.SelectedRoles.Contains(role);
- }
- }
+ // ── Grupos ───────────────────────────────────────────────────────────────
- private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject
+ private void RefreshNoGroupsText()
{
- for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++)
- {
- var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i);
- if (child is T result) return result;
- var found = FindVisualChild(child);
- if (found != null) return found;
- }
- return null;
+ if (NoGroupsText == null) return;
+ NoGroupsText.Visibility = ViewModel.GroupItems.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
}
+ // ── Event handlers ───────────────────────────────────────────────────────
+
private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
{
ViewModel.Password = PwdBox.Password;
}
- private void RoleCheckBox_Changed(object sender, RoutedEventArgs e)
- {
- if (sender is CheckBox cb && cb.Tag is string role)
- {
- if (cb.IsChecked == true)
- {
- if (!ViewModel.SelectedRoles.Contains(role))
- ViewModel.SelectedRoles.Add(role);
- }
- else
- {
- ViewModel.SelectedRoles.Remove(role);
- }
- }
- }
-
private void OkButton_Click(object sender, RoutedEventArgs e)
{
var errorText = (TextBlock?)FindName("ErrorText");
@@ -84,8 +53,8 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
if (errorText != null)
{
errorText.Text = ViewModel.IsEditMode
- ? "Full name and at least one role are required. If you set a new password, it must be 8+ chars with upper, lower and digit."
- : "Full name, email, a password of 8+ chars with upper, lower and digit, and at least one role are required.";
+ ? "El nombre es obligatorio. Si cambias la contraseña debe tener 8+ caracteres con mayúscula, minúscula y número."
+ : "Nombre, email y contraseña (8+ chars con mayúscula, minúscula y número) son obligatorios.";
errorText.Visibility = Visibility.Visible;
}
return;
@@ -107,3 +76,4 @@ public static bool ShowEditDialog(Window owner, UserEditDialogViewModel viewMode
return dialog.ShowDialog() == true;
}
}
+
diff --git a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml
new file mode 100644
index 0000000..8416b26
--- /dev/null
+++ b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml
@@ -0,0 +1,269 @@
+
+
+
+
+ #0078D7
+ #212529
+ #6C757D
+ #DEE2E6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs
new file mode 100644
index 0000000..95d5ac5
--- /dev/null
+++ b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs
@@ -0,0 +1,64 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Windows;
+using Microsoft.Win32;
+using NexusCad.Admin.ViewModels;
+
+namespace NexusCad.Admin.Views;
+
+public partial class WorkspaceEditDialog : Window
+{
+ public WorkspaceEditDialogViewModel ViewModel { get; }
+
+ public WorkspaceEditDialog(WorkspaceEditDialogViewModel viewModel)
+ {
+ InitializeComponent();
+ ViewModel = viewModel;
+ DataContext = viewModel;
+ Loaded += (_, _) =>
+ {
+ NameBox.Focus();
+ RefreshNoProjectsText();
+ };
+ viewModel.ProjectItems.CollectionChanged += (_, _) => RefreshNoProjectsText();
+ }
+
+ private void RefreshNoProjectsText()
+ {
+ NoProjectsText.Visibility = ViewModel.ProjectItems.Count == 0
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+
+ private void BrowseButton_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFolderDialog
+ {
+ Title = "Select the workspace folder for this workspace"
+ };
+ if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.FolderName))
+ ViewModel.WorkspacePath = dlg.FolderName;
+ }
+
+ private void OkButton_Click(object sender, RoutedEventArgs e)
+ {
+ ErrorText.Visibility = Visibility.Collapsed;
+
+ if (!ViewModel.IsValid())
+ {
+ ErrorText.Text = "Name is required.";
+ ErrorText.Visibility = Visibility.Visible;
+ return;
+ }
+
+ DialogResult = true;
+ Close();
+ }
+
+ public static bool ShowDialog(Window owner, WorkspaceEditDialogViewModel viewModel)
+ {
+ var dialog = new WorkspaceEditDialog(viewModel) { Owner = owner };
+ return dialog.ShowDialog() == true;
+ }
+}
diff --git a/src/NexusCad.Api/Controllers/AuthController.cs b/src/NexusCad.Api/Controllers/AuthController.cs
index 4575c62..361d6ae 100644
--- a/src/NexusCad.Api/Controllers/AuthController.cs
+++ b/src/NexusCad.Api/Controllers/AuthController.cs
@@ -4,10 +4,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using NexusCad.Api.DTOs.Auth;
using NexusCad.Api.DTOs.Common;
using NexusCad.Api.Services;
using NexusCad.Core.Constants;
+using NexusCad.Core.Interfaces;
+using NexusCad.Infrastructure.Data;
using NexusCad.Infrastructure.Identity;
namespace NexusCad.Api.Controllers;
@@ -24,6 +27,8 @@ public class AuthController : ControllerBase
private readonly SignInManager _signInManager;
private readonly JwtTokenService _jwtTokenService;
private readonly ILogger _logger;
+ private readonly IGroupRepository _groupRepository;
+ private readonly AppDbContext _context;
private readonly Microsoft.Extensions.Options.IOptions _jwtSettings;
@@ -32,12 +37,16 @@ public AuthController(
SignInManager signInManager,
JwtTokenService jwtTokenService,
Microsoft.Extensions.Options.IOptions jwtSettings,
+ IGroupRepository groupRepository,
+ AppDbContext context,
ILogger logger)
{
_userManager = userManager;
_signInManager = signInManager;
_jwtTokenService = jwtTokenService;
_jwtSettings = jwtSettings;
+ _groupRepository = groupRepository;
+ _context = context;
_logger = logger;
}
@@ -455,4 +464,109 @@ public async Task>> DeleteUser(string id)
return StatusCode(500, ApiResponse.ErrorResult("An error occurred"));
}
}
+
+ ///
+ /// Obtener las membresías de grupo de un usuario (solo Admin)
+ ///
+ [HttpGet("users/{id}/memberships")]
+ [Authorize(Roles = Roles.Admin)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>>> GetUserMemberships(string id)
+ {
+ try
+ {
+ var user = await _userManager.FindByIdAsync(id);
+ if (user == null)
+ return NotFound(ApiResponse>.ErrorResult("User not found"));
+
+ var memberships = await _context.GroupMemberships
+ .Include(m => m.Group)
+ .Where(m => m.UserId == id)
+ .ToListAsync();
+
+ var dtos = memberships.Select(m => new UserGroupMembershipDto
+ {
+ GroupId = m.GroupId,
+ GroupCode = m.Group?.Code ?? string.Empty,
+ GroupName = m.Group?.Name ?? string.Empty,
+ GroupIconUrl = m.Group?.IconUrl,
+ CreatedAt = m.CreatedAt,
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting memberships for user {UserId}", id);
+ return StatusCode(500, ApiResponse>.ErrorResult("An error occurred"));
+ }
+ }
+
+ ///
+ /// Reemplaza las membresías de grupo de un usuario (solo Admin).
+ /// Las membresías no presentes en la request son eliminadas.
+ ///
+ [HttpPut("users/{id}/memberships")]
+ [Authorize(Roles = Roles.Admin)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>>> SetUserMemberships(
+ string id, [FromBody] SetUserMembershipsRequest request)
+ {
+ try
+ {
+ var user = await _userManager.FindByIdAsync(id);
+ if (user == null)
+ return NotFound(ApiResponse>.ErrorResult("User not found"));
+
+ var existing = await _context.GroupMemberships
+ .Where(m => m.UserId == id)
+ .ToListAsync();
+
+ var desired = request.Memberships;
+ var desiredGroupIds = desired.Select(d => d.GroupId).ToHashSet();
+
+ // Remove memberships not in desired list
+ var toRemove = existing.Where(e => !desiredGroupIds.Contains(e.GroupId)).ToList();
+ _context.GroupMemberships.RemoveRange(toRemove);
+
+ // Add new memberships (the ones not already present)
+ foreach (var item in desired)
+ {
+ var existingMembership = existing.FirstOrDefault(e => e.GroupId == item.GroupId);
+ if (existingMembership == null)
+ {
+ await _groupRepository.AddMembershipAsync(item.GroupId, id);
+ }
+ }
+
+ await _context.SaveChangesAsync();
+
+ _logger.LogInformation("Updated group memberships for user {UserId}: {Count} groups", id, desired.Count);
+
+ // Return updated memberships
+ var updated = await _context.GroupMemberships
+ .Include(m => m.Group)
+ .Where(m => m.UserId == id)
+ .ToListAsync();
+
+ var dtos = updated.Select(m => new UserGroupMembershipDto
+ {
+ GroupId = m.GroupId,
+ GroupCode = m.Group?.Code ?? string.Empty,
+ GroupName = m.Group?.Name ?? string.Empty,
+ GroupIconUrl = m.Group?.IconUrl,
+ CreatedAt = m.CreatedAt,
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos, "Memberships updated successfully"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error setting memberships for user {UserId}", id);
+ return StatusCode(500, ApiResponse>.ErrorResult("An error occurred"));
+ }
+ }
}
+
diff --git a/src/NexusCad.Api/Controllers/CapturesController.cs b/src/NexusCad.Api/Controllers/CapturesController.cs
index 29b7036..fb6d380 100644
--- a/src/NexusCad.Api/Controllers/CapturesController.cs
+++ b/src/NexusCad.Api/Controllers/CapturesController.cs
@@ -16,7 +16,7 @@ namespace NexusCad.Api.Controllers;
[ApiController]
[Route("api/projects/{projectId:guid}/captures")]
[Produces("application/json")]
-[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+[Authorize(Roles = Roles.Admin)]
public class CapturesController : ControllerBase
{
private readonly AppDbContext _db;
diff --git a/src/NexusCad.Api/Controllers/DimensionRulesController.cs b/src/NexusCad.Api/Controllers/DimensionRulesController.cs
index e8f1723..25955b6 100644
--- a/src/NexusCad.Api/Controllers/DimensionRulesController.cs
+++ b/src/NexusCad.Api/Controllers/DimensionRulesController.cs
@@ -20,7 +20,7 @@ namespace NexusCad.Api.Controllers;
[ApiController]
[Route("api/projects/{projectId:guid}/dimension-rules")]
[Produces("application/json")]
-[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+[Authorize(Roles = Roles.Admin)]
public class DimensionRulesController : ControllerBase
{
private readonly AppDbContext _db;
diff --git a/src/NexusCad.Api/Controllers/GroupsController.cs b/src/NexusCad.Api/Controllers/GroupsController.cs
index ced2d23..0f0a58d 100644
--- a/src/NexusCad.Api/Controllers/GroupsController.cs
+++ b/src/NexusCad.Api/Controllers/GroupsController.cs
@@ -89,7 +89,7 @@ public async Task>> GetGroup(Guid id)
}
[HttpPost]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+ [Authorize(Roles = Roles.Admin)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task>> CreateGroup([FromBody] CreateGroupRequest request)
@@ -114,8 +114,8 @@ public async Task>> CreateGroup([FromBody] Cr
await _groupRepository.AddAsync(group);
- // El creador queda automáticamente como Owner del grupo.
- await _groupRepository.AddMembershipAsync(group.Id, userId, GroupRole.Owner);
+ // El creador queda automáticamente como miembro del grupo.
+ await _groupRepository.AddMembershipAsync(group.Id, userId);
_logger.LogInformation("Created group {Code} ({Id}) by {UserId}", group.Code, group.Id, userId);
@@ -242,7 +242,6 @@ public async Task>>> GetMembers(Gu
UserId = m.UserId,
UserName = user?.UserName,
Email = user?.Email,
- Role = m.Role,
CreatedAt = m.CreatedAt,
};
}).ToList();
@@ -265,13 +264,12 @@ public async Task>> AddMember(Guid id,
if (existing != null)
return Conflict(ApiResponse.ErrorResult("User is already a member"));
- var membership = await _groupRepository.AddMembershipAsync(id, request.UserId, request.Role);
+ var membership = await _groupRepository.AddMembershipAsync(id, request.UserId);
var dto = new GroupMemberDto
{
UserId = membership.UserId,
UserName = user.UserName,
Email = user.Email,
- Role = membership.Role,
CreatedAt = membership.CreatedAt,
};
return CreatedAtAction(nameof(GetMembers), new { id }, ApiResponse.SuccessResult(dto));
@@ -309,9 +307,106 @@ private async Task UserCanManageAsync(Guid groupId)
var userId = GetUserId();
if (userId == null)
return false;
- var membership = await _context.GroupMemberships
- .FirstOrDefaultAsync(m => m.GroupId == groupId && m.UserId == userId);
- return membership != null && membership.Role == GroupRole.Owner;
+ return await _context.GroupMemberships
+ .AnyAsync(m => m.GroupId == groupId && m.UserId == userId);
+ }
+
+ // ── Project Access ────────────────────────────────────────────────────────
+
+ ///
+ /// Devuelve los accesos a proyectos configurados para un grupo.
+ /// Si la lista está vacía, el grupo accede a todos sus propios proyectos (modo legacy).
+ ///
+ [HttpGet("{id:guid}/project-access")]
+ [Authorize(Roles = Roles.Admin)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>>> GetGroupProjectAccess(Guid id)
+ {
+ try
+ {
+ var exists = await _context.Groups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("Group not found"));
+
+ var accesses = await _context.GroupProjectAccesses
+ .Where(a => a.GroupId == id)
+ .Include(a => a.Project)
+ .ToListAsync();
+
+ var dtos = accesses.Select(a => new GroupProjectAccessDto
+ {
+ ProjectId = a.ProjectId,
+ ProjectCode = a.Project?.Code ?? string.Empty,
+ ProjectName = a.Project?.Name ?? string.Empty,
+ AccessLevel = (int)a.AccessLevel,
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting project access for group {GroupId}", id);
+ return StatusCode(500, ApiResponse>.ErrorResult("An error occurred"));
+ }
+ }
+
+ ///
+ /// Reemplaza todos los accesos a proyectos de un grupo de forma atómica.
+ /// Enviar lista vacía = eliminar todos los accesos (vuelve a modo legacy: acceso a todos los proyectos del grupo).
+ ///
+ [HttpPut("{id:guid}/project-access")]
+ [Authorize(Roles = Roles.Admin)]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>>> SetGroupProjectAccess(
+ Guid id, [FromBody] SetGroupProjectAccessRequest request)
+ {
+ try
+ {
+ var exists = await _context.Groups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("Group not found"));
+
+ // Remove all existing entries for this group
+ var existing = await _context.GroupProjectAccesses.Where(a => a.GroupId == id).ToListAsync();
+ _context.GroupProjectAccesses.RemoveRange(existing);
+
+ // Add new entries
+ foreach (var item in request.Items)
+ {
+ var projectExists = await _context.Projects.AnyAsync(p => p.Id == item.ProjectId);
+ if (!projectExists) continue;
+
+ _context.GroupProjectAccesses.Add(new Core.Entities.GroupProjectAccess
+ {
+ GroupId = id,
+ ProjectId = item.ProjectId,
+ AccessLevel = (Core.Enums.ProjectAccessLevel)item.AccessLevel,
+ CreatedAt = DateTime.UtcNow,
+ });
+ }
+
+ await _context.SaveChangesAsync();
+
+ var updated = await _context.GroupProjectAccesses
+ .Where(a => a.GroupId == id)
+ .Include(a => a.Project)
+ .ToListAsync();
+
+ var dtos = updated.Select(a => new GroupProjectAccessDto
+ {
+ ProjectId = a.ProjectId,
+ ProjectCode = a.Project?.Code ?? string.Empty,
+ ProjectName = a.Project?.Name ?? string.Empty,
+ AccessLevel = (int)a.AccessLevel,
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos, "Project access updated"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error setting project access for group {GroupId}", id);
+ return StatusCode(500, ApiResponse>.ErrorResult("An error occurred"));
+ }
}
private static GroupDto ToDto(Group g) => new()
diff --git a/src/NexusCad.Api/Controllers/LookupTablesController.cs b/src/NexusCad.Api/Controllers/LookupTablesController.cs
index 4b2bf63..7a4c54b 100644
--- a/src/NexusCad.Api/Controllers/LookupTablesController.cs
+++ b/src/NexusCad.Api/Controllers/LookupTablesController.cs
@@ -17,7 +17,7 @@ namespace NexusCad.Api.Controllers;
///
[ApiController]
[Route("api/[controller]")]
-[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+[Authorize(Roles = Roles.Admin)]
[Produces("application/json")]
public class LookupTablesController : ControllerBase
{
diff --git a/src/NexusCad.Api/Controllers/ProjectsController.cs b/src/NexusCad.Api/Controllers/ProjectsController.cs
index 2b6611a..e23883c 100644
--- a/src/NexusCad.Api/Controllers/ProjectsController.cs
+++ b/src/NexusCad.Api/Controllers/ProjectsController.cs
@@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
using NexusCad.Api.DTOs.Common;
using NexusCad.Api.DTOs.Projects;
using NexusCad.Api.Hubs;
@@ -12,6 +13,7 @@
using NexusCad.Core.Entities;
using NexusCad.Core.Enums;
using NexusCad.Core.Interfaces;
+using NexusCad.Infrastructure.Data;
namespace NexusCad.Api.Controllers;
@@ -28,24 +30,27 @@ public class ProjectsController : ControllerBase
private readonly IVariableRepository _variableRepository;
private readonly IHubContext _hubContext;
private readonly ILogger _logger;
+ private readonly AppDbContext _context;
public ProjectsController(
IProjectRepository projectRepository,
IVariableRepository variableRepository,
IHubContext hubContext,
- ILogger logger)
+ ILogger logger,
+ AppDbContext context)
{
_projectRepository = projectRepository;
_variableRepository = variableRepository;
_hubContext = hubContext;
_logger = logger;
+ _context = context;
}
///
/// Obtener lista paginada de proyectos
///
[HttpGet]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor},{Roles.Comercial}")]
+ [Authorize]
[ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
public async Task>>> GetProjects(
[FromQuery] int pageNumber = 1,
@@ -59,7 +64,28 @@ public async Task>>> Ge
var (projects, totalCount) = await _projectRepository.GetPagedAsync(
pageNumber, pageSize, status, search, groupId);
- var items = projects.Select(p => new ProjectListItemDto
+ // Filtrar por acceso de UserGroup si no es Admin
+ IEnumerable filtered;
+ if (User.IsInRole(Roles.Admin))
+ {
+ filtered = projects;
+ }
+ else
+ {
+ var userId = User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier);
+ var allowedProjectIds = await _context.UserGroupMembers
+ .Where(m => m.UserId == userId)
+ .Join(_context.UserGroupProjectAccesses,
+ m => m.UserGroupId,
+ a => a.UserGroupId,
+ (m, a) => a.ProjectId)
+ .Distinct()
+ .ToListAsync();
+ filtered = projects.Where(p => allowedProjectIds.Contains(p.Id));
+ totalCount = filtered.Count();
+ }
+
+ var items = filtered.Select(p => new ProjectListItemDto
{
Id = p.Id,
Code = p.Code,
@@ -116,6 +142,7 @@ public async Task>> GetProject(Guid id)
Status = project.Status,
FormSchemaJson = project.FormSchemaJson,
IconUrl = project.IconUrl,
+ ModelsPath = project.ModelsPath,
Version = project.Version,
CreatedBy = project.CreatedBy,
CreatedAt = project.CreatedAt,
@@ -213,7 +240,7 @@ public async Task>> GetPubli
/// Crear nuevo proyecto
///
[HttpPost]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+ [Authorize(Roles = Roles.Admin)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task>> CreateProject(
@@ -253,6 +280,7 @@ public async Task>> CreateProject(
Description = request.Description ?? string.Empty,
FormSchemaJson = request.FormSchemaJson,
IconUrl = request.IconUrl,
+ ModelsPath = request.ModelsPath,
Status = ProjectStatus.Draft,
Version = 1,
CreatedBy = userId,
@@ -271,6 +299,7 @@ public async Task>> CreateProject(
Status = project.Status,
FormSchemaJson = project.FormSchemaJson,
IconUrl = project.IconUrl,
+ ModelsPath = project.ModelsPath,
Version = project.Version,
CreatedBy = project.CreatedBy,
CreatedAt = project.CreatedAt,
@@ -295,7 +324,7 @@ public async Task>> CreateProject(
/// Actualizar proyecto existente
///
[HttpPut("{id}")]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+ [Authorize(Roles = Roles.Admin)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task>> UpdateProject(Guid id, [FromBody] UpdateProjectRequest request)
@@ -311,6 +340,7 @@ public async Task>> UpdateProject(Guid id,
project.Name = request.Name;
project.Description = request.Description ?? string.Empty;
project.IconUrl = request.IconUrl;
+ project.ModelsPath = request.ModelsPath;
if (!string.IsNullOrEmpty(request.FormSchemaJson))
{
@@ -330,6 +360,7 @@ public async Task>> UpdateProject(Guid id,
Status = project.Status,
FormSchemaJson = project.FormSchemaJson,
IconUrl = project.IconUrl,
+ ModelsPath = project.ModelsPath,
Version = project.Version,
CreatedBy = project.CreatedBy,
CreatedAt = project.CreatedAt,
@@ -354,7 +385,7 @@ public async Task>> UpdateProject(Guid id,
/// Publicar proyecto (cambia estado a Published)
///
[HttpPost("{id}/publish")]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+ [Authorize(Roles = Roles.Admin)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task>> PublishProject(Guid id)
@@ -385,6 +416,7 @@ public async Task>> PublishProject(Guid id)
Status = project.Status,
FormSchemaJson = project.FormSchemaJson,
IconUrl = project.IconUrl,
+ ModelsPath = project.ModelsPath,
Version = project.Version,
CreatedBy = project.CreatedBy,
CreatedAt = project.CreatedAt,
@@ -407,7 +439,7 @@ public async Task>> PublishProject(Guid id)
/// Despublicar proyecto (vuelve a estado Draft)
///
[HttpPost("{id}/unpublish")]
- [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+ [Authorize(Roles = Roles.Admin)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task>> UnpublishProject(Guid id)
@@ -436,6 +468,7 @@ public async Task>> UnpublishProject(Guid i
Status = project.Status,
FormSchemaJson = project.FormSchemaJson,
IconUrl = project.IconUrl,
+ ModelsPath = project.ModelsPath,
Version = project.Version,
CreatedBy = project.CreatedBy,
CreatedAt = project.CreatedAt,
diff --git a/src/NexusCad.Api/Controllers/SpecificationsController.cs b/src/NexusCad.Api/Controllers/SpecificationsController.cs
index 8af9f22..f6bea7b 100644
--- a/src/NexusCad.Api/Controllers/SpecificationsController.cs
+++ b/src/NexusCad.Api/Controllers/SpecificationsController.cs
@@ -1,4 +1,4 @@
-// Copyright (C) 2026 JaviFRx
+// Copyright (C) 2026 JaviFRx
// Licensed under AGPL v3
//
using System.Security.Claims;
@@ -61,7 +61,7 @@ public async Task
try
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
- var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial);
+ var isAdmin = User.IsInRole(Roles.Admin);
var (specifications, totalCount) = await _specificationRepository.GetPagedAsync(
pageNumber, pageSize, projectId, status, isAdmin ? null : userId);
@@ -112,7 +112,7 @@ public async Task>> GetSpecification(
// Verificar permisos (usuarios normales solo pueden ver sus propias specs)
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
- var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial);
+ var isAdmin = User.IsInRole(Roles.Admin);
if (!isAdmin && specification.CreatedBy != userId)
{
@@ -234,7 +234,7 @@ public async Task>> RecalculateSpecif
// Verificar permisos
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
- var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial);
+ var isAdmin = User.IsInRole(Roles.Admin);
if (!isAdmin && specification.CreatedBy != userId)
{
@@ -333,7 +333,7 @@ public async Task>> SubmitSpecificati
// Verificar permisos
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
- var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial);
+ var isAdmin = User.IsInRole(Roles.Admin);
if (!isAdmin && specification.CreatedBy != userId)
{
@@ -402,7 +402,7 @@ public async Task>>>
// Verificar permisos
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
- var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial);
+ var isAdmin = User.IsInRole(Roles.Admin);
if (!isAdmin && specification.CreatedBy != userId)
{
@@ -431,3 +431,4 @@ public async Task>>>
}
}
}
+
diff --git a/src/NexusCad.Api/Controllers/UserGroupsController.cs b/src/NexusCad.Api/Controllers/UserGroupsController.cs
new file mode 100644
index 0000000..c3a77d2
--- /dev/null
+++ b/src/NexusCad.Api/Controllers/UserGroupsController.cs
@@ -0,0 +1,355 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using NexusCad.Api.DTOs.Common;
+using NexusCad.Api.DTOs.UserGroups;
+using NexusCad.Core.Constants;
+using NexusCad.Core.Entities;
+using NexusCad.Core.Interfaces;
+using NexusCad.Infrastructure.Data;
+using NexusCad.Infrastructure.Identity;
+
+namespace NexusCad.Api.Controllers;
+
+///
+/// CRUD de UserGroups (grupos personalizados de usuarios) y gestión de miembros.
+/// Distinto de Groups (workspaces / contenedores de proyectos) y de roles globales de Identity.
+///
+[ApiController]
+[Route("api/usergroups")]
+[Produces("application/json")]
+[Authorize(Roles = Roles.Admin)]
+public class UserGroupsController : ControllerBase
+{
+ private readonly IUserGroupRepository _repo;
+ private readonly AppDbContext _context;
+ private readonly UserManager _userManager;
+ private readonly ILogger _logger;
+
+ public UserGroupsController(
+ IUserGroupRepository repo,
+ AppDbContext context,
+ UserManager userManager,
+ ILogger logger)
+ {
+ _repo = repo;
+ _context = context;
+ _userManager = userManager;
+ _logger = logger;
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>>> GetAll()
+ {
+ var groups = await _repo.GetAllOrderedAsync();
+ var ids = groups.Select(g => g.Id).ToList();
+ var counts = await _context.UserGroupMembers
+ .Where(m => ids.Contains(m.UserGroupId))
+ .GroupBy(m => m.UserGroupId)
+ .Select(x => new { x.Key, Count = x.Count() })
+ .ToDictionaryAsync(x => x.Key, x => x.Count);
+
+ var items = groups.Select(g => ToListItem(g, counts.GetValueOrDefault(g.Id, 0))).ToList();
+ return Ok(ApiResponse>.SuccessResult(items));
+ }
+
+ [HttpGet("{id:guid}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> GetById(Guid id)
+ {
+ var g = await _repo.GetByIdAsync(id);
+ if (g == null)
+ return NotFound(ApiResponse.ErrorResult("User group not found"));
+
+ var memberCount = await _context.UserGroupMembers.CountAsync(m => m.UserGroupId == id);
+ return Ok(ApiResponse.SuccessResult(ToDto(g, memberCount)));
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task>> Create([FromBody] CreateUserGroupRequest request)
+ {
+ var userId = GetUserId();
+ if (userId == null)
+ return Unauthorized();
+
+ if (await _repo.CodeExistsAsync(request.Code))
+ return Conflict(ApiResponse.ErrorResult(
+ $"A user group with code '{request.Code}' already exists"));
+
+ var group = new UserGroup
+ {
+ Id = Guid.NewGuid(),
+ Code = request.Code,
+ Name = request.Name,
+ Description = request.Description ?? string.Empty,
+ IconUrl = request.IconUrl,
+ CreatedBy = userId,
+ };
+
+ await _repo.AddAsync(group);
+
+ _logger.LogInformation("Created user group {Code} ({Id}) by {UserId}", group.Code, group.Id, userId);
+
+ return CreatedAtAction(nameof(GetById), new { id = group.Id },
+ ApiResponse.SuccessResult(ToDto(group, 0)));
+ }
+
+ [HttpPut("{id:guid}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> Update(Guid id, [FromBody] UpdateUserGroupRequest request)
+ {
+ var group = await _repo.GetByIdAsync(id);
+ if (group == null)
+ return NotFound(ApiResponse.ErrorResult("User group not found"));
+
+ group.Name = request.Name;
+ group.Description = request.Description ?? string.Empty;
+ group.IconUrl = request.IconUrl;
+
+ await _repo.UpdateAsync(group);
+
+ var memberCount = await _context.UserGroupMembers.CountAsync(m => m.UserGroupId == id);
+ return Ok(ApiResponse.SuccessResult(ToDto(group, memberCount)));
+ }
+
+ [HttpDelete("{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task Delete(Guid id)
+ {
+ var group = await _repo.GetByIdAsync(id);
+ if (group == null)
+ return NotFound();
+
+ await _repo.DeleteAsync(group);
+ return NoContent();
+ }
+
+ // ── Members ──────────────────────────────────────────────────────────────
+
+ [HttpGet("{id:guid}/members")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>>> GetMembers(Guid id)
+ {
+ var exists = await _context.UserGroups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("User group not found"));
+
+ var members = await _context.UserGroupMembers
+ .Where(m => m.UserGroupId == id)
+ .ToListAsync();
+
+ var userIds = members.Select(m => m.UserId).ToList();
+ var users = await _userManager.Users
+ .Where(u => userIds.Contains(u.Id))
+ .ToDictionaryAsync(u => u.Id);
+
+ var dtos = members.Select(m =>
+ {
+ users.TryGetValue(m.UserId, out var user);
+ return new UserGroupMemberDto
+ {
+ UserId = m.UserId,
+ UserName = user?.UserName,
+ Email = user?.Email,
+ FullName = user?.FullName,
+ AddedAt = m.AddedAt,
+ };
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos));
+ }
+
+ [HttpPost("{id:guid}/members")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task>> AddMember(Guid id, [FromBody] AddUserGroupMemberRequest request)
+ {
+ var exists = await _context.UserGroups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse.ErrorResult("User group not found"));
+
+ var user = await _userManager.FindByIdAsync(request.UserId);
+ if (user == null)
+ return NotFound(ApiResponse.ErrorResult("User not found"));
+
+ var existing = await _repo.GetMemberAsync(id, request.UserId);
+ if (existing != null)
+ return Conflict(ApiResponse.ErrorResult("User is already a member"));
+
+ var member = await _repo.AddMemberAsync(id, request.UserId);
+
+ return CreatedAtAction(nameof(GetMembers), new { id }, ApiResponse.SuccessResult(
+ new UserGroupMemberDto
+ {
+ UserId = member.UserId,
+ UserName = user.UserName,
+ Email = user.Email,
+ FullName = user.FullName,
+ AddedAt = member.AddedAt,
+ }));
+ }
+
+ [HttpDelete("{id:guid}/members/{userId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task RemoveMember(Guid id, string userId)
+ {
+ await _repo.RemoveMemberAsync(id, userId);
+ return NoContent();
+ }
+
+ ///
+ /// Reemplaza el conjunto completo de miembros de un grupo de forma atómica.
+ ///
+ [HttpPut("{id:guid}/members")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>>> SetMembers(
+ Guid id, [FromBody] SetUserGroupMembersRequest request)
+ {
+ var exists = await _context.UserGroups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("User group not found"));
+
+ var requestedIds = request.UserIds.Distinct().ToList();
+
+ // Validate that all requested users exist
+ var validUserIds = await _userManager.Users
+ .Where(u => requestedIds.Contains(u.Id))
+ .Select(u => u.Id)
+ .ToListAsync();
+
+ var current = await _context.UserGroupMembers
+ .Where(m => m.UserGroupId == id)
+ .ToListAsync();
+
+ var toRemove = current.Where(m => !validUserIds.Contains(m.UserId)).ToList();
+ _context.UserGroupMembers.RemoveRange(toRemove);
+
+ var currentIds = current.Select(m => m.UserId).ToHashSet();
+ foreach (var uid in validUserIds.Where(u => !currentIds.Contains(u)))
+ {
+ _context.UserGroupMembers.Add(new UserGroupMember
+ {
+ UserGroupId = id,
+ UserId = uid,
+ AddedAt = DateTime.UtcNow,
+ });
+ }
+
+ await _context.SaveChangesAsync();
+
+ return await GetMembers(id);
+ }
+
+ // ── Proyectos accesibles ──────────────────────────────────────────────────
+
+ ///
+ /// Devuelve los proyectos a los que tiene acceso este UserGroup.
+ ///
+ [HttpGet("{id:guid}/projects")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>>> GetProjects(Guid id)
+ {
+ var exists = await _context.UserGroups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("UserGroup not found"));
+
+ var accesses = await _context.UserGroupProjectAccesses
+ .Where(a => a.UserGroupId == id)
+ .Include(a => a.Project)
+ .ThenInclude(p => p!.Group)
+ .ToListAsync();
+
+ var dtos = accesses.Select(a => new UserGroupProjectDto
+ {
+ ProjectId = a.ProjectId,
+ ProjectCode = a.Project?.Code ?? string.Empty,
+ ProjectName = a.Project?.Name ?? string.Empty,
+ GroupName = a.Project?.Group?.Name,
+ CreatedAt = a.CreatedAt,
+ }).ToList();
+
+ return Ok(ApiResponse>.SuccessResult(dtos));
+ }
+
+ ///
+ /// Reemplaza de forma atómica la lista de proyectos accesibles para este UserGroup.
+ /// Enviar lista vacía quita todos los accesos.
+ ///
+ [HttpPut("{id:guid}/projects")]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task>>> SetProjects(
+ Guid id, [FromBody] SetUserGroupProjectsRequest request)
+ {
+ var exists = await _context.UserGroups.AnyAsync(g => g.Id == id);
+ if (!exists)
+ return NotFound(ApiResponse>.ErrorResult("UserGroup not found"));
+
+ // Validar que los proyectos existen
+ var validIds = await _context.Projects
+ .Where(p => request.ProjectIds.Contains(p.Id))
+ .Select(p => p.Id)
+ .ToListAsync();
+
+ // Reemplazar
+ var existing = await _context.UserGroupProjectAccesses
+ .Where(a => a.UserGroupId == id).ToListAsync();
+ _context.UserGroupProjectAccesses.RemoveRange(existing);
+
+ foreach (var pid in validIds)
+ {
+ _context.UserGroupProjectAccesses.Add(new UserGroupProjectAccess
+ {
+ UserGroupId = id,
+ ProjectId = pid,
+ CreatedAt = DateTime.UtcNow,
+ });
+ }
+
+ await _context.SaveChangesAsync();
+
+ return await GetProjects(id);
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
+
+ private static UserGroupDto ToDto(UserGroup g, int memberCount) => new()
+ {
+ Id = g.Id,
+ Code = g.Code,
+ Name = g.Name,
+ Description = g.Description,
+ IconUrl = g.IconUrl,
+ CreatedBy = g.CreatedBy,
+ CreatedAt = g.CreatedAt,
+ UpdatedAt = g.UpdatedAt,
+ MemberCount = memberCount,
+ };
+
+ private static UserGroupListItemDto ToListItem(UserGroup g, int memberCount) => new()
+ {
+ Id = g.Id,
+ Code = g.Code,
+ Name = g.Name,
+ Description = g.Description,
+ IconUrl = g.IconUrl,
+ MemberCount = memberCount,
+ CreatedAt = g.CreatedAt,
+ UpdatedAt = g.UpdatedAt,
+ };
+}
diff --git a/src/NexusCad.Api/Controllers/VariablesController.cs b/src/NexusCad.Api/Controllers/VariablesController.cs
index 185429a..64eeda2 100644
--- a/src/NexusCad.Api/Controllers/VariablesController.cs
+++ b/src/NexusCad.Api/Controllers/VariablesController.cs
@@ -16,7 +16,7 @@ namespace NexusCad.Api.Controllers;
[ApiController]
[Route("api/projects/{projectId:guid}/variables")]
[Produces("application/json")]
-[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")]
+[Authorize(Roles = Roles.Admin)]
public class VariablesController : ControllerBase
{
private readonly AppDbContext _db;
diff --git a/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs
new file mode 100644
index 0000000..1428780
--- /dev/null
+++ b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs
@@ -0,0 +1,29 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Api.DTOs.Auth;
+
+///
+/// Membresía de un usuario en un grupo, tal como la ve el admin desde la edición de usuario.
+///
+public class UserGroupMembershipDto
+{
+ public Guid GroupId { get; set; }
+ public string GroupCode { get; set; } = string.Empty;
+ public string GroupName { get; set; } = string.Empty;
+ public string? GroupIconUrl { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
+
+public class SetUserMembershipsRequest
+{
+ ///
+ /// Lista de grupos a los que debe pertenecer el usuario. Los no incluidos serán eliminados.
+ ///
+ public List Memberships { get; set; } = new();
+}
+
+public class UserMembershipItem
+{
+ public Guid GroupId { get; set; }
+}
diff --git a/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs b/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs
index bc97a80..06ea1d1 100644
--- a/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs
+++ b/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs
@@ -2,7 +2,6 @@
// Licensed under AGPL v3
//
using System.ComponentModel.DataAnnotations;
-using NexusCad.Core.Enums;
namespace NexusCad.Api.DTOs.Groups;
@@ -11,7 +10,6 @@ public class GroupMemberDto
public string UserId { get; set; } = string.Empty;
public string? UserName { get; set; }
public string? Email { get; set; }
- public GroupRole Role { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -19,7 +17,4 @@ public class AddMemberRequest
{
[Required]
public string UserId { get; set; } = string.Empty;
-
- [Required]
- public GroupRole Role { get; set; } = GroupRole.Author;
}
diff --git a/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs b/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs
new file mode 100644
index 0000000..9932d87
--- /dev/null
+++ b/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs
@@ -0,0 +1,33 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Api.DTOs.Groups;
+
+///
+/// Acceso de un grupo a un proyecto concreto.
+/// AccessLevel: 0 = Ver, 1 = Configurar
+///
+public class GroupProjectAccessDto
+{
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+ public int AccessLevel { get; set; } // 0=View, 1=Configure
+}
+
+public class SetGroupProjectAccessRequest
+{
+ ///
+ /// Lista completa de accesos. Reemplaza todos los accesos actuales del grupo.
+ /// Para quitar acceso a todos los proyectos, enviar lista vacía.
+ ///
+ public List Items { get; set; } = new();
+}
+
+public class ProjectAccessEntry
+{
+ public Guid ProjectId { get; set; }
+
+ /// 0 = View, 1 = Configure
+ public int AccessLevel { get; set; } = 1;
+}
diff --git a/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs b/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs
index 34327c3..311f00f 100644
--- a/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs
+++ b/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs
@@ -32,4 +32,11 @@ public class CreateProjectRequest
public string FormSchemaJson { get; set; } = string.Empty;
public string? IconUrl { get; set; }
+
+ ///
+ /// Ruta personalizada donde el worker buscará los modelos SolidWorks master de este proyecto.
+ /// Si null, se usa StorageOptions.GetProjectModelsPath(Code).
+ ///
+ [StringLength(500, ErrorMessage = "ModelsPath cannot exceed 500 characters")]
+ public string? ModelsPath { get; set; }
}
diff --git a/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs b/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs
index f8f2ed5..31a53d0 100644
--- a/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs
+++ b/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs
@@ -17,6 +17,11 @@ public class ProjectDto
public ProjectStatus Status { get; set; }
public string FormSchemaJson { get; set; } = string.Empty;
public string? IconUrl { get; set; }
+ ///
+ /// Ruta personalizada de modelos SolidWorks master para este proyecto.
+ /// Null = usa la ruta por defecto calculada desde StorageOptions.
+ ///
+ public string? ModelsPath { get; set; }
public int Version { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
diff --git a/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs b/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs
index 7356404..7295021 100644
--- a/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs
+++ b/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs
@@ -20,4 +20,11 @@ public class UpdateProjectRequest
public string? FormSchemaJson { get; set; }
public string? IconUrl { get; set; }
+
+ ///
+ /// Ruta personalizada donde el worker buscará los modelos SolidWorks master de este proyecto.
+ /// Si null, se usa StorageOptions.GetProjectModelsPath(Code).
+ ///
+ [StringLength(500, ErrorMessage = "ModelsPath cannot exceed 500 characters")]
+ public string? ModelsPath { get; set; }
}
diff --git a/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs
new file mode 100644
index 0000000..ec7ffaf
--- /dev/null
+++ b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs
@@ -0,0 +1,103 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using System.ComponentModel.DataAnnotations;
+
+namespace NexusCad.Api.DTOs.UserGroups;
+
+public class UserGroupDto
+{
+ public Guid Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string? IconUrl { get; set; }
+ public string CreatedBy { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+ public int MemberCount { get; set; }
+}
+
+public class UserGroupListItemDto
+{
+ public Guid Id { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string? IconUrl { get; set; }
+ public int MemberCount { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public class CreateUserGroupRequest
+{
+ [Required, StringLength(80, MinimumLength = 2)]
+ [RegularExpression("^[a-z0-9][a-z0-9-]*$",
+ ErrorMessage = "Code must be lowercase letters, digits or '-'.")]
+ public string Code { get; set; } = string.Empty;
+
+ [Required, StringLength(200, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(1000)]
+ public string? Description { get; set; }
+
+ [StringLength(500)]
+ public string? IconUrl { get; set; }
+}
+
+public class UpdateUserGroupRequest
+{
+ [Required, StringLength(200, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(1000)]
+ public string? Description { get; set; }
+
+ [StringLength(500)]
+ public string? IconUrl { get; set; }
+}
+
+public class UserGroupMemberDto
+{
+ public string UserId { get; set; } = string.Empty;
+ public string? UserName { get; set; }
+ public string? Email { get; set; }
+ public string? FullName { get; set; }
+ public DateTime AddedAt { get; set; }
+}
+
+public class AddUserGroupMemberRequest
+{
+ [Required]
+ public string UserId { get; set; } = string.Empty;
+}
+
+///
+/// Reemplaza todos los miembros de un grupo de una vez.
+///
+public class SetUserGroupMembersRequest
+{
+ public List UserIds { get; set; } = new();
+}
+
+///
+/// Proyecto al que tiene acceso un UserGroup.
+///
+public class UserGroupProjectDto
+{
+ public Guid ProjectId { get; set; }
+ public string ProjectCode { get; set; } = string.Empty;
+ public string ProjectName { get; set; } = string.Empty;
+ public string? GroupName { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
+
+///
+/// Reemplaza la lista de proyectos accesibles para un UserGroup.
+///
+public class SetUserGroupProjectsRequest
+{
+ public List ProjectIds { get; set; } = new();
+}
diff --git a/src/NexusCad.Api/NexusCad.Api.csproj b/src/NexusCad.Api/NexusCad.Api.csproj
index c28d663..451a04e 100644
--- a/src/NexusCad.Api/NexusCad.Api.csproj
+++ b/src/NexusCad.Api/NexusCad.Api.csproj
@@ -26,7 +26,7 @@
-
+
diff --git a/src/NexusCad.Api/Program.cs b/src/NexusCad.Api/Program.cs
index 2aae723..b43474a 100644
--- a/src/NexusCad.Api/Program.cs
+++ b/src/NexusCad.Api/Program.cs
@@ -57,7 +57,7 @@
Title = "NexusCad API",
Version = "v1",
Description = "API para configuración paramétrica de productos CAD con SolidWorks",
- Contact = new Microsoft.OpenApi.Models.OpenApiContact
+ Contact = new Microsoft.OpenApi.OpenApiContact
{
Name = "NexusCad Team",
Url = new Uri("https://github.com/JaviFRx/NexusCad")
@@ -73,27 +73,20 @@
}
// Configurar autenticación JWT en Swagger
- c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
+ c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.OpenApiSecurityScheme
{
Description = "JWT Authorization header usando el esquema Bearer. Ejemplo: \"Bearer {token}\"",
Name = "Authorization",
- In = Microsoft.OpenApi.Models.ParameterLocation.Header,
- Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
+ In = Microsoft.OpenApi.ParameterLocation.Header,
+ Type = Microsoft.OpenApi.SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
- c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
+ c.AddSecurityRequirement(doc => new Microsoft.OpenApi.OpenApiSecurityRequirement
{
{
- new Microsoft.OpenApi.Models.OpenApiSecurityScheme
- {
- Reference = new Microsoft.OpenApi.Models.OpenApiReference
- {
- Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
- Id = "Bearer"
- }
- },
- Array.Empty()
+ new Microsoft.OpenApi.OpenApiSecuritySchemeReference("Bearer", doc, null),
+ new List()
}
});
});
@@ -101,6 +94,10 @@
// Rules engine
builder.Services.AddSingleton();
+ // Storage configuration
+ builder.Services.Configure(
+ builder.Configuration.GetSection(NexusCad.Core.Configuration.StorageOptions.SectionName));
+
// SignalR
builder.Services.AddSignalR();
@@ -127,6 +124,7 @@
// Repositories
builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/NexusCad.Api/appsettings.json b/src/NexusCad.Api/appsettings.json
index cc1686b..af72a34 100644
--- a/src/NexusCad.Api/appsettings.json
+++ b/src/NexusCad.Api/appsettings.json
@@ -1,4 +1,5 @@
{
+ "Urls": "http://localhost:5140",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=nexuscad;Username=nexuscad;Password=nexuscad_dev_password"
},
@@ -12,6 +13,9 @@
"Serilog": {
"SeqUrl": "http://localhost:5341"
},
+ "Storage": {
+ "RootPath": "C:\\NexusCad"
+ },
"JwtSettings": {
"SecretKey": "NexusCad-Super-Secret-Key-For-Development-Only-Change-In-Production-12345678",
"Issuer": "NexusCad.Api",
diff --git a/src/NexusCad.Core/Configuration/StorageOptions.cs b/src/NexusCad.Core/Configuration/StorageOptions.cs
new file mode 100644
index 0000000..35752b4
--- /dev/null
+++ b/src/NexusCad.Core/Configuration/StorageOptions.cs
@@ -0,0 +1,83 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Core.Configuration;
+
+///
+/// Configuración centralizada de rutas de almacenamiento de NexusCad.
+/// Todas las rutas de archivos del sistema se derivan de aquí.
+/// Registro en appsettings.json bajo la sección "Storage".
+///
+public sealed class StorageOptions
+{
+ public const string SectionName = "Storage";
+
+ ///
+ /// Ruta raíz base de NexusCad en disco.
+ /// El resto de rutas, si no se especifican, se derivan de aquí.
+ /// Ejemplo: "C:\NexusCad" o "D:\Empresa\NexusCad"
+ ///
+ public string RootPath { get; set; } = @"C:\NexusCad";
+
+ ///
+ /// Donde se almacenan los modelos SolidWorks master capturados (plantillas).
+ /// Equivale al "workspace" de DriveWorks — archivos fuente que el worker copia y modifica.
+ /// Si null → {RootPath}\Models
+ ///
+ public string? ModelsPath { get; set; }
+
+ ///
+ /// Donde se escriben los archivos SolidWorks generados por el worker (SLDPRT, SLDASM, SLDDRW).
+ /// Si null → {RootPath}\Output
+ ///
+ public string? OutputPath { get; set; }
+
+ ///
+ /// Donde se guardan los documentos generados (PDF, DOCX) listos para descarga.
+ /// Si null → {RootPath}\Documents
+ ///
+ public string? DocumentsPath { get; set; }
+
+ ///
+ /// Donde se almacenan los bundles de formularios subidos por `nexuscad push` (@nexuscad/cli).
+ /// La API carga estos bundles en el iframe sandbox del runtime web.
+ /// Si null → {RootPath}\Bundles
+ ///
+ public string? BundlesPath { get; set; }
+
+ ///
+ /// Directorio de trabajo temporal para jobs en curso.
+ /// El worker crea un subdirectorio por job y lo elimina al completar.
+ /// Si null → {RootPath}\Temp
+ ///
+ public string? TempPath { get; set; }
+
+ // ── Resolvers ──────────────────────────────────────────────────────────────
+
+ public string GetModelsPath() => ModelsPath ?? Path.Combine(RootPath, "Models");
+ public string GetOutputPath() => OutputPath ?? Path.Combine(RootPath, "Output");
+ public string GetDocumentsPath() => DocumentsPath ?? Path.Combine(RootPath, "Documents");
+ public string GetBundlesPath() => BundlesPath ?? Path.Combine(RootPath, "Bundles");
+ public string GetTempPath() => TempPath ?? Path.Combine(RootPath, "Temp");
+
+ ///
+ /// Directorio de modelos master de un proyecto concreto.
+ /// Ejemplo: {ModelsPath}\CHAIR-V2\
+ ///
+ public string GetProjectModelsPath(string projectCode) =>
+ Path.Combine(GetModelsPath(), projectCode);
+
+ ///
+ /// Directorio de salida de un job concreto.
+ /// Ejemplo: {OutputPath}\{jobId}\
+ ///
+ public string GetJobOutputPath(Guid jobId) =>
+ Path.Combine(GetOutputPath(), jobId.ToString());
+
+ ///
+ /// Directorio temporal de trabajo de un job concreto.
+ /// Ejemplo: {TempPath}\{jobId}\
+ ///
+ public string GetJobTempPath(Guid jobId) =>
+ Path.Combine(GetTempPath(), jobId.ToString());
+}
diff --git a/src/NexusCad.Core/Constants/Roles.cs b/src/NexusCad.Core/Constants/Roles.cs
index 5dc9b8b..58e50a9 100644
--- a/src/NexusCad.Core/Constants/Roles.cs
+++ b/src/NexusCad.Core/Constants/Roles.cs
@@ -4,32 +4,18 @@
namespace NexusCad.Core.Constants;
///
-/// Roles de usuario del sistema
+/// Roles de usuario del sistema. El único rol global es Admin; el resto del
+/// control de acceso se gestiona mediante UserGroups y sus proyectos asignados.
///
public static class Roles
{
///
- /// Administrador - acceso total al sistema
+ /// Administrador - acceso total al sistema.
///
public const string Admin = "Admin";
///
- /// Autor - diseña proyectos, captura modelos, escribe reglas y formularios
+ /// Lista de todos los roles disponibles.
///
- public const string Autor = "Autor";
-
- ///
- /// Comercial - rellena especificaciones, genera cotizaciones
- ///
- public const string Comercial = "Comercial";
-
- ///
- /// Cliente - solo formularios públicos, acceso limitado
- ///
- public const string Cliente = "Cliente";
-
- ///
- /// Lista de todos los roles disponibles
- ///
- public static readonly string[] All = { Admin, Autor, Comercial, Cliente };
+ public static readonly string[] All = { Admin };
}
diff --git a/src/NexusCad.Core/Entities/Group.cs b/src/NexusCad.Core/Entities/Group.cs
index f10e72c..e0fc287 100644
--- a/src/NexusCad.Core/Entities/Group.cs
+++ b/src/NexusCad.Core/Entities/Group.cs
@@ -50,4 +50,9 @@ public class Group
public ICollection Memberships { get; set; } = new List();
public ICollection Projects { get; set; } = new List();
+
+ ///
+ /// Acceso explícito a proyectos. Si está vacío, todos los proyectos del grupo son accesibles (modo legacy).
+ ///
+ public ICollection ProjectAccesses { get; set; } = new List();
}
diff --git a/src/NexusCad.Core/Entities/GroupMembership.cs b/src/NexusCad.Core/Entities/GroupMembership.cs
index 437c86a..8a0ce27 100644
--- a/src/NexusCad.Core/Entities/GroupMembership.cs
+++ b/src/NexusCad.Core/Entities/GroupMembership.cs
@@ -1,12 +1,10 @@
// Copyright (C) 2026 JaviFRx
// Licensed under AGPL v3
//
-using NexusCad.Core.Enums;
-
namespace NexusCad.Core.Entities;
///
-/// Asociación N:M entre Group y ApplicationUser con un rol específico dentro del grupo.
+/// Asociación N:M entre Group (workspace) y ApplicationUser.
/// PK compuesta (GroupId, UserId).
///
public class GroupMembership
@@ -18,8 +16,6 @@ public class GroupMembership
///
public string UserId { get; set; } = string.Empty;
- public GroupRole Role { get; set; } = GroupRole.Author;
-
public DateTime CreatedAt { get; set; }
public Group? Group { get; set; }
diff --git a/src/NexusCad.Core/Entities/GroupProjectAccess.cs b/src/NexusCad.Core/Entities/GroupProjectAccess.cs
new file mode 100644
index 0000000..2442379
--- /dev/null
+++ b/src/NexusCad.Core/Entities/GroupProjectAccess.cs
@@ -0,0 +1,32 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using NexusCad.Core.Enums;
+
+namespace NexusCad.Core.Entities;
+
+///
+/// Control de acceso de un grupo (workspace) a un proyecto concreto.
+/// PK compuesta (GroupId, ProjectId).
+/// Si NO existe entrada para un proyecto, ese proyecto NO es visible para el grupo
+/// (excepto para Admins globales que ven todo).
+/// Si el grupo no tiene NINGUNA entrada, se asume acceso Configure a todos sus propios proyectos
+/// (modo legacy/backward-compatible).
+///
+public class GroupProjectAccess
+{
+ public Guid GroupId { get; set; }
+
+ public Guid ProjectId { get; set; }
+
+ ///
+ /// View = solo consulta; Configure = puede ejecutar y generar.
+ ///
+ public ProjectAccessLevel AccessLevel { get; set; } = ProjectAccessLevel.Configure;
+
+ public DateTime CreatedAt { get; set; }
+
+ // Navigation
+ public Group? Group { get; set; }
+ public Project? Project { get; set; }
+}
diff --git a/src/NexusCad.Core/Entities/Project.cs b/src/NexusCad.Core/Entities/Project.cs
index 8cf6876..f6ee29f 100644
--- a/src/NexusCad.Core/Entities/Project.cs
+++ b/src/NexusCad.Core/Entities/Project.cs
@@ -75,6 +75,13 @@ public class Project
///
public DateTime? PublishedAt { get; set; }
+ ///
+ /// Ruta en disco donde se almacenan los modelos SolidWorks master de este proyecto.
+ /// Sobreescribe Group.WorkspacePath y Storage.ModelsPath para este proyecto concreto.
+ /// Si null, el worker usa StorageOptions.GetProjectModelsPath(Code).
+ ///
+ public string? ModelsPath { get; set; }
+
///
/// Fecha de última modificación
///
diff --git a/src/NexusCad.Core/Entities/UserGroup.cs b/src/NexusCad.Core/Entities/UserGroup.cs
new file mode 100644
index 0000000..2c269ca
--- /dev/null
+++ b/src/NexusCad.Core/Entities/UserGroup.cs
@@ -0,0 +1,38 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Core.Entities;
+
+///
+/// Grupo personalizado de usuarios (distinto de los roles globales de Identity
+/// y de Group/Workspace que es contenedor de proyectos). Sirve para agrupar
+/// usuarios en categorías definidas por el admin (ej. "Comercial Madrid",
+/// "Autores externos") y, en el futuro, otorgar permisos a workspaces a nivel
+/// de grupo en lugar de usuario.
+///
+public class UserGroup
+{
+ public Guid Id { get; set; }
+
+ /// Slug único (ej. "comercial-madrid").
+ public string Code { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+
+ public string Description { get; set; } = string.Empty;
+
+ public string? IconUrl { get; set; }
+
+ public string CreatedBy { get; set; } = string.Empty;
+
+ public DateTime CreatedAt { get; set; }
+
+ public DateTime UpdatedAt { get; set; }
+
+ public ICollection Members { get; set; } = new List();
+
+ ///
+ /// Proyectos a los que tienen acceso los miembros de este grupo.
+ ///
+ public ICollection ProjectAccesses { get; set; } = new List();
+}
diff --git a/src/NexusCad.Core/Entities/UserGroupMember.cs b/src/NexusCad.Core/Entities/UserGroupMember.cs
new file mode 100644
index 0000000..13b088f
--- /dev/null
+++ b/src/NexusCad.Core/Entities/UserGroupMember.cs
@@ -0,0 +1,20 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Core.Entities;
+
+///
+/// Pertenencia de un ApplicationUser a un UserGroup.
+/// PK compuesta (UserGroupId, UserId).
+///
+public class UserGroupMember
+{
+ public Guid UserGroupId { get; set; }
+
+ /// Id del ApplicationUser (string, Identity).
+ public string UserId { get; set; } = string.Empty;
+
+ public DateTime AddedAt { get; set; }
+
+ public UserGroup? UserGroup { get; set; }
+}
diff --git a/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs b/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs
new file mode 100644
index 0000000..df89aa2
--- /dev/null
+++ b/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs
@@ -0,0 +1,22 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Core.Entities;
+
+///
+/// Acceso de un UserGroup a un Project concreto.
+/// PK compuesta (UserGroupId, ProjectId).
+/// Si un UserGroup no tiene ninguna entrada, sus miembros no ven ningún proyecto.
+///
+public class UserGroupProjectAccess
+{
+ public Guid UserGroupId { get; set; }
+
+ public Guid ProjectId { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+
+ // Navigation
+ public UserGroup? UserGroup { get; set; }
+ public Project? Project { get; set; }
+}
diff --git a/src/NexusCad.Core/Enums/ProjectAccessLevel.cs b/src/NexusCad.Core/Enums/ProjectAccessLevel.cs
new file mode 100644
index 0000000..67233b2
--- /dev/null
+++ b/src/NexusCad.Core/Enums/ProjectAccessLevel.cs
@@ -0,0 +1,16 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+namespace NexusCad.Core.Enums;
+
+///
+/// Nivel de acceso de un grupo a un proyecto concreto.
+///
+public enum ProjectAccessLevel
+{
+ /// Solo puede ver el formulario y el historial de especificaciones.
+ View = 0,
+
+ /// Puede ejecutar el configurador y generar documentos/modelos.
+ Configure = 1,
+}
diff --git a/src/NexusCad.Core/Interfaces/IGroupRepository.cs b/src/NexusCad.Core/Interfaces/IGroupRepository.cs
index b5779d4..ef88665 100644
--- a/src/NexusCad.Core/Interfaces/IGroupRepository.cs
+++ b/src/NexusCad.Core/Interfaces/IGroupRepository.cs
@@ -2,7 +2,6 @@
// Licensed under AGPL v3
//
using NexusCad.Core.Entities;
-using NexusCad.Core.Enums;
namespace NexusCad.Core.Interfaces;
@@ -20,7 +19,7 @@ public interface IGroupRepository : IRepository
Task GetMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default);
- Task AddMembershipAsync(Guid groupId, string userId, GroupRole role, CancellationToken cancellationToken = default);
+ Task AddMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default);
Task RemoveMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default);
}
diff --git a/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs b/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs
new file mode 100644
index 0000000..bf5771b
--- /dev/null
+++ b/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs
@@ -0,0 +1,27 @@
+// Copyright (C) 2026 JaviFRx
+// Licensed under AGPL v3
+//
+using NexusCad.Core.Entities;
+
+namespace NexusCad.Core.Interfaces;
+
+public interface IUserGroupRepository : IRepository
+{
+ Task GetByCodeAsync(string code, CancellationToken cancellationToken = default);
+
+ Task GetWithMembersAsync(Guid id, CancellationToken cancellationToken = default);
+
+ Task> GetAllOrderedAsync(CancellationToken cancellationToken = default);
+
+ Task CodeExistsAsync(string code, Guid? excludeId = null, CancellationToken cancellationToken = default);
+
+ Task> GetMembersAsync(Guid userGroupId, CancellationToken cancellationToken = default);
+
+ Task GetMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default);
+
+ Task AddMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default);
+
+ Task RemoveMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default);
+
+ Task> GetGroupsForUserAsync(string userId, CancellationToken cancellationToken = default);
+}
diff --git a/src/NexusCad.Infrastructure/Data/AppDbContext.cs b/src/NexusCad.Infrastructure/Data/AppDbContext.cs
index 5ab358f..5575537 100644
--- a/src/NexusCad.Infrastructure/Data/AppDbContext.cs
+++ b/src/NexusCad.Infrastructure/Data/AppDbContext.cs
@@ -20,6 +20,7 @@ public AppDbContext(DbContextOptions options) : base(options)
// Entidades de dominio
public DbSet Groups => Set();
public DbSet GroupMemberships => Set();
+ public DbSet GroupProjectAccesses => Set();
public DbSet Projects => Set();
public DbSet Specifications => Set();
public DbSet Variables => Set();
@@ -35,6 +36,9 @@ public AppDbContext(DbContextOptions options) : base(options)
public DbSet CapturedReplacementModels => Set();
public DbSet CapturedFileFormats => Set();
public DbSet DimensionRules => Set();
+ public DbSet UserGroups => Set();
+ public DbSet UserGroupMembers => Set();
+ public DbSet UserGroupProjectAccesses => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -80,6 +84,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.HasIndex(e => e.UserId);
});
+ // Configurar GroupProjectAccess (PK compuesta)
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.GroupId, e.ProjectId });
+
+ entity.HasOne(e => e.Group)
+ .WithMany(g => g.ProjectAccesses)
+ .HasForeignKey(e => e.GroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(e => e.Project)
+ .WithMany()
+ .HasForeignKey(e => e.ProjectId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasIndex(e => e.GroupId);
+ entity.HasIndex(e => e.ProjectId);
+ });
+
// Configurar Project
modelBuilder.Entity(entity =>
{
@@ -383,6 +406,50 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Cascade);
});
+ // UserGroup — grupos personalizados de usuarios (distintos de Group/Workspace y de roles globales)
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Code).IsRequired().HasMaxLength(80);
+ entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
+ entity.Property(e => e.Description).HasMaxLength(1000);
+ entity.Property(e => e.IconUrl).HasMaxLength(500);
+ entity.Property(e => e.CreatedBy).IsRequired().HasMaxLength(450);
+
+ entity.HasIndex(e => e.Code).IsUnique();
+ entity.HasIndex(e => e.CreatedAt);
+
+ entity.HasMany(g => g.Members)
+ .WithOne(m => m.UserGroup)
+ .HasForeignKey(m => m.UserGroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.UserGroupId, e.UserId });
+ entity.Property(e => e.UserId).IsRequired().HasMaxLength(450);
+ entity.HasIndex(e => e.UserId);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => new { e.UserGroupId, e.ProjectId });
+
+ entity.HasOne(e => e.UserGroup)
+ .WithMany(g => g.ProjectAccesses)
+ .HasForeignKey(e => e.UserGroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(e => e.Project)
+ .WithMany()
+ .HasForeignKey(e => e.ProjectId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasIndex(e => e.UserGroupId);
+ entity.HasIndex(e => e.ProjectId);
+ });
+
// Configurar GeneratedDocument
modelBuilder.Entity(entity =>
{
@@ -511,6 +578,15 @@ private void UpdateTimestamps()
dimRule.CreatedAt = now;
dimRule.UpdatedAt = now;
}
+ else if (entry.Entity is UserGroup userGroup)
+ {
+ userGroup.CreatedAt = now;
+ userGroup.UpdatedAt = now;
+ }
+ else if (entry.Entity is UserGroupMember ugMember)
+ {
+ ugMember.AddedAt = now;
+ }
}
else if (entry.State == EntityState.Modified)
{
@@ -542,6 +618,10 @@ private void UpdateTimestamps()
{
dimRuleUpd.UpdatedAt = now;
}
+ else if (entry.Entity is UserGroup userGroupUpd)
+ {
+ userGroupUpd.UpdatedAt = now;
+ }
}
}
}
diff --git a/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs
new file mode 100644
index 0000000..16b1a89
--- /dev/null
+++ b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs
@@ -0,0 +1,1358 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NexusCad.Infrastructure.Data;
+
+#nullable disable
+
+namespace NexusCad.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260529091846_AddProjectModelsPath")]
+ partial class AddProjectModelsPath
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.27");
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("RoleId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Alias")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CapturedModelId")
+ .HasColumnType("TEXT");
+
+ b.Property("Configuration")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("TEXT");
+
+ b.Property("DefaultValue")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("PropertyType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("SwName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CapturedModelId", "Alias")
+ .IsUnique();
+
+ b.HasIndex("CapturedModelId", "SwName", "Configuration")
+ .IsUnique();
+
+ b.ToTable("CapturedCustomProperties");
+ });
+
+ modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property