User roles

This commit is contained in:
2026-03-10 23:29:13 +02:00
parent b361f46afa
commit e89d971f41
16 changed files with 325 additions and 197 deletions

View File

@@ -53,10 +53,13 @@ public static class AuthEndpoints
new(ClaimTypes.Name, authenticatedUser.Username), new(ClaimTypes.Name, authenticatedUser.Username),
new("username", authenticatedUser.Username), new("username", authenticatedUser.Username),
new("display_name", authenticatedUser.DisplayName), 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( var token = new JwtSecurityToken(
issuer: options.Issuer, issuer: options.Issuer,
audience: options.Audience, audience: options.Audience,
@@ -71,7 +74,7 @@ public static class AuthEndpoints
AccessToken = tokenValue, AccessToken = tokenValue,
Username = authenticatedUser.Username, Username = authenticatedUser.Username,
DisplayName = authenticatedUser.DisplayName, DisplayName = authenticatedUser.DisplayName,
IsAdmin = authenticatedUser.IsAdmin, Roles = authenticatedUser.Roles,
TokenType = "Bearer", TokenType = "Bearer",
ExpiresIn = 43200 ExpiresIn = 43200
}); });

View File

@@ -2,7 +2,7 @@ public static class LokEndpoints
{ {
public static void MapLokEndpoints(WebApplication app) 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<LokService>(); var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>(); var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
@@ -33,13 +33,9 @@ public static class LokEndpoints
await httpContext.Response.WriteAsJsonAsync(createdOpenHours); await httpContext.Response.WriteAsJsonAsync(createdOpenHours);
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("HasLokRole")
.WithName("CreateLokOpenHours"); .WithName("CreateLokOpenHours");
if (!app.Environment.IsDevelopment())
{
createLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
app.MapGet("/lok/open-hours", async (HttpContext httpContext) => app.MapGet("/lok/open-hours", async (HttpContext httpContext) =>
{ {
var lokService = httpContext.RequestServices.GetRequiredService<LokService>(); var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
@@ -60,7 +56,7 @@ public static class LokEndpoints
.RequireCors("PublicReadCors") .RequireCors("PublicReadCors")
.WithName("GetLokOpenHours"); .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<LokService>(); var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var deleted = await lokService.DeleteOpenHours(id); var deleted = await lokService.DeleteOpenHours(id);
@@ -78,14 +74,10 @@ public static class LokEndpoints
httpContext.Response.StatusCode = StatusCodes.Status204NoContent; httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("HasLokRole")
.WithName("DeleteLokOpenHours"); .WithName("DeleteLokOpenHours");
if (!app.Environment.IsDevelopment()) app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) =>
{
deleteLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
var updateLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) =>
{ {
var lokService = httpContext.RequestServices.GetRequiredService<LokService>(); var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>(); var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
@@ -125,14 +117,10 @@ public static class LokEndpoints
await httpContext.Response.WriteAsJsonAsync(updatedOpenHours); await httpContext.Response.WriteAsJsonAsync(updatedOpenHours);
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("HasLokRole")
.WithName("UpdateLokOpenHours"); .WithName("UpdateLokOpenHours");
if (!app.Environment.IsDevelopment()) app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) =>
{
updateLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
var setActiveLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) =>
{ {
var lokService = httpContext.RequestServices.GetRequiredService<LokService>(); var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var activated = await lokService.SetActiveOpenHours(id); var activated = await lokService.SetActiveOpenHours(id);
@@ -154,11 +142,7 @@ public static class LokEndpoints
}); });
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("HasLokRole")
.WithName("SetActiveLokOpenHours"); .WithName("SetActiveLokOpenHours");
if (!app.Environment.IsDevelopment())
{
setActiveLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
} }
} }

View File

@@ -11,7 +11,7 @@ public static class UserEndpoints
await httpContext.Response.WriteAsJsonAsync(users); await httpContext.Response.WriteAsJsonAsync(users);
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly") .RequireAuthorization("HasAdminRole")
.WithName("GetUsers"); .WithName("GetUsers");
app.MapPost("/users", async (HttpContext httpContext) => app.MapPost("/users", async (HttpContext httpContext) =>
@@ -56,7 +56,7 @@ public static class UserEndpoints
} }
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly") .RequireAuthorization("HasAdminRole")
.WithName("CreateUser"); .WithName("CreateUser");
app.MapPut("/users/{username}", async (HttpContext httpContext, string username) => app.MapPut("/users/{username}", async (HttpContext httpContext, string username) =>
@@ -97,9 +97,9 @@ public static class UserEndpoints
return; 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; httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new await httpContext.Response.WriteAsJsonAsync(new
@@ -124,7 +124,7 @@ public static class UserEndpoints
await httpContext.Response.WriteAsJsonAsync(updatedUser); await httpContext.Response.WriteAsJsonAsync(updatedUser);
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly") .RequireAuthorization("HasAdminRole")
.WithName("UpdateUser"); .WithName("UpdateUser");
app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) => app.MapDelete("/users/{username}", async (HttpContext httpContext, string username) =>
@@ -154,8 +154,8 @@ public static class UserEndpoints
return; return;
} }
var adminCount = await userService.GetAdminCount(); var adminCount = await userService.GetUsersWithRoleCount(AppRoles.Admin);
if (existingUser.IsAdmin && adminCount <= 1) if (existingUser.Roles.Contains(AppRoles.Admin) && adminCount <= 1)
{ {
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new await httpContext.Response.WriteAsJsonAsync(new
@@ -180,7 +180,7 @@ public static class UserEndpoints
httpContext.Response.StatusCode = StatusCodes.Status204NoContent; httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
}) })
.RequireCors("FrontendWriteCors") .RequireCors("FrontendWriteCors")
.RequireAuthorization("AdminOnly") .RequireAuthorization("HasAdminRole")
.WithName("DeleteUser"); .WithName("DeleteUser");
} }
} }

View File

@@ -1,3 +1,11 @@
public static class AppRoles
{
public const string Lok = "lok";
public const string Admin = "admin";
public static readonly IReadOnlyList<string> All = [Lok, Admin];
}
public class AppUser public class AppUser
{ {
public long Id { get; set; } public long Id { get; set; }
@@ -10,9 +18,9 @@ public class AppUser
public DateTime LastUpdated { get; set; } public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
} }
public class AppUserView public class AppUserView
@@ -23,9 +31,9 @@ public class AppUserView
public DateTime LastUpdated { get; set; } public DateTime LastUpdated { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
} }
public class AppUserCreateRequest public class AppUserCreateRequest
@@ -34,16 +42,16 @@ public class AppUserCreateRequest
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
} }
public class AppUserUpdateRequest public class AppUserUpdateRequest
{ {
public string? Password { get; set; } public string? Password { get; set; }
public bool IsAdmin { get; set; }
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = [];
} }

View File

@@ -87,16 +87,16 @@ public class Program
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
{ {
options.AddPolicy("OpenHoursWrite", policy => options.AddPolicy("HasLokRole", policy =>
{ {
policy.RequireAuthenticatedUser(); policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "openhours:write"); policy.RequireRole(AppRoles.Lok, AppRoles.Admin);
}); });
options.AddPolicy("AdminOnly", policy => options.AddPolicy("HasAdminRole", policy =>
{ {
policy.RequireAuthenticatedUser(); 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 CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_Username
ON Users(username);"; ON Users(username);";
command.ExecuteNonQuery(); 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();
}
} }
} }

View File

@@ -15,25 +15,32 @@ public class UserService
await EnsureOpenConnection(); await EnsureOpenConnection();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var normalizedUsername = admin.Username.Trim().ToLowerInvariant();
await using var command = _connection.CreateCommand(); await using var upsertCommand = _connection.CreateCommand();
command.CommandText = @" upsertCommand.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName) INSERT INTO Users (username, password, added, lastUpdated, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName) VALUES (@username, @password, @added, @lastUpdated, @displayName)
ON CONFLICT(username) DO UPDATE SET ON CONFLICT(username) DO UPDATE SET
password = excluded.password, password = excluded.password,
lastUpdated = excluded.lastUpdated, 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()); upsertCommand.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", admin.Password); upsertCommand.Parameters.AddWithValue("@password", admin.Password);
command.Parameters.AddWithValue("@added", now.ToString("O")); upsertCommand.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); upsertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", 1); upsertCommand.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim());
command.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<AppUser?> Authenticate(string username, string password) public async Task<AppUser?> Authenticate(string username, string password)
@@ -42,9 +49,12 @@ public class UserService
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName,
FROM Users GROUP_CONCAT(ur.roleName) AS roles
WHERE username = @username FROM Users u
LEFT JOIN UserRoles ur ON ur.userId = u.id
WHERE u.username = @username
GROUP BY u.id
LIMIT 1;"; LIMIT 1;";
command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant()); command.Parameters.AddWithValue("@username", username.Trim().ToLowerInvariant());
@@ -71,9 +81,12 @@ public class UserService
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
SELECT username, added, lastUpdated, isAdmin, displayName SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName,
FROM Users GROUP_CONCAT(ur.roleName) AS roles
ORDER BY username ASC;"; 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(); await using var reader = await command.ExecuteReaderAsync();
@@ -85,8 +98,8 @@ public class UserService
Username = reader["username"]?.ToString() ?? string.Empty, Username = reader["username"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()), Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.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 now = DateTime.UtcNow;
var normalizedUsername = request.Username.Trim().ToLowerInvariant(); var normalizedUsername = request.Username.Trim().ToLowerInvariant();
await using var command = _connection.CreateCommand(); await using var insertCommand = _connection.CreateCommand();
command.CommandText = @" insertCommand.CommandText = @"
INSERT INTO Users (username, password, added, lastUpdated, isAdmin, displayName) INSERT INTO Users (username, password, added, lastUpdated, displayName)
VALUES (@username, @password, @added, @lastUpdated, @isAdmin, @displayName);"; VALUES (@username, @password, @added, @lastUpdated, @displayName);
SELECT last_insert_rowid();";
command.Parameters.AddWithValue("@username", normalizedUsername); insertCommand.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", request.Password); insertCommand.Parameters.AddWithValue("@password", request.Password);
command.Parameters.AddWithValue("@added", now.ToString("O")); insertCommand.Parameters.AddWithValue("@added", now.ToString("O"));
command.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); insertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O"));
command.Parameters.AddWithValue("@isAdmin", request.IsAdmin ? 1 : 0); insertCommand.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
command.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 return new AppUserView
{ {
Username = normalizedUsername, Username = normalizedUsername,
Added = now, Added = now,
LastUpdated = 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 UPDATE Users
SET password = @password, SET password = @password,
lastUpdated = @lastUpdated, lastUpdated = @lastUpdated,
isAdmin = @isAdmin,
displayName = @displayName displayName = @displayName
WHERE username = @username;"; WHERE username = @username;";
command.Parameters.AddWithValue("@username", normalizedUsername); command.Parameters.AddWithValue("@username", normalizedUsername);
command.Parameters.AddWithValue("@password", string.IsNullOrWhiteSpace(request.Password) ? currentUser.Password : request.Password); command.Parameters.AddWithValue("@password", string.IsNullOrWhiteSpace(request.Password) ? currentUser.Password : request.Password);
command.Parameters.AddWithValue("@lastUpdated", 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()); command.Parameters.AddWithValue("@displayName", request.DisplayName.Trim());
var affectedRows = await command.ExecuteNonQueryAsync(); var affectedRows = await command.ExecuteNonQueryAsync();
@@ -158,13 +179,20 @@ public class UserService
return null; 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 return new AppUserView
{ {
Username = normalizedUsername, Username = normalizedUsername,
Added = currentUser.Added, Added = currentUser.Added,
LastUpdated = now, 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; return affectedRows > 0;
} }
public async Task<int> GetAdminCount() public async Task<int> GetUsersWithRoleCount(string roleName)
{ {
await EnsureOpenConnection(); await EnsureOpenConnection();
await using var command = _connection.CreateCommand(); 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()); return Convert.ToInt32(await command.ExecuteScalarAsync());
} }
@@ -194,29 +223,61 @@ public class UserService
{ {
await EnsureOpenConnection(); await EnsureOpenConnection();
var user = await GetUserByUsername(username.Trim().ToLowerInvariant()); await using var command = _connection.CreateCommand();
if (user is null) 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 null;
} }
return new AppUserView return new AppUserView
{ {
Username = user.Username, Username = reader["username"]?.ToString() ?? string.Empty,
Added = user.Added, Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = user.LastUpdated, LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()),
IsAdmin = user.IsAdmin, DisplayName = reader["displayName"]?.ToString() ?? string.Empty,
DisplayName = user.DisplayName Roles = ParseRoles(reader["roles"]?.ToString())
}; };
} }
private async Task SetUserRoles(long userId, IReadOnlyList<string> 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<AppUser?> GetUserByUsername(string username) private async Task<AppUser?> GetUserByUsername(string username)
{ {
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
SELECT id, username, password, added, lastUpdated, isAdmin, displayName SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName,
FROM Users GROUP_CONCAT(ur.roleName) AS roles
WHERE username = @username FROM Users u
LEFT JOIN UserRoles ur ON ur.userId = u.id
WHERE u.username = @username
GROUP BY u.id
LIMIT 1;"; LIMIT 1;";
command.Parameters.AddWithValue("@username", username); command.Parameters.AddWithValue("@username", username);
@@ -239,11 +300,17 @@ public class UserService
Password = reader["password"]?.ToString() ?? string.Empty, Password = reader["password"]?.ToString() ?? string.Empty,
Added = ParseDate(reader["added"]?.ToString()), Added = ParseDate(reader["added"]?.ToString()),
LastUpdated = ParseDate(reader["lastUpdated"]?.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<string> ParseRoles(string? value)
{
if (string.IsNullOrEmpty(value)) return [];
return [.. value.Split(',').Where(r => !string.IsNullOrWhiteSpace(r))];
}
private static DateTime ParseDate(string? value) private static DateTime ParseDate(string? value)
{ {
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed)) if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
@@ -254,24 +321,15 @@ public class UserService
return DateTime.MinValue; 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() private async Task EnsureOpenConnection()
{ {
if (_connection.State != ConnectionState.Open) if (_connection.State != ConnectionState.Open)
{ {
await _connection.OpenAsync(); await _connection.OpenAsync();
await using var pragma = _connection.CreateCommand();
pragma.CommandText = "PRAGMA foreign_keys = ON;";
await pragma.ExecuteNonQueryAsync();
} }
} }
} }

View File

@@ -16,6 +16,11 @@ CREATE TABLE IF NOT EXISTS Users (
password TEXT NOT NULL, password TEXT NOT NULL,
added TEXT NOT NULL, added TEXT NOT NULL,
lastUpdated TEXT NOT NULL, lastUpdated TEXT NOT NULL,
isAdmin INTEGER NOT NULL DEFAULT 0,
displayName TEXT NOT NULL DEFAULT '' 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)
);

Binary file not shown.

View File

@@ -4,7 +4,7 @@ type AuthTokenResponse = {
accessToken: string; accessToken: string;
username: string; username: string;
displayName: string; displayName: string;
isAdmin: boolean; roles: string[];
tokenType: string; tokenType: string;
expiresIn: number; expiresIn: number;
}; };
@@ -63,21 +63,21 @@ export type User = {
username: string; username: string;
added: string; added: string;
lastUpdated: string; lastUpdated: string;
isAdmin: boolean;
displayName: string; displayName: string;
roles: string[];
}; };
export type CreateUserInput = { export type CreateUserInput = {
username: string; username: string;
password: string; password: string;
isAdmin: boolean;
displayName: string; displayName: string;
roles: string[];
}; };
export type UpdateUserInput = { export type UpdateUserInput = {
password?: string; password?: string;
isAdmin: boolean;
displayName: string; displayName: string;
roles: string[];
}; };
export async function queryApiVersion(): Promise<string> { export async function queryApiVersion(): Promise<string> {

View File

@@ -14,7 +14,7 @@ main {
} }
h1 { h1 {
@apply uppercase text-6xl text-[#8E4F24] font-thin; @apply uppercase text-3xl sm:text-6xl text-[#8E4F24] font-thin;
} }
button { button {

View File

@@ -24,9 +24,9 @@ function AppShell() {
useEffect(() => { useEffect(() => {
const storedUsername = localStorage.getItem("session-username"); const storedUsername = localStorage.getItem("session-username");
const storedDisplayName = localStorage.getItem("session-display-name"); 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"); const storedToken = localStorage.getItem("session-token");
if (!storedUsername || !storedDisplayName || !storedToken || !storedIsAdmin) { if (!storedUsername || !storedDisplayName || !storedToken) {
setSession(null); setSession(null);
return; return;
} }
@@ -34,7 +34,7 @@ function AppShell() {
setSession({ setSession({
username: storedUsername, username: storedUsername,
displayName: storedDisplayName, displayName: storedDisplayName,
isAdmin: storedIsAdmin === "true", roles: (storedRoles ?? "").split(",").filter(Boolean),
token: storedToken, token: storedToken,
}); });
}, [setSession]); }, [setSession]);
@@ -52,7 +52,7 @@ function AppShell() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route
path="/management" path="/management"
element={session?.isAdmin ? <Management /> : <Navigate to="/" replace />} element={session?.roles.includes("admin") ? <Management /> : <Navigate to="/" replace />}
/> />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
<Route path="/index.html" element={<Navigate to="/" replace />} /> <Route path="/index.html" element={<Navigate to="/" replace />} />
@@ -64,7 +64,7 @@ function AppShell() {
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter future={{ v7_relativeSplatPath: true }}>
<AppShell /> <AppShell />
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -26,54 +26,60 @@ export default function Nav() {
}`; }`;
return ( return (
<nav className="fixed top-0 left-0 z-50 flex w-full items-center justify-between bg-[#70421E] px-4 py-3 text-sm font-medium shadow-sm"> <nav className="fixed top-0 left-0 z-50 w-full bg-[#70421E] shadow-sm">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link> <div className="flex flex-col sm:flex-row sm:items-center px-4 py-2 text-sm font-medium">
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link> {/* Links — bottom on mobile (order-2), left on desktop (sm:order-1) */}
{session?.isAdmin ? ( <div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link> <Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
) : null} <Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
{session?.roles.includes("admin") ? (
<div className="ml-auto flex items-center gap-2"> <Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
<b className="text-[#F5D1A9]">{session?.displayName ?? ""}</b> ) : null}
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button
type="button"
onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.fi")}
</button>
<button
type="button"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}
</button>
</div> </div>
{session ? ( {/* Controls — top on mobile (order-1), right on desktop (sm:order-2 sm:ml-auto) */}
<button <div className="order-1 sm:order-2 sm:ml-auto flex items-center justify-center gap-2">
type="button" <b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
onClick={signOut} <div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]" <button
> type="button"
{t("nav.signOut")} onClick={() => setLanguage("fi")}
</button> className={`rounded px-2 py-1 text-xs ${language === "fi"
) : ( ? "bg-[#E3A977] text-[#4C250E]"
<Link : "text-[#F5D1A9] hover:text-[#FFF7EE]"
to="/login" }`}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]" >
> {t("nav.language.fi")}
{t("nav.login")} </button>
</Link> <button
)} type="button"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}
</button>
</div>
{session ? (
<button
type="button"
onClick={signOut}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.signOut")}
</button>
) : (
<Link
to="/login"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.login")}
</Link>
)}
</div>
</div> </div>
</nav> </nav>
); );

View File

@@ -66,17 +66,17 @@ const translations = {
"management.username": "Username", "management.username": "Username",
"management.password": "Password", "management.password": "Password",
"management.displayName": "Display name", "management.displayName": "Display name",
"management.isAdmin": "Is admin",
"management.added": "Added", "management.added": "Added",
"management.updated": "Last updated", "management.updated": "Last updated",
"management.admin": "Admin",
"management.user": "User",
"management.loading": "Loading users...", "management.loading": "Loading users...",
"management.requiredFields": "management.requiredFields":
"Username, display name and password are required", "Username, display name and password are required",
"management.loadError": "Failed to load users", "management.loadError": "Failed to load users",
"management.saveError": "Failed to save user", "management.saveError": "Failed to save user",
"management.deleteError": "Failed to delete user", "management.deleteError": "Failed to delete user",
"management.roles": "Roles",
"management.rolesAssign": "Assign roles",
"management.rolesNone": "No roles assigned",
"notFound.title": "Page Not Found", "notFound.title": "Page Not Found",
"notFound.heading": "Not Found", "notFound.heading": "Not Found",
"notFound.message": "Sorry, the page youre looking for doesn't exist", "notFound.message": "Sorry, the page youre looking for doesn't exist",
@@ -146,17 +146,17 @@ const translations = {
"management.username": "Käyttäjätunnus", "management.username": "Käyttäjätunnus",
"management.password": "Salasana", "management.password": "Salasana",
"management.displayName": "Näyttönimi", "management.displayName": "Näyttönimi",
"management.isAdmin": "Ylläpitäjä",
"management.added": "Lisätty", "management.added": "Lisätty",
"management.updated": "Viimeksi päivitetty", "management.updated": "Viimeksi päivitetty",
"management.admin": "Ylläpitäjä",
"management.user": "Käyttäjä",
"management.loading": "Ladataan käyttäjiä...", "management.loading": "Ladataan käyttäjiä...",
"management.requiredFields": "management.requiredFields":
"Käyttäjätunnus, näyttönimi ja salasana vaaditaan", "Käyttäjätunnus, näyttönimi ja salasana vaaditaan",
"management.loadError": "Käyttäjien haku epäonnistui", "management.loadError": "Käyttäjien haku epäonnistui",
"management.saveError": "Käyttäjän tallennus epäonnistui", "management.saveError": "Käyttäjän tallennus epäonnistui",
"management.deleteError": "Käyttäjän poisto epäonnistui", "management.deleteError": "Käyttäjän poisto epäonnistui",
"management.roles": "Roolit",
"management.rolesAssign": "Määritä roolit",
"management.rolesNone": "Ei rooleja",
"notFound.title": "Sivua ei löytynyt", "notFound.title": "Sivua ei löytynyt",
"notFound.heading": "Ei löytynyt", "notFound.heading": "Ei löytynyt",
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa", "notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",

View File

@@ -38,13 +38,13 @@ export default function Login() {
setSession({ setSession({
username: auth.username, username: auth.username,
displayName: auth.displayName, displayName: auth.displayName,
isAdmin: auth.isAdmin, roles: auth.roles,
token: auth.accessToken, token: auth.accessToken,
}); });
localStorage.setItem("session-username", auth.username); localStorage.setItem("session-username", auth.username);
localStorage.setItem("session-display-name", auth.displayName); localStorage.setItem("session-display-name", auth.displayName);
localStorage.setItem("session-is-admin", auth.isAdmin ? "true" : "false"); localStorage.setItem("session-roles", auth.roles.join(","));
localStorage.setItem("session-token", auth.accessToken); localStorage.setItem("session-token", auth.accessToken);
setError(""); setError("");
navigate("/"); navigate("/");

View File

@@ -1,7 +1,9 @@
import { FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { createUser, deleteUser, queryUsers, updateUser, type User } from "~/api"; import { createUser, deleteUser, queryUsers, updateUser, type User } from "~/api";
import { useT } from "~/i18n"; import { useT } from "~/i18n";
const AVAILABLE_ROLES = ["lok", "admin"];
type Mode = "create" | "edit"; type Mode = "create" | "edit";
export default function Management() { export default function Management() {
@@ -11,7 +13,7 @@ export default function Management() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [isAdmin, setIsAdmin] = useState(false); const [selectedRoles, setSelectedRoles] = useState<Set<string>>(new Set());
const [selectedUsername, setSelectedUsername] = useState(""); const [selectedUsername, setSelectedUsername] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -42,7 +44,7 @@ export default function Management() {
setUsername(""); setUsername("");
setPassword(""); setPassword("");
setDisplayName(""); setDisplayName("");
setIsAdmin(false); setSelectedRoles(new Set());
setSelectedUsername(""); setSelectedUsername("");
}; };
@@ -51,11 +53,20 @@ export default function Management() {
setUsername(user.username); setUsername(user.username);
setPassword(""); setPassword("");
setDisplayName(user.displayName); setDisplayName(user.displayName);
setIsAdmin(user.isAdmin); setSelectedRoles(new Set(user.roles));
setSelectedUsername(user.username); setSelectedUsername(user.username);
setError(""); setError("");
}; };
const toggleRole = (role: string) => {
setSelectedRoles((prev) => {
const next = new Set(prev);
if (next.has(role)) next.delete(role);
else next.add(role);
return next;
});
};
const onSubmit = async (event: FormEvent) => { const onSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -70,13 +81,13 @@ export default function Management() {
username: username.trim(), username: username.trim(),
password: password, password: password,
displayName: displayName.trim(), displayName: displayName.trim(),
isAdmin, roles: [...selectedRoles],
}); });
} else { } else {
await updateUser(selectedUsername, { await updateUser(selectedUsername, {
password: password.trim() ? password : undefined, password: password.trim() ? password : undefined,
displayName: displayName.trim(), displayName: displayName.trim(),
isAdmin, roles: [...selectedRoles],
}); });
} }
@@ -100,7 +111,7 @@ export default function Management() {
}; };
return ( return (
<main className="w-full px-4"> <main className="w-full px-4 !justify-start pt-28 sm:pt-20">
<h1>{t("management.heading")}</h1> <h1>{t("management.heading")}</h1>
<form onSubmit={onSubmit} className="mx-auto mt-4 w-full max-w-2xl space-y-3 rounded-md border border-[#C99763] bg-[#FFF7EE] p-4"> <form onSubmit={onSubmit} className="mx-auto mt-4 w-full max-w-2xl space-y-3 rounded-md border border-[#C99763] bg-[#FFF7EE] p-4">
@@ -142,15 +153,21 @@ export default function Management() {
/> />
</label> </label>
<label htmlFor="management-is-admin" className="flex items-center gap-2 text-left"> <div className="block text-left">
<input <span className="block">{t("management.rolesAssign")}</span>
id="management-is-admin" <div className="mt-1 flex flex-wrap gap-3">
type="checkbox" {AVAILABLE_ROLES.map((role) => (
checked={isAdmin} <label key={role} className="flex items-center gap-1.5 text-sm">
onChange={(event) => setIsAdmin(event.target.checked)} <input
/> type="checkbox"
{t("management.isAdmin")} checked={selectedRoles.has(role)}
</label> onChange={() => toggleRole(role)}
/>
{role}
</label>
))}
</div>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -193,9 +210,13 @@ export default function Management() {
<div className="text-xs text-[#8E4F24]"> <div className="text-xs text-[#8E4F24]">
{t("management.updated")}: {new Date(user.lastUpdated).toLocaleString()} {t("management.updated")}: {new Date(user.lastUpdated).toLocaleString()}
</div> </div>
<div className="text-xs text-[#8E4F24]"> {user.roles.length > 0 ? (
{user.isAdmin ? t("management.admin") : t("management.user")} <div className="mt-1 flex flex-wrap gap-1">
</div> {user.roles.map((role) => (
<span key={role} className="rounded bg-[#E3A977] px-1.5 py-0.5 text-xs text-[#4C250E]">{role}</span>
))}
</div>
) : null}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@@ -6,7 +6,7 @@ export type Language = "fi" | "en";
export type Session = { export type Session = {
username: string; username: string;
displayName: string; displayName: string;
isAdmin: boolean; roles: string[];
token: string; token: string;
}; };