Implemented ERI v1 server dialog

This commit is contained in:
Thorsten Sommer 2025-01-13 17:11:46 +01:00
parent 06471eff78
commit ee55cd17b8
Signed by: tsommer
GPG Key ID: 371BBA77A02C0108
5 changed files with 457 additions and 2 deletions

View File

@ -0,0 +1,128 @@
@using ERI_Client.V1
<MudDialog>
<DialogContent>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="Data Source Name"
Class="mb-6"
MaxLength="40"
Counter="40"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingName"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lightbulb"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
<MudStack Row="@true">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataHostname"
Label="ERI v1 Server Hostname"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingHostname"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.NetworkCheck"
AdornmentColor="Color.Info"
Variant="Variant.Text"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
<MudNumericField
Label="Port"
Immediate="@true"
Min="1" Max="65535"
Validation="@this.dataSourceValidation.ValidatePort"
@bind-Value="@this.dataPort"
Variant="Variant.Text"
Margin="Margin.Dense"/>
</MudStack>
@if (!this.IsConnectionEncrypted())
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
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.
</MudJustifiedText>
}
@if (this.IsConnectionPossible())
{
<MudStack Row="@true" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="@this.GetTestResultColor()" StartIcon="@this.GetTestResultIcon()" Class="mb-3" OnClick="@this.TestConnection">
Test connection & read available metadata
</MudButton>
<MudText Typo="Typo.body1" Class="mb-3">
@this.GetTestResultText()
</MudText>
</MudStack>
}
@if(this.availableAuthMethods.Count > 0 || this.dataAuthMethod != default)
{
<MudSelect @bind-Value="@this.dataAuthMethod" Text="@this.dataAuthMethod.DisplayName()" Label="Authentication Method" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateAuthMethod">
@foreach (var authMethod in this.availableAuthMethods)
{
<MudSelectItem Value="@authMethod">@authMethod.DisplayName()</MudSelectItem>
}
</MudSelect>
}
@if (this.NeedsSecret())
{
if (this.dataAuthMethod is AuthMethod.USERNAME_PASSWORD)
{
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataUsername"
Label="Username"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidateUsername"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person2"
AdornmentColor="Color.Info"
Variant="Variant.Text"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
}
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataSecret"
Label="@this.GetSecretLabel()"
Class="mb-6"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidatingSecret"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Security"
AdornmentColor="Color.Info"
Variant="Variant.Text"
InputType="InputType.Password"
UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
}
</MudForm>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">Cancel</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@:Update
}
else
{
@:Add
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -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<ProviderDialog> Logger { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private readonly DataSourceValidation dataSourceValidation;
private readonly Encryption encryption = Program.ENCRYPTION;
/// <summary>
/// The list of used data source names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedDataSourcesNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataSecretStorageIssue = string.Empty;
private string dataEditingPreviousInstanceName = string.Empty;
private HttpClient? httpClient;
private List<AuthMethod> 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();
}

View File

@ -38,4 +38,9 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
/// The authentication method to use.
/// </summary>
public AuthMethod AuthMethod { get; init; } = AuthMethod.NONE;
/// <summary>
/// The username to use for authentication, when the auth. method is USERNAME_PASSWORD.
/// </summary>
public string Username { get; init; } = string.Empty;
}

View File

@ -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",
};
}

View File

@ -14,6 +14,12 @@ public sealed class DataSourceValidation
public Func<bool> GetSelectedCloudEmbedding { get; init; } = () => false;
public Func<bool> GetTestedConnection { get; init; } = () => false;
public Func<bool> GetTestedConnectionResult { get; init; } = () => false;
public Func<IReadOnlyList<AuthMethod>> 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;
}
}