Support multiple enterprise configurations

This commit is contained in:
Thorsten Sommer 2026-02-15 14:08:27 +01:00
parent ea4e3f0199
commit 3bfd402a28
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
9 changed files with 366 additions and 201 deletions

View File

@ -211,9 +211,12 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
//
// Check if there is an enterprise configuration plugin to download:
//
var enterpriseEnvironment = this.MessageBus.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT).FirstOrDefault();
if (enterpriseEnvironment != default)
await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl);
var enterpriseEnvironments = this.MessageBus
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
.Where(env => env != default)
.ToList();
foreach (var env in enterpriseEnvironments)
await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
// Initialize the enterprise encryption service for decrypting API keys:
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);

View File

@ -49,26 +49,29 @@
<MudListItem T="string" Icon="@Icons.Material.Outlined.Memory" Text="@TauriVersion"/>
<MudListItem T="string" Icon="@Icons.Material.Outlined.Translate" Text="@this.OSLanguage"/>
<MudListItem T="string" Icon="@Icons.Material.Outlined.Business">
@switch (EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive)
@switch (HasAnyActiveEnvironment)
{
case false when this.configPlug is null:
case false when this.configPlugins.Count == 0:
<MudText Typo="Typo.body1">
@T("This is a private AI Studio installation. It runs without an enterprise configuration.")
</MudText>
break;
case false:
<MudText Typo="Typo.body1">
@T("AI Studio runs with an enterprise configuration using a configuration plugin, without central configuration management.")
@T("AI Studio runs with an enterprise configuration using configuration plugins, without central configuration management.")
</MudText>
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration plugin ID:") @this.configPlug!.Id</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@this.configPlug!.Id.ToString()/>
</div>
</MudText>
@foreach (var plug in this.configPlugins)
{
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration plugin ID:") @plug.Id</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@plug.Id.ToString()/>
</div>
</MudText>
}
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
@ -87,26 +90,28 @@
</MudCollapse>
break;
case true when this.configPlug is null:
case true when this.configPlugins.Count == 0:
<MudText Typo="Typo.body1">
@T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is not yet available.")
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.")
</MudText>
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId.ToString()/>
</div>
</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl/>
</div>
</MudText>
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
{
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
</div>
</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
</div>
</MudText>
}
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
@ -127,32 +132,36 @@
case true:
<MudText Typo="Typo.body1">
@T("AI Studio runs with an enterprise configuration and a configuration server. The configuration plugin is active.")
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.")
</MudText>
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Enterprise configuration ID:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationId.ToString()/>
</div>
</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration server:") @EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.ConfigurationServerUrl/>
</div>
</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration plugin ID:") @this.configPlug!.Id</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@this.configPlug!.Id.ToString()/>
</div>
</MudText>
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
{
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
</div>
</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
</div>
</MudText>
}
@foreach (var plug in this.configPlugins)
{
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@T("Configuration plugin ID:") @plug.Id</span>
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@plug.Id.ToString()/>
</div>
</MudText>
}
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>

View File

@ -69,12 +69,14 @@ public partial class Information : MSGComponentBase
private bool showDatabaseDetails;
private IPluginMetadata? configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION);
private List<IPluginMetadata> configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList();
private sealed record DatabaseDisplayInfo(string Label, string Value);
private readonly List<DatabaseDisplayInfo> databaseDisplayInfo = new();
private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive);
/// <summary>
/// Determines whether the enterprise configuration has details that can be shown/hidden.
/// Returns true if there are details available, false otherwise.
@ -83,16 +85,16 @@ public partial class Information : MSGComponentBase
{
get
{
return EnterpriseEnvironmentService.CURRENT_ENVIRONMENT.IsActive switch
return HasAnyActiveEnvironment switch
{
// Case 1: No enterprise config and no plugin - no details available
false when this.configPlug is null => false,
false when this.configPlugins.Count == 0 => false,
// Case 2: Enterprise config with plugin but no central management - has details
false => true,
// Case 3: Enterprise config active but no plugin - has details
true when this.configPlug is null => true,
true when this.configPlugins.Count == 0 => true,
// Case 4: Enterprise config active with plugin - has details
true => true
@ -128,7 +130,7 @@ public partial class Information : MSGComponentBase
switch (triggeredEvent)
{
case Event.PLUGINS_RELOADED:
this.configPlug = PluginFactory.AvailablePlugins.FirstOrDefault(x => x.Type is PluginType.CONFIGURATION);
this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList();
await this.InvokeAsync(this.StateHasChanged);
break;
}

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public sealed record EnterpriseConfig(string Id, string ServerUrl);

View File

@ -4,7 +4,7 @@ namespace AIStudio.Tools.Services;
public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentService> logger, RustService rustService) : BackgroundService
{
public static EnterpriseEnvironment CURRENT_ENVIRONMENT;
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
#if DEBUG
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
@ -33,84 +33,125 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
try
{
logger.LogInformation("Start updating of the enterprise environment.");
Guid enterpriseRemoveConfigId;
try
{
enterpriseRemoveConfigId = await rustService.EnterpriseEnvRemoveConfigId();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise remove configuration ID from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvRemoveConfigId failed");
return;
}
var isPlugin2RemoveInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == enterpriseRemoveConfigId);
if (enterpriseRemoveConfigId != Guid.Empty && isPlugin2RemoveInUse)
{
logger.LogWarning("The enterprise environment configuration ID '{EnterpriseRemoveConfigId}' must be removed.", enterpriseRemoveConfigId);
PluginFactory.RemovePluginAsync(enterpriseRemoveConfigId);
}
string? enterpriseConfigServerUrl;
//
// Step 1: Handle deletions first.
//
List<Guid> deleteConfigIds;
try
{
enterpriseConfigServerUrl = await rustService.EnterpriseEnvConfigServerUrl();
deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise configuration server URL from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigServerUrl failed");
logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed");
return;
}
Guid enterpriseConfigId;
try
foreach (var deleteId in deleteConfigIds)
{
enterpriseConfigId = await rustService.EnterpriseEnvConfigId();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise configuration ID from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigId failed");
return;
}
var etag = await PluginFactory.DetermineConfigPluginETagAsync(enterpriseConfigId, enterpriseConfigServerUrl);
var nextEnterpriseEnvironment = new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag);
if (CURRENT_ENVIRONMENT != nextEnterpriseEnvironment)
{
logger.LogInformation("The enterprise environment has changed. Updating the current environment.");
CURRENT_ENVIRONMENT = nextEnterpriseEnvironment;
switch (enterpriseConfigServerUrl)
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId);
if (isPluginInUse)
{
case null when enterpriseConfigId == Guid.Empty:
case not null when string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty:
logger.LogInformation("AI Studio runs without an enterprise configuration.");
break;
case null:
logger.LogWarning("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}'), but the configuration server URL is not set.", enterpriseConfigId);
break;
case not null when !string.IsNullOrWhiteSpace(enterpriseConfigServerUrl) && enterpriseConfigId == Guid.Empty:
logger.LogWarning("AI Studio runs with an enterprise configuration server URL ('{EnterpriseConfigServerUrl}'), but the configuration ID is not set.", enterpriseConfigServerUrl);
break;
default:
logger.LogInformation("AI Studio runs with an enterprise configuration id ('{EnterpriseConfigId}') and configuration server URL ('{EnterpriseConfigServerUrl}').", enterpriseConfigId, enterpriseConfigServerUrl);
if(isFirstRun)
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, new EnterpriseEnvironment(enterpriseConfigServerUrl, enterpriseConfigId, etag));
else
await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseConfigId, enterpriseConfigServerUrl);
break;
logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId);
PluginFactory.RemovePluginAsync(deleteId);
}
}
else
logger.LogInformation("The enterprise environment has not changed. No update required.");
//
// Step 2: Fetch all active configurations.
//
List<EnterpriseEnvironment> fetchedConfigs;
try
{
fetchedConfigs = await rustService.EnterpriseEnvConfigs();
}
catch (Exception e)
{
logger.LogError(e, "Failed to fetch the enterprise configurations from the Rust service.");
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvConfigs failed");
return;
}
//
// Step 3: Determine ETags and build the next environment list.
//
var nextEnvironments = new List<EnterpriseEnvironment>();
foreach (var config in fetchedConfigs)
{
if (!config.IsActive)
{
logger.LogWarning("Skipping inactive enterprise configuration with ID '{ConfigId}'. There is either no valid server URL or config ID set.", config.ConfigurationId);
continue;
}
var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
nextEnvironments.Add(config with { ETag = etag });
}
if (nextEnvironments.Count == 0)
{
if (CURRENT_ENVIRONMENTS.Count > 0)
{
logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs.");
// Remove plugins for configs that were previously active:
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
{
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
if (isPluginInUse)
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
}
}
else
logger.LogInformation("AI Studio runs without any enterprise configurations.");
CURRENT_ENVIRONMENTS = [];
return;
}
//
// Step 4: Compare with current environments and process changes.
//
var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet();
var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet();
// Remove plugins for configs that are no longer present:
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
{
if (!nextIds.Contains(oldEnv.ConfigurationId))
{
logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId);
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
if (isPluginInUse)
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
}
}
// Process new or changed configs:
foreach (var nextEnv in nextEnvironments)
{
var currentEnv = CURRENT_ENVIRONMENTS.FirstOrDefault(e => e.ConfigurationId == nextEnv.ConfigurationId);
if (currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag).
{
logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId);
continue;
}
var isNew = !currentIds.Contains(nextEnv.ConfigurationId);
if(isNew)
logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
else
logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId);
if (isFirstRun)
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv);
else
await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
}
CURRENT_ENVIRONMENTS = nextEnvironments;
}
catch (Exception e)
{

View File

@ -1,71 +1,9 @@
namespace AIStudio.Tools.Services;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
/// <summary>
/// Tries to read the enterprise environment for the current user's configuration ID.
/// </summary>
/// <returns>
/// Returns the empty Guid when the environment is not set or the request fails.
/// Otherwise, the configuration ID.
/// </returns>
public async Task<Guid> EnterpriseEnvConfigId()
{
var result = await this.http.GetAsync("/system/enterprise/config/id");
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to query the enterprise configuration ID: '{result.StatusCode}'");
return Guid.Empty;
}
Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId);
return configurationId;
}
/// <summary>
/// Tries to read the enterprise environment for a configuration ID, which must be removed.
/// </summary>
/// <remarks>
/// Removing a configuration ID is necessary when the user moved to another department or
/// left the company, or when the configuration ID is no longer valid.
/// </remarks>
/// <returns>
/// Returns the empty Guid when the environment is not set or the request fails.
/// Otherwise, the configuration ID.
/// </returns>
public async Task<Guid> EnterpriseEnvRemoveConfigId()
{
var result = await this.http.DeleteAsync("/system/enterprise/config/id");
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to query the enterprise configuration ID for removal: '{result.StatusCode}'");
return Guid.Empty;
}
Guid.TryParse(await result.Content.ReadAsStringAsync(), out var configurationId);
return configurationId;
}
/// <summary>
/// Tries to read the enterprise environment for the current user's configuration server URL.
/// </summary>
/// <returns>
/// Returns null when the environment is not set or the request fails.
/// Otherwise, the configuration server URL.
/// </returns>
public async Task<string> EnterpriseEnvConfigServerUrl()
{
var result = await this.http.GetAsync("/system/enterprise/config/server");
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to query the enterprise configuration server URL: '{result.StatusCode}'");
return string.Empty;
}
var serverUrl = await result.Content.ReadAsStringAsync();
return string.IsNullOrWhiteSpace(serverUrl) ? string.Empty : serverUrl;
}
/// <summary>
/// Tries to read the enterprise environment for the configuration encryption secret.
/// </summary>
@ -85,4 +23,67 @@ public sealed partial class RustService
var encryptionSecret = await result.Content.ReadAsStringAsync();
return string.IsNullOrWhiteSpace(encryptionSecret) ? string.Empty : encryptionSecret;
}
/// <summary>
/// Reads all enterprise configurations (multi-config support).
/// </summary>
/// <returns>
/// Returns a list of enterprise environments parsed from the Rust runtime.
/// The ETag is not yet determined; callers must resolve it separately.
/// </returns>
public async Task<List<EnterpriseEnvironment>> EnterpriseEnvConfigs()
{
var result = await this.http.GetAsync("/system/enterprise/configs");
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'");
return [];
}
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
if (configs is null)
return [];
var environments = new List<EnterpriseEnvironment>();
foreach (var config in configs)
{
if (Guid.TryParse(config.Id, out var id))
environments.Add(new EnterpriseEnvironment(config.ServerUrl, id, null));
else
this.logger!.LogWarning($"Skipping enterprise config with invalid ID: '{config.Id}'.");
}
return environments;
}
/// <summary>
/// Reads all enterprise configuration IDs that should be deleted.
/// </summary>
/// <returns>
/// Returns a list of GUIDs representing configuration IDs to remove.
/// </returns>
public async Task<List<Guid>> EnterpriseEnvDeleteConfigIds()
{
var result = await this.http.GetAsync("/system/enterprise/delete-configs");
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'");
return [];
}
var ids = await result.Content.ReadFromJsonAsync<List<string>>(this.jsonRustSerializerOptions);
if (ids is null)
return [];
var guids = new List<Guid>();
foreach (var idStr in ids)
{
if (Guid.TryParse(idStr, out var id))
guids.Add(id);
else
this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'.");
}
return guids;
}
}

View File

@ -3,6 +3,7 @@
- Added an app setting to enable administration options for IT staff to configure and maintain organization-wide settings.
- Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled.
- Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled.
- Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details.
- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users.
- Improved the workspaces experience by using a different color for the delete button to avoid confusion.
- Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition.

View File

@ -1,7 +1,9 @@
use std::env;
use std::sync::OnceLock;
use log::{debug, warn};
use log::{debug, info, warn};
use rocket::{delete, get};
use rocket::serde::json::Json;
use serde::Serialize;
use sys_locale::get_locale;
use crate::api_token::APIToken;
@ -143,23 +145,124 @@ pub fn read_enterprise_env_config_encryption_secret(_token: APIToken) -> String
)
}
/// Represents a single enterprise configuration entry with an ID and server URL.
#[derive(Serialize)]
pub struct EnterpriseConfig {
pub id: String,
pub server_url: String,
}
/// Returns all enterprise configurations. Supports the new multi-config format
/// (`id1@url1;id2@url2`) as well as the legacy single-config environment variables.
#[get("/system/enterprise/configs")]
pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>> {
info!("Trying to read the enterprise environment for all configurations.");
// Try the new combined format first:
let combined = get_enterprise_configuration(
"configs",
"MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS",
);
if !combined.is_empty() {
// Parse the new format: id1@url1;id2@url2; ...
let configs: Vec<EnterpriseConfig> = combined
.split(';')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
// Split at the first '@' (GUIDs never contain '@'):
entry.split_once('@').and_then(|(id, url)| {
let id = id.trim().to_string();
let url = url.trim().to_string();
if id.is_empty() || url.is_empty() {
None
} else {
Some(EnterpriseConfig { id, server_url: url })
}
})
})
.collect();
return Json(configs);
}
// Fallback: read the legacy single-config variables:
let config_id = get_enterprise_configuration(
"config_id",
"MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID",
);
let config_server_url = get_enterprise_configuration(
"config_server_url",
"MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL",
);
if !config_id.is_empty() && !config_server_url.is_empty() {
return Json(vec![EnterpriseConfig {
id: config_id,
server_url: config_server_url,
}]);
}
Json(vec![])
}
/// Returns all enterprise configuration IDs that should be deleted. Supports the new
/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable.
#[get("/system/enterprise/delete-configs")]
pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json<Vec<String>> {
info!("Trying to read the enterprise environment for configuration IDs to delete.");
// Try the new combined format first:
let combined = get_enterprise_configuration(
"delete_config_ids",
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS",
);
if !combined.is_empty() {
let ids: Vec<String> = combined
.split(';')
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect();
return Json(ids);
}
// Fallback: read the legacy single-delete variable:
let delete_id = get_enterprise_configuration(
"delete_config_id",
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
);
if !delete_id.is_empty() {
return Json(vec![delete_id]);
}
Json(vec![])
}
fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
cfg_if::cfg_if! {
if #[cfg(target_os = "windows")] {
debug!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT' or environment variables.");
info!(r"Detected a Windows machine, trying to read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}' or the environment variable '{}'.", _reg_value, env_name);
use windows_registry::*;
let key_path = r"Software\github\MindWork AI Studio\Enterprise IT";
let key = match CURRENT_USER.open(key_path) {
Ok(key) => key,
Err(_) => {
debug!(r"Could not read the registry key HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT. Falling back to environment variables.");
info!(r"Could not read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT\{}'. Falling back to the environment variable '{}'.", _reg_value, env_name);
return match env::var(env_name) {
Ok(val) => {
debug!("Falling back to the environment variable '{}' was successful.", env_name);
info!("Falling back to the environment variable '{}' was successful.", env_name);
val
},
Err(_) => {
debug!("Falling back to the environment variable '{}' was not successful.", env_name);
info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name);
"".to_string()
},
}
@ -169,14 +272,14 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
match key.get_string(_reg_value) {
Ok(val) => val,
Err(_) => {
debug!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to environment variables.", _reg_value);
info!(r"We could read the registry key 'HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT', but the value '{}' could not be read. Falling back to the environment variable '{}'.", _reg_value, env_name);
match env::var(env_name) {
Ok(val) => {
debug!("Falling back to the environment variable '{}' was successful.", env_name);
info!("Falling back to the environment variable '{}' was successful.", env_name);
val
},
Err(_) => {
debug!("Falling back to the environment variable '{}' was not successful.", env_name);
info!("Falling back to the environment variable '{}' was not successful. It seems that there is no enterprise environment available.", env_name);
"".to_string()
}
}
@ -184,11 +287,11 @@ fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
}
} else {
// In the case of macOS or Linux, we just read the environment variable:
debug!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name);
info!(r"Detected a Unix machine, trying to read the environment variable '{}'.", env_name);
match env::var(env_name) {
Ok(val) => val,
Err(_) => {
debug!("The environment variable '{}' was not found.", env_name);
info!("The environment variable '{}' was not found. It seems that there is no enterprise environment available.", env_name);
"".to_string()
}
}

View File

@ -86,6 +86,8 @@ pub fn start_runtime_api() {
crate::environment::delete_enterprise_env_config_id,
crate::environment::read_enterprise_env_config_server_url,
crate::environment::read_enterprise_env_config_encryption_secret,
crate::environment::read_enterprise_configs,
crate::environment::read_enterprise_delete_config_ids,
crate::file_data::extract_data,
crate::log::get_log_paths,
crate::log::log_event,