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