diff --git a/Directory.Packages.props b/Directory.Packages.props index 77bdfb45..19d2fdf8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,9 +3,55 @@ true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -14,49 +60,14 @@ + - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -68,7 +79,6 @@ - diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props deleted file mode 100644 index 0db241e6..00000000 --- a/src/PostIt/Directory.Packages.props +++ /dev/null @@ -1,23 +0,0 @@ - - - - true - - - - - - - - - - - - - - - - - - - diff --git a/src/PostIt/PostIt.slnx b/src/PostIt/PostIt.slnx deleted file mode 100644 index dab49d68..00000000 --- a/src/PostIt/PostIt.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/PostIt/PostIt/App.axaml b/src/PostIt/PostIt/App.axaml index b947594e..b179024a 100644 --- a/src/PostIt/PostIt/App.axaml +++ b/src/PostIt/PostIt/App.axaml @@ -1,10 +1,8 @@ - - + x:Class="PostIt.App"> + @@ -14,4 +12,4 @@ - \ No newline at end of file + diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 2377bd71..6919f09d 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -6,11 +6,16 @@ using System.Linq; using Avalonia.Markup.Xaml; using PostIt.ViewModels; using PostIt.Views; +using Avalonia.Controls; namespace PostIt; public partial class App : Application { + public App() + { + } + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -39,4 +44,4 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index cbd2a8be..725a6c46 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -18,6 +18,15 @@ All + + + + + + + + PreserveNewest + diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs index 7ce7a9e4..ee5d77e5 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using System.Threading.Tasks; +using IdentityModel.OidcClient; using PostIt.Models; namespace PostIt.Services; @@ -14,8 +16,8 @@ public sealed class BlogApiClient : IDisposable private readonly HttpClient _httpClient; private readonly JsonSerializerOptions _serializerOptions; - public BlogApiClient(string baseUrl, string? bearerToken = null) - : this(CreateHttpClient(baseUrl, bearerToken)) + public BlogApiClient(string baseUrl, string? accessToken = null) + : this(CreateHttpClient(baseUrl, accessToken)) { } @@ -28,18 +30,13 @@ public sealed class BlogApiClient : IDisposable }; } - private static HttpClient CreateHttpClient(string baseUrl, string? bearerToken) + private static HttpClient CreateHttpClient(string baseUrl, string? accessToken) { - var client = new HttpClient + var client = new HttpClient { BaseAddress = new Uri(baseUrl) }; + if (!string.IsNullOrWhiteSpace(accessToken)) { - BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") - }; - - if (!string.IsNullOrWhiteSpace(bearerToken)) - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken.Trim()); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); } - return client; } diff --git a/src/PostIt/PostIt/Services/LoopbackBrowser.cs b/src/PostIt/PostIt/Services/LoopbackBrowser.cs new file mode 100644 index 00000000..c9dd06b1 --- /dev/null +++ b/src/PostIt/PostIt/Services/LoopbackBrowser.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using IdentityModel.OidcClient.Browser; + +namespace PostIt.Services; + + public class LoopbackBrowser : IBrowser + { + public async Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) + { + if (!Uri.TryCreate(options.EndUrl, UriKind.Absolute, out var endUri)) + { + return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Invalid end URL" }; + } + + var prefix = endUri.GetLeftPart(UriPartial.Path); + if (!prefix.EndsWith("/")) prefix += "/"; + + using var listener = new HttpListener(); + listener.Prefixes.Add(prefix); + listener.Start(); + + try + { + Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true }); + + var context = await listener.GetContextAsync().ConfigureAwait(false); + var response = context.Response; + var responseString = "Authentication complete. You can close this window."; + var buffer = Encoding.UTF8.GetBytes(responseString); + response.ContentLength64 = buffer.Length; + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + response.OutputStream.Close(); + + var raw = context.Request.Url!.ToString(); + return new BrowserResult + { + ResultType = BrowserResultType.Success, + Response = raw + }; + } + catch (Exception ex) + { + return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message }; + } + finally + { + try { listener.Stop(); } catch { } + } + } + } diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs new file mode 100644 index 00000000..38927ec2 --- /dev/null +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -0,0 +1,17 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using System; + +public partial class AuthenticationSettings : ObservableObject +{ + + [ObservableProperty] + public partial string Authority { get; set; } + + [ObservableProperty] + public partial string ClientId { get; set; } + + [ObservableProperty] + public partial string ClientSecret { get; set; } + + +} diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs new file mode 100644 index 00000000..462faedd --- /dev/null +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -0,0 +1,84 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using IdentityModel.OidcClient; +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; + +namespace PostIt; + +public partial class Settings : ObservableObject +{ + const string SettingsFileName = "settings.json"; + IStorageFolder? folder = null; + + [ObservableProperty] + public partial AuthenticationSettings Authentication { get; set; } = new(); + + [ObservableProperty] + public partial bool DarkMode { get; set; } = false; + + [ObservableProperty] + public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; + + + [ObservableProperty] + public partial string[] Scopes { get; set; } + + internal OidcClientOptions GetOidcClientOptions() + { + return new OidcClientOptions + { + Authority = Authentication.Authority, + ClientId = Authentication.ClientId, + ClientSecret = Authentication.ClientSecret, + Scope = string.Join(' ', this.Scopes) + }; + + } + + internal async Task Load(IStorageProvider storageProvider) + { + var configFile = await storageProvider.TryGetFileFromPathAsync( + Path.Combine(AppContext.BaseDirectory, SettingsFileName)); + + if (configFile is null) + { + Console.Error.WriteLine("🩎 No settings file found."); + return; // no settings file + } + try { + using var stream = await configFile.OpenReadAsync(); + + + using var reader = new StreamReader( stream); + var json = await reader.ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(json)) + { + Console.Error.WriteLine("🩎 Settings file is empty."); + return ; + } + + var settings = JsonSerializer.Deserialize(json); + + if (settings is null) + { + Console.Error.WriteLine("🩎 Settings file is invalid."); + return ; + } + this.Authentication = settings.Authentication; + this.DarkMode = settings.DarkMode; + this.ApiUrl = settings.ApiUrl; + this.Scopes = settings.Scopes; + + + } + catch (Exception ex) + { + Console.Error.WriteLine($"🩎 Error loading settings: {ex.Message}"); + } + } +} diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 124251b4..6dcc951d 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -3,9 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -13,49 +11,50 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Models; using PostIt.Services; +using Avalonia.Styling; namespace PostIt.ViewModels; public partial class MainViewModel : ViewModelBase { - [ObservableProperty] - private string _authority = "https://localhost:5001"; [ObservableProperty] - private string _clientId = "postit"; + public partial string StatusMessage { get; set; } [ObservableProperty] - private string _clientSecret = "postit-secret"; + public partial string SearchText { get; set; } [ObservableProperty] - private string _scope = "blog"; + public partial string BearerToken { get; set; } [ObservableProperty] - private string _apiUrl = "http://localhost:5000"; + public partial ObservableCollection Posts { get; set; } [ObservableProperty] - private string _searchText = string.Empty; + public partial ObservableCollection FilteredPosts { get; set; } [ObservableProperty] - private string? _bearerToken; + public partial BlogPost? SelectedPost{ get; set; } [ObservableProperty] - private ObservableCollection _posts = new(); + public partial bool IsBusy{ get; set; } [ObservableProperty] - private ObservableCollection _filteredPosts = new(); - + ThemeVariant themeVariant = ThemeVariant.Default; + [ObservableProperty] - private BlogPost? _selectedPost; - - [ObservableProperty] - private string _statusMessage = "Ready"; - - [ObservableProperty] - private bool _isBusy; + public partial Settings Settings { get; private set; } public MainViewModel() { + SearchText = string.Empty; + Posts = new ObservableCollection(); + FilteredPosts = new ObservableCollection(); + SelectedPost = null; + BearerToken = string.Empty; + IsBusy = false; + StatusMessage = "Ready"; + Settings = new Settings(); } partial void OnSearchTextChanged(string value) @@ -74,7 +73,7 @@ public partial class MainViewModel : ViewModelBase } [RelayCommand] - public async Task LoadPostsAsync() + internal async Task LoadPosts() { await ExecuteAsync(async () => { @@ -92,26 +91,51 @@ public partial class MainViewModel : ViewModelBase } [RelayCommand] - public void Search() + internal void Search() { ApplyFilter(); } [RelayCommand] - public async Task LoginAsync() + internal async Task Login() { await ExecuteAsync(async () => { + // Try interactive OIDC login first + try + { + + var loginWin = new PostIt.Views.LoginWindow(); + var result = await loginWin.StartLoginAsync(Settings.GetOidcClientOptions()); + + if (result is not null && !result.IsError && !string.IsNullOrWhiteSpace(result.AccessToken)) + { + BearerToken = result.AccessToken; + StatusMessage = "Interactive token acquired."; + return; + } + } + catch + { + // ignore and fallback to client credentials + } + + // Fallback to client credentials if interactive fails var tokenResponse = await RequestClientCredentialsTokenAsync(); + if (string.IsNullOrWhiteSpace(tokenResponse.AccessToken)) + { + throw new InvalidOperationException("Failed to acquire a token using client credentials."); + } + BearerToken = tokenResponse.AccessToken; StatusMessage = string.IsNullOrWhiteSpace(tokenResponse.Error) - ? "Bearer token acquired." + ? "Bearer token acquired (client credentials)." : $"Token acquired with warning: {tokenResponse.ErrorDescription}"; }); } [RelayCommand] - public async Task SaveAsync() + internal async Task Save() { if (SelectedPost is null) { @@ -146,7 +170,7 @@ public partial class MainViewModel : ViewModelBase } [RelayCommand] - public async Task DeleteAsync() + internal async Task Delete() { if (SelectedPost is null || SelectedPost.Id == 0) { @@ -165,7 +189,7 @@ public partial class MainViewModel : ViewModelBase } [RelayCommand] - public void New() + internal void New() { SelectedPost = new BlogPost { @@ -178,6 +202,7 @@ public partial class MainViewModel : ViewModelBase StatusMessage = "New blog post ready."; } + private async Task RefreshPostsAsync() { using var client = CreateClient(); @@ -198,6 +223,8 @@ public partial class MainViewModel : ViewModelBase private void ApplyFilter() { + if (Posts is null) return; + var query = SearchText?.Trim(); var filtered = string.IsNullOrWhiteSpace(query) ? Posts.OrderByDescending(p => p.DateModified) @@ -234,7 +261,7 @@ public partial class MainViewModel : ViewModelBase private async Task RequestClientCredentialsTokenAsync() { using var client = new HttpClient(); - var discoveryUrl = Authority.TrimEnd('/') + "/.well-known/openid-configuration"; + var discoveryUrl = Settings.Authentication.Authority.TrimEnd('/') + "/.well-known/openid-configuration"; var discoveryDocument = await client.GetFromJsonAsync(discoveryUrl, JsonOptions); if (discoveryDocument is null || string.IsNullOrWhiteSpace(discoveryDocument.TokenEndpoint)) @@ -247,9 +274,9 @@ public partial class MainViewModel : ViewModelBase Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", - ["client_id"] = ClientId, - ["client_secret"] = ClientSecret, - ["scope"] = Scope, + ["client_id"] = Settings.Authentication.ClientId, + ["client_secret"] = Settings.Authentication.ClientSecret, + ["scope"] = string.Join(' ', Settings.Scopes), }) }; @@ -278,7 +305,7 @@ public partial class MainViewModel : ViewModelBase }; private BlogApiClient CreateClient() - => new BlogApiClient(ApiUrl, BearerToken); + => new BlogApiClient(Settings.ApiUrl, BearerToken); private void UpdateCommandStates() { diff --git a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs new file mode 100644 index 00000000..aab3b2af --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs @@ -0,0 +1,19 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace PostIt.ViewModels; + +public partial class SettingsViewModel : ViewModelBase +{ + [ObservableProperty] + public partial bool DarkMode { get; set; } + + [ObservableProperty] + public partial string Authority { get; set; } + + [ObservableProperty] + public partial string ClientId { get; set; } + + [ObservableProperty] + public partial string ClientSecret { get; set; } + +} diff --git a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs index 9e159fa3..3d28c6dd 100644 --- a/src/PostIt/PostIt/ViewModels/ViewModelBase.cs +++ b/src/PostIt/PostIt/ViewModels/ViewModelBase.cs @@ -1,7 +1,8 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using Avalonia.Styling; +using CommunityToolkit.Mvvm.ComponentModel; namespace PostIt.ViewModels; -public abstract class ViewModelBase : ObservableObject +public abstract partial class ViewModelBase : ObservableObject { } diff --git a/src/PostIt/PostIt/Views/LoginWindow.axaml b/src/PostIt/PostIt/Views/LoginWindow.axaml new file mode 100644 index 00000000..6d80609b --- /dev/null +++ b/src/PostIt/PostIt/Views/LoginWindow.axaml @@ -0,0 +1,13 @@ + + + + + +