Compare commits
No commits in common. "2c6ab134f5c0264310e460b6d56d6773fccf841c" and "b82034855804e8bf64cb4e21f28e9d2e1a260d71" have entirely different histories.
2c6ab134f5
...
b820348558
14 changed files with 92 additions and 378 deletions
|
|
@ -67,7 +67,7 @@ public class BearerScopeTests
|
||||||
Scopes = userScopes,
|
Scopes = userScopes,
|
||||||
RedirectUri = "postit://callback",
|
RedirectUri = "postit://callback",
|
||||||
},
|
},
|
||||||
ApiUrl = "https://example.invalid/api/v1/",
|
BusinessApiUrl = "https://example.invalid/api/v1/",
|
||||||
};
|
};
|
||||||
|
|
||||||
var tokensPath = Path.Combine(
|
var tokensPath = Path.Combine(
|
||||||
|
|
@ -266,7 +266,7 @@ public class BearerScopeTests
|
||||||
// private HttpClient is independent, so we resolve the
|
// private HttpClient is independent, so we resolve the
|
||||||
// absolute URI ourselves from Settings.BusinessApiUrl —
|
// absolute URI ourselves from Settings.BusinessApiUrl —
|
||||||
// the same URL BlogApiClient would have set as BaseAddress.
|
// the same URL BlogApiClient would have set as BaseAddress.
|
||||||
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
|
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||||
using var req = new HttpRequestMessage(method, absolute);
|
using var req = new HttpRequestMessage(method, absolute);
|
||||||
req.Headers.Authorization =
|
req.Headers.Authorization =
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ public class PostAclDialogTests
|
||||||
HttpMethod method, string path, object? body = null,
|
HttpMethod method, string path, object? body = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
|
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||||
using var req = new HttpRequestMessage(method, absolute);
|
using var req = new HttpRequestMessage(method, absolute);
|
||||||
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
@ -123,8 +123,8 @@ public class PostAclDialogTests
|
||||||
var handler = new CountingHttpHandler();
|
var handler = new CountingHttpHandler();
|
||||||
var settings = new Settings();
|
var settings = new Settings();
|
||||||
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
|
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
|
||||||
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
|
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
|
||||||
var circleClient = new CircleApiClient(api, settings.ApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton(settings);
|
services.AddSingleton(settings);
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ public class SettingsLoadTests
|
||||||
settings.Authentication.RedirectUri =
|
settings.Authentication.RedirectUri =
|
||||||
global::AuthenticationSettings.DesktopRedirectUri;
|
global::AuthenticationSettings.DesktopRedirectUri;
|
||||||
|
|
||||||
settings.ApiUrl = flip
|
settings.BusinessApiUrl = flip
|
||||||
? "https://a.example.test/api/v1/"
|
? "https://a.example.test/api/v1/"
|
||||||
: "https://b.example.test/api/v1/";
|
: "https://b.example.test/api/v1/";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ public class YavscApiClientTests
|
||||||
// calls CallAsync("posts", ...) directly (bypassing
|
// calls CallAsync("posts", ...) directly (bypassing
|
||||||
// BlogApiClient, which is the only thing that would set
|
// BlogApiClient, which is the only thing that would set
|
||||||
// it in production). Mirror prod here.
|
// it in production). Mirror prod here.
|
||||||
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
|
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
||||||
|
|
||||||
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
||||||
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
||||||
|
|
@ -118,7 +118,7 @@ public class YavscApiClientTests
|
||||||
RedirectUri = "postit://callback",
|
RedirectUri = "postit://callback",
|
||||||
Scopes = new[] { "openid" },
|
Scopes = new[] { "openid" },
|
||||||
},
|
},
|
||||||
ApiUrl = "https://127.0.0.1:5003/api/v1",
|
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
|
||||||
};
|
};
|
||||||
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
||||||
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
||||||
|
|
@ -162,7 +162,7 @@ public class YavscApiClientTests
|
||||||
RedirectUri = authority.LoopbackRedirectUri,
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
Scopes = new[] { "openid", "profile", "blog" }
|
Scopes = new[] { "openid", "profile", "blog" }
|
||||||
},
|
},
|
||||||
ApiUrl = apiBaseUrl
|
BusinessApiUrl = apiBaseUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
||||||
|
|
@ -175,7 +175,7 @@ public class YavscApiClientTests
|
||||||
// directly (bypassing BlogApiClient) rely on the same
|
// directly (bypassing BlogApiClient) rely on the same
|
||||||
// BaseAddress the production chain sets in BlogApiClient's
|
// BaseAddress the production chain sets in BlogApiClient's
|
||||||
// ctor. Mirror that here so "posts" resolves to the stub.
|
// ctor. Mirror that here so "posts" resolves to the stub.
|
||||||
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
||||||
|
|
||||||
// Force the API client to use the test browser by routing the
|
// Force the API client to use the test browser by routing the
|
||||||
// LoginInteractiveAsync call through a small wrapper.
|
// LoginInteractiveAsync call through a small wrapper.
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers
|
||||||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||||
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
|
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
||||||
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
// Vues
|
// Vues
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
|
@ -352,77 +351,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
_store.Save(_tokens);
|
_store.Save(_tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Upload a user avatar to the Yavsc API. The server expects a
|
|
||||||
/// single multipart file named <c>file</c> and validates the image
|
|
||||||
/// content type before persisting it.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string> SetAvatarAsync(
|
|
||||||
Stream imageStream,
|
|
||||||
string fileName,
|
|
||||||
string? contentType = null,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
if (imageStream is null)
|
|
||||||
throw new ArgumentNullException(nameof(imageStream));
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
|
||||||
throw new ArgumentException("A file name is required.", nameof(fileName));
|
|
||||||
|
|
||||||
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
|
|
||||||
|
|
||||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var attemptUpload = async () =>
|
|
||||||
{
|
|
||||||
if (imageStream.CanSeek)
|
|
||||||
imageStream.Position = 0;
|
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
|
||||||
using var fileContent = new StreamContent(imageStream);
|
|
||||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
|
||||||
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
|
|
||||||
content.Add(fileContent, "file", fileName);
|
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
|
||||||
{
|
|
||||||
Content = content,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await attemptUpload().ConfigureAwait(false);
|
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
response.Dispose();
|
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
|
||||||
response = await attemptUpload().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
||||||
if (string.IsNullOrWhiteSpace(payload))
|
|
||||||
return "Avatar mis à jour.";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var json = JsonDocument.Parse(payload);
|
|
||||||
if (json.RootElement.TryGetProperty("message", out var msgEl))
|
|
||||||
{
|
|
||||||
var message = msgEl.GetString();
|
|
||||||
if (!string.IsNullOrWhiteSpace(message))
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Keep a user-friendly fallback when the API payload is not JSON.
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Avatar mis à jour.";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
_store.Clear();
|
_store.Clear();
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Yavsc.Abstract.Workflow;
|
using Yavsc.Abstract.Workflow;
|
||||||
using Yavsc.Api.Client;
|
using Yavsc.Api.Client;
|
||||||
using Yavsc.Models.Billing;
|
|
||||||
using Yavsc.Models.Haircut;
|
using Yavsc.Models.Haircut;
|
||||||
namespace PostIt.ViewModels.Commands;
|
namespace PostIt.ViewModels.Commands;
|
||||||
|
|
||||||
|
|
@ -22,6 +21,7 @@ public partial class BrushViewModel : RdvViewModel
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial HairPrestationDto? SelectedPrestation { get; set; }
|
public partial HairPrestationDto? SelectedPrestation { get; set; }
|
||||||
|
|
||||||
|
|
||||||
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
|
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
|
||||||
: base(activity, performer, form, billingClient)
|
: base(activity, performer, form, billingClient)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
|
||||||
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string SearchText { get; set; } = string.Empty;
|
public partial string SearchText { get; set; } = string.Empty;
|
||||||
|
|
@ -45,7 +45,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 OnApiUrlChanged(string value) => MarkDirty();
|
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
||||||
partial void OnSearchTextChanged(string value) => MarkDirty();
|
partial void OnSearchTextChanged(string value) => MarkDirty();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
|
||||||
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
||||||
? settings.BlogsApiUrl
|
? settings.BlogsApiUrl
|
||||||
: legacyApiUrl ?? this.BlogsApiUrl;
|
: legacyApiUrl ?? this.BlogsApiUrl;
|
||||||
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
|
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
|
||||||
? settings.ApiUrl
|
? settings.BusinessApiUrl
|
||||||
: this.ApiUrl;
|
: this.BusinessApiUrl;
|
||||||
this.SearchText = settings.SearchText ?? string.Empty;
|
this.SearchText = settings.SearchText ?? string.Empty;
|
||||||
if (!(settings.Authentication is null))
|
if (!(settings.Authentication is null))
|
||||||
{
|
{
|
||||||
|
|
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
|
||||||
};
|
};
|
||||||
this.DarkMode = false;
|
this.DarkMode = false;
|
||||||
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
|
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
|
||||||
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
|
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
|
||||||
this.SearchText = string.Empty;
|
this.SearchText = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,13 @@
|
||||||
<TextBlock Grid.Row="5"
|
<TextBlock Grid.Row="5"
|
||||||
Text="Motif"
|
Text="Motif"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,0,12,8" />
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsReason}" />
|
||||||
<TextBox Grid.Row="5"
|
<TextBox Grid.Row="5"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{Binding Reason, Mode=TwoWay}"
|
Text="{Binding Reason, Mode=TwoWay}"
|
||||||
Margin="0,0,0,8" />
|
Margin="0,0,0,8"
|
||||||
|
IsVisible="{Binding ShowsReason}" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
@ -58,12 +60,14 @@
|
||||||
<TextBlock Grid.Row="10"
|
<TextBlock Grid.Row="10"
|
||||||
Text="Prestation"
|
Text="Prestation"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,0,12,8" />
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsSinglePrestation}" />
|
||||||
<ComboBox Grid.Row="10"
|
<ComboBox Grid.Row="10"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
ItemsSource="{Binding AvailablePrestations}"
|
ItemsSource="{Binding AvailablePrestations}"
|
||||||
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
|
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8"
|
||||||
|
IsVisible="{Binding ShowsSinglePrestation}">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
|
|
@ -74,14 +78,39 @@
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="11"
|
||||||
|
Text="Prestations"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsMultiplePrestations}" />
|
||||||
|
<ListBox Grid.Row="11"
|
||||||
|
Grid.Column="1"
|
||||||
|
ItemsSource="{Binding MultiPrestations}"
|
||||||
|
IsVisible="{Binding ShowsMultiplePrestations}"
|
||||||
|
MaxHeight="180"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</CheckBox>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
<TextBlock Grid.Row="12"
|
<TextBlock Grid.Row="12"
|
||||||
Text="Informations"
|
Text="Informations"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,0,12,8" />
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||||
<TextBox Grid.Row="12"
|
<TextBox Grid.Row="12"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||||
Margin="0,0,0,8" />
|
Margin="0,0,0,8"
|
||||||
|
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||||
|
|
||||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||||
Content="Je consens à la création de cette commande"
|
Content="Je consens à la création de cette commande"
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,13 @@
|
||||||
<TextBlock Grid.Row="5"
|
<TextBlock Grid.Row="5"
|
||||||
Text="Motif"
|
Text="Motif"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,0,12,8" />
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsReason}" />
|
||||||
<TextBox Grid.Row="5"
|
<TextBox Grid.Row="5"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{Binding Reason, Mode=TwoWay}"
|
Text="{Binding Reason, Mode=TwoWay}"
|
||||||
Margin="0,0,0,8" />
|
Margin="0,0,0,8"
|
||||||
|
IsVisible="{Binding ShowsReason}" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||||
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||||
|
|
@ -55,11 +57,13 @@
|
||||||
<TextBlock Grid.Row="12"
|
<TextBlock Grid.Row="12"
|
||||||
Text="Informations"
|
Text="Informations"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="0,0,12,8" />
|
Margin="0,0,12,8"
|
||||||
|
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||||
<TextBox Grid.Row="12"
|
<TextBox Grid.Row="12"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||||
Margin="0,0,0,8" />
|
Margin="0,0,0,8"
|
||||||
|
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||||
|
|
||||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||||
Content="Je consens à la création de cette commande"
|
Content="Je consens à la création de cette commande"
|
||||||
|
|
|
||||||
|
|
@ -39,28 +39,13 @@
|
||||||
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
||||||
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="8" Text="API URL"/>
|
<TextBlock Grid.Row="8" Text="Business API URL"/>
|
||||||
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||||
Text="{Binding ApiUrl, Mode=TwoWay}"/>
|
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||||
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="12" Spacing="8">
|
|
||||||
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
|
|
||||||
Opacity="0.8"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
|
|
||||||
<TextBlock x:Name="AvatarUploadStatusText"
|
|
||||||
Text=""
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
|
||||||
<Button x:Name="ChooseAvatarButton"
|
|
||||||
Content="Choisir l'avatar"
|
|
||||||
Click="ChooseAvatar_Click"
|
|
||||||
Margin="0,0,8,0"/>
|
|
||||||
|
|
||||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||||
VM. The source generator emits an ICommand property whose
|
VM. The source generator emits an ICommand property whose
|
||||||
name matches the source method exactly (no "Command"
|
name matches the source method exactly (no "Command"
|
||||||
|
|
@ -69,11 +54,10 @@
|
||||||
de binding pour [RelayCommand]" for the full rationale.
|
de binding pour [RelayCommand]" for the full rationale.
|
||||||
IsEnabled tracks IsDirty so the button auto-disables
|
IsEnabled tracks IsDirty so the button auto-disables
|
||||||
when there's nothing to persist. -->
|
when there's nothing to persist. -->
|
||||||
<Button Content="Sauver"
|
<Button Grid.Row="12" Content="Sauver"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
Command="{Binding Save}"
|
Command="{Binding Save}"
|
||||||
IsEnabled="{Binding IsDirty}"/>
|
IsEnabled="{Binding IsDirty}"/>
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,12 @@
|
||||||
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Platform.Storage;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using PostIt.Services;
|
|
||||||
|
|
||||||
namespace PostIt.Views;
|
namespace PostIt.Views;
|
||||||
public partial class SettingsPage: ContentPage
|
public partial class SettingsPage: ContentPage
|
||||||
{
|
{
|
||||||
private const int MaxAvatarSizeMegabytes = 2;
|
|
||||||
private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
|
|
||||||
|
|
||||||
private int _avatarStatusVersion;
|
|
||||||
|
|
||||||
public SettingsPage()
|
public SettingsPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
|
|
||||||
ChooseAvatarButton.IsEnabled = false;
|
|
||||||
SetAvatarStatus("Sélection d'un fichier avatar...", Brushes.Gray);
|
|
||||||
|
|
||||||
var topLevel = TopLevel.GetTopLevel(this);
|
|
||||||
if (topLevel is null)
|
|
||||||
{
|
|
||||||
SetAvatarStatus("Impossible d'accéder à la fenêtre active.", Brushes.IndianRed);
|
|
||||||
ChooseAvatarButton.IsEnabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
|
||||||
{
|
|
||||||
Title = "Choisir un avatar",
|
|
||||||
AllowMultiple = false,
|
|
||||||
FileTypeFilter = new[]
|
|
||||||
{
|
|
||||||
new FilePickerFileType("Images")
|
|
||||||
{
|
|
||||||
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var file = files.FirstOrDefault();
|
|
||||||
if (file is null)
|
|
||||||
{
|
|
||||||
SetAvatarStatus("Upload annulé.", Brushes.Gray);
|
|
||||||
ChooseAvatarButton.IsEnabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
SetAvatarStatus("Upload avatar en cours...", Brushes.Gray);
|
|
||||||
|
|
||||||
await using var stream = await file.OpenReadAsync();
|
|
||||||
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
|
|
||||||
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
|
|
||||||
SetAvatarStatus(string.IsNullOrWhiteSpace(message)
|
|
||||||
? "Avatar mis à jour."
|
|
||||||
: message, Brushes.ForestGreen);
|
|
||||||
|
|
||||||
_ = ClearSuccessStatusLaterAsync(statusVersion);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
SetAvatarStatus($"Échec upload avatar: {ex.Message}", Brushes.IndianRed);
|
|
||||||
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ChooseAvatarButton.IsEnabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ClearSuccessStatusLaterAsync(int statusVersion)
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
|
|
||||||
if (statusVersion != _avatarStatusVersion)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (AvatarUploadStatusText.Foreground == Brushes.ForestGreen)
|
|
||||||
{
|
|
||||||
SetAvatarStatus($"Avatar prêt. Formats supportés: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", Brushes.Gray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetAvatarStatus(string message, IBrush color)
|
|
||||||
{
|
|
||||||
AvatarUploadStatusText.Foreground = color;
|
|
||||||
AvatarUploadStatusText.Text = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetMimeType(string fileName)
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
|
||||||
return ext switch
|
|
||||||
{
|
|
||||||
".png" => "image/png",
|
|
||||||
".jpg" or ".jpeg" => "image/jpeg",
|
|
||||||
".webp" => "image/webp",
|
|
||||||
".gif" => "image/gif",
|
|
||||||
_ => "application/octet-stream"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using ImageMagick;
|
|
||||||
|
|
||||||
using Yavsc.Models;
|
using Yavsc.Models;
|
||||||
using Yavsc.Api.Helpers;
|
using Yavsc.Api.Helpers;
|
||||||
|
|
@ -16,17 +14,6 @@ namespace Yavsc.WebApi.Controllers
|
||||||
[Authorize("ApiScope")]
|
[Authorize("ApiScope")]
|
||||||
public class ApiAccountController : Controller
|
public class ApiAccountController : Controller
|
||||||
{
|
{
|
||||||
private const long MaxAvatarSizeBytes = 2 * 1024 * 1024;
|
|
||||||
private static readonly string[] AcceptedAvatarMimeTypes =
|
|
||||||
[
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/webp",
|
|
||||||
"image/gif",
|
|
||||||
"image/bmp",
|
|
||||||
"image/tiff",
|
|
||||||
];
|
|
||||||
|
|
||||||
readonly ApplicationDbContext _dbContext;
|
readonly ApplicationDbContext _dbContext;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
|
@ -83,93 +70,14 @@ namespace Yavsc.WebApi.Controllers
|
||||||
public async Task<IActionResult> SetAvatar()
|
public async Task<IActionResult> SetAvatar()
|
||||||
{
|
{
|
||||||
var user = await GetUserData(User.GetUserId());
|
var user = await GetUserData(User.GetUserId());
|
||||||
if (!Request.HasFormContentType)
|
|
||||||
{
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
status = "invalid_request",
|
|
||||||
message = "A multipart/form-data request is required.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Request.Form.Files.Count!=1)
|
if (Request.Form.Files.Count!=1)
|
||||||
{
|
return new BadRequestResult();
|
||||||
return BadRequest(new
|
if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
|
||||||
{
|
return new BadRequestResult();
|
||||||
status = "invalid_file_count",
|
|
||||||
message = "Exactly one file is required.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var avatarFile = Request.Form.Files[0];
|
var info = user.ReceiveAvatar(Request.Form.Files[0]);
|
||||||
if (avatarFile.Length <= 0)
|
|
||||||
{
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
status = "empty_file",
|
|
||||||
message = "The uploaded file is empty.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (avatarFile.Length > MaxAvatarSizeBytes)
|
|
||||||
{
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
status = "file_too_large",
|
|
||||||
message = "Avatar is too large.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!AcceptedAvatarMimeTypes.Any(m => string.Equals(m, avatarFile.ContentType, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new
|
|
||||||
{
|
|
||||||
status = "unsupported_media_type",
|
|
||||||
message = "Unsupported image format.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var info = user.ReceiveAvatar(avatarFile);
|
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
return Ok(new
|
return Ok(info);
|
||||||
{
|
|
||||||
status = "uploaded",
|
|
||||||
message = "Avatar uploaded successfully.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
avatar = info,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (MagickException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Avatar upload failed: invalid image data for user {UserId}", user.Id);
|
|
||||||
return BadRequest(new
|
|
||||||
{
|
|
||||||
status = "invalid_image_data",
|
|
||||||
message = "Image content could not be decoded.",
|
|
||||||
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
|
||||||
maxFileSizeBytes = MaxAvatarSizeBytes,
|
|
||||||
outputFormat = "image/png",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("identity")]
|
[HttpGet("identity")]
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@
|
||||||
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
|
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: SiteSettings.Value.ApiUrl.TrimEnd('/');
|
: SiteSettings.Value.ApiUrl.TrimEnd('/');
|
||||||
const int maxAvatarSizeMb = 2;
|
|
||||||
var acceptedAvatarFormats = new[] { "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff" };
|
|
||||||
var acceptedAvatarMimeTypes = "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff";
|
|
||||||
var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl)
|
var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl)
|
||||||
? "/api/v1/account/set-avatar"
|
? "/api/v1/account/set-avatar"
|
||||||
: $"{apiBaseUrl}/api/v1/account/set-avatar";
|
: $"{apiBaseUrl}/api/v1/account/set-avatar";
|
||||||
|
|
@ -24,63 +21,31 @@
|
||||||
(() => {
|
(() => {
|
||||||
const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken));
|
const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken));
|
||||||
const uploadUrlBase = "@setAvatarUrl";
|
const uploadUrlBase = "@setAvatarUrl";
|
||||||
const statusNode = document.getElementById('avatar-upload-status');
|
|
||||||
const uploadUrl = bearer
|
const uploadUrl = bearer
|
||||||
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
|
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
|
||||||
: uploadUrlBase;
|
: uploadUrlBase;
|
||||||
|
|
||||||
function setStatus(message, isError) {
|
|
||||||
if (!statusNode) return;
|
|
||||||
statusNode.textContent = message || '';
|
|
||||||
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
|
|
||||||
}
|
|
||||||
|
|
||||||
Dropzone.options.postavatar = {
|
Dropzone.options.postavatar = {
|
||||||
maxFilesize: @maxAvatarSizeMb, // MB
|
maxFilesize: 2, // MB (an avatar)
|
||||||
acceptedFiles: "@acceptedAvatarMimeTypes",
|
|
||||||
maxFiles: 1,
|
|
||||||
addRemoveLinks: true,
|
|
||||||
autoProcessQueue: true,
|
autoProcessQueue: true,
|
||||||
|
accept: function(file, done) {
|
||||||
|
if (file.name == "justinbieber.jpg") {
|
||||||
|
done("Naha, you don't.");
|
||||||
|
}
|
||||||
|
else { done(); }
|
||||||
|
},
|
||||||
url: uploadUrl,
|
url: uploadUrl,
|
||||||
init: function() {
|
init: function() {
|
||||||
this.on('sending', function(file, xhr) {
|
this.on('sending', function(file, xhr) {
|
||||||
setStatus('Upload en cours...', false);
|
|
||||||
if (bearer) {
|
if (bearer) {
|
||||||
xhr.setRequestHeader('Authorization', 'Bearer ' + bearer);
|
xhr.setRequestHeader('Authorization', 'Bearer ' + bearer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.on('success', function(file, response) {
|
|
||||||
const msg = response && response.message
|
|
||||||
? response.message
|
|
||||||
: 'Avatar mis a jour.';
|
|
||||||
setStatus(msg + ' Format de sortie: PNG.', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.on('error', function(file, errorMessage, xhr) {
|
|
||||||
let message = 'Echec de l\'upload avatar.';
|
|
||||||
if (xhr && xhr.responseText) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(xhr.responseText);
|
|
||||||
if (payload && payload.message) {
|
|
||||||
message = payload.message;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
}
|
|
||||||
} else if (typeof errorMessage === 'string' && errorMessage.length > 0) {
|
|
||||||
message = errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(message + ' Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille max: @maxAvatarSizeMb MB.', true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
<img src="@previewAvatarSrc">
|
<img src="@previewAvatarSrc">
|
||||||
<p>Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille maximale: @maxAvatarSizeMb MB. L'image finale est en PNG.</p>
|
|
||||||
<p id="avatar-upload-status" aria-live="polite"></p>
|
|
||||||
|
|
||||||
<form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data">
|
<form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data">
|
||||||
<div class="fallback">
|
<div class="fallback">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue