Mise en place de l'authentification Keycloak
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
KEYCLOAK_DB_NAME=keycloak
|
||||||
|
KEYCLOAK_DB_USER=keycloak
|
||||||
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
|
KEYCLOAK_ADMIN_USER=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.codex
|
.codex
|
||||||
|
.env
|
||||||
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
WhatsApp Video 2026-04-11 at 20.38.50.mp4
|
||||||
ChessCubing.App/bin/
|
ChessCubing.App/bin/
|
||||||
ChessCubing.App/obj/
|
ChessCubing.App/obj/
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
<CascadingAuthenticationState>
|
||||||
<Found Context="routeData">
|
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
|
<Found Context="routeData">
|
||||||
</Found>
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
|
||||||
</Router>
|
<Authorizing>
|
||||||
|
<main class="rules-shell">
|
||||||
|
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
|
||||||
|
<p class="eyebrow">Authentification</p>
|
||||||
|
<h1>Verification de la session en cours...</h1>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</Authorizing>
|
||||||
|
<NotAuthorized>
|
||||||
|
<RedirectToLogin />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeRouteView>
|
||||||
|
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
13
ChessCubing.App/Components/RedirectToLogin.razor
Normal file
13
ChessCubing.App/Components/RedirectToLogin.razor
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var returnUrl = string.IsNullOrWhiteSpace(relativePath)
|
||||||
|
? "/"
|
||||||
|
: relativePath.StartsWith("/", StringComparison.Ordinal) ? relativePath : $"/{relativePath}";
|
||||||
|
|
||||||
|
Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
85
ChessCubing.App/Components/UserAccessBar.razor
Normal file
85
ChessCubing.App/Components/UserAccessBar.razor
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
@using System.Security.Claims
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized Context="authState">
|
||||||
|
<div class="user-access-bar">
|
||||||
|
<div class="user-access-copy">
|
||||||
|
<span class="micro-label">Compte Keycloak</span>
|
||||||
|
<strong>@BuildDisplayName(authState.User)</strong>
|
||||||
|
<span class="user-access-meta">@BuildMeta(authState.User)</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-access-actions">
|
||||||
|
<a class="button ghost small" href="@LogoutHref">Se deconnecter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="user-access-bar">
|
||||||
|
<div class="user-access-copy">
|
||||||
|
<span class="micro-label">Compte Keycloak</span>
|
||||||
|
<strong>Connexion requise pour lancer et reprendre les matchs</strong>
|
||||||
|
<span class="user-access-meta">Chaque compte conserve son propre etat de match dans ce navigateur.</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-access-actions">
|
||||||
|
<a class="button primary small" href="@LoginHref">Se connecter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string LoginHref => BuildAuthHref("login", CurrentReturnUrl);
|
||||||
|
private string LogoutHref => BuildAuthHref("logout", "/");
|
||||||
|
|
||||||
|
private string CurrentReturnUrl
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativePath.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? relativePath
|
||||||
|
: $"/{relativePath}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildAuthHref(string action, string returnUrl)
|
||||||
|
=> $"authentication/{action}?returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||||
|
|
||||||
|
private static string BuildDisplayName(ClaimsPrincipal user)
|
||||||
|
=> user.Identity?.Name
|
||||||
|
?? user.FindFirst("name")?.Value
|
||||||
|
?? user.FindFirst("preferred_username")?.Value
|
||||||
|
?? "Utilisateur connecte";
|
||||||
|
|
||||||
|
private static string BuildMeta(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var details = new List<string>();
|
||||||
|
var email = user.FindFirst("email")?.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
details.Add(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
var roles = user.FindAll("role")
|
||||||
|
.Select(claim => claim.Value)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (roles.Length > 0)
|
||||||
|
{
|
||||||
|
details.Add($"Roles : {string.Join(", ", roles)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return details.Count > 0
|
||||||
|
? string.Join(" | ", details)
|
||||||
|
: "Session authentifiee via Keycloak.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/application"
|
@page "/application"
|
||||||
@page "/application.html"
|
@page "/application.html"
|
||||||
|
@attribute [Authorize]
|
||||||
@inject BrowserBridge Browser
|
@inject BrowserBridge Browser
|
||||||
@inject MatchStore Store
|
@inject MatchStore Store
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
<a class="button ghost" href="index.html">Accueil du site</a>
|
<a class="button ghost" href="index.html">Accueil du site</a>
|
||||||
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
|
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
|
||||||
</div>
|
</div>
|
||||||
|
<UserAccessBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="hero-preview">
|
<aside class="hero-preview">
|
||||||
|
|||||||
13
ChessCubing.App/Pages/Authentication.razor
Normal file
13
ChessCubing.App/Pages/Authentication.razor
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@page "/authentication/{action}"
|
||||||
|
|
||||||
|
<main class="rules-shell">
|
||||||
|
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
|
||||||
|
<p class="eyebrow">Authentification</p>
|
||||||
|
<RemoteAuthenticatorView Action="@Action" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string? Action { get; set; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/chrono"
|
@page "/chrono"
|
||||||
@page "/chrono.html"
|
@page "/chrono.html"
|
||||||
|
@attribute [Authorize]
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@inject MatchStore Store
|
@inject MatchStore Store
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/cube"
|
@page "/cube"
|
||||||
@page "/cube.html"
|
@page "/cube.html"
|
||||||
|
@attribute [Authorize]
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@inject BrowserBridge Browser
|
@inject BrowserBridge Browser
|
||||||
@inject MatchStore Store
|
@inject MatchStore Store
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
|
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
|
||||||
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
|
<a class="button ghost" href="/brice/">Ouvrir l'appli de Brice</a>
|
||||||
</div>
|
</div>
|
||||||
|
<UserAccessBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="hero-preview">
|
<aside class="hero-preview">
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
<a class="button primary" href="application.html">Ouvrir l'application</a>
|
||||||
<a class="button secondary" href="index.html">Retour a l'accueil</a>
|
<a class="button secondary" href="index.html">Retour a l'accueil</a>
|
||||||
</div>
|
</div>
|
||||||
|
<UserAccessBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="hero-preview">
|
<aside class="hero-preview">
|
||||||
|
|||||||
@@ -2,13 +2,57 @@ using ChessCubing.App;
|
|||||||
using ChessCubing.App.Services;
|
using ChessCubing.App.Services;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
builder.RootComponents.Add<App>("#app");
|
builder.RootComponents.Add<App>("#app");
|
||||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||||
|
|
||||||
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? "/auth/realms/chesscubing";
|
||||||
|
var keycloakClientId = builder.Configuration["Keycloak:ClientId"] ?? "chesscubing-web";
|
||||||
|
var keycloakResponseType = builder.Configuration["Keycloak:ResponseType"] ?? "code";
|
||||||
|
var postLogoutRedirectUri = builder.Configuration["Keycloak:PostLogoutRedirectUri"] ?? "/";
|
||||||
|
var defaultScopes = builder.Configuration
|
||||||
|
.GetSection("Keycloak:DefaultScopes")
|
||||||
|
.GetChildren()
|
||||||
|
.Select(child => child.Value)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
builder.Services
|
||||||
|
.AddOidcAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.ProviderOptions.Authority = ResolveUri(builder.HostEnvironment.BaseAddress, keycloakAuthority);
|
||||||
|
options.ProviderOptions.ClientId = keycloakClientId;
|
||||||
|
options.ProviderOptions.ResponseType = keycloakResponseType;
|
||||||
|
options.ProviderOptions.RedirectUri = ResolveUri(builder.HostEnvironment.BaseAddress, "authentication/login-callback");
|
||||||
|
options.ProviderOptions.PostLogoutRedirectUri = ResolveUri(builder.HostEnvironment.BaseAddress, postLogoutRedirectUri);
|
||||||
|
|
||||||
|
options.ProviderOptions.DefaultScopes.Clear();
|
||||||
|
foreach (var scope in defaultScopes.Length == 0 ? ["openid", "profile", "email"] : defaultScopes)
|
||||||
|
{
|
||||||
|
options.ProviderOptions.DefaultScopes.Add(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
options.UserOptions.NameClaim = "preferred_username";
|
||||||
|
options.UserOptions.RoleClaim = "role";
|
||||||
|
})
|
||||||
|
.AddAccountClaimsPrincipalFactory<KeycloakAccountFactory>();
|
||||||
|
|
||||||
builder.Services.AddScoped<BrowserBridge>();
|
builder.Services.AddScoped<BrowserBridge>();
|
||||||
|
builder.Services.AddScoped<UserSession>();
|
||||||
builder.Services.AddScoped<MatchStore>();
|
builder.Services.AddScoped<MatchStore>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|
||||||
|
static string ResolveUri(string baseAddress, string value)
|
||||||
|
{
|
||||||
|
if (Uri.TryCreate(value, UriKind.Absolute, out var absoluteUri))
|
||||||
|
{
|
||||||
|
return absoluteUri.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uri(new Uri(baseAddress), value).ToString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ public sealed class BrowserBridge(IJSRuntime jsRuntime)
|
|||||||
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
|
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
|
||||||
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
|
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
|
||||||
|
|
||||||
public ValueTask<string?> ReadMatchJsonAsync()
|
public ValueTask<string?> ReadMatchJsonAsync(string storageKey, string windowNameKey)
|
||||||
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey);
|
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", storageKey, windowNameKey);
|
||||||
|
|
||||||
public ValueTask WriteMatchJsonAsync(string json)
|
public ValueTask WriteMatchJsonAsync(string storageKey, string windowNameKey, string json)
|
||||||
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey, json);
|
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", storageKey, windowNameKey, json);
|
||||||
|
|
||||||
public ValueTask ClearMatchAsync()
|
public ValueTask ClearMatchAsync(string storageKey, string windowNameKey)
|
||||||
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", MatchStore.StorageKey);
|
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", storageKey, windowNameKey);
|
||||||
|
|
||||||
public ValueTask<bool> PlayCubePhaseAlertAsync()
|
public ValueTask<bool> PlayCubePhaseAlertAsync()
|
||||||
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");
|
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");
|
||||||
|
|||||||
53
ChessCubing.App/Services/KeycloakAccountFactory.cs
Normal file
53
ChessCubing.App/Services/KeycloakAccountFactory.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class KeycloakAccountFactory(IAccessTokenProviderAccessor accessor)
|
||||||
|
: AccountClaimsPrincipalFactory<RemoteUserAccount>(accessor)
|
||||||
|
{
|
||||||
|
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
|
||||||
|
RemoteUserAccount account,
|
||||||
|
RemoteAuthenticationUserOptions options)
|
||||||
|
{
|
||||||
|
var user = await base.CreateUserAsync(account, options);
|
||||||
|
if (user.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
|
||||||
|
{
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleClaimType = options.RoleClaim ?? "role";
|
||||||
|
var existingRoles = identity.FindAll(roleClaimType)
|
||||||
|
.Select(claim => claim.Value)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var role in ReadRealmRoles(account))
|
||||||
|
{
|
||||||
|
if (existingRoles.Add(role))
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim(roleClaimType, role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ReadRealmRoles(RemoteUserAccount account)
|
||||||
|
{
|
||||||
|
if (!account.AdditionalProperties.TryGetValue("realm_access", out var realmAccessValue) ||
|
||||||
|
realmAccessValue is not JsonElement realmAccessElement ||
|
||||||
|
realmAccessElement.ValueKind != JsonValueKind.Object ||
|
||||||
|
!realmAccessElement.TryGetProperty("roles", out var rolesElement) ||
|
||||||
|
rolesElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rolesElement.EnumerateArray()
|
||||||
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||||
|
.Select(item => item.GetString())
|
||||||
|
.OfType<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ using ChessCubing.App.Models;
|
|||||||
|
|
||||||
namespace ChessCubing.App.Services;
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
public sealed class MatchStore(BrowserBridge browser)
|
public sealed class MatchStore(BrowserBridge browser, UserSession userSession)
|
||||||
{
|
{
|
||||||
public const string StorageKey = "chesscubing-arena-state-v2";
|
public const string StorageKeyPrefix = "chesscubing-arena-state-v3";
|
||||||
public const string WindowNameKey = "chesscubing-arena-state-v2:";
|
public const string WindowNameKeyPrefix = "chesscubing-arena-state-v3";
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
{
|
{
|
||||||
@@ -16,6 +16,8 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
private bool _dirty;
|
private bool _dirty;
|
||||||
private long _lastPersistedAt;
|
private long _lastPersistedAt;
|
||||||
|
private string? _activeStorageKey;
|
||||||
|
private string? _activeWindowNameKey;
|
||||||
|
|
||||||
public MatchState? Current { get; private set; }
|
public MatchState? Current { get; private set; }
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
public async Task EnsureLoadedAsync()
|
public async Task EnsureLoadedAsync()
|
||||||
{
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
if (IsLoaded)
|
if (IsLoaded)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -30,7 +33,7 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var raw = await browser.ReadMatchJsonAsync();
|
var raw = await browser.ReadMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
{
|
{
|
||||||
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
|
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
|
||||||
@@ -61,6 +64,7 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
public async Task SaveAsync()
|
public async Task SaveAsync()
|
||||||
{
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
if (!IsLoaded)
|
if (!IsLoaded)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -68,14 +72,14 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
if (Current is null)
|
if (Current is null)
|
||||||
{
|
{
|
||||||
await browser.ClearMatchAsync();
|
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_lastPersistedAt = MatchEngine.NowUnixMs();
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(Current, JsonOptions);
|
var json = JsonSerializer.Serialize(Current, JsonOptions);
|
||||||
await browser.WriteMatchJsonAsync(json);
|
await browser.WriteMatchJsonAsync(storageScope.StorageKey, storageScope.WindowNameKey, json);
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_lastPersistedAt = MatchEngine.NowUnixMs();
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
}
|
}
|
||||||
@@ -97,10 +101,27 @@ public sealed class MatchStore(BrowserBridge browser)
|
|||||||
|
|
||||||
public async Task ClearAsync()
|
public async Task ClearAsync()
|
||||||
{
|
{
|
||||||
|
var storageScope = await SyncStorageScopeAsync();
|
||||||
Current = null;
|
Current = null;
|
||||||
IsLoaded = true;
|
IsLoaded = true;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_lastPersistedAt = MatchEngine.NowUnixMs();
|
_lastPersistedAt = MatchEngine.NowUnixMs();
|
||||||
await browser.ClearMatchAsync();
|
await browser.ClearMatchAsync(storageScope.StorageKey, storageScope.WindowNameKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<UserStorageScope> SyncStorageScopeAsync()
|
||||||
|
{
|
||||||
|
var storageScope = await userSession.GetStorageScopeAsync();
|
||||||
|
if (_activeStorageKey is not null &&
|
||||||
|
(_activeStorageKey != storageScope.StorageKey || _activeWindowNameKey != storageScope.WindowNameKey))
|
||||||
|
{
|
||||||
|
Current = null;
|
||||||
|
IsLoaded = false;
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeStorageKey = storageScope.StorageKey;
|
||||||
|
_activeWindowNameKey = storageScope.WindowNameKey;
|
||||||
|
return storageScope;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
ChessCubing.App/Services/UserSession.cs
Normal file
39
ChessCubing.App/Services/UserSession.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
||||||
|
namespace ChessCubing.App.Services;
|
||||||
|
|
||||||
|
public sealed class UserSession(AuthenticationStateProvider authenticationStateProvider)
|
||||||
|
{
|
||||||
|
public async ValueTask<UserStorageScope> GetStorageScopeAsync()
|
||||||
|
{
|
||||||
|
var authState = await authenticationStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userId = ResolveUserId(authState.User);
|
||||||
|
|
||||||
|
return new UserStorageScope(
|
||||||
|
$"{MatchStore.StorageKeyPrefix}:{userId}",
|
||||||
|
$"{MatchStore.WindowNameKeyPrefix}:{userId}:");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveUserId(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawIdentifier = user.FindFirst("sub")?.Value
|
||||||
|
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||||
|
?? user.FindFirst("preferred_username")?.Value
|
||||||
|
?? user.Identity?.Name;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(rawIdentifier))
|
||||||
|
{
|
||||||
|
return "authenticated";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.EscapeDataString(rawIdentifier.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct UserStorageScope(string StorageKey, string WindowNameKey);
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
@using ChessCubing.App.Components
|
@using ChessCubing.App.Components
|
||||||
@using ChessCubing.App.Models
|
@using ChessCubing.App.Models
|
||||||
@using ChessCubing.App.Services
|
@using ChessCubing.App.Services
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
|||||||
13
ChessCubing.App/wwwroot/appsettings.json
Normal file
13
ChessCubing.App/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Keycloak": {
|
||||||
|
"Authority": "/auth/realms/chesscubing",
|
||||||
|
"ClientId": "chesscubing-web",
|
||||||
|
"ResponseType": "code",
|
||||||
|
"PostLogoutRedirectUri": "/",
|
||||||
|
"DefaultScopes": [
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,14 +146,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearMatchState(storageKey) {
|
clearMatchState(storageKey, windowNameKey) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(storageKey);
|
window.localStorage.removeItem(storageKey);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.name = "";
|
if (window.name && window.name.startsWith(windowNameKey)) {
|
||||||
|
window.name = "";
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -22,11 +22,30 @@ Le coeur de l'application se trouve dans `ChessCubing.App/`.
|
|||||||
- `ChessCubing.App/Pages/` : pages Razor du site et de l'application
|
- `ChessCubing.App/Pages/` : pages Razor du site et de l'application
|
||||||
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
|
- `ChessCubing.App/Services/MatchEngine.cs` : logique metier des matchs
|
||||||
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
|
- `ChessCubing.App/Services/MatchStore.cs` : persistance navigateur
|
||||||
|
- `ChessCubing.App/Services/KeycloakAccountFactory.cs` : adaptation des roles Keycloak vers les claims Blazor
|
||||||
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
|
- `ChessCubing.App/wwwroot/` : assets statiques, manifeste, PDFs, appli Ethan
|
||||||
- `docker-compose.yml` + `Dockerfile` : build Blazor puis service via nginx
|
- `keycloak/realm/chesscubing-realm.json` : realm importable avec client OIDC et roles
|
||||||
|
- `docker-compose.yml` + `Dockerfile` : build Blazor, service via nginx et stack Keycloak/Postgres
|
||||||
|
|
||||||
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
|
Le projet continue a exposer les routes historiques `index.html`, `application.html`, `chrono.html`, `cube.html` et `reglement.html`.
|
||||||
|
|
||||||
|
## Authentification Keycloak
|
||||||
|
|
||||||
|
L'application embarque maintenant une authentification OpenID Connect basee sur Keycloak.
|
||||||
|
|
||||||
|
- les pages `application.html`, `chrono.html` et `cube.html` demandent une connexion
|
||||||
|
- la page d'accueil et la page reglement affichent l'etat de session courant
|
||||||
|
- les roles Keycloak du realm sont exposes dans l'application
|
||||||
|
- l'etat du match est isole par utilisateur dans le navigateur grace a une cle de stockage derivee du compte connecte
|
||||||
|
|
||||||
|
Le realm importe par defaut :
|
||||||
|
|
||||||
|
- realm : `chesscubing`
|
||||||
|
- client public OIDC : `chesscubing-web`
|
||||||
|
- roles de realm : `admin`, `organizer`, `player`
|
||||||
|
|
||||||
|
La gestion des utilisateurs se fait ensuite dans la console d'administration Keycloak.
|
||||||
|
|
||||||
## Demarrage local
|
## Demarrage local
|
||||||
|
|
||||||
### Avec Docker
|
### Avec Docker
|
||||||
@@ -38,6 +57,17 @@ docker compose up -d --build
|
|||||||
|
|
||||||
L'application est ensuite disponible sur `http://localhost:8080`.
|
L'application est ensuite disponible sur `http://localhost:8080`.
|
||||||
|
|
||||||
|
La console Keycloak est servie via le meme nginx sur `http://localhost:8080/auth/admin/`.
|
||||||
|
|
||||||
|
Identifiants d'administration par defaut pour le premier demarrage local :
|
||||||
|
|
||||||
|
- utilisateur : `admin`
|
||||||
|
- mot de passe : `admin`
|
||||||
|
|
||||||
|
Ces valeurs peuvent etre surchargees via les variables d'environnement de `.env.example`.
|
||||||
|
|
||||||
|
Si vous voulez reimporter completement le realm fourni dans `keycloak/realm/`, il faut repartir d'une base vide, par exemple avec `docker compose down -v` avant le redemarrage.
|
||||||
|
|
||||||
### Avec .NET 10
|
### Avec .NET 10
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -45,6 +75,8 @@ dotnet restore ChessCubing.App/ChessCubing.App.csproj
|
|||||||
dotnet run --project ChessCubing.App/ChessCubing.App.csproj
|
dotnet run --project ChessCubing.App/ChessCubing.App.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Le mode Docker reste la voie recommandee pour cette integration, car nginx y reverse-proxy egalement Keycloak sous `/auth/`.
|
||||||
|
|
||||||
## Deploiement dans un LXC Proxmox
|
## Deploiement dans un LXC Proxmox
|
||||||
|
|
||||||
Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git.
|
Deux scripts Bash permettent de creer un conteneur LXC Debian sur Proxmox puis de le mettre a jour depuis Git.
|
||||||
@@ -56,6 +88,8 @@ Prerrequis sur la machine qui lance les scripts :
|
|||||||
|
|
||||||
Le deploiement dans le LXC n'utilise pas Docker. Le script clone le depot, publie l'application Blazor dans le conteneur, puis sert le resultat via `nginx`.
|
Le deploiement dans le LXC n'utilise pas Docker. Le script clone le depot, publie l'application Blazor dans le conteneur, puis sert le resultat via `nginx`.
|
||||||
|
|
||||||
|
Attention : la pile Keycloak fournie ici est actuellement prete a l'emploi dans la stack Docker du projet. Les scripts LXC existants ne provisionnent pas encore automatiquement Keycloak ni sa base Postgres.
|
||||||
|
|
||||||
### Installer un nouveau LXC
|
### Installer un nouveau LXC
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -95,6 +129,9 @@ bash -c "$(curl -fsSL https://git.jeannerot.fr/christophe/chesscubing/raw/branch
|
|||||||
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
|
- `ChessCubing.App/Pages/CubePage.razor` : phase cube
|
||||||
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
- `ChessCubing.App/Pages/RulesPage.razor` : synthese du reglement
|
||||||
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
|
- `ChessCubing.App/Services/MatchEngine.cs` : regles de jeu et transitions
|
||||||
|
- `ChessCubing.App/Services/KeycloakAccountFactory.cs` : transformation des roles Keycloak en claims Blazor
|
||||||
|
- `ChessCubing.App/wwwroot/appsettings.json` : configuration OIDC du client WebAssembly
|
||||||
|
- `keycloak/realm/chesscubing-realm.json` : realm, roles et client Keycloak importes
|
||||||
- `docker-compose.yml` + `Dockerfile` : execution locale
|
- `docker-compose.yml` + `Dockerfile` : execution locale
|
||||||
- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox
|
- `scripts/install-proxmox-lxc.sh` : creation et deploiement d'un LXC Proxmox
|
||||||
- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git
|
- `scripts/update-proxmox-lxc.sh` : mise a jour d'un LXC existant depuis Git
|
||||||
|
|||||||
@@ -2,6 +2,52 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
container_name: chesscubing-web
|
container_name: chesscubing-web
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.2
|
||||||
|
container_name: chesscubing-keycloak
|
||||||
|
command: ["start-dev", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
KC_DB: postgres
|
||||||
|
KC_DB_URL_HOST: postgres
|
||||||
|
KC_DB_URL_PORT: 5432
|
||||||
|
KC_DB_URL_DATABASE: ${KEYCLOAK_DB_NAME:-keycloak}
|
||||||
|
KC_DB_USERNAME: ${KEYCLOAK_DB_USER:-keycloak}
|
||||||
|
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||||
|
KC_PROXY_HEADERS: xforwarded
|
||||||
|
KC_HOSTNAME: http://localhost:8080/auth
|
||||||
|
KC_HTTP_RELATIVE_PATH: /auth
|
||||||
|
KC_HOSTNAME_STRICT: "false"
|
||||||
|
volumes:
|
||||||
|
- ./keycloak/realm:/opt/keycloak/data/import:ro
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: chesscubing-keycloak-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${KEYCLOAK_DB_NAME:-keycloak}
|
||||||
|
POSTGRES_USER: ${KEYCLOAK_DB_USER:-keycloak}
|
||||||
|
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak}
|
||||||
|
volumes:
|
||||||
|
- keycloak-postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${KEYCLOAK_DB_USER:-keycloak} -d ${KEYCLOAK_DB_NAME:-keycloak}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
keycloak-postgres:
|
||||||
|
|||||||
62
keycloak/realm/chesscubing-realm.json
Normal file
62
keycloak/realm/chesscubing-realm.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"realm": "chesscubing",
|
||||||
|
"enabled": true,
|
||||||
|
"displayName": "ChessCubing",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"rememberMe": true,
|
||||||
|
"resetPasswordAllowed": true,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"sslRequired": "external",
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"description": "Administrateur ChessCubing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "organizer",
|
||||||
|
"description": "Organisateur de rencontre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "player",
|
||||||
|
"description": "Joueur ChessCubing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "chesscubing-web",
|
||||||
|
"name": "ChessCubing Web",
|
||||||
|
"description": "Client OIDC public pour l'application Blazor WebAssembly.",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"rootUrl": "http://localhost:8080/",
|
||||||
|
"baseUrl": "http://localhost:8080/",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:8080/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:8080"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "http://localhost:8080/*"
|
||||||
|
},
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"acr",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
nginx.conf
10
nginx.conf
@@ -21,6 +21,16 @@ server {
|
|||||||
try_files $uri $uri/ /brice/index.html;
|
try_files $uri $uri/ /brice/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /auth/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://keycloak:8080/auth/;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
41
styles.css
41
styles.css
@@ -1047,6 +1047,36 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
|
|||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-access-bar {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(12, 14, 20, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-access-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-access-copy strong {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-access-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-access-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.rule-metrics {
|
.rule-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -1310,12 +1340,23 @@ body[data-page="cube"] .zone-button.cube-hold-ready::after {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-access-bar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-link,
|
.brand-link,
|
||||||
.utility-button {
|
.utility-button {
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.user-access-bar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.phase-body {
|
.phase-body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user