diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor
index e69de29b..64e307b9 100644
--- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor
+++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor
@@ -0,0 +1,128 @@
+@using ERI_Client.V1
+
+
+
+ @* ReSharper disable once CSharpWarnings::CS8974 *@
+
+
+
+ @* ReSharper disable once CSharpWarnings::CS8974 *@
+
+
+
+
+
+ @if (!this.IsConnectionEncrypted())
+ {
+
+ Please note: the connection to the ERI v1 server is not encrypted. This means that all
+ data sent to the server is transmitted in plain text. Please ask the ERI server administrator
+ to enable encryption.
+
+ }
+
+ @if (this.IsConnectionPossible())
+ {
+
+
+ Test connection & read available metadata
+
+
+ @this.GetTestResultText()
+
+
+ }
+
+ @if(this.availableAuthMethods.Count > 0 || this.dataAuthMethod != default)
+ {
+
+ @foreach (var authMethod in this.availableAuthMethods)
+ {
+ @authMethod.DisplayName()
+ }
+
+ }
+
+ @if (this.NeedsSecret())
+ {
+ if (this.dataAuthMethod is AuthMethod.USERNAME_PASSWORD)
+ {
+ @* ReSharper disable once CSharpWarnings::CS8974 *@
+
+ }
+
+ @* ReSharper disable once CSharpWarnings::CS8974 *@
+
+ }
+
+
+
+
+
+ Cancel
+
+ @if(this.IsEditing)
+ {
+ @:Update
+ }
+ else
+ {
+ @:Add
+ }
+
+
+
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs
index 5c77c72b..1e4eaf7d 100644
--- a/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs
+++ b/app/MindWork AI Studio/Dialogs/DataSourceERI_V1Dialog.razor.cs
@@ -1,15 +1,277 @@
+using AIStudio.Settings;
using AIStudio.Settings.DataModel;
+using AIStudio.Tools.Validation;
+
+using ERI_Client.V1;
using Microsoft.AspNetCore.Components;
// ReSharper disable InconsistentNaming
namespace AIStudio.Dialogs;
-public partial class DataSourceERI_V1Dialog : ComponentBase
+public partial class DataSourceERI_V1Dialog : ComponentBase, ISecretId
{
+ [CascadingParameter]
+ private MudDialogInstance MudDialog { get; set; } = null!;
+
[Parameter]
public bool IsEditing { get; set; }
[Parameter]
public DataSourceERI_V1 DataSource { get; set; }
+
+ [Inject]
+ private SettingsManager SettingsManager { get; init; } = null!;
+
+ [Inject]
+ private ILogger Logger { get; init; } = null!;
+
+ [Inject]
+ private RustService RustService { get; init; } = null!;
+
+ private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new();
+
+ private readonly DataSourceValidation dataSourceValidation;
+ private readonly Encryption encryption = Program.ENCRYPTION;
+
+ ///
+ /// The list of used data source names. We need this to check for uniqueness.
+ ///
+ private List UsedDataSourcesNames { get; set; } = [];
+
+ private bool dataIsValid;
+ private string[] dataIssues = [];
+ private string dataSecretStorageIssue = string.Empty;
+ private string dataEditingPreviousInstanceName = string.Empty;
+ private HttpClient? httpClient;
+ private List availableAuthMethods = [];
+ private bool connectionTested;
+ private bool connectionSuccessfulTested;
+
+ private uint dataNum;
+ private string dataSecret = string.Empty;
+ private string dataId = Guid.NewGuid().ToString();
+ private string dataName = string.Empty;
+ private string dataHostname = string.Empty;
+ private int dataPort;
+ private AuthMethod dataAuthMethod;
+ private string dataUsername = string.Empty;
+
+ // We get the form reference from Blazor code to validate it manually:
+ private MudForm form = null!;
+
+ public DataSourceERI_V1Dialog()
+ {
+ this.dataSourceValidation = new()
+ {
+ GetAuthMethod = () => this.dataAuthMethod,
+ GetPreviousDataSourceName = () => this.dataEditingPreviousInstanceName,
+ GetUsedDataSourceNames = () => this.UsedDataSourcesNames,
+ GetSecretStorageIssue = () => this.dataSecretStorageIssue,
+ GetTestedConnection = () => this.connectionTested,
+ GetTestedConnectionResult = () => this.connectionSuccessfulTested,
+ GetAvailableAuthMethods = () => this.availableAuthMethods,
+ };
+ }
+
+ #region Overrides of ComponentBase
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Configure the spellchecking for the instance name input:
+ this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
+
+ // Load the used instance names:
+ this.UsedDataSourcesNames = this.SettingsManager.ConfigurationData.DataSources.Select(x => x.Name.ToLowerInvariant()).ToList();
+
+ // When editing, we need to load the data:
+ if(this.IsEditing)
+ {
+ this.dataEditingPreviousInstanceName = this.DataSource.Name.ToLowerInvariant();
+ this.dataNum = this.DataSource.Num;
+ this.dataId = this.DataSource.Id;
+ this.dataName = this.DataSource.Name;
+ this.dataHostname = this.DataSource.Hostname;
+ this.dataPort = this.DataSource.Port;
+ this.dataAuthMethod = this.DataSource.AuthMethod;
+ this.dataUsername = this.DataSource.Username;
+
+ if (this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD)
+ {
+ // Load the secret:
+ var requestedSecret = await this.RustService.GetSecret(this);
+ if (requestedSecret.Success)
+ this.dataSecret = await requestedSecret.Secret.Decrypt(this.encryption);
+ else
+ {
+ this.dataSecret = string.Empty;
+ this.dataSecretStorageIssue = $"Failed to load the auth. secret from the operating system. The message was: {requestedSecret.Issue}. You might ignore this message and provide the secret again.";
+ await this.form.Validate();
+ }
+ }
+ }
+
+ await base.OnInitializedAsync();
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ // Reset the validation when not editing and on the first render.
+ // We don't want to show validation errors when the user opens the dialog.
+ if(!this.IsEditing && firstRender)
+ this.form.ResetValidation();
+
+ await base.OnAfterRenderAsync(firstRender);
+ }
+
+ #endregion
+
+ #region Implementation of ISecretId
+
+ public string SecretId => this.dataId;
+
+ public string SecretName => this.dataName;
+
+ #endregion
+
+ private DataSourceERI_V1 CreateDataSource()
+ {
+ var cleanedHostname = this.dataHostname.Trim();
+ return new DataSourceERI_V1
+ {
+ Id = this.dataId,
+ Num = this.dataNum,
+ Port = this.dataPort,
+ Name = this.dataName,
+ Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname,
+ AuthMethod = this.dataAuthMethod,
+ Username = this.dataUsername,
+ Type = DataSourceType.ERI_V1,
+ };
+ }
+
+ private bool IsConnectionEncrypted() => this.dataHostname.StartsWith("https://", StringComparison.InvariantCultureIgnoreCase);
+
+ private bool IsConnectionPossible()
+ {
+ if(this.dataSourceValidation.ValidatingHostname(this.dataHostname) is not null)
+ return false;
+
+ if(this.dataSourceValidation.ValidatePort(this.dataPort) is not null)
+ return false;
+
+ return true;
+ }
+
+ private async Task TestConnection()
+ {
+ try
+ {
+ this.httpClient = new HttpClient
+ {
+ BaseAddress = new Uri($"{this.dataHostname}:{this.dataPort}"),
+ Timeout = TimeSpan.FromSeconds(5),
+ };
+
+ using (this.httpClient)
+ {
+ var client = new Client(this.httpClient);
+ var authSchemes = await client.GetAuthMethodsAsync();
+ if (authSchemes is null)
+ {
+ await this.form.Validate();
+
+ Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
+ this.dataIssues[^1] = "Failed to connect to the ERI v1 server. The server did not respond.";
+ return;
+ }
+
+ this.availableAuthMethods = authSchemes.Select(n => n.AuthMethod).ToList();
+
+ this.connectionTested = true;
+ this.connectionSuccessfulTested = true;
+ this.Logger.LogInformation("Connection to the ERI v1 server was successful tested.");
+ }
+ }
+ catch (Exception e)
+ {
+ await this.form.Validate();
+
+ Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
+ this.dataIssues[^1] = $"Failed to connect to the ERI v1 server. The message was: {e.Message}";
+ this.Logger.LogError($"Failed to connect to the ERI v1 server. Message: {e.Message}");
+
+ this.connectionTested = true;
+ this.connectionSuccessfulTested = false;
+ }
+ }
+
+ private string GetTestResultText()
+ {
+ if(!this.connectionTested)
+ return "Not tested yet.";
+
+ return this.connectionSuccessfulTested ? "Connection successful." : "Connection failed.";
+ }
+
+ private Color GetTestResultColor()
+ {
+ if (!this.connectionTested)
+ return Color.Default;
+
+ return this.connectionSuccessfulTested ? Color.Success : Color.Error;
+ }
+
+ private string GetTestResultIcon()
+ {
+ if (!this.connectionTested)
+ return Icons.Material.Outlined.HourglassEmpty;
+
+ return this.connectionSuccessfulTested ? Icons.Material.Outlined.CheckCircle : Icons.Material.Outlined.Error;
+ }
+
+ private bool NeedsSecret() => this.dataAuthMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD;
+
+ private string GetSecretLabel() => this.dataAuthMethod switch
+ {
+ AuthMethod.TOKEN => "Access Token",
+ AuthMethod.USERNAME_PASSWORD => "Password",
+ _ => "Secret",
+ };
+
+ private async Task Store()
+ {
+ await this.form.Validate();
+
+ var testConnectionValidation = this.dataSourceValidation.ValidateTestedConnection();
+ if(testConnectionValidation is not null)
+ {
+ Array.Resize(ref this.dataIssues, this.dataIssues.Length + 1);
+ this.dataIssues[^1] = testConnectionValidation;
+ this.dataIsValid = false;
+ }
+
+ this.dataSecretStorageIssue = string.Empty;
+
+ // When the data is not valid, we don't store it:
+ if (!this.dataIsValid)
+ return;
+
+ var addedDataSource = this.CreateDataSource();
+ if (!string.IsNullOrWhiteSpace(this.dataSecret))
+ {
+ // Store the secret in the OS secure storage:
+ var storeResponse = await this.RustService.SetSecret(this, this.dataSecret);
+ if (!storeResponse.Success)
+ {
+ this.dataSecretStorageIssue = $"Failed to store the auth. secret in the operating system. The message was: {storeResponse.Issue}. Please try again.";
+ await this.form.Validate();
+ return;
+ }
+ }
+
+ this.MudDialog.Close(DialogResult.Ok(addedDataSource));
+ }
+
+ private void Cancel() => this.MudDialog.Cancel();
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs
index b5534f29..387accf0 100644
--- a/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs
+++ b/app/MindWork AI Studio/Settings/DataModel/DataSourceERI_V1.cs
@@ -38,4 +38,9 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
/// The authentication method to use.
///
public AuthMethod AuthMethod { get; init; } = AuthMethod.NONE;
+
+ ///
+ /// The username to use for authentication, when the auth. method is USERNAME_PASSWORD.
+ ///
+ public string Username { get; init; } = string.Empty;
}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/AuthMethodsV1Extensions.cs b/app/MindWork AI Studio/Tools/AuthMethodsV1Extensions.cs
new file mode 100644
index 00000000..e26a20b5
--- /dev/null
+++ b/app/MindWork AI Studio/Tools/AuthMethodsV1Extensions.cs
@@ -0,0 +1,16 @@
+using ERI_Client.V1;
+
+namespace AIStudio.Tools;
+
+public static class AuthMethodsV1Extensions
+{
+ public static string DisplayName(this AuthMethod authMethod) => authMethod switch
+ {
+ AuthMethod.NONE => "None",
+ AuthMethod.USERNAME_PASSWORD => "Username & Password",
+ AuthMethod.KERBEROS => "SSO (Kerberos)",
+ AuthMethod.TOKEN => "Access Token",
+
+ _ => "Unknown authentication method",
+ };
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Tools/Validation/DataSourceValidation.cs b/app/MindWork AI Studio/Tools/Validation/DataSourceValidation.cs
index 1fdd359a..2ba77270 100644
--- a/app/MindWork AI Studio/Tools/Validation/DataSourceValidation.cs
+++ b/app/MindWork AI Studio/Tools/Validation/DataSourceValidation.cs
@@ -14,6 +14,12 @@ public sealed class DataSourceValidation
public Func GetSelectedCloudEmbedding { get; init; } = () => false;
+ public Func GetTestedConnection { get; init; } = () => false;
+
+ public Func GetTestedConnectionResult { get; init; } = () => false;
+
+ public Func> GetAvailableAuthMethods { get; init; } = () => [];
+
public string? ValidatingHostname(string hostname)
{
if(string.IsNullOrWhiteSpace(hostname))
@@ -27,6 +33,25 @@ public sealed class DataSourceValidation
return null;
}
+
+ public string? ValidatePort(int port)
+ {
+ if(port is < 1 or > 65535)
+ return "The port must be between 1 and 65535.";
+
+ return null;
+ }
+
+ public string? ValidateUsername(string username)
+ {
+ if(this.GetAuthMethod() is not AuthMethod.USERNAME_PASSWORD)
+ return null;
+
+ if(string.IsNullOrWhiteSpace(username))
+ return "The username must not be empty.";
+
+ return null;
+ }
public string? ValidatingSecret(string secret)
{
@@ -41,7 +66,7 @@ public sealed class DataSourceValidation
if (string.IsNullOrWhiteSpace(secret))
return authMethod switch
{
- AuthMethod.TOKEN => "Please enter your secure token.",
+ AuthMethod.TOKEN => "Please enter your secure access token.",
AuthMethod.USERNAME_PASSWORD => "Please enter your password.",
_ => "Please enter the secret necessary for authentication."
@@ -102,4 +127,23 @@ public sealed class DataSourceValidation
return null;
}
+
+ public string? ValidateTestedConnection()
+ {
+ if(!this.GetTestedConnection())
+ return "Please test the connection before saving.";
+
+ if(!this.GetTestedConnectionResult())
+ return "The connection test failed. Please check the connection settings.";
+
+ return null;
+ }
+
+ public string? ValidateAuthMethod(AuthMethod authMethod)
+ {
+ if(!this.GetAvailableAuthMethods().Contains(authMethod))
+ return "Please select one valid authentication method.";
+
+ return null;
+ }
}
\ No newline at end of file