Add users management page.

This commit is contained in:
2025-11-16 23:09:47 +00:00
parent 80ba603265
commit b06406e20f
10 changed files with 1208 additions and 19 deletions

View File

@@ -33,15 +33,16 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.11"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="9.0.11"/>
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1"/>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls" Version="7.1.2" />
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI.Controls" Version="7.1.200" />
<!-- Add more uno community toolkit references here -->
</ItemGroup>
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls" Version="7.1.2"/>
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI.Controls" Version="7.1.200"/>
<!-- Add more uno community toolkit references here -->
</ItemGroup>
</Project>

View File

@@ -21,6 +21,7 @@
<local:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<local:InvertBoolToVisibilityConverter x:Key="InvertBoolToVisibilityConverter" />
<local:NullToVisibilityConverter x:Key="NullToVisibilityConverter" />
<local:RolesListConverter x:Key="RolesListConverter" />
</ResourceDictionary>
</Application.Resources>

View File

@@ -129,6 +129,7 @@ public partial class App : Application
AuthService>();
services.AddSingleton<ITokenService, TokenService>();
services.AddSingleton<IJwtService, JwtService>();
services.AddSingleton<FlagCache>();
services.AddSingleton<CompanyLogoCache>();
services.AddSingleton<MachinePhotoCache>();
@@ -200,6 +201,7 @@ public partial class App : Application
new ViewMap<SoundSynthListPage, SoundSynthsListViewModel>(),
new ViewMap<SoundSynthDetailPage, SoundSynthDetailViewModel>(),
new ViewMap<SettingsPage, SettingsViewModel>(),
new ViewMap<UsersPage, UsersViewModel>(),
new DataViewMap<SecondPage, SecondViewModel, Entity>());
routes.Register(new RouteMap("",
@@ -247,7 +249,8 @@ public partial class App : Application
]),
new RouteMap("gpus",
views.FindByViewModel<GpuListViewModel>(),
Nested:
Nested
:
[
new RouteMap("gpu-details",
views.FindByViewModel<
@@ -275,6 +278,8 @@ public partial class App : Application
]),
new RouteMap("settings",
views.FindByViewModel<SettingsViewModel>()),
new RouteMap("users",
views.FindByViewModel<UsersViewModel>()),
new RouteMap("Second",
views.FindByViewModel<SecondViewModel>())
])

View File

@@ -41,10 +41,11 @@
</UnoFeatures>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Marechai.Data\Marechai.Data.csproj" />
<ProjectReference Include="..\Marechai.Data\Marechai.Data.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Humanizer" />
<PackageReference Include="Humanizer"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
</ItemGroup>
<ItemGroup>
<Compile Update="Presentation\Views\Shell.xaml.cs">
@@ -79,16 +80,20 @@
<DependentUpon>Sidebar.xaml</DependentUpon>
<IsDefaultItem>true</IsDefaultItem>
</Compile>
<Compile Update="Presentation\Views\UsersPage.xaml.cs">
<DependentUpon>UsersPage.xaml</DependentUpon>
<IsDefaultItem>true</IsDefaultItem>
</Compile>
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls" />
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI.Controls" />
<!-- Add more uno community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
<PackageReference Include="CommunityToolkit.WinUI.UI.Controls"/>
<!-- Add more community toolkit references here -->
</ItemGroup>
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI.Controls"/>
<!-- Add more uno community toolkit references here -->
</ItemGroup>
</Project>

View File

@@ -231,6 +231,19 @@
Visibility="{Binding IsSidebarOpen}">
<StackPanel Orientation="Vertical"
Spacing="0">
<!-- Users (Uberadmin only) -->
<Button Content="User Management"
Command="{Binding NavigateToUsersCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="16,10,16,10"
FontSize="13"
Background="Transparent"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
BorderThickness="0"
CornerRadius="0"
Visibility="{Binding IsUberadminUser, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Login/Logout -->
<Button Content="{Binding LoginLogoutButtonText}"
Command="{Binding LoginLogoutCommand}"

View File

@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
using Marechai.App.Services;
using Marechai.App.Services.Authentication;
using Uno.Extensions.Authentication;
using Uno.Extensions.Navigation;
using Uno.Extensions.Toolkit;
@@ -13,11 +15,15 @@ namespace Marechai.App.Presentation.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly IAuthenticationService _authService;
private readonly IJwtService _jwtService;
private readonly IStringLocalizer _localizer;
private readonly INavigator _navigator;
private readonly ITokenService _tokenService;
[ObservableProperty]
private bool _isSidebarOpen = true;
[ObservableProperty]
private bool _isUberadminUser;
[ObservableProperty]
private Dictionary<string, string> _localizedStrings = new();
[ObservableProperty]
private string _loginLogoutButtonText = "";
@@ -31,11 +37,13 @@ public partial class MainViewModel : ObservableObject
public MainViewModel(IStringLocalizer localizer, IOptions<AppConfig> appInfo, INavigator navigator,
NewsViewModel newsViewModel, IColorThemeService colorThemeService, IThemeService themeService,
IAuthenticationService authService)
IAuthenticationService authService, IJwtService jwtService, ITokenService tokenService)
{
_navigator = navigator;
_localizer = localizer;
_authService = authService;
_jwtService = jwtService;
_tokenService = tokenService;
NewsViewModel = newsViewModel;
Title = "Marechai";
Title += $" - {localizer["ApplicationName"]}";
@@ -63,6 +71,7 @@ public partial class MainViewModel : ObservableObject
NavigateToProcessorsCommand = new AsyncRelayCommand(() => NavigateTo("processors"));
NavigateToSoftwareCommand = new AsyncRelayCommand(() => NavigateTo("software"));
NavigateToSoundSynthesizersCommand = new AsyncRelayCommand(() => NavigateTo("sound-synths"));
NavigateToUsersCommand = new AsyncRelayCommand(() => NavigateTo("users"));
NavigateToSettingsCommand = new AsyncRelayCommand(() => NavigateTo("settings"));
LoginLogoutCommand = new RelayCommand(HandleLoginLogout);
ToggleSidebarCommand = new RelayCommand(() => IsSidebarOpen = !IsSidebarOpen);
@@ -71,6 +80,7 @@ public partial class MainViewModel : ObservableObject
_authService.LoggedOut += OnLoggedOut;
UpdateLoginLogoutButtonText();
UpdateUberadminStatus();
}
public string? Title { get; }
@@ -90,6 +100,7 @@ public partial class MainViewModel : ObservableObject
public ICommand NavigateToProcessorsCommand { get; }
public ICommand NavigateToSoftwareCommand { get; }
public ICommand NavigateToSoundSynthesizersCommand { get; }
public ICommand NavigateToUsersCommand { get; }
public ICommand NavigateToSettingsCommand { get; }
public ICommand LoginLogoutCommand { get; }
public ICommand ToggleSidebarCommand { get; }
@@ -171,16 +182,38 @@ public partial class MainViewModel : ObservableObject
LoginLogoutButtonText = isAuthenticated ? LocalizedStrings["Logout"] : LocalizedStrings["Login"];
}
private void UpdateUberadminStatus()
{
try
{
string token = _tokenService.GetToken();
if(!string.IsNullOrWhiteSpace(token))
{
IEnumerable<string> roles = _jwtService.GetRoles(token);
IsUberadminUser = roles.Contains("Uberadmin", StringComparer.OrdinalIgnoreCase);
}
else
IsUberadminUser = false;
}
catch
{
IsUberadminUser = false;
}
}
private void OnLoggedOut(object? sender, EventArgs e)
{
// Update button text when user logs out
UpdateLoginLogoutButtonText();
UpdateUberadminStatus();
}
public void RefreshAuthenticationState()
{
// Public method to refresh authentication state (called after login)
UpdateLoginLogoutButtonText();
UpdateUberadminStatus();
}
private async void HandleLoginLogout()
@@ -192,6 +225,7 @@ public partial class MainViewModel : ObservableObject
// Logout
await _authService.LogoutAsync(null, CancellationToken.None);
UpdateLoginLogoutButtonText();
UpdateUberadminStatus();
}
else
{

View File

@@ -0,0 +1,440 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Marechai.App.Services.Authentication;
namespace Marechai.App.Presentation.ViewModels;
/// <summary>
/// ViewModel for user management page (Uberadmin only)
/// </summary>
public partial class UsersViewModel : ObservableObject
{
private readonly ApiClient _apiClient;
private readonly IJwtService _jwtService;
private readonly IStringLocalizer _localizer;
private readonly ILogger<UsersViewModel> _logger;
private readonly ITokenService _tokenService;
[ObservableProperty]
private ObservableCollection<string> _availableRoles = [];
[ObservableProperty]
private string _confirmPassword = string.Empty;
[ObservableProperty]
private string _dialogTitle = string.Empty;
[ObservableProperty]
private string _dialogType = string.Empty;
private string? _editingUserId;
[ObservableProperty]
private string _email = string.Empty;
[ObservableProperty]
private string _errorMessage = string.Empty;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _isDataLoaded;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _password = string.Empty;
[ObservableProperty]
private string _phoneNumber = string.Empty;
[ObservableProperty]
private string? _selectedRole;
[ObservableProperty]
private UserDto? _selectedUser;
[ObservableProperty]
private string _userName = string.Empty;
[ObservableProperty]
private ObservableCollection<string> _userRoles = [];
[ObservableProperty]
private ObservableCollection<UserDto> _users = [];
public UsersViewModel(ApiClient apiClient, IJwtService jwtService, ITokenService tokenService,
ILogger<UsersViewModel> logger, IStringLocalizer localizer)
{
_apiClient = apiClient;
_jwtService = jwtService;
_tokenService = tokenService;
_logger = logger;
_localizer = localizer;
LoadUsersCommand = new AsyncRelayCommand(LoadUsersAsync);
DeleteUserCommand = new AsyncRelayCommand<UserDto>(DeleteUserAsync);
OpenAddUserDialogCommand = new AsyncRelayCommand(OpenAddUserDialogAsync);
OpenEditUserDialogCommand = new AsyncRelayCommand<UserDto>(OpenEditUserDialogAsync);
OpenChangePasswordDialogCommand = new AsyncRelayCommand<UserDto>(OpenChangePasswordDialogAsync);
OpenManageRolesDialogCommand = new AsyncRelayCommand<UserDto>(OpenManageRolesDialogAsync);
SaveUserCommand = new AsyncRelayCommand(SaveUserAsync);
SavePasswordCommand = new AsyncRelayCommand(SavePasswordAsync);
AddRoleCommand = new AsyncRelayCommand(AddRoleAsync);
RemoveRoleCommand = new AsyncRelayCommand<string>(RemoveRoleAsync);
CloseDialogCommand = new RelayCommand(CloseDialog);
}
public IAsyncRelayCommand LoadUsersCommand { get; }
public IAsyncRelayCommand<UserDto> DeleteUserCommand { get; }
public IAsyncRelayCommand OpenAddUserDialogCommand { get; }
public IAsyncRelayCommand<UserDto> OpenEditUserDialogCommand { get; }
public IAsyncRelayCommand<UserDto> OpenChangePasswordDialogCommand { get; }
public IAsyncRelayCommand<UserDto> OpenManageRolesDialogCommand { get; }
public IAsyncRelayCommand SaveUserCommand { get; }
public IAsyncRelayCommand SavePasswordCommand { get; }
public IAsyncRelayCommand AddRoleCommand { get; }
public IAsyncRelayCommand<string> RemoveRoleCommand { get; }
public IRelayCommand CloseDialogCommand { get; }
/// <summary>
/// Checks if the current user is Uberadmin
/// </summary>
public bool IsUberadmin
{
get
{
string? token = _tokenService.GetToken();
IEnumerable<string>? roles = _jwtService.GetRoles(token);
return roles.Contains("Uberadmin", StringComparer.OrdinalIgnoreCase);
}
}
public event EventHandler<string>? ShowDialogRequested;
private async Task LoadUsersAsync()
{
try
{
IsLoading = true;
HasError = false;
ErrorMessage = string.Empty;
Users.Clear();
List<UserDto>? usersResponse = await _apiClient.Users.GetAsync();
if(usersResponse != null)
{
foreach(UserDto user in usersResponse) Users.Add(user);
IsDataLoaded = true;
}
}
catch(Exception ex)
{
_logger.LogError(ex, "Error loading users");
ErrorMessage = _localizer["Failed to load users. Please try again."];
HasError = true;
}
finally
{
IsLoading = false;
}
}
private async Task DeleteUserAsync(UserDto? user)
{
if(user?.Id == null) return;
try
{
await _apiClient.Users[user.Id].DeleteAsync();
await LoadUsersAsync();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error deleting user");
ErrorMessage = _localizer["Failed to delete user."];
HasError = true;
}
}
private async Task OpenAddUserDialogAsync()
{
try
{
_editingUserId = null;
DialogTitle = _localizer["Add User"];
DialogType = "AddEdit";
Email = string.Empty;
UserName = string.Empty;
PhoneNumber = string.Empty;
Password = string.Empty;
ConfirmPassword = string.Empty;
ShowDialogRequested?.Invoke(this, "AddEdit");
}
catch(Exception ex)
{
_logger.LogError(ex, "Error opening add user dialog");
}
}
private async Task OpenEditUserDialogAsync(UserDto? user)
{
if(user?.Id == null) return;
try
{
_editingUserId = user.Id;
DialogTitle = _localizer["Edit User"];
DialogType = "AddEdit";
Email = user.Email ?? string.Empty;
UserName = user.UserName ?? string.Empty;
PhoneNumber = user.PhoneNumber ?? string.Empty;
Password = string.Empty;
ConfirmPassword = string.Empty;
ShowDialogRequested?.Invoke(this, "AddEdit");
}
catch(Exception ex)
{
_logger.LogError(ex, "Error opening edit user dialog");
}
}
private async Task OpenChangePasswordDialogAsync(UserDto? user)
{
if(user?.Id == null) return;
try
{
_editingUserId = user.Id;
DialogTitle = _localizer["Change Password"];
DialogType = "Password";
Password = string.Empty;
ConfirmPassword = string.Empty;
ShowDialogRequested?.Invoke(this, "Password");
}
catch(Exception ex)
{
_logger.LogError(ex, "Error opening change password dialog");
}
}
private async Task OpenManageRolesDialogAsync(UserDto? user)
{
if(user?.Id == null) return;
try
{
_editingUserId = user.Id;
DialogTitle = _localizer["Manage Roles"];
DialogType = "Roles";
// Load available roles
List<string>? rolesResponse = await _apiClient.Users.Roles.GetAsync();
AvailableRoles.Clear();
if(rolesResponse != null)
{
foreach(string role in rolesResponse)
if(!string.IsNullOrWhiteSpace(role))
AvailableRoles.Add(role);
}
_logger.LogInformation($"Loaded {AvailableRoles.Count} available roles");
// Load user's current roles
UserRoles.Clear();
if(user.Roles != null)
foreach(string role in user.Roles)
UserRoles.Add(role);
ShowDialogRequested?.Invoke(this, "Roles");
}
catch(Exception ex)
{
_logger.LogError(ex, "Error opening manage roles dialog");
}
}
private async Task SaveUserAsync()
{
try
{
if(string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(UserName))
{
ErrorMessage = _localizer["Email and username are required."];
HasError = true;
return;
}
if(_editingUserId == null)
{
// Create new user
if(string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = _localizer["Password is required for new users."];
HasError = true;
return;
}
if(Password != ConfirmPassword)
{
ErrorMessage = _localizer["Passwords do not match."];
HasError = true;
return;
}
var createRequest = new CreateUserRequest
{
Email = Email,
UserName = UserName,
PhoneNumber = PhoneNumber,
Password = Password
};
await _apiClient.Users.PostAsync(createRequest);
}
else
{
// Update existing user
var updateRequest = new UpdateUserRequest
{
Email = Email,
UserName = UserName,
PhoneNumber = PhoneNumber
};
await _apiClient.Users[_editingUserId].PutAsync(updateRequest);
}
CloseDialog();
await LoadUsersAsync();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error saving user");
ErrorMessage = _localizer["Failed to save user."];
HasError = true;
}
}
private async Task SavePasswordAsync()
{
if(_editingUserId == null) return;
try
{
if(string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = _localizer["Password is required."];
HasError = true;
return;
}
if(Password != ConfirmPassword)
{
ErrorMessage = _localizer["Passwords do not match."];
HasError = true;
return;
}
var changePasswordRequest = new ChangePasswordRequest
{
NewPassword = Password
};
await _apiClient.Users[_editingUserId].Password.PostAsync(changePasswordRequest);
CloseDialog();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error changing password");
ErrorMessage = _localizer["Failed to change password."];
HasError = true;
}
}
private async Task AddRoleAsync()
{
if(_editingUserId == null || string.IsNullOrWhiteSpace(SelectedRole)) return;
try
{
if(UserRoles.Contains(SelectedRole))
{
ErrorMessage = _localizer["User already has this role."];
HasError = true;
return;
}
var addRoleRequest = new UserRoleRequest
{
RoleName = SelectedRole
};
await _apiClient.Users[_editingUserId].Roles.PostAsync(addRoleRequest);
UserRoles.Add(SelectedRole);
// Reload users to refresh the list
await LoadUsersAsync();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error adding role");
ErrorMessage = _localizer["Failed to add role."];
HasError = true;
}
}
private async Task RemoveRoleAsync(string? role)
{
if(_editingUserId == null || string.IsNullOrWhiteSpace(role)) return;
try
{
await _apiClient.Users[_editingUserId].Roles[role].DeleteAsync();
UserRoles.Remove(role);
// Reload users to refresh the list
await LoadUsersAsync();
}
catch(Exception ex)
{
_logger.LogError(ex, "Error removing role");
ErrorMessage = _localizer["Failed to remove role."];
HasError = true;
}
}
private void CloseDialog()
{
DialogType = string.Empty;
_editingUserId = null;
Email = string.Empty;
UserName = string.Empty;
PhoneNumber = string.Empty;
Password = string.Empty;
ConfirmPassword = string.Empty;
UserRoles.Clear();
AvailableRoles.Clear();
HasError = false;
ErrorMessage = string.Empty;
}
}

View File

@@ -0,0 +1,184 @@
<?xml version="1.0"
encoding="utf-8"?>
<Page x:Class="Marechai.App.Presentation.Views.UsersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.UI.Controls"
x:Name="PageRoot"
NavigationCacheMode="Required"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid RowSpacing="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header with Title -->
<Grid Grid.Row="0"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
Padding="16,12">
<!-- Page Title -->
<StackPanel VerticalAlignment="Center">
<TextBlock Text="User Management"
FontSize="20"
FontWeight="SemiBold"
Foreground="{ThemeResource TextControlForeground}" />
<TextBlock Text="Manage users, roles, and permissions"
FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}"
Margin="0,4,0,0" />
</StackPanel>
</Grid>
<!-- Main Content -->
<Grid Grid.Row="1">
<!-- Access Denied Message -->
<StackPanel Visibility="{Binding IsUberadmin, Converter={StaticResource InvertBoolToVisibilityConverter}}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="24"
Spacing="16"
MaxWidth="400">
<InfoBar IsOpen="True"
Severity="Warning"
Title="Access Denied"
Message="You must be an Uberadmin to access user management."
IsClosable="False" />
</StackPanel>
<!-- Content (only visible to Uberadmin) -->
<Grid Visibility="{Binding IsUberadmin, Converter={StaticResource BoolToVisibilityConverter}}"
Canvas.ZIndex="0">
<!-- Loading State -->
<StackPanel Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="32"
Spacing="16">
<ProgressRing IsActive="True"
IsIndeterminate="True"
Height="64"
Width="64"
Foreground="{ThemeResource SystemAccentColor}" />
<TextBlock Text="Loading users..."
FontSize="14"
TextAlignment="Center"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</StackPanel>
<!-- Error State -->
<StackPanel Visibility="{Binding HasError, Converter={StaticResource BoolToVisibilityConverter}}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="24"
Spacing="16"
MaxWidth="400"
Canvas.ZIndex="100">
<InfoBar IsOpen="True"
Severity="Error"
Title="Unable to Load Users"
Message="{Binding ErrorMessage}"
IsClosable="False" />
<Button Content="Retry"
Command="{Binding LoadUsersCommand}"
HorizontalAlignment="Center"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
<!-- Users DataGrid -->
<Grid Visibility="{Binding IsDataLoaded, Converter={StaticResource BoolToVisibilityConverter}}"
Canvas.ZIndex="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Command Bar -->
<CommandBar Grid.Row="0"
DefaultLabelPosition="Right">
<AppBarButton Icon="Add"
Label="Add User"
Command="{Binding OpenAddUserDialogCommand}" />
<AppBarButton Icon="Refresh"
Label="Refresh"
Command="{Binding LoadUsersCommand}" />
</CommandBar>
<!-- DataGrid -->
<controls:DataGrid Grid.Row="1"
Margin="16"
ItemsSource="{Binding Users}"
SelectedItem="{Binding SelectedUser, Mode=TwoWay}"
AutoGenerateColumns="False"
GridLinesVisibility="None"
HeadersVisibility="Column"
AlternatingRowBackground="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
IsReadOnly="True"
SelectionMode="Single"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<controls:DataGrid.Columns>
<controls:DataGridTextColumn Header="Email"
Binding="{Binding Email}"
Width="2*" />
<controls:DataGridTextColumn Header="Username"
Binding="{Binding UserName}"
Width="*" />
<controls:DataGridTextColumn Header="Phone"
Binding="{Binding PhoneNumber}"
Width="*" />
<controls:DataGridTextColumn Header="Roles"
Width="2*"
Binding="{Binding Roles, Converter={StaticResource RolesListConverter}}" />
<controls:DataGridTemplateColumn Header="Actions"
Width="Auto">
<controls:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal"
Spacing="4">
<Button Content="Edit"
Command="{Binding DataContext.OpenEditUserDialogCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Style="{ThemeResource TextBlockButtonStyle}"
Padding="8,4" />
<Button Content="Password"
Command="{Binding DataContext.OpenChangePasswordDialogCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Style="{ThemeResource TextBlockButtonStyle}"
Padding="8,4" />
<Button Content="Roles"
Command="{Binding DataContext.OpenManageRolesDialogCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Style="{ThemeResource TextBlockButtonStyle}"
Padding="8,4" />
<Button Content="Delete"
Command="{Binding DataContext.DeleteUserCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Foreground="{ThemeResource SystemErrorTextColor}"
Style="{ThemeResource TextBlockButtonStyle}"
Padding="8,4" />
</StackPanel>
</DataTemplate>
</controls:DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumn>
</controls:DataGrid.Columns>
</controls:DataGrid>
</Grid>
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,394 @@
using System;
using System.Diagnostics;
using Marechai.App.Presentation.ViewModels;
using Microsoft.UI;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
namespace Marechai.App.Presentation.Views;
/// <summary>
/// User management page for Uberadmins
/// </summary>
public sealed partial class UsersPage : Page
{
private ContentDialog? _currentOpenDialog;
private UsersViewModel? _currentViewModel;
public UsersPage()
{
InitializeComponent();
Loaded += UsersPage_Loaded;
DataContextChanged += UsersPage_DataContextChanged;
}
private void UsersPage_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
// Unsubscribe from previous ViewModel
if(_currentViewModel != null) _currentViewModel.ShowDialogRequested -= OnShowDialogRequested;
// Subscribe to new ViewModel
if(DataContext is UsersViewModel vm)
{
_currentViewModel = vm;
vm.ShowDialogRequested += OnShowDialogRequested;
if(vm.IsUberadmin)
{
// Load data when DataContext is set and user is Uberadmin
vm.LoadUsersCommand.Execute(null);
}
}
}
private void UsersPage_Loaded(object sender, RoutedEventArgs e)
{
if(DataContext is UsersViewModel vm && vm.IsUberadmin)
{
// Load data when page is loaded (fallback)
vm.LoadUsersCommand.Execute(null);
}
}
private async void OnShowDialogRequested(object? sender, string dialogType)
{
// Close any currently open dialog first
if(_currentOpenDialog != null)
{
_currentOpenDialog.Hide();
_currentOpenDialog = null;
}
if(DataContext is not UsersViewModel vm) return;
ContentDialog? dialog = null;
switch(dialogType)
{
case "AddEdit":
{
var emailBox = new TextBox
{
Header = "Email",
Text = vm.Email
};
emailBox.TextChanged += (s, e) => vm.Email = emailBox.Text;
var userNameBox = new TextBox
{
Header = "Username",
Text = vm.UserName
};
userNameBox.TextChanged += (s, e) => vm.UserName = userNameBox.Text;
var phoneBox = new TextBox
{
Header = "Phone Number",
Text = vm.PhoneNumber
};
phoneBox.TextChanged += (s, e) => vm.PhoneNumber = phoneBox.Text;
var passwordBox = new PasswordBox
{
Header = "Password",
Password = vm.Password,
PlaceholderText = "Leave blank to keep current (when editing)"
};
passwordBox.PasswordChanged += (s, e) => vm.Password = passwordBox.Password;
var confirmPasswordBox = new PasswordBox
{
Header = "Confirm Password",
Password = vm.ConfirmPassword
};
confirmPasswordBox.PasswordChanged += (s, e) => vm.ConfirmPassword = confirmPasswordBox.Password;
dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = vm.DialogTitle,
PrimaryButtonText = "Save",
CloseButtonText = "Cancel",
PrimaryButtonCommand = vm.SaveUserCommand,
CloseButtonCommand = vm.CloseDialogCommand,
DefaultButton = ContentDialogButton.Primary,
Content = new StackPanel
{
Spacing = 12,
MinWidth = 400,
Children =
{
emailBox,
userNameBox,
phoneBox,
passwordBox,
confirmPasswordBox
}
}
};
}
break;
case "Password":
{
var passwordBox = new PasswordBox
{
Header = "New Password",
Password = vm.Password
};
passwordBox.PasswordChanged += (s, e) => vm.Password = passwordBox.Password;
var confirmPasswordBox = new PasswordBox
{
Header = "Confirm Password",
Password = vm.ConfirmPassword
};
confirmPasswordBox.PasswordChanged += (s, e) => vm.ConfirmPassword = confirmPasswordBox.Password;
dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = vm.DialogTitle,
PrimaryButtonText = "Change Password",
CloseButtonText = "Cancel",
PrimaryButtonCommand = vm.SavePasswordCommand,
CloseButtonCommand = vm.CloseDialogCommand,
DefaultButton = ContentDialogButton.Primary,
Content = new StackPanel
{
Spacing = 12,
MinWidth = 400,
Children =
{
passwordBox,
confirmPasswordBox
}
}
};
}
break;
case "Roles":
{
Debug.WriteLine($"Creating Roles dialog. Available roles count: {vm.AvailableRoles.Count}");
foreach(string role in vm.AvailableRoles) Debug.WriteLine($" - Role: {role}");
var rolesContent = new Grid
{
RowSpacing = 16,
MinWidth = 450
};
rolesContent.RowDefinitions.Add(new RowDefinition
{
Height = GridLength.Auto
});
rolesContent.RowDefinitions.Add(new RowDefinition
{
Height = new GridLength(1, GridUnitType.Star)
});
var addRolePanel = new StackPanel
{
Spacing = 8
};
addRolePanel.Children.Add(new TextBlock
{
Text = "Add Role",
FontWeight = FontWeights.SemiBold
});
var addRoleGrid = new Grid
{
ColumnSpacing = 8
};
addRoleGrid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(1, GridUnitType.Star)
});
addRoleGrid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = GridLength.Auto
});
// Use ListView with SingleSelection instead of ComboBox - ComboBox has issues with programmatic creation
var roleListView = new ListView
{
SelectionMode = ListViewSelectionMode.Single,
HorizontalAlignment = HorizontalAlignment.Stretch,
MaxHeight = 200,
MinWidth = 300
};
Debug.WriteLine($"Creating ListView for role selection with {vm.AvailableRoles.Count} roles");
// Populate the ListView
foreach(string role in vm.AvailableRoles)
{
var item = new ListViewItem
{
Content = role
};
roleListView.Items.Add(item);
Debug.WriteLine($" Added role to ListView: {role}");
}
Debug.WriteLine($"ListView Items.Count: {roleListView.Items.Count}");
roleListView.SelectionChanged += (s, e) =>
{
if(roleListView.SelectedItem is ListViewItem selectedItem &&
selectedItem.Content is string selectedRole)
{
vm.SelectedRole = selectedRole;
Debug.WriteLine($"Selected role from ListView: {selectedRole}");
}
};
Grid.SetColumn(roleListView, 0);
addRoleGrid.Children.Add(roleListView);
var addButton = new Button
{
Content = "Add",
Command = vm.AddRoleCommand
};
Grid.SetColumn(addButton, 1);
addRoleGrid.Children.Add(addButton);
addRolePanel.Children.Add(addRoleGrid);
Grid.SetRow(addRolePanel, 0);
rolesContent.Children.Add(addRolePanel);
var currentRolesPanel = new StackPanel
{
Spacing = 8,
Margin = new Thickness(0, 8, 0, 0)
};
currentRolesPanel.Children.Add(new TextBlock
{
Text = "Current Roles",
FontWeight = FontWeights.SemiBold
});
// Create a StackPanel to display roles dynamically
var rolesStack = new StackPanel
{
Spacing = 4
};
// Handler to refresh the roles list
void UpdateRolesList()
{
rolesStack.Children.Clear();
foreach(string role in vm.UserRoles)
{
var roleItem = new Grid
{
ColumnSpacing = 8,
Margin = new Thickness(0, 4, 0, 4)
};
roleItem.ColumnDefinitions.Add(new ColumnDefinition
{
Width = new GridLength(1, GridUnitType.Star)
});
roleItem.ColumnDefinitions.Add(new ColumnDefinition
{
Width = GridLength.Auto
});
var roleText = new TextBlock
{
Text = role,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(roleText, 0);
roleItem.Children.Add(roleText);
var removeButton = new Button
{
Content = "Remove",
Foreground = new SolidColorBrush(Colors.Red),
CommandParameter = role
};
removeButton.Click += (s, e) =>
{
var btn = s as Button;
var roleToRemove = btn?.CommandParameter as string;
if(roleToRemove != null && vm.RemoveRoleCommand.CanExecute(roleToRemove))
{
vm.RemoveRoleCommand.Execute(roleToRemove);
UpdateRolesList();
}
};
Grid.SetColumn(removeButton, 1);
roleItem.Children.Add(removeButton);
rolesStack.Children.Add(roleItem);
}
}
// Listen to collection changes
vm.UserRoles.CollectionChanged += (s, e) => UpdateRolesList();
// Initial population
UpdateRolesList();
var scrollViewer = new ScrollViewer
{
MaxHeight = 200,
Content = rolesStack
};
currentRolesPanel.Children.Add(scrollViewer);
Grid.SetRow(currentRolesPanel, 1);
rolesContent.Children.Add(currentRolesPanel);
dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = vm.DialogTitle,
CloseButtonText = "Close",
CloseButtonCommand = vm.CloseDialogCommand,
DefaultButton = ContentDialogButton.Close,
Content = rolesContent
};
}
break;
}
if(dialog != null)
{
_currentOpenDialog = dialog;
await dialog.ShowAsync();
_currentOpenDialog = null;
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
namespace Marechai.App.Services.Authentication;
public interface IJwtService
{
IEnumerable<string> GetRoles(string token);
string? GetUserId(string token);
string? GetUserName(string token);
string? GetEmail(string token);
bool IsTokenValid(string token);
}
public sealed class JwtService : IJwtService
{
/// <inheritdoc />
public IEnumerable<string> GetRoles(string token)
{
if(string.IsNullOrWhiteSpace(token)) return [];
try
{
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = handler.ReadJwtToken(token);
return jwtToken.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
}
catch
{
return [];
}
}
/// <inheritdoc />
public string? GetUserId(string token)
{
if(string.IsNullOrWhiteSpace(token)) return null;
try
{
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = handler.ReadJwtToken(token);
return jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Sid)?.Value;
}
catch
{
return null;
}
}
/// <inheritdoc />
public string? GetUserName(string token)
{
if(string.IsNullOrWhiteSpace(token)) return null;
try
{
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = handler.ReadJwtToken(token);
return jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
}
catch
{
return null;
}
}
/// <inheritdoc />
public string? GetEmail(string token)
{
if(string.IsNullOrWhiteSpace(token)) return null;
try
{
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = handler.ReadJwtToken(token);
return jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
}
catch
{
return null;
}
}
/// <inheritdoc />
public bool IsTokenValid(string token)
{
if(string.IsNullOrWhiteSpace(token)) return false;
try
{
var handler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = handler.ReadJwtToken(token);
// Check if token has expired (if expiration is set)
if(jwtToken.ValidTo != DateTime.MinValue) return jwtToken.ValidTo > DateTime.UtcNow;
return true;
}
catch
{
return false;
}
}
}