From e89d971f41d83ba5c712ed664ead64d2c8364e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veikko=20Lintuj=C3=A4rvi?= Date: Tue, 10 Mar 2026 23:29:13 +0200 Subject: [PATCH] User roles --- api/App/Endpoints/AuthEndpoints.cs | 9 +- api/App/Endpoints/LokEndpoints.cs | 32 ++--- api/App/Endpoints/UserEndpoints.cs | 16 +-- api/App/Models/AppUser.cs | 24 ++-- api/App/Program.cs | 51 +++++++- api/App/Services/UserService.cs | 188 +++++++++++++++++++---------- api/Database/init.sql | 7 +- api/Database/klapi.db | Bin 28672 -> 53248 bytes ui/src/api/index.ts | 8 +- ui/src/app.css | 2 +- ui/src/app.tsx | 10 +- ui/src/components/Nav.tsx | 98 ++++++++------- ui/src/i18n.ts | 12 +- ui/src/routes/login.tsx | 4 +- ui/src/routes/management.tsx | 59 ++++++--- ui/src/state/appState.ts | 2 +- 16 files changed, 325 insertions(+), 197 deletions(-) diff --git a/api/App/Endpoints/AuthEndpoints.cs b/api/App/Endpoints/AuthEndpoints.cs index ff8123b..91d0ed1 100644 --- a/api/App/Endpoints/AuthEndpoints.cs +++ b/api/App/Endpoints/AuthEndpoints.cs @@ -53,10 +53,13 @@ public static class AuthEndpoints 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") }; + foreach (var role in authenticatedUser.Roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + var token = new JwtSecurityToken( issuer: options.Issuer, audience: options.Audience, @@ -71,7 +74,7 @@ public static class AuthEndpoints AccessToken = tokenValue, Username = authenticatedUser.Username, DisplayName = authenticatedUser.DisplayName, - IsAdmin = authenticatedUser.IsAdmin, + Roles = authenticatedUser.Roles, TokenType = "Bearer", ExpiresIn = 43200 }); diff --git a/api/App/Endpoints/LokEndpoints.cs b/api/App/Endpoints/LokEndpoints.cs index 313785f..7a9316a 100644 --- a/api/App/Endpoints/LokEndpoints.cs +++ b/api/App/Endpoints/LokEndpoints.cs @@ -2,7 +2,7 @@ public static class LokEndpoints { public static void MapLokEndpoints(WebApplication app) { - var createLokOpenHoursEndpoint = app.MapPost("/lok/open-hours", async (HttpContext httpContext) => + app.MapPost("/lok/open-hours", async (HttpContext httpContext) => { var lokService = httpContext.RequestServices.GetRequiredService(); var openHours = await httpContext.Request.ReadFromJsonAsync(); @@ -33,13 +33,9 @@ public static class LokEndpoints await httpContext.Response.WriteAsJsonAsync(createdOpenHours); }) .RequireCors("FrontendWriteCors") + .RequireAuthorization("HasLokRole") .WithName("CreateLokOpenHours"); - if (!app.Environment.IsDevelopment()) - { - createLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); - } - app.MapGet("/lok/open-hours", async (HttpContext httpContext) => { var lokService = httpContext.RequestServices.GetRequiredService(); @@ -60,7 +56,7 @@ public static class LokEndpoints .RequireCors("PublicReadCors") .WithName("GetLokOpenHours"); - var deleteLokOpenHoursEndpoint = app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => + app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var deleted = await lokService.DeleteOpenHours(id); @@ -78,14 +74,10 @@ public static class LokEndpoints httpContext.Response.StatusCode = StatusCodes.Status204NoContent; }) .RequireCors("FrontendWriteCors") + .RequireAuthorization("HasLokRole") .WithName("DeleteLokOpenHours"); - if (!app.Environment.IsDevelopment()) - { - deleteLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); - } - - var updateLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => + app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var openHours = await httpContext.Request.ReadFromJsonAsync(); @@ -125,14 +117,10 @@ public static class LokEndpoints await httpContext.Response.WriteAsJsonAsync(updatedOpenHours); }) .RequireCors("FrontendWriteCors") + .RequireAuthorization("HasLokRole") .WithName("UpdateLokOpenHours"); - if (!app.Environment.IsDevelopment()) - { - updateLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); - } - - var setActiveLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) => + app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var activated = await lokService.SetActiveOpenHours(id); @@ -154,11 +142,7 @@ public static class LokEndpoints }); }) .RequireCors("FrontendWriteCors") + .RequireAuthorization("HasLokRole") .WithName("SetActiveLokOpenHours"); - - if (!app.Environment.IsDevelopment()) - { - setActiveLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); - } } } \ No newline at end of file diff --git a/api/App/Endpoints/UserEndpoints.cs b/api/App/Endpoints/UserEndpoints.cs index c5fb927..2d96222 100644 --- a/api/App/Endpoints/UserEndpoints.cs +++ b/api/App/Endpoints/UserEndpoints.cs @@ -11,7 +11,7 @@ public static class UserEndpoints await httpContext.Response.WriteAsJsonAsync(users); }) .RequireCors("FrontendWriteCors") - .RequireAuthorization("AdminOnly") + .RequireAuthorization("HasAdminRole") .WithName("GetUsers"); app.MapPost("/users", async (HttpContext httpContext) => @@ -56,7 +56,7 @@ public static class UserEndpoints } }) .RequireCors("FrontendWriteCors") - .RequireAuthorization("AdminOnly") + .RequireAuthorization("HasAdminRole") .WithName("CreateUser"); app.MapPut("/users/{username}", async (HttpContext httpContext, string username) => @@ -97,9 +97,9 @@ public static class UserEndpoints return; } - var adminCount = await userService.GetAdminCount(); + var adminCount = await userService.GetUsersWithRoleCount(AppRoles.Admin); - if (existingUser.IsAdmin && !request.IsAdmin && adminCount <= 1) + if (existingUser.Roles.Contains(AppRoles.Admin) && !request.Roles.Contains(AppRoles.Admin) && adminCount <= 1) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(new @@ -124,7 +124,7 @@ public static class UserEndpoints await httpContext.Response.WriteAsJsonAsync(updatedUser); }) .RequireCors("FrontendWriteCors") - .RequireAuthorization("AdminOnly") + .RequireAuthorization("HasAdminRole") .WithName("UpdateUser"); app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) => @@ -154,8 +154,8 @@ public static class UserEndpoints return; } - var adminCount = await userService.GetAdminCount(); - if (existingUser.IsAdmin && adminCount <= 1) + var adminCount = await userService.GetUsersWithRoleCount(AppRoles.Admin); + if (existingUser.Roles.Contains(AppRoles.Admin) && adminCount <= 1) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(new @@ -180,7 +180,7 @@ public static class UserEndpoints httpContext.Response.StatusCode = StatusCodes.Status204NoContent; }) .RequireCors("FrontendWriteCors") - .RequireAuthorization("AdminOnly") + .RequireAuthorization("HasAdminRole") .WithName("DeleteUser"); } } diff --git a/api/App/Models/AppUser.cs b/api/App/Models/AppUser.cs index 3f0408e..81986b6 100644 --- a/api/App/Models/AppUser.cs +++ b/api/App/Models/AppUser.cs @@ -1,3 +1,11 @@ +public static class AppRoles +{ + public const string Lok = "lok"; + public const string Admin = "admin"; + + public static readonly IReadOnlyList All = [Lok, Admin]; +} + public class AppUser { public long Id { get; set; } @@ -10,9 +18,9 @@ public class AppUser public DateTime LastUpdated { get; set; } - public bool IsAdmin { get; set; } - public string DisplayName { get; set; } = string.Empty; + + public List Roles { get; set; } = []; } public class AppUserView @@ -23,9 +31,9 @@ public class AppUserView public DateTime LastUpdated { get; set; } - public bool IsAdmin { get; set; } - public string DisplayName { get; set; } = string.Empty; + + public List Roles { get; set; } = []; } public class AppUserCreateRequest @@ -34,16 +42,16 @@ public class AppUserCreateRequest public string Password { get; set; } = string.Empty; - public bool IsAdmin { get; set; } - public string DisplayName { get; set; } = string.Empty; + + public List Roles { get; set; } = []; } public class AppUserUpdateRequest { public string? Password { get; set; } - public bool IsAdmin { get; set; } - public string DisplayName { get; set; } = string.Empty; + + public List Roles { get; set; } = []; } diff --git a/api/App/Program.cs b/api/App/Program.cs index af7e8e9..c0b2636 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -87,16 +87,16 @@ public class Program builder.Services.AddAuthorization(options => { - options.AddPolicy("OpenHoursWrite", policy => + options.AddPolicy("HasLokRole", policy => { policy.RequireAuthenticatedUser(); - policy.RequireClaim("scope", "openhours:write"); + policy.RequireRole(AppRoles.Lok, AppRoles.Admin); }); - options.AddPolicy("AdminOnly", policy => + options.AddPolicy("HasAdminRole", policy => { policy.RequireAuthenticatedUser(); - policy.RequireClaim("is_admin", "true"); + policy.RequireRole(AppRoles.Admin); }); }); @@ -198,6 +198,49 @@ public class Program CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Username ON Users(username);"; command.ExecuteNonQuery(); + + // Migration: if UserRoles still has roleId column, rebuild it with roleName + command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('UserRoles') WHERE name = 'roleId';"; + var userRolesHasRoleId = Convert.ToInt32(command.ExecuteScalar()) > 0; + + if (userRolesHasRoleId) + { + command.CommandText = @" + CREATE TABLE IF NOT EXISTS UserRoles_new ( + userId INTEGER NOT NULL REFERENCES Users(id) ON DELETE CASCADE, + roleName TEXT NOT NULL, + PRIMARY KEY (userId, roleName) + ); + INSERT OR IGNORE INTO UserRoles_new (userId, roleName) + SELECT ur.userId, r.name + FROM UserRoles ur + JOIN Roles r ON r.id = ur.roleId; + DROP TABLE UserRoles; + ALTER TABLE UserRoles_new RENAME TO UserRoles;"; + command.ExecuteNonQuery(); + } + + // Migration: drop old Roles table if it exists + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Roles';"; + var rolesTableExists = Convert.ToInt32(command.ExecuteScalar()) > 0; + + if (rolesTableExists) + { + command.CommandText = "DROP TABLE Roles;"; + command.ExecuteNonQuery(); + } + + // Migration: if Users table still has isAdmin column, migrate isAdmin=1 users to admin role + command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('Users') WHERE name = 'isAdmin';"; + var usersHasIsAdmin = Convert.ToInt32(command.ExecuteScalar()) > 0; + + if (usersHasIsAdmin) + { + command.CommandText = @" + INSERT OR IGNORE INTO UserRoles (userId, roleName) + SELECT id, 'admin' FROM Users WHERE isAdmin = 1;"; + command.ExecuteNonQuery(); + } } } diff --git a/api/App/Services/UserService.cs b/api/App/Services/UserService.cs index 653a269..237faca 100644 --- a/api/App/Services/UserService.cs +++ b/api/App/Services/UserService.cs @@ -15,25 +15,32 @@ public class UserService await EnsureOpenConnection(); var now = DateTime.UtcNow; + var normalizedUsername = admin.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) + await using var upsertCommand = _connection.CreateCommand(); + upsertCommand.CommandText = @" + INSERT INTO Users (username, password, added, lastUpdated, displayName) + VALUES (@username, @password, @added, @lastUpdated, @displayName) ON CONFLICT(username) DO UPDATE SET password = excluded.password, lastUpdated = excluded.lastUpdated, - isAdmin = 1, - displayName = excluded.displayName;"; + displayName = excluded.displayName; + SELECT id FROM Users WHERE username = @username;"; - 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()); + upsertCommand.Parameters.AddWithValue("@username", normalizedUsername); + upsertCommand.Parameters.AddWithValue("@password", admin.Password); + upsertCommand.Parameters.AddWithValue("@added", now.ToString("O")); + upsertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); + upsertCommand.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim()); - await command.ExecuteNonQueryAsync(); + var userId = Convert.ToInt64(await upsertCommand.ExecuteScalarAsync()); + + await using var roleCommand = _connection.CreateCommand(); + roleCommand.CommandText = @" + INSERT OR IGNORE INTO UserRoles (userId, roleName) VALUES (@userId, @roleName);"; + roleCommand.Parameters.AddWithValue("@userId", userId); + roleCommand.Parameters.AddWithValue("@roleName", AppRoles.Admin); + await roleCommand.ExecuteNonQueryAsync(); } public async Task Authenticate(string username, string password) @@ -42,9 +49,12 @@ public class UserService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT id, username, password, added, lastUpdated, isAdmin, displayName - FROM Users - WHERE username = @username + SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, + GROUP_CONCAT(ur.roleName) AS roles + FROM Users u + LEFT JOIN UserRoles ur ON ur.userId = u.id + WHERE u.username = @username + GROUP BY u.id LIMIT 1;"; command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant()); @@ -71,9 +81,12 @@ public class UserService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT username, added, lastUpdated, isAdmin, displayName - FROM Users - ORDER BY username ASC;"; + SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, + GROUP_CONCAT(ur.roleName) AS roles + FROM Users u + LEFT JOIN UserRoles ur ON ur.userId = u.id + GROUP BY u.id + ORDER BY u.username ASC;"; await using var reader = await command.ExecuteReaderAsync(); @@ -85,8 +98,8 @@ public class UserService 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 + DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + Roles = ParseRoles(reader["roles"]?.ToString()) }); } @@ -100,27 +113,37 @@ public class UserService 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);"; + await using var insertCommand = _connection.CreateCommand(); + insertCommand.CommandText = @" + INSERT INTO Users (username, password, added, lastUpdated, displayName) + VALUES (@username, @password, @added, @lastUpdated, @displayName); + SELECT last_insert_rowid();"; - 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()); + insertCommand.Parameters.AddWithValue("@username", normalizedUsername); + insertCommand.Parameters.AddWithValue("@password", request.Password); + insertCommand.Parameters.AddWithValue("@added", now.ToString("O")); + insertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); + insertCommand.Parameters.AddWithValue("@displayName", request.DisplayName.Trim()); - await command.ExecuteNonQueryAsync(); + var insertedId = Convert.ToInt64(await insertCommand.ExecuteScalarAsync()); + + var validRoles = request.Roles + .Select(r => r.Trim().ToLowerInvariant()) + .Where(r => AppRoles.All.Contains(r)) + .ToList(); + + if (validRoles.Count > 0) + { + await SetUserRoles(insertedId, validRoles); + } return new AppUserView { Username = normalizedUsername, Added = now, LastUpdated = now, - IsAdmin = request.IsAdmin, - DisplayName = request.DisplayName.Trim() + DisplayName = request.DisplayName.Trim(), + Roles = validRoles }; } @@ -142,14 +165,12 @@ public class UserService 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(); @@ -158,13 +179,20 @@ public class UserService return null; } + var validRoles = request.Roles + .Select(r => r.Trim().ToLowerInvariant()) + .Where(r => AppRoles.All.Contains(r)) + .ToList(); + + await SetUserRoles(currentUser.Id, validRoles); + return new AppUserView { Username = normalizedUsername, Added = currentUser.Added, LastUpdated = now, - IsAdmin = request.IsAdmin, - DisplayName = request.DisplayName.Trim() + DisplayName = request.DisplayName.Trim(), + Roles = validRoles }; } @@ -180,12 +208,13 @@ public class UserService return affectedRows > 0; } - public async Task GetAdminCount() + public async Task GetUsersWithRoleCount(string roleName) { await EnsureOpenConnection(); await using var command = _connection.CreateCommand(); - command.CommandText = "SELECT COUNT(*) FROM Users WHERE isAdmin = 1;"; + command.CommandText = "SELECT COUNT(*) FROM UserRoles WHERE roleName = @roleName;"; + command.Parameters.AddWithValue("@roleName", roleName.Trim().ToLowerInvariant()); return Convert.ToInt32(await command.ExecuteScalarAsync()); } @@ -194,29 +223,61 @@ public class UserService { await EnsureOpenConnection(); - var user = await GetUserByUsername(username.Trim().ToLowerInvariant()); - if (user is null) + await using var command = _connection.CreateCommand(); + command.CommandText = @" + SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, + GROUP_CONCAT(ur.roleName) AS roles + FROM Users u + LEFT JOIN UserRoles ur ON ur.userId = u.id + WHERE u.username = @username + GROUP BY u.id + LIMIT 1;"; + + command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant()); + + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) { return null; } return new AppUserView { - Username = user.Username, - Added = user.Added, - LastUpdated = user.LastUpdated, - IsAdmin = user.IsAdmin, - DisplayName = user.DisplayName + Username = reader["username"]?.ToString() ?? string.Empty, + Added = ParseDate(reader["added"]?.ToString()), + LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), + DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + Roles = ParseRoles(reader["roles"]?.ToString()) }; } + private async Task SetUserRoles(long userId, IReadOnlyList roleNames) + { + await using var deleteCommand = _connection.CreateCommand(); + deleteCommand.CommandText = "DELETE FROM UserRoles WHERE userId = @userId;"; + deleteCommand.Parameters.AddWithValue("@userId", userId); + await deleteCommand.ExecuteNonQueryAsync(); + + foreach (var roleName in roleNames) + { + await using var insertCommand = _connection.CreateCommand(); + insertCommand.CommandText = "INSERT OR IGNORE INTO UserRoles (userId, roleName) VALUES (@userId, @roleName);"; + insertCommand.Parameters.AddWithValue("@userId", userId); + insertCommand.Parameters.AddWithValue("@roleName", roleName); + await insertCommand.ExecuteNonQueryAsync(); + } + } + 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 + SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, + GROUP_CONCAT(ur.roleName) AS roles + FROM Users u + LEFT JOIN UserRoles ur ON ur.userId = u.id + WHERE u.username = @username + GROUP BY u.id LIMIT 1;"; command.Parameters.AddWithValue("@username", username); @@ -239,11 +300,17 @@ public class UserService 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 + DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + Roles = ParseRoles(reader["roles"]?.ToString()) }; } + private static List ParseRoles(string? value) + { + if (string.IsNullOrEmpty(value)) return []; + return [.. value.Split(',').Where(r => !string.IsNullOrWhiteSpace(r))]; + } + private static DateTime ParseDate(string? value) { if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed)) @@ -254,24 +321,15 @@ public class UserService 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(); + + await using var pragma = _connection.CreateCommand(); + pragma.CommandText = "PRAGMA foreign_keys = ON;"; + await pragma.ExecuteNonQueryAsync(); } } } diff --git a/api/Database/init.sql b/api/Database/init.sql index 4857eb8..da92e52 100644 --- a/api/Database/init.sql +++ b/api/Database/init.sql @@ -16,6 +16,11 @@ CREATE TABLE IF NOT EXISTS Users ( password TEXT NOT NULL, added TEXT NOT NULL, lastUpdated TEXT NOT NULL, - isAdmin INTEGER NOT NULL DEFAULT 0, displayName TEXT NOT NULL DEFAULT '' ); + +CREATE TABLE IF NOT EXISTS UserRoles ( + userId INTEGER NOT NULL REFERENCES Users(id) ON DELETE CASCADE, + roleName TEXT NOT NULL, + PRIMARY KEY (userId, roleName) +); diff --git a/api/Database/klapi.db b/api/Database/klapi.db index 727013118f5c26623985a1f28df86474184f82a9..042dbba99f8047c63aefe88cf1fd0791ada054c5 100644 GIT binary patch delta 1191 zcmbW0&u`LT7{~jz4BA3@$2e3+9Is2ZLD)!JpfI8bHeM$ggUKLqUI>Q9APn4KnI=p2 zLu0&c3r8^r>68zqHU?fWUpLzvuhho_(WnZ$pgt+yW;IOW-eRkK$WUR z?O}bnQfg@V!fK;tjPu>i=d0RUUOqDvglk|xC-K*LL19ek82V7Dh)`3qjW$2p zoq=2`aVv*;ua`B8*Tr6i4Ru~N8m_f&-8Ip0vE}HWh4&&1Pq|opMGyq%Y@u8(sFRAM zs1s6PLJDT(P&5>bN)dlp4Mie~bf;V@m$gcXS5-AY^VDSyyNFU)UejbH@UKvfhNP$* z^oPTWs)ps}Yj=`{A6gFd26{M#5rMH8x}Ro7K(ba&KhUZ-YpW}Dh67|#Kl!@(mGsQ* zl%=d9N0mr481e@rlfiH#)O?5ik5xj$l VH*q2`1bR6u*HLgL-~@V?`3;0dMVHceh$8wNJ+8V0^xK51ST?!UZ(+%=m8 z1z5N@*Kki@*(|8g&aZ3C#Kj=V=_<_&1cl|PhDOFl21aJO2F5@XVq|D#Xl7+>qGx1b zXkl(>GWmeK&t^e`>-@3;Ow6i`KKa@H1*v%+`K3k0Oq`6&;*6oisX*o?gARFNh;{=* dD { diff --git a/ui/src/app.css b/ui/src/app.css index b61b750..31c85be 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -14,7 +14,7 @@ main { } h1 { - @apply uppercase text-6xl text-[#8E4F24] font-thin; + @apply uppercase text-3xl sm:text-6xl text-[#8E4F24] font-thin; } button { diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 193710c..387aa9b 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -24,9 +24,9 @@ function AppShell() { useEffect(() => { const storedUsername = localStorage.getItem("session-username"); const storedDisplayName = localStorage.getItem("session-display-name"); - const storedIsAdmin = localStorage.getItem("session-is-admin"); + const storedRoles = localStorage.getItem("session-roles"); const storedToken = localStorage.getItem("session-token"); - if (!storedUsername || !storedDisplayName || !storedToken || !storedIsAdmin) { + if (!storedUsername || !storedDisplayName || !storedToken) { setSession(null); return; } @@ -34,7 +34,7 @@ function AppShell() { setSession({ username: storedUsername, displayName: storedDisplayName, - isAdmin: storedIsAdmin === "true", + roles: (storedRoles ?? "").split(",").filter(Boolean), token: storedToken, }); }, [setSession]); @@ -52,7 +52,7 @@ function AppShell() { } /> : } + element={session?.roles.includes("admin") ? : } /> } /> } /> @@ -64,7 +64,7 @@ function AppShell() { export default function App() { return ( - + ); diff --git a/ui/src/components/Nav.tsx b/ui/src/components/Nav.tsx index e30d53b..7301532 100644 --- a/ui/src/components/Nav.tsx +++ b/ui/src/components/Nav.tsx @@ -26,54 +26,60 @@ export default function Nav() { }`; return ( -