setup the avatar
This commit is contained in:
parent
b820348558
commit
343538a7ea
14 changed files with 300 additions and 92 deletions
|
|
@ -67,7 +67,7 @@ public class BearerScopeTests
|
||||||
Scopes = userScopes,
|
Scopes = userScopes,
|
||||||
RedirectUri = "postit://callback",
|
RedirectUri = "postit://callback",
|
||||||
},
|
},
|
||||||
BusinessApiUrl = "https://example.invalid/api/v1/",
|
ApiUrl = "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.BusinessApiUrl), path);
|
var absolute = new Uri(new Uri(Settings.ApiUrl), 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.BusinessApiUrl), path);
|
var absolute = new Uri(new Uri(Settings.ApiUrl), 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.BusinessApiUrl);
|
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
|
||||||
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
|
var circleClient = new CircleApiClient(api, settings.ApiUrl);
|
||||||
|
|
||||||
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.BusinessApiUrl = flip
|
settings.ApiUrl = 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.BusinessApiUrl);
|
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||||
|
|
||||||
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" },
|
||||||
},
|
},
|
||||||
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
|
ApiUrl = "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" }
|
||||||
},
|
},
|
||||||
BusinessApiUrl = apiBaseUrl
|
ApiUrl = 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.BusinessApiUrl);
|
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||||
|
|
||||||
// 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.BusinessApiUrl);
|
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
|
||||||
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
|
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
||||||
var userDirectory = new UserDirectory(userSearchClient);
|
var userDirectory = new UserDirectory(userSearchClient);
|
||||||
|
|
||||||
// Vues
|
// Vues
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -351,6 +352,56 @@ 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LogoutAsync()
|
public async Task LogoutAsync()
|
||||||
{
|
{
|
||||||
_store.Clear();
|
_store.Clear();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -21,7 +22,6 @@ 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 BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
public partial string ApiUrl { 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 OnBusinessApiUrlChanged(string value) => MarkDirty();
|
partial void OnApiUrlChanged(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.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
|
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
|
||||||
? settings.BusinessApiUrl
|
? settings.ApiUrl
|
||||||
: this.BusinessApiUrl;
|
: this.ApiUrl;
|
||||||
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.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
|
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
|
||||||
this.SearchText = string.Empty;
|
this.SearchText = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,11 @@
|
||||||
<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" />
|
||||||
|
|
@ -60,14 +58,12 @@
|
||||||
<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">
|
||||||
|
|
@ -78,39 +74,14 @@
|
||||||
</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,13 +29,11 @@
|
||||||
<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" />
|
||||||
|
|
@ -57,13 +55,11 @@
|
||||||
<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,13 +39,18 @@
|
||||||
<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="Business API URL"/>
|
<TextBlock Grid.Row="8" Text="API URL"/>
|
||||||
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
Text="{Binding ApiUrl, 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" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button 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"
|
||||||
|
|
@ -54,10 +59,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 Grid.Row="12" Content="Sauver"
|
<Button Content="Sauver"
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Command="{Binding Save}"
|
Command="{Binding Save}"
|
||||||
IsEnabled="{Binding IsDirty}"/>
|
IsEnabled="{Binding IsDirty}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
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
|
||||||
|
|
@ -9,4 +15,56 @@ public partial class SettingsPage: ContentPage
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
if (topLevel is null)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = await file.OpenReadAsync();
|
||||||
|
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||||
|
await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.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,6 +2,8 @@
|
||||||
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;
|
||||||
|
|
@ -14,6 +16,17 @@ 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;
|
||||||
|
|
||||||
|
|
@ -70,14 +83,93 @@ 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.Form.Files.Count!=1)
|
if (!Request.HasFormContentType)
|
||||||
return new BadRequestResult();
|
{
|
||||||
if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
|
return BadRequest(new
|
||||||
return new BadRequestResult();
|
{
|
||||||
|
status = "invalid_request",
|
||||||
|
message = "A multipart/form-data request is required.",
|
||||||
|
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
||||||
|
maxFileSizeBytes = MaxAvatarSizeBytes,
|
||||||
|
outputFormat = "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var info = user.ReceiveAvatar(Request.Form.Files[0]);
|
if (Request.Form.Files.Count != 1)
|
||||||
|
{
|
||||||
|
return BadRequest(new
|
||||||
|
{
|
||||||
|
status = "invalid_file_count",
|
||||||
|
message = "Exactly one file is required.",
|
||||||
|
acceptedMimeTypes = AcceptedAvatarMimeTypes,
|
||||||
|
maxFileSizeBytes = MaxAvatarSizeBytes,
|
||||||
|
outputFormat = "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var avatarFile = 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(info);
|
return Ok(new
|
||||||
|
{
|
||||||
|
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,6 +4,9 @@
|
||||||
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";
|
||||||
|
|
@ -21,31 +24,63 @@
|
||||||
(() => {
|
(() => {
|
||||||
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;
|
||||||
Dropzone.options.postavatar = {
|
|
||||||
maxFilesize: 2, // MB (an avatar)
|
function setStatus(message, isError) {
|
||||||
autoProcessQueue: true,
|
if (!statusNode) return;
|
||||||
accept: function(file, done) {
|
statusNode.textContent = message || '';
|
||||||
if (file.name == "justinbieber.jpg") {
|
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
|
||||||
done("Naha, you don't.");
|
|
||||||
}
|
}
|
||||||
else { done(); }
|
|
||||||
},
|
Dropzone.options.postavatar = {
|
||||||
|
maxFilesize: @maxAvatarSizeMb, // MB
|
||||||
|
acceptedFiles: "@acceptedAvatarMimeTypes",
|
||||||
|
maxFiles: 1,
|
||||||
|
addRemoveLinks: true,
|
||||||
|
autoProcessQueue: true,
|
||||||
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