Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3207352c3 |
|||
|
12f8b52173 |
|||
|
1faa80c884 |
7 changed files with 268 additions and 63 deletions
62
CHANGELOG.md
62
CHANGELOG.md
|
|
@ -1,5 +1,67 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.8-rc23] - unstable
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
|
||||||
|
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
|
||||||
|
d'avatar, confirmation e-mail, préférence de courriel mensuel,
|
||||||
|
création ou changement de mot de passe et activation/désactivation
|
||||||
|
de l'authentification à deux facteurs.
|
||||||
|
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
|
||||||
|
comptes bancaires, dissociation des connexions externes et suppression
|
||||||
|
du compte avec confirmation du nom d'utilisateur.
|
||||||
|
* [PostIt] Configuration du profil professionnel et des activités,
|
||||||
|
navigation vers les cercles et les publications depuis le compte.
|
||||||
|
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
|
||||||
|
dans les métadonnées du profil et consultation de la consommation
|
||||||
|
de ressources.
|
||||||
|
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
|
||||||
|
ressources, API de synthèse et migration `ResourceUsageRecords`.
|
||||||
|
* [Tests] Couverture des parcours profil et professionnel, de l'API de
|
||||||
|
consommation de ressources et du renouvellement des jetons :
|
||||||
|
rotation à usage unique, appels simultanés, réponses 401 concurrentes
|
||||||
|
et rejet d'un jeton de renouvellement révoqué.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* [PostIt] Réorganisation de la page de compte en sections de profil,
|
||||||
|
sécurité et préférences ; actions de modification désactivées tant
|
||||||
|
que le profil n'est pas chargé ou qu'une opération est en cours.
|
||||||
|
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
|
||||||
|
dans les pages de profil et de configuration professionnelle.
|
||||||
|
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
|
||||||
|
PostIt, avec l'état de sécurité, les connexions externes, les comptes
|
||||||
|
bancaires et les compteurs du compte.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
|
||||||
|
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
|
||||||
|
empêchait l'activation du contrôleur de compte et provoquait une 500.
|
||||||
|
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
|
||||||
|
ne contient pas d'identifiant utilisateur ou que le compte n'existe
|
||||||
|
plus, au lieu d'une exception serveur.
|
||||||
|
* [PostIt] Synchronisation de tous les chemins de renouvellement des
|
||||||
|
jetons, y compris après une 401 et au démarrage : les appels
|
||||||
|
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
|
||||||
|
`invalid_grant` efface la session et invite à se reconnecter.
|
||||||
|
* [PostIt] Effacement des informations obsolètes après un échec de
|
||||||
|
rechargement du profil et normalisation des valeurs de calendrier
|
||||||
|
lors de la sauvegarde.
|
||||||
|
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
|
||||||
|
lors de son enregistrement.
|
||||||
|
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
|
||||||
|
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
|
||||||
|
et validation de longueur des champs.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
|
||||||
|
crédits ne sont pas encore disponibles dans l'interface native.
|
||||||
|
|
||||||
## [1.0.8-rc22] - unstable
|
## [1.0.8-rc22] - unstable
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,19 @@ public class HomePageProviderFlowTests
|
||||||
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
|
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Administration_page_tracks_selected_user()
|
||||||
|
{
|
||||||
|
var vm = new AdministrationPageViewModel();
|
||||||
|
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
|
||||||
|
|
||||||
|
vm.Users.Add(user);
|
||||||
|
vm.SelectedUser = user;
|
||||||
|
|
||||||
|
Assert.Same(user, vm.SelectedUser);
|
||||||
|
Assert.Equal("alice", vm.SelectedUser?.UserName);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UserProfilePage_exposes_full_name_and_address_fields()
|
public void UserProfilePage_exposes_full_name_and_address_fields()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
||||||
private readonly RSA _rsa;
|
private readonly RSA _rsa;
|
||||||
private readonly string _kid;
|
private readonly string _kid;
|
||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public string Issuer { get; }
|
public string Issuer { get; }
|
||||||
public string LoopbackRedirectUri { get; }
|
public string LoopbackRedirectUri { get; }
|
||||||
|
public int RefreshRequestCount { get; private set; }
|
||||||
|
|
||||||
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
|
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
|
||||||
{
|
{
|
||||||
|
|
@ -100,7 +102,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
||||||
["response_types_supported"] = new[] { "code" },
|
["response_types_supported"] = new[] { "code" },
|
||||||
["subject_types_supported"] = new[] { "public" },
|
["subject_types_supported"] = new[] { "public" },
|
||||||
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
|
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
|
||||||
["grant_types_supported"] = new[] { "authorization_code" },
|
["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
|
||||||
["code_challenge_methods_supported"] = new[] { "S256" },
|
["code_challenge_methods_supported"] = new[] { "S256" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -132,6 +134,16 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
||||||
body = await reader.ReadToEndAsync();
|
body = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
var form = ParseForm(body);
|
var form = ParseForm(body);
|
||||||
|
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
|
||||||
|
{
|
||||||
|
RefreshRequestCount++;
|
||||||
|
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|
||||||
|
|| !_refreshTokens.Remove(suppliedToken))
|
||||||
|
{
|
||||||
|
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// We accept any code and don't validate PKCE on the stub side;
|
// We accept any code and don't validate PKCE on the stub side;
|
||||||
// the OidcClient itself validates the redirect_uri match.
|
// the OidcClient itself validates the redirect_uri match.
|
||||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
|
@ -142,11 +154,13 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
||||||
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
|
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
|
||||||
["exp"] = now + 600,
|
["exp"] = now + 600,
|
||||||
["iat"] = now,
|
["iat"] = now,
|
||||||
|
["jti"] = Guid.NewGuid().ToString("N"),
|
||||||
};
|
};
|
||||||
|
|
||||||
var accessToken = SignJwt(claims);
|
var accessToken = SignJwt(claims);
|
||||||
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
_refreshTokens.Add(refreshToken);
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
access_token = accessToken,
|
access_token = accessToken,
|
||||||
|
|
@ -193,10 +207,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
|
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200)
|
||||||
{
|
{
|
||||||
response.ContentType = "application/json";
|
response.ContentType = "application/json";
|
||||||
response.StatusCode = 200;
|
response.StatusCode = statusCode;
|
||||||
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||||
await response.OutputStream.WriteAsync(bytes);
|
await response.OutputStream.WriteAsync(bytes);
|
||||||
response.Close();
|
response.Close();
|
||||||
|
|
|
||||||
|
|
@ -370,20 +370,105 @@ public class YavscApiClientTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SKIPPED — see comment.
|
[Fact]
|
||||||
//
|
public async Task Concurrent_silent_login_and_api_calls_share_one_refresh()
|
||||||
// We can't cover "TrySilentLoginAsync purges the store when the
|
{
|
||||||
// refresh token is rejected" with OidcStubAuthority: the stub's
|
using var authority = await OIDCStubAuthority.StartAsync();
|
||||||
// /connect/token endpoint is unconditional and hands out a fresh
|
using var apiServer = new StubApiServer();
|
||||||
// refresh token regardless of what the caller sends. To exercise
|
await apiServer.StartAsync();
|
||||||
// the RefreshFailedException path we'd need an authority option
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||||
// to fail on a specific refresh-token string; until then the
|
var tokensPath = TokensPath();
|
||||||
// production refresh-failure path is covered manually (and by
|
try
|
||||||
// the structural guarantee that _store.Clear() runs in the catch
|
{
|
||||||
// block of ForceRefreshAsync when result.IsError).
|
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||||
//
|
ExpireCachedAccessToken(tokensPath);
|
||||||
// [Fact]
|
var store = new TokenStore(tokensPath);
|
||||||
// public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
|
var previousRefreshToken = store.Load()!.RefreshToken;
|
||||||
|
await using var client = new YavscApiClient(settings, store);
|
||||||
|
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
var silentLogins = Enumerable.Range(0, 8)
|
||||||
|
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
|
||||||
|
var calls = Enumerable.Range(0, 8)
|
||||||
|
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
|
||||||
|
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
|
||||||
|
|
||||||
|
Assert.All(silentLogins, login => Assert.True(login.Result));
|
||||||
|
Assert.All(calls, call => Assert.NotEmpty(call.Result));
|
||||||
|
Assert.Equal(1, authority.RefreshRequestCount);
|
||||||
|
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
|
||||||
|
Assert.True(client.HasValidSession);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrent_401_responses_share_one_refresh()
|
||||||
|
{
|
||||||
|
using var authority = await OIDCStubAuthority.StartAsync();
|
||||||
|
using var apiServer = new StubApiServer();
|
||||||
|
await apiServer.StartAsync();
|
||||||
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||||
|
var tokensPath = TokensPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||||
|
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
var calls = Enumerable.Range(0, 8)
|
||||||
|
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
|
||||||
|
await Task.WhenAll(calls);
|
||||||
|
|
||||||
|
Assert.All(calls, call => Assert.NotEmpty(call.Result));
|
||||||
|
Assert.Equal(1, authority.RefreshRequestCount);
|
||||||
|
Assert.True(client.HasValidSession);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
|
||||||
|
{
|
||||||
|
using var authority = await OIDCStubAuthority.StartAsync();
|
||||||
|
using var apiServer = new StubApiServer();
|
||||||
|
await apiServer.StartAsync();
|
||||||
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
||||||
|
var tokensPath = TokensPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
|
||||||
|
var store = new TokenStore(tokensPath);
|
||||||
|
var tokens = store.Load()!;
|
||||||
|
store.Save(tokens with
|
||||||
|
{
|
||||||
|
RefreshToken = "revoked-refresh-token",
|
||||||
|
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
|
||||||
|
});
|
||||||
|
await using var client = new YavscApiClient(settings, store);
|
||||||
|
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
|
||||||
|
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
Assert.True(error.IsPermanent);
|
||||||
|
Assert.Contains("reconnecter", error.Message);
|
||||||
|
Assert.False(client.HasValidSession);
|
||||||
|
Assert.Null(store.Load());
|
||||||
|
Assert.Equal(0, apiServer.RequestCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static T Last<T>(System.Collections.Generic.List<T> list)
|
private static T Last<T>(System.Collections.Generic.List<T> list)
|
||||||
{
|
{
|
||||||
|
|
@ -467,6 +552,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
|
||||||
public string BaseUrl { get; private set; } = string.Empty;
|
public string BaseUrl { get; private set; } = string.Empty;
|
||||||
public List<string> SeenBearers { get; } = new();
|
public List<string> SeenBearers { get; } = new();
|
||||||
public int RequestCount => _requestCount;
|
public int RequestCount => _requestCount;
|
||||||
|
public string? RejectedBearer { get; set; }
|
||||||
|
|
||||||
public StubApiServer(bool forceFirstRequest = false)
|
public StubApiServer(bool forceFirstRequest = false)
|
||||||
{
|
{
|
||||||
|
|
@ -499,7 +585,8 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
|
||||||
if (!string.IsNullOrEmpty(auth))
|
if (!string.IsNullOrEmpty(auth))
|
||||||
SeenBearers.Add(auth!);
|
SeenBearers.Add(auth!);
|
||||||
|
|
||||||
if (_forceFirstRequest && _requestCount == 1)
|
if ((_forceFirstRequest && _requestCount == 1)
|
||||||
|
|| (RejectedBearer is not null && auth == RejectedBearer))
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 401;
|
ctx.Response.StatusCode = 401;
|
||||||
ctx.Response.Close();
|
ctx.Response.Close();
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
// Server rejected: could be revocation, clock skew, audience mismatch.
|
// Server rejected: could be revocation, clock skew, audience mismatch.
|
||||||
// Force a refresh and retry exactly once.
|
// Force a refresh and retry exactly once.
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false);
|
||||||
|
|
||||||
using var retry = new HttpRequestMessage(method, path);
|
using var retry = new HttpRequestMessage(method, path);
|
||||||
if (contentFactory is not null)
|
if (contentFactory is not null)
|
||||||
|
|
@ -379,13 +379,46 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
|
||||||
|
{
|
||||||
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
|
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
|
var tokens = _tokens;
|
||||||
|
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
|
||||||
|
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
|
||||||
|
|
||||||
|
if (rejectedAccessToken is null
|
||||||
|
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
|
||||||
|
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
|
||||||
|
if (result.IsError)
|
||||||
|
{
|
||||||
|
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
|
||||||
|
if (isPermanent)
|
||||||
|
{
|
||||||
|
_store.Clear();
|
||||||
|
_tokens = null;
|
||||||
|
}
|
||||||
|
throw new RefreshFailedException(
|
||||||
|
isPermanent
|
||||||
|
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
|
||||||
|
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
|
||||||
|
isPermanent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tokens = new RefreshTokenRecord(
|
||||||
|
AccessToken: result.AccessToken!,
|
||||||
|
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
|
||||||
|
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
|
||||||
|
IdToken: result.IdentityToken ?? tokens.IdToken);
|
||||||
|
|
||||||
|
_store.Save(_tokens);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -393,30 +426,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ForceRefreshAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
|
|
||||||
throw new InvalidOperationException("No refresh token available.");
|
|
||||||
|
|
||||||
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
|
|
||||||
if (result.IsError)
|
|
||||||
{
|
|
||||||
// Refresh token dead: revoked, expired, or rotation-theft
|
|
||||||
// detected. Purge and force an interactive re-login.
|
|
||||||
_store.Clear();
|
|
||||||
_tokens = null;
|
|
||||||
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
_tokens = new RefreshTokenRecord(
|
|
||||||
AccessToken: result.AccessToken!,
|
|
||||||
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
|
|
||||||
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
|
|
||||||
IdToken: result.IdentityToken ?? _tokens.IdToken);
|
|
||||||
|
|
||||||
_store.Save(_tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upload a user avatar to the Yavsc API. The server expects a
|
/// Upload a user avatar to the Yavsc API. The server expects a
|
||||||
/// single multipart file named <c>file</c> and validates the image
|
/// single multipart file named <c>file</c> and validates the image
|
||||||
|
|
@ -459,8 +468,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
var response = await attemptUpload().ConfigureAwait(false);
|
var response = await attemptUpload().ConfigureAwait(false);
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
|
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
|
||||||
response = await attemptUpload().ConfigureAwait(false);
|
response = await attemptUpload().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,8 +524,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
||||||
var response = await sendOnce().ConfigureAwait(false);
|
var response = await sendOnce().ConfigureAwait(false);
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
|
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
|
||||||
response.Dispose();
|
response.Dispose();
|
||||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
|
||||||
response = await sendOnce().ConfigureAwait(false);
|
response = await sendOnce().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ public partial class AdministrationPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string StatusMessage { get; set; } = "Chargement…";
|
public partial string StatusMessage { get; set; } = "Chargement…";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial UserInfo? SelectedUser { get; set; }
|
||||||
|
|
||||||
public IReadOnlyList<UserInfo> FilteredUsers =>
|
public IReadOnlyList<UserInfo> FilteredUsers =>
|
||||||
string.IsNullOrWhiteSpace(SearchText)
|
string.IsNullOrWhiteSpace(SearchText)
|
||||||
? Users.ToList()
|
? Users.ToList()
|
||||||
|
|
|
||||||
|
|
@ -24,21 +24,36 @@
|
||||||
TextWrapping="Wrap"/>
|
TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<ListBox Grid.Row="3"
|
<Grid Grid.Row="3" ColumnDefinitions="1.3*,0.7*" ColumnSpacing="12">
|
||||||
ItemsSource="{Binding FilteredUsers}">
|
<ListBox ItemsSource="{Binding FilteredUsers}"
|
||||||
<ListBox.ItemTemplate>
|
SelectedItem="{Binding SelectedUser, Mode=TwoWay}">
|
||||||
<DataTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
<DataTemplate>
|
||||||
<StackPanel Spacing="4">
|
<Border Padding="8" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6">
|
||||||
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding UserName, StringFormat='Nom : {0}'}" FontWeight="SemiBold"/>
|
||||||
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||||
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||||
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
<TextBlock Text="{Binding DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
<TextBlock Text="{Binding UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||||
</Border>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</Border>
|
||||||
</ListBox.ItemTemplate>
|
</DataTemplate>
|
||||||
</ListBox>
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Padding="12" BorderBrush="#D0D7DE" BorderThickness="1" CornerRadius="6" Background="#F6F8FA">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Détails" FontWeight="SemiBold" FontSize="18"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.UserName, StringFormat='Nom d’utilisateur : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.FullName, StringFormat='Nom complet : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.Email, StringFormat='Email : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.Address, StringFormat='Adresse : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.DedicatedGoogleCalendar, StringFormat='Calendrier : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.BankInfoSummary, StringFormat='Banque : {0}'}" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding SelectedUser.UserId, StringFormat='ID : {0}'}" Foreground="#4B5563" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue