An activity interface

This commit is contained in:
Paul Schneider 2026-08-30 23:55:09 +01:00
commit 6e2fec1620
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
30 changed files with 847 additions and 10 deletions

24
contrib/.env-sample Normal file
View file

@ -0,0 +1,24 @@
# parametres de déploiement au Makefile
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=yavsc
POSTGRES_USER=yavsc
POSTGRES_PASSWORD=<your-password-here>
HTTP_HOST=localhost
Org_PORT=83
Blogs_PORT=85
Api_PORT=87
PostIt_CLIENT_ID=postit
ASPNETCORE_Smtp__Host="mercure.pschneider.fr"
ASPNETCORE_Smtp__Port=465
ASPNETCORE_Smtp__SenderName="Paul Schneider"
ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr"
ASPNETCORE_Smtp__UserName="paul"
ASPNETCORE_Smtp__Password="<your-smtp-password-here>"
DESTDIR=/srv/www/yavsc

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Org Blogs APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -9,9 +9,11 @@ generated/:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \ BASEAPPDIR="$(BASEAPPDIR)" \
@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs copy-services: copy-service-Org copy-service-Blogs copy-service-Api
copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-binaries: build_publish_Org build_publish_Blogs stop-services copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \ echo "$${project} -> $${LCAPI}" ; \
@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services
build_publish_%: clean_publish_dir_% build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%: clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
@ -84,6 +89,7 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@ -92,4 +98,3 @@ showConfig:
clean: clean:
@rm -rf generated @rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

@ -0,0 +1,37 @@
[Unit]
Description=yavsc-Blogs
After=syslog.target
After=network.target
Wants=postgresql.service
After=postgresql.service
[Service]
RestartSec=5s
Type=simple
User=yavsc
Group=yavsc
WorkingDirectory=/srv/www/yavsc
ExecStart=/srv/www/yavsc/Yavsc.Blogs
Restart=always
Environment="HOME="
Environment="ANTHROPIC_API_KEY=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"
Environment="ANTHROPIC_MAX_TOKENS=255"
Environment="ASPNETCORE_Environment="
Environment="ASPNETCORE_Kestrel__Endpoints__Http=http://localhost:Blogs_"
Environment="ASPNETCORE_ConnectionStrings__YavscConnection=Server=localhost;Port=5432;Database=yavsc;Username=yavsc;Password=4T/X+fOnE;"
Environment="ASPNETCORE_Smtp__Host=\"mercure.pschneider.f\""
Environment="ASPNETCORE_Smtp__Port=465"
Environment="ASPNETCORE_Smtp__SenderName=\"Paul Schneider\""
Environment="ASPNETCORE_Smtp__SenderEmail=\"paul@pschneider.fr\""
Environment="ASPNETCORE_Smtp__UserName=\"paul\""
Environment="ASPNETCORE_Smtp__Password=\"j\0Dsn5=t\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=yavscBlogs
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,113 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ActivitiesPageViewModelTests
{
[Fact]
public async Task ActivityApiClient_uses_business_absolute_paths()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
await client.GetPerformersAsync("brush-pro", TestContext.Current.CancellationToken);
Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]);
Assert.Equal("https://business.example/api/v1/activity/brush-pro/performers", api.Paths[1]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var vm = new ActivitiesPageViewModel(client);
await vm.RefreshAsync();
Assert.Equal("brush", vm.SelectedActivity?.Code);
Assert.Single(vm.Specializations);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
await vm.ShowSpecializationAsync(vm.Specializations[0]);
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Bob", vm.Performers[0].UserName);
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
await vm.ShowSpecializationAsync(null);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
}
private sealed class StubActivityApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<ActivityBrowseItemDto>))
{
var activities = new List<ActivityBrowseItemDto>
{
new()
{
Code = "brush",
Name = "Brush",
Description = "Coiffure à domicile",
PerformerCount = 1,
Children = new List<ActivityBrowseItemDto>
{
new()
{
Code = "brush-pro",
Name = "Brush Pro",
Description = "Spécialisation premium",
ParentCode = "brush",
PerformerCount = 1,
}
}
}
};
return Task.FromResult((T)(object)activities);
}
if (typeof(T) == typeof(List<ActivityPerformerDto>))
{
var performers = path.EndsWith("brush-pro/performers", StringComparison.Ordinal)
? new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-2", UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro" }
}
: new List<ActivityPerformerDto>
{
new() { PerformerId = "pro-1", UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush" }
};
return Task.FromResult((T)(object)performers);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -94,6 +94,26 @@ public class SignaturePadControlTests
Assert.NotEqual(first.Strokes, third.Strokes); Assert.NotEqual(first.Strokes, third.Strokes);
} }
[Fact]
public void PendingStroke_is_exposed_only_while_capturing()
{
var pad = new SignaturePadControl();
Assert.Empty(pad.PendingStroke);
pad.BeginCaptureForTest();
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
pad.SealStrokeForTest();
Assert.Empty(pad.PendingStroke);
Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
}
[Fact] [Fact]
public void Clear_empties_buffer_and_raises_redraw() public void Clear_empties_buffer_and_raises_redraw()
{ {

View file

@ -212,6 +212,17 @@ public class SignaturePadControl : TemplatedControl
_pendingPoints++; _pendingPoints++;
} }
/// <summary>
/// Test hook: mark the control as actively capturing so tests
/// can exercise the live-preview path without synthetic pointer
/// events.
/// </summary>
internal void BeginCaptureForTest()
{
_capturing = true;
_pendingPoints = 0;
}
/// <summary> /// <summary>
/// Test hook: seal the currently-pending stroke with a length /// Test hook: seal the currently-pending stroke with a length
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at /// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at

View file

@ -23,6 +23,7 @@ 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 userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
// Vues // Vues
@ -45,6 +46,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api); services.AddSingleton<YavscApiClient>(api);
@ -52,10 +54,12 @@ public static class ServiceCollectionHelpers
services.AddSingleton(circleClient); services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient); services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
// Dialogs (modal-light pages): the ViewLocator resolves // Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or // them when a caller pushes a PostAclDialogViewModel or

View file

@ -39,6 +39,7 @@ public class ViewLocator : IDataTemplate
MainViewModel => services.GetRequiredService<MainPage>(), MainViewModel => services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(), AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(), CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),

View file

@ -0,0 +1,219 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitiesPageViewModel : ViewModelBase
{
private readonly ActivityApiClient _client;
private bool _syncingSelection;
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Activities { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedActivity { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityBrowseItemDto> Specializations { get; set; } = new();
[ObservableProperty]
public partial ActivityBrowseItemDto? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityPerformerDto> Performers { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
public ActivityBrowseItemDto? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ActivitiesPageViewModel(ActivityApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
partial void OnSelectedActivityChanged(ActivityBrowseItemDto? value)
{
if (_syncingSelection) return;
_ = ShowActivitySafeAsync(value);
}
partial void OnSelectedSpecializationChanged(ActivityBrowseItemDto? value)
{
if (_syncingSelection) return;
_ = ShowSpecializationSafeAsync(value);
}
private async Task ShowActivitySafeAsync(ActivityBrowseItemDto? value)
{
try
{
await ShowActivityAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
private async Task ShowSpecializationSafeAsync(ActivityBrowseItemDto? value)
{
try
{
await ShowSpecializationAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetCatalogAsync();
Activities = new ObservableCollection<ActivityBrowseItemDto>(list ?? new());
var first = Activities.FirstOrDefault();
await ShowActivityAsync(first);
if (first is null)
{
StatusMessage = "Aucune activité disponible.";
}
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Activities = new ObservableCollection<ActivityBrowseItemDto>();
Specializations = new ObservableCollection<ActivityBrowseItemDto>();
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public async Task ShowActivityAsync(ActivityBrowseItemDto? activity)
{
_syncingSelection = true;
try
{
SelectedActivity = activity;
SelectedSpecialization = null;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(SelectedActivityLabel));
OnPropertyChanged(nameof(CurrentActivityLabel));
Specializations = new ObservableCollection<ActivityBrowseItemDto>(activity?.Children ?? new());
if (activity is null)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
return;
}
await LoadPerformersAsync(activity);
}
public async Task ShowSpecializationAsync(ActivityBrowseItemDto? specialization)
{
_syncingSelection = true;
try
{
SelectedSpecialization = specialization;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(CurrentActivityLabel));
if (specialization is null)
{
if (SelectedActivity is not null)
{
await LoadPerformersAsync(SelectedActivity);
}
return;
}
await LoadPerformersAsync(specialization);
}
private async Task LoadPerformersAsync(ActivityBrowseItemDto activity)
{
IsBusy = true;
try
{
var list = await _client.GetPerformersAsync(activity.Code);
Performers = new ObservableCollection<ActivityPerformerDto>(list ?? new());
StatusMessage = $"{activity.Name} · {Performers.Count} prestataire(s)";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityPerformerDto>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,4 +1,9 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -24,8 +29,25 @@ public class HomePageViewModel : ViewModelBase
Settings = settings; Settings = settings;
SessionStatus = sessionStatus; SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
} }
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync()); public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushMainPageAsync);
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can

View file

@ -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://business.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;
@ -154,7 +154,10 @@ public partial class Settings : ViewModelBase
{ {
"openid", // OIDC: required for the id_token "openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims "profile", // OIDC: standard profile claims
"offline_access" // OIDC: required to receive a refresh_token "offline_access", // OIDC: required to receive a refresh_token
"blogs",
"api"
}; };
/// <summary> /// <summary>
@ -297,8 +300,15 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below. // → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate) else lock (_mutationGate)
{ {
var legacyApiUrl = TryReadLegacyApiUrl(json);
this.Authentication = settings.Authentication; this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode; this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
? settings.BusinessApiUrl
: this.BusinessApiUrl;
this.SearchText = settings.SearchText ?? string.Empty; this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null)) if (!(settings.Authentication is null))
{ {
@ -343,6 +353,26 @@ public partial class Settings : ViewModelBase
} }
} }
private static string? TryReadLegacyApiUrl(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
&& apiUrl.ValueKind == JsonValueKind.String)
{
return apiUrl.GetString();
}
}
catch
{
// Ignore legacy payload parse errors: normal deserialization
// already reports actionable diagnostics to the caller.
}
return null;
}
private void UseDefaultSettings() private void UseDefaultSettings()
{ {
this.Authentication = new AuthenticationSettings this.Authentication = new AuthenticationSettings
@ -353,6 +383,8 @@ public partial class Settings : ViewModelBase
Scopes = AuthenticationSettings.DefaultScopes Scopes = AuthenticationSettings.DefaultScopes
}; };
this.DarkMode = false; this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty; this.SearchText = string.Empty;
} }

View file

@ -0,0 +1,79 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.ActivitiesPage"
x:DataType="vm:ActivitiesPageViewModel"
Header="Activités">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Activités" FontWeight="Bold" Margin="0,0,0,8" />
<ListBox Grid.Row="1"
ItemsSource="{Binding Activities}"
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Spécialisations" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding SelectedActivityLabel, StringFormat='Pour : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Specializations}"
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityBrowseItemDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Prestataires: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Prestataires" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2" ItemsSource="{Binding Performers}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityPerformerDto">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding ExtraActivityCount, StringFormat='Autres spécialisations: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ActivitiesPage : ContentPage
{
public ActivitiesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -18,5 +18,9 @@
Command="{Binding OpenBlogs}" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Parcourir les activités"
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -62,9 +62,16 @@ public partial class SignaturePage : ContentPage
var h = PadFrame.Bounds.Height; var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes; var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0; int i = 0;
while (i < strokes.Count) while (i < sealedCount)
{ {
int k = strokes[i]; int k = strokes[i];
if (k <= 0) break; if (k <= 0) break;
@ -88,7 +95,6 @@ public partial class SignaturePage : ContentPage
InkLayer.Children.Add(poly); InkLayer.Children.Add(poly);
} }
var pending = _control.PendingStroke;
if (pending.Count > 0) if (pending.Count > 0)
{ {
var poly = new Polyline var poly = new Polyline

View file

@ -0,0 +1,17 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Activity node returned by the browsing API.
/// </summary>
public sealed class ActivityBrowseItemDto
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ParentCode { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Photo { get; set; } = string.Empty;
public int Rate { get; set; }
public int PerformerCount { get; set; }
public List<CommandFormSummaryDto> Forms { get; set; } = new();
public List<ActivityBrowseItemDto> Children { get; set; } = new();
}

View file

@ -0,0 +1,18 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight performer description returned for one activity.
/// </summary>
public sealed class ActivityPerformerDto
{
public string PerformerId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public bool Active { get; set; }
public bool AcceptNotifications { get; set; }
public bool AcceptPublicContact { get; set; }
public string WebSite { get; set; } = string.Empty;
public string ActivityCode { get; set; } = string.Empty;
public string ActivityName { get; set; } = string.Empty;
public string SettingsClassName { get; set; } = string.Empty;
public int ExtraActivityCount { get; set; }
}

View file

@ -0,0 +1,11 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight command-form description exposed to API clients.
/// </summary>
public sealed class CommandFormSummaryDto
{
public long Id { get; set; }
public string ActionName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View file

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Workflow;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for browsing business activities and their performers.
/// Uses absolute URLs so it can coexist with other Yavsc clients that
/// target a different API host on the same shared transport.
/// </summary>
public sealed class ActivityApiClient
{
private const string PathPrefix = "activity";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
}
public Task<List<ActivityBrowseItemDto>> GetCatalogAsync(
string? parentCode = null,
CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(parentCode)
? $"{PathPrefix}/catalog"
: $"{PathPrefix}/catalog?parentCode={Uri.EscapeDataString(parentCode)}";
return _api.CallAsync<List<ActivityBrowseItemDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<List<ActivityPerformerDto>> GetPerformersAsync(
string activityCode,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(activityCode))
throw new ArgumentException("Activity code is required.", nameof(activityCode));
return _api.CallAsync<List<ActivityPerformerDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/performers"),
ct: ct);
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
}

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Workflow;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Workflow; using Yavsc.Models.Workflow;
@ -8,6 +9,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/activity")] [Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller public class ActivityApiController : Controller
@ -26,6 +28,86 @@ namespace Yavsc.Controllers
return _context.Activities.Include(a=>a.Forms).Where( a => !a.Hidden ); return _context.Activities.Include(a=>a.Forms).Where( a => !a.Hidden );
} }
[HttpGet("catalog")]
public async Task<ActionResult<IEnumerable<ActivityBrowseItemDto>>> GetCatalog(
CancellationToken cancellationToken,
[FromQuery] string parentCode = null)
{
var activities = await _context.Activities
.AsNoTracking()
.Include(a => a.Forms)
.Include(a => a.Children)
.ThenInclude(c => c.Forms)
.Where(a => !a.Hidden && a.ParentCode == parentCode)
.OrderByDescending(a => a.Rate)
.ToListAsync(cancellationToken);
var codes = activities
.Select(a => a.Code)
.Concat(activities.SelectMany(a => a.Children.Where(c => !c.Hidden).Select(c => c.Code)))
.Distinct()
.ToArray();
var performerCounts = await _context.Performers
.AsNoTracking()
.Where(p => p.Active)
.SelectMany(
p => p.Activity
.Where(a => codes.Contains(a.DoesCode))
.Select(a => new { a.DoesCode, p.PerformerId }))
.GroupBy(x => x.DoesCode)
.Select(g => new { Code = g.Key, Count = g.Select(x => x.PerformerId).Distinct().Count() })
.ToDictionaryAsync(x => x.Code, x => x.Count, cancellationToken);
return Ok(activities.Select(a => ToBrowseItem(a, performerCounts)).ToList());
}
[HttpGet("{id}/performers")]
public async Task<ActionResult<IEnumerable<ActivityPerformerDto>>> GetPerformers(
[FromRoute] string id,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest("Activity code is required.");
}
var activity = await _context.Activities
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Code == id, cancellationToken);
if (activity is null)
{
return NotFound();
}
var performers = await _context.Performers
.AsNoTracking()
.Include(p => p.Performer)
.Include(p => p.Activity)
.ThenInclude(a => a.Does)
.Where(p => p.Active && p.Activity.Any(a => a.DoesCode == id))
.OrderBy(p => p.Rate)
.Select(p => new ActivityPerformerDto
{
PerformerId = p.PerformerId,
UserName = p.Performer.UserName,
Active = p.Active,
AcceptNotifications = p.AcceptNotifications,
AcceptPublicContact = p.AcceptPublicContact,
WebSite = p.WebSite,
ActivityCode = id,
ActivityName = activity.Name,
SettingsClassName = p.Activity
.Where(a => a.DoesCode == id)
.Select(a => a.Does.SettingsClassName)
.FirstOrDefault(),
ExtraActivityCount = p.Activity.Count(a => a.DoesCode != id)
})
.ToListAsync(cancellationToken);
return Ok(performers);
}
// GET: api/ActivityApi/5 // GET: api/ActivityApi/5
[HttpGet("{id}", Name = "GetActivity")] [HttpGet("{id}", Name = "GetActivity")]
public async Task<IActionResult> GetActivity([FromRoute] string id) public async Task<IActionResult> GetActivity([FromRoute] string id)
@ -144,5 +226,51 @@ namespace Yavsc.Controllers
{ {
return _context.Activities.Count(e => e.Code == id) > 0; return _context.Activities.Count(e => e.Code == id) > 0;
} }
private static ActivityBrowseItemDto ToBrowseItem(
Activity activity,
IReadOnlyDictionary<string, int> performerCounts)
{
return new ActivityBrowseItemDto
{
Code = activity.Code,
Name = activity.Name,
ParentCode = activity.ParentCode,
Description = activity.Description,
Photo = activity.Photo,
Rate = activity.Rate,
PerformerCount = performerCounts.TryGetValue(activity.Code, out var count) ? count : 0,
Forms = activity.Forms
.Select(f => new CommandFormSummaryDto
{
Id = f.Id,
ActionName = f.ActionName,
Title = f.Title,
})
.ToList(),
Children = activity.Children
.Where(c => !c.Hidden)
.OrderByDescending(c => c.Rate)
.Select(c => new ActivityBrowseItemDto
{
Code = c.Code,
Name = c.Name,
ParentCode = c.ParentCode,
Description = c.Description,
Photo = c.Photo,
Rate = c.Rate,
PerformerCount = performerCounts.TryGetValue(c.Code, out var childCount) ? childCount : 0,
Forms = c.Forms
.Select(f => new CommandFormSummaryDto
{
Id = f.Id,
ActionName = f.ActionName,
Title = f.Title,
})
.ToList(),
})
.ToList(),
};
}
} }
} }

View file

@ -19,6 +19,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
[Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize] [Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller public class BillingController : Controller
{ {

View file

@ -12,6 +12,7 @@ namespace Yavsc.Controllers
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")] [Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller public class BookQueryApiController : Controller

View file

@ -9,6 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/estimate"), Authorize] [Route(Constants.APIPrefix + "/estimate"), Authorize]
public class EstimateApiController : Controller public class EstimateApiController : Controller

View file

@ -1,4 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
@ -7,6 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")] [Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
public class EstimateTemplatesApiController : Controller public class EstimateTemplatesApiController : Controller

View file

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
@ -6,6 +7,7 @@ using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Authorize]
[Route(Constants.APIPrefix + "/front")] [Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller public class FrontOfficeApiController : Controller
{ {

View file

@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -6,6 +7,7 @@ using Yavsc.Models;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Authorize]
[Route(Constants.APIPrefix + "/payment")] [Route(Constants.APIPrefix + "/payment")]
public class PaymentApiController : Controller public class PaymentApiController : Controller
{ {

View file

@ -10,6 +10,7 @@ namespace Yavsc.Controllers
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Services; using Yavsc.Services;
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/performers")] [Route(Constants.APIPrefix + "/performers")]
public class PerformersApiController : Controller public class PerformersApiController : Controller

View file

@ -7,6 +7,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Authorize]
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/ProductApi")] [Route(Constants.APIPrefix + "/ProductApi")]
public class ProductApiController : Controller public class ProductApiController : Controller

View file

@ -14,7 +14,7 @@ public static class Constants
// 'offline_access' is handled by IdentityServer8 itself and never // 'offline_access' is handled by IdentityServer8 itself and never
// needs an explicit ApiScope row. // needs an explicit ApiScope row.
public static readonly string[] BuildInApiScopes = { public static readonly string[] BuildInApiScopes = {
"admin", "moderation", "performer", "client" }; "admin", "moderation", "performer", "client", "api" };
// One ApiResource per application scope. Each scope is exposed by // One ApiResource per application scope. Each scope is exposed by
// exactly one resource, named after the scope ("admin" -> "admin" // exactly one resource, named after the scope ("admin" -> "admin"
@ -29,6 +29,7 @@ public static class Constants
new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" }, new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" },
new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" }, new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" },
new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" }, new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" },
new ApiResourceScopeSpecification { ScopeName = "api", Description = "Core API access", ResourceName = "api", ResourceDisplayName = "Yavsc Core API" },
new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" } new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" }
}; };
} }

View file

@ -803,6 +803,7 @@ public static class HostingExtensions
// silent refresh path to work; without it IdentityServer // silent refresh path to work; without it IdentityServer
// refuses to issue a refresh_token. // refuses to issue a refresh_token.
"blogs", "blogs",
"api",
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId, IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
IdentityServer8.IdentityServerConstants.StandardScopes.Profile, IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess, IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,