Implement Google authentication (for Web) and user management system

This commit is contained in:
Michał Zieliński
2025-07-19 22:50:38 +02:00
parent b673fd2da3
commit 14c61ca1ee
25 changed files with 1072 additions and 41 deletions

View File

@@ -24,11 +24,7 @@
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Pages\ProductList.razor" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
<Folder Include="wwwroot\images\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
@using Bimix.UI.Shared.Services
@inject AuthService AuthService
@inject NavigationManager Navigation
@if (_isLoading)
{
<div class="d-flex justify-center align-center" style="height: 100vh;">
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
</div>
}
else if (_isAuthenticated)
{
@ChildContent
}
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
private bool _isLoading = true;
private bool _isAuthenticated = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
Console.WriteLine("AuthGuard: Checking authentication...");
// ZAWSZE sprawdź localStorage przy inicjalizacji
_isAuthenticated = await AuthService.CheckAuthenticationAsync();
_isLoading = false;
Console.WriteLine($"AuthGuard: isAuthenticated={_isAuthenticated}");
if (!_isAuthenticated)
{
Console.WriteLine("AuthGuard: Redirecting to /login");
Navigation.NavigateTo("/login", replace: true);
}
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,121 @@
@using Microsoft.Extensions.Configuration
@using Bimix.UI.Shared.Services
@inject IJSRuntime JS
@inject IConfiguration Configuration
@inject AuthService AuthService
@inject NavigationManager NavigationManager
<MudCard Class="login-card" Elevation="8">
<MudCardContent Class="pa-8 d-flex flex-column align-center">
<MudText Typo="Typo.h4" Class="mb-4">Witaj w Bimix</MudText>
<MudText Typo="Typo.body1" Class="mb-6 text-center">
Zaloguj się używając konta Google
</MudText>
<MudButton
Variant="Variant.Filled"
StartIcon="@Icons.Custom.Brands.Google"
Size="Size.Large"
OnClick="HandleGoogleSignIn"
Disabled="@_isLoading">
@if (_isLoading)
{
<MudProgressCircular Class="mr-3" Size="Size.Small" Indeterminate="true"></MudProgressCircular>
}
else
{
<span>Zaloguj z Google</span>
}
</MudButton>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">
@_errorMessage
</MudAlert>
}
</MudCardContent>
</MudCard>
@code {
private bool _isLoading = false;
private string _errorMessage = string.Empty;
private static LoginCard? _instance;
protected override void OnInitialized()
{
_instance = this;
}
private async Task HandleGoogleSignIn()
{
try
{
_isLoading = true;
_errorMessage = string.Empty;
StateHasChanged();
var clientId = Configuration["GoogleAuth:ClientId"];
if (string.IsNullOrEmpty(clientId))
{
throw new Exception("Google ClientId is not configured.");
}
await JS.InvokeVoidAsync("initGoogleSignIn", clientId);
}
catch (Exception ex)
{
_errorMessage = "Błąd podczas logownia. Spróbuj ponownie";
_isLoading = false;
StateHasChanged();
}
}
[JSInvokable]
public static async Task OnGoogleSignInSuccess(string idToken)
{
Console.WriteLine($"Google ID Token: {idToken}");
if (_instance != null)
{
await _instance.AuthService.SetAuthenticationAsync(idToken);
_instance._isLoading = false;
_instance._errorMessage = string.Empty;
_instance.NavigationManager.NavigateTo("/dashboard", replace:true);
await _instance.InvokeAsync(() => _instance.StateHasChanged());
}
}
[JSInvokable]
public static async Task OnGoggleSignInError(string error)
{
Console.WriteLine($"Google SignIn Error: {error}");
if (_instance != null)
{
_instance._isLoading = false;
_instance._errorMessage = "Błąd logowanie Google. Spróbuj ponownie";
await _instance.InvokeAsync(() => _instance.StateHasChanged());
}
}
}
<style>
.login-card {
max-width: 400px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.google-signin-button {
width: 100%;
padding: 12px 24px;
}
</style>

View File

@@ -0,0 +1,8 @@
@inherits LayoutComponentBase
<MudThemeProvider/>
<MudDialogProvider/>
<MudSnackbarProvider/>
@Body

View File

@@ -1,41 +1,43 @@
@using MudBlazor
@inherits LayoutComponentBase
<MudThemeProvider/>
<MudDialogProvider/>
<MudSnackbarProvider/>
<AuthGuard>
<MudThemeProvider/>
<MudDialogProvider/>
<MudSnackbarProvider/>
<MudLayout>
<MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider>
<MudAppBar Elevation="0">
<MudIconButton
Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer"
Class="mud-hidden-md-up"/>
<MudSpacer/>
<MudText Typo="Typo.h6">Bimix</MudText>
</MudAppBar>
<MudLayout>
<MudBreakpointProvider OnBreakpointChanged="OnBreakpointChanged"></MudBreakpointProvider>
<MudAppBar Elevation="0">
<MudIconButton
Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer"
Class="mud-hidden-md-up"/>
<MudSpacer/>
<MudText Typo="Typo.h6">Bimix</MudText>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen"
Anchor="Anchor.Start"
Variant="@_drawerVariant"
Elevation="1"
ClipMode="DrawerClipMode.Always"
Class="mud-width-250">
<MudNavMenu>
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
<MudNavLink Href="/products" Icon="@Icons.Material.Filled.Inventory">Products</MudNavLink>
</MudNavMenu>
</MudDrawer>
<MudDrawer @bind-Open="_drawerOpen"
Anchor="Anchor.Start"
Variant="@_drawerVariant"
Elevation="1"
ClipMode="DrawerClipMode.Always"
Class="mud-width-250">
<MudNavMenu>
<MudNavLink Href="/dashboard" Icon="@Icons.Material.Filled.Dashboard">Dashboard</MudNavLink>
<MudNavLink Href="/products" Icon="@Icons.Material.Filled.Inventory">Products</MudNavLink>
</MudNavMenu>
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="my-4">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.ExtraExtraLarge" Class="my-4">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
</AuthGuard>
@code {
@@ -60,7 +62,8 @@
_drawerVariant = DrawerVariant.Persistent;
_drawerOpen = true;
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,46 @@
@page "/login"
@layout EmptyLayout
<div class="login-page">
<div class="login-container">
<LoginCard />
</div>
</div>
<style>
html, body {
margin: 0 !important;
padding: 0 !important;
height: 100% !important;
overflow: hidden !important;
}
#app {
height: 100% !important;
}
.login-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('_content/Bimix.UI.Shared/images/login-background.jpg') no-repeat center;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
padding: 20px;
width: 100%;
max-width: 450px;
}
</style>

View File

@@ -0,0 +1,86 @@
using Microsoft.JSInterop;
namespace Bimix.UI.Shared.Services;
public class AuthService
{
private readonly IJSRuntime _jsRuntime;
private bool? _isAuthenticated;
public event Action<bool>? AuthenticationStateChanged;
public AuthService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public bool IsAuthenticated => _isAuthenticated ?? false;
public async Task<bool> CheckAuthenticationAsync()
{
try
{
var token = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "google_token");
_isAuthenticated = !string.IsNullOrEmpty(token);
Console.WriteLine($"AuthService.CheckAuthentication: token={(!string.IsNullOrEmpty(token) ? "EXISTS" : "NULL")}, isAuth={_isAuthenticated}");
return _isAuthenticated.Value;
}
catch (Exception ex)
{
Console.WriteLine($"AuthService.CheckAuthentication ERROR: {ex.Message}");
_isAuthenticated = false;
return false;
}
}
public async Task SetAuthenticationAsync(string token)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "google_token", token);
_isAuthenticated = true;
Console.WriteLine($"AuthService.SetAuthentication: token saved, isAuth={_isAuthenticated}");
AuthenticationStateChanged?.Invoke(true);
}
catch (Exception ex)
{
Console.WriteLine($"AuthService.SetAuthentication ERROR: {ex.Message}");
}
}
public async Task ClearAuthenticationAsync()
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "google_token");
_isAuthenticated = false;
Console.WriteLine($"AuthService.ClearAuthentication: token removed");
AuthenticationStateChanged?.Invoke(false);
}
catch (Exception ex)
{
Console.WriteLine($"AuthService.ClearAuthentication ERROR: {ex.Message}");
}
}
public async Task<string?> GetTokenAsync()
{
if (_isAuthenticated != true)
{
await CheckAuthenticationAsync();
}
if (_isAuthenticated != true) return null;
try
{
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "google_token");
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Bimix.UI.Shared.Services;
// TODO it's a good place for this file?
public class GoogleAuthConfig
{
public string ClientId { get; set; } = string.Empty;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

View File

@@ -0,0 +1,35 @@
window.initGoogleSignIn = async function(clientId) {
try {
if (!clientId) {
throw new Error('ClientId is required');
}
// Inicjalizacja Google Sign-In z dynamicznym ClientId
google.accounts.id.initialize({
client_id: clientId,
callback: handleGoogleResponse,
auto_select: false,
cancel_on_tap_outside: true
});
// Wyświetl popup logowania
google.accounts.id.prompt((notification) => {
if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
console.log('Google Sign-In popup not displayed');
}
});
} catch (error) {
console.error('Google Sign-In initialization error:', error);
DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', error.message);
}
};
function handleGoogleResponse(response) {
if (response.credential) {
// Token otrzymany - wyślij do Blazor
DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInSuccess', response.credential);
} else {
DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', 'No credential received');
}
}