From 2d1923d68db85cc9302289469b730327ab9f0a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veikko=20Lintuj=C3=A4rvi?= Date: Tue, 3 Mar 2026 23:15:04 +0200 Subject: [PATCH] User management --- api/App.Tests/ApiEndpointsTests.cs | 81 +++++++- api/App/Endpoints/AuthEndpoints.cs | 33 ++-- api/App/Endpoints/UserEndpoints.cs | 186 ++++++++++++++++++ api/App/Models/AppUser.cs | 49 +++++ api/App/Program.cs | 25 ++- api/App/Services/UserService.cs | 277 +++++++++++++++++++++++++++ api/App/appsettings.Development.json | 11 +- api/App/appsettings.json | 11 +- api/Database/init.sql | 10 + api/Database/klapi.db | Bin 16384 -> 28672 bytes ui/src/api/index.ts | 69 ++++++- ui/src/app.tsx | 18 +- ui/src/components/Nav.tsx | 9 +- ui/src/i18n.ts | 71 +++++-- ui/src/routes/login.tsx | 41 ++-- ui/src/routes/management.tsx | 225 ++++++++++++++++++++++ ui/src/state/appState.ts | 4 +- 17 files changed, 1046 insertions(+), 74 deletions(-) create mode 100644 api/App/Endpoints/UserEndpoints.cs create mode 100644 api/App/Models/AppUser.cs create mode 100644 api/App/Services/UserService.cs create mode 100644 ui/src/routes/management.tsx diff --git a/api/App.Tests/ApiEndpointsTests.cs b/api/App.Tests/ApiEndpointsTests.cs index 5d399ae..bc555b7 100644 --- a/api/App.Tests/ApiEndpointsTests.cs +++ b/api/App.Tests/ApiEndpointsTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; namespace App.Tests; @@ -164,7 +165,7 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu { var tokenResponse = await _client.PostAsJsonAsync("/auth/token", new { - email = "admin@klapi.local", + username = "admin", password = "changeme" }); @@ -191,6 +192,54 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); } + + [Fact] + public async Task UserManagement_Crud_WorksForAdminInProduction() + { + var tokenResponse = await _client.PostAsJsonAsync("/auth/token", new + { + username = "admin", + password = "changeme" + }); + + Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode); + var auth = await tokenResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(auth); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + + var createResponse = await _client.PostAsJsonAsync("/users", new + { + username = "editor", + password = "editorpass", + isAdmin = false, + displayName = "Editor User" + }); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var usersResponse = await _client.GetAsync("/users"); + Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode); + var users = await usersResponse.Content.ReadFromJsonAsync>(); + Assert.NotNull(users); + Assert.Contains(users, user => user.Username == "editor"); + + var updateResponse = await _client.PutAsJsonAsync("/users/editor", new + { + password = "editorpass2", + isAdmin = true, + displayName = "Editor Admin" + }); + + Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); + var updatedUser = await updateResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(updatedUser); + Assert.True(updatedUser.IsAdmin); + Assert.Equal("Editor Admin", updatedUser.DisplayName); + + var deleteResponse = await _client.DeleteAsync("/users/editor"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } } public abstract class ApiTestFactoryBase(string environmentName) : WebApplicationFactory @@ -207,12 +256,13 @@ public abstract class ApiTestFactoryBase(string environmentName) : WebApplicatio configBuilder.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}", - ["Auth:Issuer"] = "klapi-api-tests", - ["Auth:Audience"] = "klapi-ui-tests", - ["Auth:SigningKey"] = "test-signing-key-which-is-at-least-32-characters-long", + ["Auth:Issuer"] = "klapi-api", + ["Auth:Audience"] = "klapi-ui", + ["Auth:SigningKey"] = "change-this-to-a-long-random-32-char-minimum-key", ["Auth:AllowedOrigins:0"] = "http://localhost:5173", - ["Auth:Users:0:Email"] = "admin@klapi.local", - ["Auth:Users:0:Password"] = "changeme" + ["Auth:Admin:Username"] = "admin", + ["Auth:Admin:Password"] = "changeme", + ["Auth:Admin:DisplayName"] = "Administrator" }); }); } @@ -278,9 +328,26 @@ public class AuthTokenDto { public string AccessToken { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public bool IsAdmin { get; set; } public string TokenType { get; set; } = string.Empty; public int ExpiresIn { get; set; } } + +public class UserDto +{ + public string Username { get; set; } = string.Empty; + + public DateTime Added { get; set; } + + public DateTime LastUpdated { get; set; } + + public bool IsAdmin { get; set; } + + public string DisplayName { get; set; } = string.Empty; +} diff --git a/api/App/Endpoints/AuthEndpoints.cs b/api/App/Endpoints/AuthEndpoints.cs index 15d4ee8..ff8123b 100644 --- a/api/App/Endpoints/AuthEndpoints.cs +++ b/api/App/Endpoints/AuthEndpoints.cs @@ -4,12 +4,13 @@ using System.Text; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -public record AuthTokenRequest(string Email, string Password); +public record AuthTokenRequest(string? Username, string Password); -public class AuthUser +public class AuthAdminOptions { - public string Email { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; } public class AuthOptions @@ -18,28 +19,28 @@ public class AuthOptions public string Audience { get; set; } = "klapi-ui"; public string SigningKey { get; set; } = string.Empty; public List AllowedOrigins { get; set; } = []; - public List Users { get; set; } = []; + public AuthAdminOptions Admin { get; set; } = new(); } public static class AuthEndpoints { public static void MapAuthEndpoints(WebApplication app) { - app.MapPost("/auth/token", ( + app.MapPost("/auth/token", async ( HttpContext httpContext, IOptions authOptions, + UserService userService, AuthTokenRequest request) => { - if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { - return Results.BadRequest(new { Message = "Email and password are required." }); + return Results.BadRequest(new { Message = "Username and password are required." }); } var options = authOptions.Value; - var user = options.Users.FirstOrDefault(item => - string.Equals(item.Email, request.Email.Trim(), StringComparison.OrdinalIgnoreCase)); + var authenticatedUser = await userService.Authenticate(request.Username, request.Password); - if (user is null || !string.Equals(user.Password, request.Password, StringComparison.Ordinal)) + if (authenticatedUser is null) { return Results.Unauthorized(); } @@ -48,9 +49,11 @@ public static class AuthEndpoints var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List { - new(JwtRegisteredClaimNames.Sub, user.Email), - new(JwtRegisteredClaimNames.Email, user.Email), - new(ClaimTypes.Name, user.Email), + new(JwtRegisteredClaimNames.Sub, authenticatedUser.Username), + new(ClaimTypes.Name, authenticatedUser.Username), + new("username", authenticatedUser.Username), + new("display_name", authenticatedUser.DisplayName), + new("is_admin", authenticatedUser.IsAdmin ? "true" : "false"), new("scope", "openhours:write") }; @@ -66,7 +69,9 @@ public static class AuthEndpoints return Results.Ok(new { AccessToken = tokenValue, - Email = user.Email, + Username = authenticatedUser.Username, + DisplayName = authenticatedUser.DisplayName, + IsAdmin = authenticatedUser.IsAdmin, TokenType = "Bearer", ExpiresIn = 43200 }); diff --git a/api/App/Endpoints/UserEndpoints.cs b/api/App/Endpoints/UserEndpoints.cs new file mode 100644 index 0000000..c5fb927 --- /dev/null +++ b/api/App/Endpoints/UserEndpoints.cs @@ -0,0 +1,186 @@ +using Microsoft.Data.Sqlite; + +public static class UserEndpoints +{ + public static void MapUserEndpoints(WebApplication app) + { + app.MapGet("/users", async (HttpContext httpContext) => + { + var userService = httpContext.RequestServices.GetRequiredService(); + var users = await userService.GetUsers(); + await httpContext.Response.WriteAsJsonAsync(users); + }) + .RequireCors("FrontendWriteCors") + .RequireAuthorization("AdminOnly") + .WithName("GetUsers"); + + app.MapPost("/users", async (HttpContext httpContext) => + { + var userService = httpContext.RequestServices.GetRequiredService(); + var request = await httpContext.Request.ReadFromJsonAsync(); + + if (request is null) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Request body is required." + }); + return; + } + + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.DisplayName)) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Username, password and display name are required." + }); + return; + } + + try + { + var createdUser = await userService.CreateUser(request); + httpContext.Response.StatusCode = StatusCodes.Status201Created; + httpContext.Response.Headers.Location = $"/users/{createdUser.Username}"; + await httpContext.Response.WriteAsJsonAsync(createdUser); + } + catch (SqliteException) + { + httpContext.Response.StatusCode = StatusCodes.Status409Conflict; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "User with the same username already exists." + }); + } + }) + .RequireCors("FrontendWriteCors") + .RequireAuthorization("AdminOnly") + .WithName("CreateUser"); + + app.MapPut("/users/{username}", async (HttpContext httpContext, string username) => + { + var userService = httpContext.RequestServices.GetRequiredService(); + var request = await httpContext.Request.ReadFromJsonAsync(); + + if (request is null) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Request body is required." + }); + return; + } + + if (string.IsNullOrWhiteSpace(request.DisplayName)) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Display name is required." + }); + return; + } + + var normalizedTargetUsername = username.Trim().ToLowerInvariant(); + var existingUser = await userService.GetUser(normalizedTargetUsername); + + if (existingUser is null) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "User not found." + }); + return; + } + + var adminCount = await userService.GetAdminCount(); + + if (existingUser.IsAdmin && !request.IsAdmin && adminCount <= 1) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Cannot remove admin role from the last admin user." + }); + return; + } + + var updatedUser = await userService.UpdateUser(username, request); + + if (updatedUser is null) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "User not found." + }); + return; + } + + await httpContext.Response.WriteAsJsonAsync(updatedUser); + }) + .RequireCors("FrontendWriteCors") + .RequireAuthorization("AdminOnly") + .WithName("UpdateUser"); + + app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) => + { + var userService = httpContext.RequestServices.GetRequiredService(); + var currentUsername = httpContext.User.Identity?.Name?.Trim().ToLowerInvariant() ?? string.Empty; + var normalizedTargetUsername = username.Trim().ToLowerInvariant(); + + var existingUser = await userService.GetUser(normalizedTargetUsername); + if (existingUser is null) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "User not found." + }); + return; + } + + if (currentUsername == normalizedTargetUsername) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "You cannot delete your own user account." + }); + return; + } + + var adminCount = await userService.GetAdminCount(); + if (existingUser.IsAdmin && adminCount <= 1) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Cannot delete the last admin user." + }); + return; + } + + var deleted = await userService.DeleteUser(username); + + if (!deleted) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "User not found." + }); + return; + } + + httpContext.Response.StatusCode = StatusCodes.Status204NoContent; + }) + .RequireCors("FrontendWriteCors") + .RequireAuthorization("AdminOnly") + .WithName("DeleteUser"); + } +} diff --git a/api/App/Models/AppUser.cs b/api/App/Models/AppUser.cs new file mode 100644 index 0000000..3f0408e --- /dev/null +++ b/api/App/Models/AppUser.cs @@ -0,0 +1,49 @@ +public class AppUser +{ + public long Id { get; set; } + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public DateTime Added { get; set; } + + public DateTime LastUpdated { get; set; } + + public bool IsAdmin { get; set; } + + public string DisplayName { get; set; } = string.Empty; +} + +public class AppUserView +{ + public string Username { get; set; } = string.Empty; + + public DateTime Added { get; set; } + + public DateTime LastUpdated { get; set; } + + public bool IsAdmin { get; set; } + + public string DisplayName { get; set; } = string.Empty; +} + +public class AppUserCreateRequest +{ + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public bool IsAdmin { get; set; } + + public string DisplayName { get; set; } = string.Empty; +} + +public class AppUserUpdateRequest +{ + public string? Password { get; set; } + + public bool IsAdmin { get; set; } + + public string DisplayName { get; set; } = string.Empty; +} diff --git a/api/App/Program.cs b/api/App/Program.cs index b21afd7..5eb51fd 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -30,15 +30,18 @@ public class Program throw new InvalidOperationException("Auth:SigningKey must be at least 32 characters long."); } - if (authOptions.Users.Count == 0) + if (string.IsNullOrWhiteSpace(authOptions.Admin.Username) + || string.IsNullOrWhiteSpace(authOptions.Admin.Password) + || string.IsNullOrWhiteSpace(authOptions.Admin.DisplayName)) { - throw new InvalidOperationException("At least one user must be configured under Auth:Users."); + throw new InvalidOperationException("Auth:Admin username, password and display name must be configured."); } builder.Services.Configure(builder.Configuration.GetSection("Auth")); builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString)); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddCors(options => { options.AddPolicy("PublicReadCors", policy => @@ -82,6 +85,12 @@ public class Program policy.RequireAuthenticatedUser(); policy.RequireClaim("scope", "openhours:write"); }); + + options.AddPolicy("AdminOnly", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("is_admin", "true"); + }); }); builder.Services.AddOpenApi(); @@ -177,9 +186,20 @@ public class Program ON LokOpenHours(isActive) WHERE isActive = 1;"; command.ExecuteNonQuery(); + + command.CommandText = @" + CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Username + ON Users(username);"; + command.ExecuteNonQuery(); } } + using (var scope = app.Services.CreateScope()) + { + var userService = scope.ServiceProvider.GetRequiredService(); + userService.EnsureAdminUser(authOptions.Admin).GetAwaiter().GetResult(); + } + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -198,6 +218,7 @@ public class Program SystemEndpoints.MapSystemEndpoints(app); AuthEndpoints.MapAuthEndpoints(app); LokEndpoints.MapLokEndpoints(app); + UserEndpoints.MapUserEndpoints(app); app.Run(); } diff --git a/api/App/Services/UserService.cs b/api/App/Services/UserService.cs new file mode 100644 index 0000000..653a269 --- /dev/null +++ b/api/App/Services/UserService.cs @@ -0,0 +1,277 @@ +using System.Data; +using Microsoft.Data.Sqlite; + +public class UserService +{ + private readonly SqliteConnection _connection; + + public UserService(SqliteConnection connection) + { + _connection = connection; + } + + public async Task EnsureAdminUser(AuthAdminOptions admin) + { + await EnsureOpenConnection(); + + var now = DateTime.UtcNow; + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName) + VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName) + ON CONFLICT(username) DO UPDATE SET + password = excluded.password, + lastUpdated = excluded.lastUpdated, + isAdmin = 1, + displayName = excluded.displayName;"; + + command.Parameters.AddWithValue("@username", admin.Username.Trim().ToLowerInvariant()); + command.Parameters.AddWithValue("@password", admin.Password); + command.Parameters.AddWithValue("@added", now.ToString("O")); + command.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); + command.Parameters.AddWithValue("@isAdmin", 1); + command.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim()); + + await command.ExecuteNonQueryAsync(); + } + + public async Task Authenticate(string username, string password) + { + await EnsureOpenConnection(); + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + SELECT id, username, password, added, lastUpdated, isAdmin, displayName + FROM Users + WHERE username = @username + LIMIT 1;"; + + command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant()); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return null; + } + + var user = ReadUser(reader); + + if (!string.Equals(user.Password, password, StringComparison.Ordinal)) + { + return null; + } + + return user; + } + + public async Task> GetUsers() + { + await EnsureOpenConnection(); + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + SELECT username, added, lastUpdated, isAdmin, displayName + FROM Users + ORDER BY username ASC;"; + + await using var reader = await command.ExecuteReaderAsync(); + + var users = new List(); + while (await reader.ReadAsync()) + { + users.Add(new AppUserView + { + Username = reader["username"]?.ToString() ?? string.Empty, + Added = ParseDate(reader["added"]?.ToString()), + LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), + IsAdmin = ParseBoolean(reader["isAdmin"]), + DisplayName = reader["displayName"]?.ToString() ?? string.Empty + }); + } + + return users; + } + + public async Task CreateUser(AppUserCreateRequest request) + { + await EnsureOpenConnection(); + + var now = DateTime.UtcNow; + var normalizedUsername = request.Username.Trim().ToLowerInvariant(); + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName) + VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName);"; + + command.Parameters.AddWithValue("@username", normalizedUsername); + command.Parameters.AddWithValue("@password", request.Password); + command.Parameters.AddWithValue("@added", now.ToString("O")); + command.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); + command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0); + command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim()); + + await command.ExecuteNonQueryAsync(); + + return new AppUserView + { + Username = normalizedUsername, + Added = now, + LastUpdated = now, + IsAdmin = request.IsAdmin, + DisplayName = request.DisplayName.Trim() + }; + } + + public async Task UpdateUser(string username, AppUserUpdateRequest request) + { + await EnsureOpenConnection(); + + var normalizedUsername = username.Trim().ToLowerInvariant(); + var now = DateTime.UtcNow; + + var currentUser = await GetUserByUsername(normalizedUsername); + if (currentUser is null) + { + return null; + } + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + UPDATE Users + SET password = @password, + lastUpdated = @lastUpdated, + isAdmin = @isAdmin, + displayName = @displayName + WHERE username = @username;"; + + command.Parameters.AddWithValue("@username", normalizedUsername); + command.Parameters.AddWithValue("@password", string.IsNullOrWhiteSpace(request.Password) ? currentUser.Password : request.Password); + command.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); + command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0); + command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim()); + + var affectedRows = await command.ExecuteNonQueryAsync(); + if (affectedRows == 0) + { + return null; + } + + return new AppUserView + { + Username = normalizedUsername, + Added = currentUser.Added, + LastUpdated = now, + IsAdmin = request.IsAdmin, + DisplayName = request.DisplayName.Trim() + }; + } + + public async Task DeleteUser(string username) + { + await EnsureOpenConnection(); + + await using var command = _connection.CreateCommand(); + command.CommandText = "DELETE FROM Users WHERE username = @username;"; + command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant()); + + var affectedRows = await command.ExecuteNonQueryAsync(); + return affectedRows > 0; + } + + public async Task GetAdminCount() + { + await EnsureOpenConnection(); + + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM Users WHERE isAdmin = 1;"; + + return Convert.ToInt32(await command.ExecuteScalarAsync()); + } + + public async Task GetUser(string username) + { + await EnsureOpenConnection(); + + var user = await GetUserByUsername(username.Trim().ToLowerInvariant()); + if (user is null) + { + return null; + } + + return new AppUserView + { + Username = user.Username, + Added = user.Added, + LastUpdated = user.LastUpdated, + IsAdmin = user.IsAdmin, + DisplayName = user.DisplayName + }; + } + + private async Task GetUserByUsername(string username) + { + await using var command = _connection.CreateCommand(); + command.CommandText = @" + SELECT id, username, password, added, lastUpdated, isAdmin, displayName + FROM Users + WHERE username = @username + LIMIT 1;"; + + command.Parameters.AddWithValue("@username", username); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return null; + } + + return ReadUser(reader); + } + + private static AppUser ReadUser(SqliteDataReader reader) + { + return new AppUser + { + Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]), + Username = reader["username"]?.ToString() ?? string.Empty, + Password = reader["password"]?.ToString() ?? string.Empty, + Added = ParseDate(reader["added"]?.ToString()), + LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), + IsAdmin = ParseBoolean(reader["isAdmin"]), + DisplayName = reader["displayName"]?.ToString() ?? string.Empty + }; + } + + private static DateTime ParseDate(string? value) + { + if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed)) + { + return parsed; + } + + return DateTime.MinValue; + } + + private static bool ParseBoolean(object? value) + { + return value switch + { + bool boolValue => boolValue, + long longValue => longValue == 1, + int intValue => intValue == 1, + string stringValue when int.TryParse(stringValue, out var parsedInt) => parsedInt == 1, + string stringValue when bool.TryParse(stringValue, out var parsedBool) => parsedBool, + _ => false + }; + } + + private async Task EnsureOpenConnection() + { + if (_connection.State != ConnectionState.Open) + { + await _connection.OpenAsync(); + } + } +} diff --git a/api/App/appsettings.Development.json b/api/App/appsettings.Development.json index 66e5213..5955111 100644 --- a/api/App/appsettings.Development.json +++ b/api/App/appsettings.Development.json @@ -18,11 +18,10 @@ "http://localhost:4173", "http://127.0.0.1:4173" ], - "Users": [ - { - "Email": "admin@klapi.local", - "Password": "changeme" - } - ] + "Admin": { + "Username": "admin", + "Password": "changeme", + "DisplayName": "Administrator" + } } } diff --git a/api/App/appsettings.json b/api/App/appsettings.json index c51cd8b..baa5594 100644 --- a/api/App/appsettings.json +++ b/api/App/appsettings.json @@ -18,12 +18,11 @@ "http://localhost:4173", "http://127.0.0.1:4173" ], - "Users": [ - { - "Email": "admin@klapi.local", - "Password": "changeme" - } - ] + "Admin": { + "Username": "admin", + "Password": "changeme", + "DisplayName": "Administrator" + } }, "AllowedHosts": "*" } diff --git a/api/Database/init.sql b/api/Database/init.sql index 394ac9f..4857eb8 100644 --- a/api/Database/init.sql +++ b/api/Database/init.sql @@ -9,3 +9,13 @@ CREATE TABLE IF NOT EXISTS LokOpenHours ( paragraph4 TEXT NOT NULL DEFAULT '', kitchenNotice TEXT NOT NULL DEFAULT '' ); + +CREATE TABLE IF NOT EXISTS Users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + added TEXT NOT NULL, + lastUpdated TEXT NOT NULL, + isAdmin INTEGER NOT NULL DEFAULT 0, + displayName TEXT NOT NULL DEFAULT '' +); diff --git a/api/Database/klapi.db b/api/Database/klapi.db index 4b0b0e60b4eaa9f4e9662f1037f1a8349e618a01..f92d99284b528e8d5f40947a482605fbd5ce2739 100644 GIT binary patch delta 721 zcmZo@U~G86I6+#Fje&uI9f)CoWulI;BpZWXn4Fel@4Fg{;pER!v_g`K??wXB_ zEZpApZ0zFt;*5>1nRzLx6`m3Cp~b01#b7coF*g;=V|Na6bqsM;2=(&}40ToT^mB2I zP{2^iHF+bi)Z_!aTsHoG3SfO2r9iVlrf4<^vapMbi!-(`mLw+SKuiTIVTBqU;^^c9 zw4gY3@)|z+w11e;`Lv13YZW**REA+GMOK?-tk8(m!897BCV6bwK{r(_lvgviNTasE_0yMt3#K^$NOxM5| zh(e4EtqcsUj12Y6jLi%!Ow6P3$%TLn$V<(OVgwo~>*~s>o(S@Baz { const data = await fetchApi<{ version: string }>("/"); return data.version; @@ -163,14 +186,54 @@ export async function setActiveLokOpenHours( } export async function requestAuthToken( - email: string, + username: string, password: string, ): Promise { return await fetchApi("/auth/token", { method: "POST", body: JSON.stringify({ - email, + username, password, }), }); } + +export async function queryUsers(): Promise { + return await fetchApi("/users", { + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, + }); +} + +export async function createUser(input: CreateUserInput): Promise { + return await fetchApi("/users", { + method: "POST", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, + body: JSON.stringify(input), + }); +} + +export async function updateUser( + username: string, + input: UpdateUserInput, +): Promise { + return await fetchApi(`/users/${encodeURIComponent(username)}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, + body: JSON.stringify(input), + }); +} + +export async function deleteUser(username: string): Promise { + await fetchApi(`/users/${encodeURIComponent(username)}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, + }); +} diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 978c8ab..193710c 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -1,10 +1,11 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { useEffect } from "react"; -import { useSetRecoilState } from "recoil"; +import { useRecoilValue, useSetRecoilState } from "recoil"; import Nav from "~/components/Nav"; import Home from "~/routes/index"; import About from "~/routes/about"; import Login from "~/routes/login"; +import Management from "~/routes/management"; import NotFound from "~/routes/[...404]"; import Toasts from "~/components/Toasts"; import { initializeLanguage, useLanguage } from "~/i18n"; @@ -13,6 +14,7 @@ import "./app.css"; function AppShell() { const { language, setLanguage } = useLanguage(); + const session = useRecoilValue(sessionAtom); const setSession = useSetRecoilState(sessionAtom); useEffect(() => { @@ -20,15 +22,19 @@ function AppShell() { }, [setLanguage]); useEffect(() => { - const storedEmail = localStorage.getItem("session-email"); + const storedUsername = localStorage.getItem("session-username"); + const storedDisplayName = localStorage.getItem("session-display-name"); + const storedIsAdmin = localStorage.getItem("session-is-admin"); const storedToken = localStorage.getItem("session-token"); - if (!storedEmail || !storedToken) { + if (!storedUsername || !storedDisplayName || !storedToken || !storedIsAdmin) { setSession(null); return; } setSession({ - email: storedEmail, + username: storedUsername, + displayName: storedDisplayName, + isAdmin: storedIsAdmin === "true", token: storedToken, }); }, [setSession]); @@ -44,6 +50,10 @@ function AppShell() { } /> } /> } /> + : } + /> } /> } /> diff --git a/ui/src/components/Nav.tsx b/ui/src/components/Nav.tsx index 5e677cd..e30d53b 100644 --- a/ui/src/components/Nav.tsx +++ b/ui/src/components/Nav.tsx @@ -12,7 +12,9 @@ export default function Nav() { const signOut = () => { setSession(null); - localStorage.removeItem("session-email"); + localStorage.removeItem("session-username"); + localStorage.removeItem("session-display-name"); + localStorage.removeItem("session-is-admin"); localStorage.removeItem("session-token"); navigate("/login"); }; @@ -27,9 +29,12 @@ export default function Nav() {