Merge branch 'fix/postit-acl-and-pub' into release/1.0.8-rc6
All checks were successful
Dotnet build and test / build (push) Successful in 7m31s
All checks were successful
Dotnet build and test / build (push) Successful in 7m31s
This commit is contained in:
commit
1b237fa540
16 changed files with 385 additions and 229 deletions
5
.vscode/tasks.json
vendored
5
.vscode/tasks.json
vendored
|
|
@ -7,12 +7,13 @@
|
||||||
"fileLocation": ["relative", "${workspaceFolder}"],
|
"fileLocation": ["relative", "${workspaceFolder}"],
|
||||||
"source": "dotnet",
|
"source": "dotnet",
|
||||||
"pattern": {
|
"pattern": {
|
||||||
"regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$",
|
"regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
|
||||||
"file": 1,
|
"file": 1,
|
||||||
"line": 2,
|
"line": 2,
|
||||||
"column": 3,
|
"column": 3,
|
||||||
"severity": 4,
|
"severity": 4,
|
||||||
"message": 5
|
"code": 5,
|
||||||
|
"message": 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,21 +1,18 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Toutes les modifications notables de PostIt et de la plateforme Yavsc
|
## [1.0.8-rc7] - unstable
|
||||||
sont documentées dans ce fichier.
|
|
||||||
|
|
||||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
### Added
|
||||||
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
À noter : la **parité du numéro de patch** porte une signification de canal :
|
* [PostIt] The search pattern now persists
|
||||||
|
|
||||||
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
|
### Changed
|
||||||
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
|
|
||||||
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
|
|
||||||
|
|
||||||
Cette convention est partagée avec le dépôt
|
* The blog spot path is now `/api/v1/blogspot` (yet in last release)
|
||||||
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
|
|
||||||
pour la production des paquets `.deb`.
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [Yavsc.Org][TODO] La forme de l'email de l'utilisateur est maintenant validée avant l'envoi du formulaire d'enregistrement
|
||||||
|
|
||||||
## [1.0.8-rc6] - unstable
|
## [1.0.8-rc6] - unstable
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,26 @@ Les tests sont répartis en :
|
||||||
item « Tests d'intégration smoke par BC ».
|
item « Tests d'intégration smoke par BC ».
|
||||||
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
|
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
|
||||||
|
|
||||||
|
## Le CHANGELOG.md
|
||||||
|
|
||||||
|
Le `CHANGELOG.md` est un document de changement de version
|
||||||
|
|
||||||
|
Toutes les modifications notables de PostIt et de la plateforme Yavsc
|
||||||
|
sont documentées dans ce fichier.
|
||||||
|
|
||||||
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||||
|
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
À noter : la **parité du numéro de patch** porte une signification de canal :
|
||||||
|
|
||||||
|
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
|
||||||
|
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
|
||||||
|
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
|
||||||
|
|
||||||
|
Cette convention est partagée avec le dépôt
|
||||||
|
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
|
||||||
|
pour la production des paquets `.deb`.
|
||||||
|
|
||||||
## Navigation (PostIt)
|
## Navigation (PostIt)
|
||||||
|
|
||||||
La navigation est centralisée dans
|
La navigation est centralisée dans
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ public class MainPageSaveTests
|
||||||
Assert.NotEmpty(recorder.Calls);
|
Assert.NotEmpty(recorder.Calls);
|
||||||
var (method, path, body) = recorder.FirstCall;
|
var (method, path, body) = recorder.FirstCall;
|
||||||
Assert.Equal(HttpMethod.Post, method);
|
Assert.Equal(HttpMethod.Post, method);
|
||||||
Assert.Equal("blog", path);
|
Assert.Equal("blogspot", path);
|
||||||
var sent = Assert.IsType<BlogPostDto>(body);
|
var sent = Assert.IsType<BlogPostDto>(body);
|
||||||
Assert.Equal(typed, sent.Title);
|
Assert.Equal(typed, sent.Title);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
public class SettingsLoadTests
|
public class SettingsLoadTests
|
||||||
|
|
@ -149,4 +151,26 @@ public class SettingsLoadTests
|
||||||
|
|
||||||
Assert.True(settings.Loaded);
|
Assert.True(settings.Loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchText_is_serialized_in_settings_and_round_trips()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.ViewModels.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://example.test/",
|
||||||
|
ClientId = "postit-tests",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.SearchText = "bonjour";
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(settings);
|
||||||
|
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(roundTrip);
|
||||||
|
Assert.Equal("bonjour", roundTrip.SearchText);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ private void ConfigureRootView(MainView rootView)
|
||||||
{
|
{
|
||||||
var app = (App)Current!;
|
var app = (App)Current!;
|
||||||
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();
|
||||||
|
await mainVm.InitializeAsync();
|
||||||
await app.PushPageAsync(mainVm);
|
await app.PushPageAsync(mainVm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,17 +42,9 @@ public static class ServiceCollectionHelpers
|
||||||
// the navigation stack, each bound to a fresh
|
// the navigation stack, each bound to a fresh
|
||||||
// SettingsViewModel and missing any in-flight edits.
|
// SettingsViewModel and missing any in-flight edits.
|
||||||
services.AddSingleton<SettingsPage>();
|
services.AddSingleton<SettingsPage>();
|
||||||
services.AddTransient<HomePage>();
|
services.AddSingleton<HomePage>();
|
||||||
services.AddTransient<SignaturePage>();
|
services.AddSingleton<SignaturePage>();
|
||||||
services.AddTransient<CirclesPage>();
|
services.AddSingleton<CirclesPage>();
|
||||||
// Dialogs (modal-light pages): the ViewLocator resolves
|
|
||||||
// them when a caller pushes a PostAclDialogViewModel or
|
|
||||||
// AddCircleMemberDialogViewModel via App.PushPageAsync.
|
|
||||||
// App.PushPageAsync overwrites the page's DataContext with
|
|
||||||
// the caller-built VM, so the parameterless ctor is enough
|
|
||||||
// here — the parametrised ctors stay for direct test wiring.
|
|
||||||
services.AddTransient<PostAclDialog>();
|
|
||||||
services.AddTransient<AddCircleMemberDialog>();
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
services.AddSingleton<YavscApiClient>(api);
|
services.AddSingleton<YavscApiClient>(api);
|
||||||
|
|
@ -61,18 +53,25 @@ public static class ServiceCollectionHelpers
|
||||||
services.AddSingleton(blogAclClient);
|
services.AddSingleton(blogAclClient);
|
||||||
services.AddSingleton(userSearchClient);
|
services.AddSingleton(userSearchClient);
|
||||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddSingleton<HomePageViewModel>();
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddSingleton<SignaturePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddSingleton<CirclesPageViewModel>();
|
||||||
services.AddTransient<CirclesPageViewModel>();
|
|
||||||
|
|
||||||
|
// Dialogs (modal-light pages): the ViewLocator resolves
|
||||||
|
// them when a caller pushes a PostAclDialogViewModel or
|
||||||
|
// AddCircleMemberDialogViewModel via App.PushPageAsync.
|
||||||
|
// App.PushPageAsync overwrites the page's DataContext with
|
||||||
|
// the caller-built VM, so the parameterless ctor is enough
|
||||||
|
// here — the parametrised ctors stay for direct test wiring.
|
||||||
|
services.AddTransient<PostAclDialog>();
|
||||||
|
services.AddTransient<AddCircleMemberDialog>();
|
||||||
// Persistent session banner: one instance for the lifetime of
|
// Persistent session banner: one instance for the lifetime of
|
||||||
// the app so the same VM survives page navigation.
|
// the app so the same VM survives page navigation.
|
||||||
var sessionStatus = new SessionStatusViewModel { Api = api };
|
var sessionStatus = new SessionStatusViewModel { Api = api };
|
||||||
sessionStatus.Refresh();
|
sessionStatus.Refresh();
|
||||||
services.AddSingleton(sessionStatus);
|
services.AddSingleton(sessionStatus);
|
||||||
services.AddTransient<SessionStatusBanner>();
|
services.AddSingleton<SessionStatusBanner>();
|
||||||
|
services.AddSingleton<MainViewModel>();
|
||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ public partial class MainViewModel : ViewModelBase
|
||||||
/// mutable field. Toggling is its own action.</summary>
|
/// mutable field. Toggling is its own action.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool DraftIsPublished { get; set; }
|
public partial bool DraftIsPublished { get; set; }
|
||||||
|
public bool IsLoaded { get; private set; }
|
||||||
public Settings SettingsModel { get; }
|
public Settings SettingsModel { get; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
|
|
@ -72,6 +72,194 @@ public partial class MainViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial Settings Settings { get; private set; }
|
public partial Settings Settings { get; private set; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
await ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
var posts = await BlogClient!.GetPostsAsync();
|
||||||
|
Posts.Clear();
|
||||||
|
foreach (var post in posts.OrderByDescending(p => p.DateModified))
|
||||||
|
{
|
||||||
|
Posts.Add(post);
|
||||||
|
}
|
||||||
|
ApplyFilter();
|
||||||
|
StatusMessage = $"Loaded {Posts.Count} posts.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task SearchAsync() {
|
||||||
|
await RefreshAsync();
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task SaveAsync()
|
||||||
|
{
|
||||||
|
// The button is already disabled when the title is empty
|
||||||
|
// (see CanSave), but the test path (and any programmatic
|
||||||
|
// ICommand.Execute) bypasses CanExecute, so we still
|
||||||
|
// guard here. Better to no-op with a status message
|
||||||
|
// than to send a request the server will reject.
|
||||||
|
if (string.IsNullOrWhiteSpace(DraftTitle))
|
||||||
|
{
|
||||||
|
StatusMessage = "Title is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Build a fresh BlogPostDto from the editor buffer on
|
||||||
|
// every Save — we no longer mutate SelectedPost in
|
||||||
|
// place. The previous behaviour copied the buffer
|
||||||
|
// (which was a no-op when SelectedPost was null)
|
||||||
|
// back onto the model and relied on a
|
||||||
|
// [Required] violation to surface the missing
|
||||||
|
// input; the new shape keeps the editor buffer as
|
||||||
|
// the single source of truth for outgoing payloads
|
||||||
|
// and the selected post as a read-only hint for
|
||||||
|
// the update path.
|
||||||
|
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||||
|
{
|
||||||
|
var draft = new BlogPostDto
|
||||||
|
{
|
||||||
|
Title = DraftTitle,
|
||||||
|
Article = DraftArticle ?? string.Empty,
|
||||||
|
DateCreated = DateTime.UtcNow,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
IsPublished = DraftIsPublished
|
||||||
|
};
|
||||||
|
var created = await BlogClient!.CreatePostAsync(draft);
|
||||||
|
if (created is not null)
|
||||||
|
{
|
||||||
|
SelectedPost = created;
|
||||||
|
StatusMessage = $"Created post {created.Id}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var update = new BlogPostDto
|
||||||
|
{
|
||||||
|
Id = SelectedPost.Id,
|
||||||
|
AuthorId = SelectedPost.AuthorId,
|
||||||
|
Photo = SelectedPost.Photo,
|
||||||
|
Title = DraftTitle,
|
||||||
|
Article = DraftArticle ?? string.Empty,
|
||||||
|
DateCreated = SelectedPost.DateCreated,
|
||||||
|
DateModified = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
|
||||||
|
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
await RefreshPostsAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task DeleteAsync()
|
||||||
|
{
|
||||||
|
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "Select an existing post before deleting.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await BlogClient!.DeletePostAsync(SelectedPost.Id);
|
||||||
|
StatusMessage = $"Deleted post {SelectedPost.Id}.";
|
||||||
|
SelectedPost = null;
|
||||||
|
await RefreshPostsAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the publication state of the currently selected
|
||||||
|
/// post. Pushes the new state to
|
||||||
|
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
|
||||||
|
/// locally in <see cref="DraftIsPublished"/> + the
|
||||||
|
/// selected post so the UI updates without a full
|
||||||
|
/// refresh.
|
||||||
|
///
|
||||||
|
/// <para>The toggle is its own action — separate from Save
|
||||||
|
/// — because <c>Publish</c> is not part of the
|
||||||
|
/// <c>BlogPostDto</c> payload. Bundling it into Save
|
||||||
|
/// would require a wire-shape change and a second server
|
||||||
|
/// overload; the dedicated endpoint keeps the wire
|
||||||
|
/// contract clean.</para>
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task TogglePublishAsync()
|
||||||
|
{
|
||||||
|
if (SelectedPost is null || SelectedPost.Id == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
var desired = !DraftIsPublished;
|
||||||
|
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
|
||||||
|
DraftIsPublished = desired;
|
||||||
|
// Mirror into the selected post so a subsequent
|
||||||
|
// RefreshPostsAsync() doesn't blow away the
|
||||||
|
// locally flipped state until the round-trip
|
||||||
|
// re-hydrates it.
|
||||||
|
SelectedPost.IsPublished = desired;
|
||||||
|
StatusMessage = desired
|
||||||
|
? $"Billet {SelectedPost.Id} publié."
|
||||||
|
: $"Billet {SelectedPost.Id} remis en brouillon.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DEV ONLY: open the signature capture page. The production
|
||||||
|
/// entry point is a SignalR push from Yavsc.Org ("devis
|
||||||
|
/// received, sign here"); this command is the dev-time
|
||||||
|
/// shortcut to reach the page without that infrastructure.
|
||||||
|
/// Aligned on the same VM-first navigation pattern as
|
||||||
|
/// <see cref="OpenSettings"/>: the VM resolves the target VM
|
||||||
|
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
|
||||||
|
/// the matching <c>Control</c> at bind time. No
|
||||||
|
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
|
||||||
|
/// access from the view layer.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
internal async Task OpenSignatureDevAsync()
|
||||||
|
{
|
||||||
|
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
||||||
|
public async Task ManageAclAsync()
|
||||||
|
{
|
||||||
|
if (SelectedPost is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Select an existing post before managing ACL.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenCirclesAsync()
|
||||||
|
{
|
||||||
|
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
|
||||||
|
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
|
||||||
|
{
|
||||||
|
var sp = ResolveServices();
|
||||||
|
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
|
||||||
|
var circleClient = sp.GetRequiredService<CircleApiClient>();
|
||||||
|
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// API surface that hits the Yavsc.Blogs deployment at
|
/// API surface that hits the Yavsc.Blogs deployment at
|
||||||
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
|
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
|
||||||
|
|
@ -130,17 +318,18 @@ public partial class MainViewModel : ViewModelBase
|
||||||
|
|
||||||
private void Init(Settings? settings)
|
private void Init(Settings? settings)
|
||||||
{
|
{
|
||||||
SearchText = string.Empty;
|
|
||||||
Posts = new ObservableCollection<BlogPostDto>();
|
Posts = new ObservableCollection<BlogPostDto>();
|
||||||
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
FilteredPosts = new ObservableCollection<BlogPostDto>();
|
||||||
SelectedPost = null;
|
SelectedPost = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
StatusMessage = "Ready";
|
StatusMessage = "Ready";
|
||||||
Settings = settings ?? new Settings();
|
Settings = settings ?? new Settings();
|
||||||
|
SearchText = Settings.SearchText;
|
||||||
WindowTitle = "PostIt";
|
WindowTitle = "PostIt";
|
||||||
DraftTitle = string.Empty;
|
DraftTitle = string.Empty;
|
||||||
DraftArticle = string.Empty;
|
DraftArticle = string.Empty;
|
||||||
DraftIsPublished = false;
|
DraftIsPublished = false;
|
||||||
|
IsLoaded = false;
|
||||||
// Production path: DI injects the canonical Settings singleton
|
// Production path: DI injects the canonical Settings singleton
|
||||||
// and we use it as-is. Test path: tests call this constructor
|
// and we use it as-is. Test path: tests call this constructor
|
||||||
// without a Settings argument; we fall back to a fresh
|
// without a Settings argument; we fall back to a fresh
|
||||||
|
|
@ -152,6 +341,15 @@ public partial class MainViewModel : ViewModelBase
|
||||||
// (thread-safe dispatcher marshalling) so the duplicate
|
// (thread-safe dispatcher marshalling) so the duplicate
|
||||||
// instance is now merely wasteful, not dangerous.
|
// instance is now merely wasteful, not dangerous.
|
||||||
|
|
||||||
|
Settings.PropertyChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(Settings.SearchText))
|
||||||
|
{
|
||||||
|
SearchText = Settings.SearchText;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Save is enabled as soon as the user has typed
|
/// <summary>Save is enabled as soon as the user has typed
|
||||||
|
|
@ -179,7 +377,14 @@ public partial class MainViewModel : ViewModelBase
|
||||||
Init(settings);
|
Init(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
partial void OnSearchTextChanged(string value)
|
||||||
|
{
|
||||||
|
if (Settings is not null && Settings.SearchText != value)
|
||||||
|
{
|
||||||
|
Settings.SearchText = value;
|
||||||
|
}
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedPostChanged(BlogPostDto? value)
|
partial void OnSelectedPostChanged(BlogPostDto? value)
|
||||||
{
|
{
|
||||||
|
|
@ -206,170 +411,6 @@ public partial class MainViewModel : ViewModelBase
|
||||||
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
internal async Task LoadPosts()
|
|
||||||
{
|
|
||||||
await ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
var posts = await BlogClient!.GetPostsAsync();
|
|
||||||
Posts.Clear();
|
|
||||||
foreach (var post in posts.OrderByDescending(p => p.DateModified))
|
|
||||||
{
|
|
||||||
Posts.Add(post);
|
|
||||||
}
|
|
||||||
ApplyFilter();
|
|
||||||
StatusMessage = $"Loaded {Posts.Count} posts.";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
internal void Search() => ApplyFilter();
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
internal async Task Save()
|
|
||||||
{
|
|
||||||
// The button is already disabled when the title is empty
|
|
||||||
// (see CanSave), but the test path (and any programmatic
|
|
||||||
// ICommand.Execute) bypasses CanExecute, so we still
|
|
||||||
// guard here. Better to no-op with a status message
|
|
||||||
// than to send a request the server will reject.
|
|
||||||
if (string.IsNullOrWhiteSpace(DraftTitle))
|
|
||||||
{
|
|
||||||
StatusMessage = "Title is required.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
// Build a fresh BlogPostDto from the editor buffer on
|
|
||||||
// every Save — we no longer mutate SelectedPost in
|
|
||||||
// place. The previous behaviour copied the buffer
|
|
||||||
// (which was a no-op when SelectedPost was null)
|
|
||||||
// back onto the model and relied on a
|
|
||||||
// [Required] violation to surface the missing
|
|
||||||
// input; the new shape keeps the editor buffer as
|
|
||||||
// the single source of truth for outgoing payloads
|
|
||||||
// and the selected post as a read-only hint for
|
|
||||||
// the update path.
|
|
||||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
|
||||||
{
|
|
||||||
var draft = new BlogPostDto
|
|
||||||
{
|
|
||||||
Title = DraftTitle,
|
|
||||||
Article = DraftArticle ?? string.Empty,
|
|
||||||
DateCreated = DateTime.UtcNow,
|
|
||||||
DateModified = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
var created = await BlogClient!.CreatePostAsync(draft);
|
|
||||||
if (created is not null)
|
|
||||||
{
|
|
||||||
SelectedPost = created;
|
|
||||||
StatusMessage = $"Created post {created.Id}.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var update = new BlogPostDto
|
|
||||||
{
|
|
||||||
Id = SelectedPost.Id,
|
|
||||||
AuthorId = SelectedPost.AuthorId,
|
|
||||||
Photo = SelectedPost.Photo,
|
|
||||||
Title = DraftTitle,
|
|
||||||
Article = DraftArticle ?? string.Empty,
|
|
||||||
DateCreated = SelectedPost.DateCreated,
|
|
||||||
DateModified = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update);
|
|
||||||
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
|
||||||
}
|
|
||||||
|
|
||||||
await RefreshPostsAsync();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
internal async Task Delete()
|
|
||||||
{
|
|
||||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
|
||||||
{
|
|
||||||
StatusMessage = "Select an existing post before deleting.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
await BlogClient!.DeletePostAsync(SelectedPost.Id);
|
|
||||||
StatusMessage = $"Deleted post {SelectedPost.Id}.";
|
|
||||||
SelectedPost = null;
|
|
||||||
await RefreshPostsAsync();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Toggle the publication state of the currently selected
|
|
||||||
/// post. Pushes the new state to
|
|
||||||
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
|
|
||||||
/// locally in <see cref="DraftIsPublished"/> + the
|
|
||||||
/// selected post so the UI updates without a full
|
|
||||||
/// refresh.
|
|
||||||
///
|
|
||||||
/// <para>The toggle is its own action — separate from Save
|
|
||||||
/// — because <c>Publish</c> is not part of the
|
|
||||||
/// <c>BlogPostDto</c> payload. Bundling it into Save
|
|
||||||
/// would require a wire-shape change and a second server
|
|
||||||
/// overload; the dedicated endpoint keeps the wire
|
|
||||||
/// contract clean.</para>
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
internal async Task TogglePublish()
|
|
||||||
{
|
|
||||||
if (SelectedPost is null || SelectedPost.Id == 0)
|
|
||||||
{
|
|
||||||
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
var desired = !DraftIsPublished;
|
|
||||||
await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
|
|
||||||
DraftIsPublished = desired;
|
|
||||||
// Mirror into the selected post so a subsequent
|
|
||||||
// RefreshPostsAsync() doesn't blow away the
|
|
||||||
// locally flipped state until the round-trip
|
|
||||||
// re-hydrates it.
|
|
||||||
SelectedPost.IsPublished = desired;
|
|
||||||
StatusMessage = desired
|
|
||||||
? $"Billet {SelectedPost.Id} publié."
|
|
||||||
: $"Billet {SelectedPost.Id} remis en brouillon.";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DEV ONLY: open the signature capture page. The production
|
|
||||||
/// entry point is a SignalR push from Yavsc.Org ("devis
|
|
||||||
/// received, sign here"); this command is the dev-time
|
|
||||||
/// shortcut to reach the page without that infrastructure.
|
|
||||||
/// Aligned on the same VM-first navigation pattern as
|
|
||||||
/// <see cref="OpenSettings"/>: the VM resolves the target VM
|
|
||||||
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
|
|
||||||
/// the matching <c>Control</c> at bind time. No
|
|
||||||
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
|
|
||||||
/// access from the view layer.
|
|
||||||
/// </summary>
|
|
||||||
[RelayCommand]
|
|
||||||
internal async Task OpenSignatureDev()
|
|
||||||
{
|
|
||||||
await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
|
|
||||||
{
|
|
||||||
var sp = ResolveServices();
|
|
||||||
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
|
|
||||||
var circleClient = sp.GetRequiredService<CircleApiClient>();
|
|
||||||
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshPostsAsync()
|
private async Task RefreshPostsAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -426,28 +467,18 @@ public partial class MainViewModel : ViewModelBase
|
||||||
|
|
||||||
private void UpdateCommandStates()
|
private void UpdateCommandStates()
|
||||||
{
|
{
|
||||||
LoadPostsCommand.NotifyCanExecuteChanged();
|
RefreshCommand.NotifyCanExecuteChanged();
|
||||||
SaveCommand.NotifyCanExecuteChanged();
|
SaveCommand.NotifyCanExecuteChanged();
|
||||||
DeleteCommand.NotifyCanExecuteChanged();
|
DeleteCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
internal async Task InitializeAsync()
|
||||||
[RelayCommand(CanExecute = nameof(CanManageAcl))]
|
|
||||||
public async Task ManageAcl()
|
|
||||||
{
|
{
|
||||||
if (SelectedPost is null)
|
if (!IsLoaded)
|
||||||
{
|
{
|
||||||
StatusMessage = "Select an existing post before managing ACL.";
|
await RefreshAsync();
|
||||||
return;
|
IsLoaded = true;
|
||||||
}
|
}
|
||||||
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
public async Task OpenCircles()
|
|
||||||
{
|
|
||||||
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
|
|
||||||
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ public partial class Settings : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string SearchText { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Catch top-level mutations: the four ObservableProperty
|
/// Catch top-level mutations: the four ObservableProperty
|
||||||
/// setters above all funnel through here, and we flip
|
/// setters above all funnel through here, and we flip
|
||||||
|
|
@ -43,6 +46,7 @@ public partial class Settings : ViewModelBase
|
||||||
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
||||||
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
||||||
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
||||||
|
partial void OnSearchTextChanged(string value) => MarkDirty();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authentication can be reassigned wholesale by
|
/// Authentication can be reassigned wholesale by
|
||||||
|
|
@ -295,6 +299,7 @@ public partial class Settings : ViewModelBase
|
||||||
{
|
{
|
||||||
this.Authentication = settings.Authentication;
|
this.Authentication = settings.Authentication;
|
||||||
this.DarkMode = settings.DarkMode;
|
this.DarkMode = settings.DarkMode;
|
||||||
|
this.SearchText = settings.SearchText ?? string.Empty;
|
||||||
if (!(settings.Authentication is null))
|
if (!(settings.Authentication is null))
|
||||||
{
|
{
|
||||||
this.Authentication = new AuthenticationSettings();
|
this.Authentication = new AuthenticationSettings();
|
||||||
|
|
@ -348,6 +353,7 @@ public partial class Settings : ViewModelBase
|
||||||
Scopes = AuthenticationSettings.DefaultScopes
|
Scopes = AuthenticationSettings.DefaultScopes
|
||||||
};
|
};
|
||||||
this.DarkMode = false;
|
this.DarkMode = false;
|
||||||
|
this.SearchText = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,15 @@
|
||||||
VerticalAlignment="Top">
|
VerticalAlignment="Top">
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Command="{Binding LoadPosts}" Content="Load posts" />
|
<Button Command="{Binding RefreshAsync}" Content="Refresh" />
|
||||||
<Button Command="{Binding Search}" Content="Filter" />
|
<Button Command="{Binding SearchAsync}" Content="Filter" />
|
||||||
<Button Command="{Binding Save}" Content="Save" />
|
<Button Command="{Binding SaveAsync}" Content="Save" />
|
||||||
<Button Command="{Binding Delete}" Content="Delete" />
|
<Button Command="{Binding DeleteAsync}" Content="Delete" />
|
||||||
<Button x:Name="ManageAclButton"
|
<Button x:Name="ManageAclButton"
|
||||||
Command="{Binding ManageAcl}"
|
Command="{Binding ManageAclAsync}"
|
||||||
Content="ACL" />
|
Content="ACL" />
|
||||||
<Button x:Name="OpenCirclesButton"
|
<Button x:Name="OpenCirclesButton"
|
||||||
Command="{Binding OpenCircles}"
|
Command="{Binding OpenCirclesAsync}"
|
||||||
Content="Mes cercles" />
|
Content="Mes cercles" />
|
||||||
<!-- Publication toggle: a CheckBox wired to
|
<!-- Publication toggle: a CheckBox wired to
|
||||||
DraftIsPublished. Clicking it fires
|
DraftIsPublished. Clicking it fires
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
and the buffer in sync. -->
|
and the buffer in sync. -->
|
||||||
<CheckBox Content="Publié"
|
<CheckBox Content="Publié"
|
||||||
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
|
||||||
Command="{Binding TogglePublishCommand}"
|
Command="{Binding TogglePublishAsync}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<!--
|
<!--
|
||||||
DEV ONLY: temporary shortcut to open the signature
|
DEV ONLY: temporary shortcut to open the signature
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
MainPage.axaml.cs once the SignalR handler lands.
|
MainPage.axaml.cs once the SignalR handler lands.
|
||||||
-->
|
-->
|
||||||
<Button x:Name="OpenSignatureDevButton"
|
<Button x:Name="OpenSignatureDevButton"
|
||||||
Command="{Binding OpenSignatureDev}"
|
Command="{Binding OpenSignatureDevAsync}"
|
||||||
Content="[DEV] Signature"
|
Content="[DEV] Signature"
|
||||||
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
||||||
|
|
@ -8,4 +10,16 @@ public partial class MainPage : ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnApplyTemplate(e);
|
||||||
|
if (DataContext is ViewModels.MainViewModel vm)
|
||||||
|
{
|
||||||
|
if (!vm.IsLoaded)
|
||||||
|
{
|
||||||
|
vm.RefreshAsync().Wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
|
|
@ -8,4 +9,16 @@ public partial class MainView : UserControl
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ViewModels.MainViewModel vm)
|
||||||
|
{
|
||||||
|
if (!vm.IsLoaded)
|
||||||
|
{
|
||||||
|
vm.RefreshAsync().Wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,5 @@
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
|
||||||
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
|
|
||||||
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,6 @@ namespace Yavsc.Org.Tests
|
||||||
client.Calls.Select(c => c.Kind).ToArray());
|
client.Calls.Select(c => c.Kind).ToArray());
|
||||||
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address);
|
Assert.Equal(_serverFixture.SiteSettings.Owner.EMail, client.LastSentMessage?.To.Mailboxes.First().Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,5 @@
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
|
||||||
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" />
|
||||||
<PackageVersion Include="YamlDotNet" Version="18.0.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using Yavsc.Settings;
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
|
|
||||||
namespace Yavsc.Services
|
namespace Yavsc.Services
|
||||||
|
|
@ -53,45 +54,96 @@ namespace Yavsc.Services
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public Task SendEmailAsync(string email, string subject, string htmlMessage)
|
public Task SendEmailAsync(string email, string subject, string htmlMessage)
|
||||||
{
|
{
|
||||||
return SendEmailAsync("", email, subject, htmlMessage);
|
return SendEmailAsync(null, email, subject, htmlMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static MailboxAddress BuildMailboxAddress(string? displayName, string? rawAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawAddress))
|
||||||
|
{
|
||||||
|
throw new FormatException("Email address is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = rawAddress.Trim();
|
||||||
|
if (candidate.Contains('<') || candidate.Contains('>'))
|
||||||
|
{
|
||||||
|
var emailMatch = Regex.Match(candidate,
|
||||||
|
@"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
|
||||||
|
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (emailMatch.Success)
|
||||||
|
{
|
||||||
|
candidate = emailMatch.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
candidate = candidate.Trim('<', '>', '"', '\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = candidate.Trim('"', '\'', '<', '>', ' ');
|
||||||
|
candidate = candidate.Replace(" ", string.Empty);
|
||||||
|
|
||||||
|
if (!MailboxAddress.TryParse(candidate, out var parsedAddress))
|
||||||
|
{
|
||||||
|
throw new FormatException($"Invalid email address '{rawAddress}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeName = string.IsNullOrWhiteSpace(displayName)
|
||||||
|
? parsedAddress.Name
|
||||||
|
: displayName.Trim();
|
||||||
|
|
||||||
|
return new MailboxAddress(safeName ?? string.Empty, parsedAddress.Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
|
public async Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
|
||||||
{
|
{
|
||||||
logger.LogInformation($"SendEmail for {email} : {subject}");
|
try
|
||||||
MimeMessage msg = new();
|
|
||||||
msg.From.Add(new MailboxAddress(siteSettings.Owner.Name,
|
|
||||||
siteSettings.Owner.EMail));
|
|
||||||
msg.To.Add(new MailboxAddress(name, email));
|
|
||||||
TextPart text;
|
|
||||||
msg.Body = text = new TextPart("html")
|
|
||||||
{
|
{
|
||||||
Text = $"<html><body>{htmlMessage}</body></html>"
|
logger.LogInformation($"SendEmail for {email} : {subject}");
|
||||||
};
|
MimeMessage msg = new();
|
||||||
|
msg.From.Add(BuildMailboxAddress(siteSettings.Owner.Name, siteSettings.Owner.EMail));
|
||||||
msg.Subject = subject;
|
msg.To.Add(BuildMailboxAddress(name, email));
|
||||||
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
|
TextPart text;
|
||||||
siteSettings.Authority
|
msg.Body = text = new TextPart("html")
|
||||||
);
|
|
||||||
using ISmtpClient sc = _smtpClientFactory.CreateClient();
|
|
||||||
{
|
|
||||||
sc.Timeout = 30000;
|
|
||||||
sc.Connect(
|
|
||||||
smtpSettings.Host,
|
|
||||||
smtpSettings.Port,
|
|
||||||
SecureSocketOptions.Auto
|
|
||||||
);
|
|
||||||
|
|
||||||
if (smtpSettings.UserName != null)
|
|
||||||
{
|
{
|
||||||
sc.Authenticate(smtpSettings.UserName, smtpSettings.Password);
|
Text = $"<html><body>{htmlMessage}</body></html>"
|
||||||
}
|
};
|
||||||
|
|
||||||
await sc.SendAsync(msg);
|
msg.Subject = subject;
|
||||||
logger.LogInformation($"Sent : {msg.MessageId}");
|
msg.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(
|
||||||
sc.Disconnect(true);
|
siteSettings.Authority
|
||||||
|
);
|
||||||
|
using ISmtpClient sc = _smtpClientFactory.CreateClient();
|
||||||
|
{
|
||||||
|
sc.Timeout = 30000;
|
||||||
|
sc.Connect(
|
||||||
|
smtpSettings.Host,
|
||||||
|
smtpSettings.Port,
|
||||||
|
SecureSocketOptions.Auto
|
||||||
|
);
|
||||||
|
|
||||||
|
if (smtpSettings.UserName != null)
|
||||||
|
{
|
||||||
|
sc.Authenticate(smtpSettings.UserName, smtpSettings.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sc.SendAsync(msg);
|
||||||
|
logger.LogInformation($"Sent : {msg.MessageId}");
|
||||||
|
sc.Disconnect(true);
|
||||||
|
}
|
||||||
|
return msg.MessageId;
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Refusing to send email because the recipient or sender address is malformed. To={To}, From={From}", email, siteSettings.Owner.EMail);
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send email. To={To}, Subject={Subject}", email, subject);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
return msg.MessageId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SendEmailFromCriteria(string Criteria)
|
public void SendEmailFromCriteria(string Criteria)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue