User management

This commit is contained in:
2026-03-03 23:15:04 +02:00
parent 667fa25525
commit 2d1923d68d
17 changed files with 1046 additions and 74 deletions

View File

@@ -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<AuthTokenDto>();
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<List<UserDto>>();
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<UserDto>();
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<Program>
@@ -207,12 +256,13 @@ public abstract class ApiTestFactoryBase(string environmentName) : WebApplicatio
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["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;
}

View File

@@ -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<string> AllowedOrigins { get; set; } = [];
public List<AuthUser> 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> 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<Claim>
{
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
});

View File

@@ -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<UserService>();
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<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserCreateRequest>();
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<UserService>();
var request = await httpContext.Request.ReadFromJsonAsync<AppUserUpdateRequest>();
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<UserService>();
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");
}
}

49
api/App/Models/AppUser.cs Normal file
View File

@@ -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;
}

View File

@@ -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<AuthOptions>(builder.Configuration.GetSection("Auth"));
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>();
builder.Services.AddScoped<UserService>();
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>();
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();
}

View File

@@ -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<AppUser?> 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<List<AppUserView>> 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<AppUserView>();
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<AppUserView> 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<AppUserView?> 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<bool> 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<int> 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<AppUserView?> 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<AppUser?> 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();
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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": "*"
}

View File

@@ -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 ''
);

Binary file not shown.

View File

@@ -2,7 +2,9 @@ import { buildApiUrl } from "./url";
type AuthTokenResponse = {
accessToken: string;
email: string;
username: string;
displayName: string;
isAdmin: boolean;
tokenType: string;
expiresIn: number;
};
@@ -57,6 +59,27 @@ export type LokOpenHoursInput = {
kitchenNotice: string;
};
export type User = {
username: string;
added: string;
lastUpdated: string;
isAdmin: boolean;
displayName: string;
};
export type CreateUserInput = {
username: string;
password: string;
isAdmin: boolean;
displayName: string;
};
export type UpdateUserInput = {
password?: string;
isAdmin: boolean;
displayName: string;
};
export async function queryApiVersion(): Promise<string> {
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<AuthTokenResponse> {
return await fetchApi<AuthTokenResponse>("/auth/token", {
method: "POST",
body: JSON.stringify({
email,
username,
password,
}),
});
}
export async function queryUsers(): Promise<User[]> {
return await fetchApi<User[]>("/users", {
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}
export async function createUser(input: CreateUserInput): Promise<User> {
return await fetchApi<User>("/users", {
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(input),
});
}
export async function updateUser(
username: string,
input: UpdateUserInput,
): Promise<User> {
return await fetchApi<User>(`/users/${encodeURIComponent(username)}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(input),
});
}
export async function deleteUser(username: string): Promise<void> {
await fetchApi<void>(`/users/${encodeURIComponent(username)}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}

View File

@@ -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() {
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/login" element={<Login />} />
<Route
path="/management"
element={session?.isAdmin ? <Management /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<NotFound />} />
<Route path="/index.html" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -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() {
<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">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
{session?.isAdmin ? (
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null}
<div className="ml-auto flex items-center gap-2">
<b className="text-[#F5D1A9]">{session?.email ?? ""}</b>
<b className="text-[#F5D1A9]">{session?.displayName ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button
type="button"

View File

@@ -8,6 +8,7 @@ const translations = {
en: {
"nav.home": "Home",
"nav.about": "About",
"nav.management": "Management",
"nav.login": "Login",
"nav.signOut": "Sign Out",
"nav.language.fi": "FI",
@@ -44,29 +45,53 @@ const translations = {
"about.title": "About",
"about.apiVersion": "API version",
"about.loading": "Loading...",
"login.title": "Sign In",
"login.heading": "Sign in",
"login.email": "Email",
"login.title": "Klapi",
"login.heading": "Klapi",
"login.subheading": "Livonsaaren Tietokonepaja's administration console",
"login.username": "Username",
"login.password": "Password",
"login.submit": "Submit",
"management.title": "User Management",
"management.heading": "User Management",
"management.users": "Users",
"management.create": "Create user",
"management.edit": "Edit user",
"management.update": "Save changes",
"management.cancel": "Cancel",
"management.delete": "Delete",
"management.username": "Username",
"management.password": "Password",
"management.displayName": "Display name",
"management.isAdmin": "Is admin",
"management.added": "Added",
"management.updated": "Last updated",
"management.admin": "Admin",
"management.user": "User",
"management.loading": "Loading users...",
"management.requiredFields":
"Username, display name and password are required",
"management.loadError": "Failed to load users",
"management.saveError": "Failed to save user",
"management.deleteError": "Failed to delete user",
"notFound.title": "Page Not Found",
"notFound.heading": "Not Found",
"notFound.message": "Sorry, the page youre looking for doesn't exist",
"notFound.goHome": "Go Home",
"error.title": "Error",
"errors.requiredEmailPassword": "Email and password are required",
"errors.invalidEmailOrPassword": "Invalid email or password",
"errors.requiredUsernamePassword": "Username and password are required",
"errors.invalidUsernameOrPassword": "Invalid username or password",
},
fi: {
"nav.home": "Etusivu",
"nav.about": "Tietoja",
"nav.management": "Hallinta",
"nav.login": "Kirjaudu",
"nav.signOut": "Kirjaudu ulos",
"nav.language.fi": "FI",
"nav.language.en": "EN",
"meta.description": "React + Recoil -esimerkki",
"home.title": "Etusivu",
"home.heading": "KlAPI",
"home.heading": "Klapi",
"home.signedInAs": "Olet kirjautunut käyttäjänä",
"home.logoAlt": "logo",
"home.openHours.heading": "Aukioloaikaversiot",
@@ -96,18 +121,42 @@ const translations = {
"about.title": "Tietoja",
"about.apiVersion": "API-versio",
"about.loading": "Ladataan...",
"login.title": "Kirjaudu",
"login.heading": "Kirjaudu sisään",
"login.email": "Sähköposti",
"login.title": "Klapi",
"login.heading": "Klapi",
"login.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
"login.username": "Käyttäjätunnus",
"login.password": "Salasana",
"login.submit": "Lähetä",
"management.title": "Käyttäjähallinta",
"management.heading": "Käyttäjähallinta",
"management.users": "Käyttäjät",
"management.create": "Luo käyttäjä",
"management.edit": "Muokkaa käyttäjää",
"management.update": "Tallenna muutokset",
"management.cancel": "Peruuta",
"management.delete": "Poista",
"management.username": "Käyttäjätunnus",
"management.password": "Salasana",
"management.displayName": "Näyttönimi",
"management.isAdmin": "Ylläpitäjä",
"management.added": "Lisätty",
"management.updated": "Viimeksi päivitetty",
"management.admin": "Ylläpitäjä",
"management.user": "Käyttäjä",
"management.loading": "Ladataan käyttäjiä...",
"management.requiredFields":
"Käyttäjätunnus, näyttönimi ja salasana vaaditaan",
"management.loadError": "Käyttäjien haku epäonnistui",
"management.saveError": "Käyttäjän tallennus epäonnistui",
"management.deleteError": "Käyttäjän poisto epäonnistui",
"notFound.title": "Sivua ei löytynyt",
"notFound.heading": "Ei löytynyt",
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",
"notFound.goHome": "Takaisin etusivulle",
"error.title": "Virhe",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana",
},
} as const;

View File

@@ -9,7 +9,7 @@ export default function Login() {
const t = useT();
const navigate = useNavigate();
const [session, setSession] = useRecoilState(sessionAtom);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -25,45 +25,50 @@ export default function Login() {
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || !password.trim()) {
setError(t("errors.requiredEmailPassword"));
if (!username.trim() || !password.trim()) {
setError(t("errors.requiredUsernamePassword"));
return;
}
const normalizedEmail = email.trim().toLowerCase();
const normalizedUsername = username.trim().toLowerCase();
try {
const auth = await requestAuthToken(normalizedEmail, password);
const auth = await requestAuthToken(normalizedUsername, password);
setSession({
email: auth.email,
username: auth.username,
displayName: auth.displayName,
isAdmin: auth.isAdmin,
token: auth.accessToken,
});
localStorage.setItem("session-email", auth.email);
localStorage.setItem("session-username", auth.username);
localStorage.setItem("session-display-name", auth.displayName);
localStorage.setItem("session-is-admin", auth.isAdmin ? "true" : "false");
localStorage.setItem("session-token", auth.accessToken);
setError("");
navigate("/");
} catch {
setError(t("errors.invalidEmailOrPassword"));
setError(t("errors.invalidUsernameOrPassword"));
}
};
return (
<main>
<h1>{t("login.heading")}</h1>
<h2>{t("login.subheading")}</h2>
<form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
<label htmlFor="email" className="block w-full text-left">
{t("login.email")}
<label htmlFor="username" className="block w-full text-left">
{t("login.username")}
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="john@doe.com"
id="username"
name="username"
type="text"
autoComplete="username"
placeholder={t("login.username")}
required
value={email}
onChange={(event) => setEmail(event.target.value)}
value={username}
onChange={(event) => setUsername(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
@@ -75,7 +80,7 @@ export default function Login() {
name="password"
type="password"
autoComplete="current-password"
minLength={6}
placeholder={t("login.password")}
required
value={password}
onChange={(event) => setPassword(event.target.value)}

View File

@@ -0,0 +1,225 @@
import { FormEvent, useEffect, useState } from "react";
import { createUser, deleteUser, queryUsers, updateUser, type User } from "~/api";
import { useT } from "~/i18n";
type Mode = "create" | "edit";
export default function Management() {
const t = useT();
const [users, setUsers] = useState<User[]>([]);
const [mode, setMode] = useState<Mode>("create");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [selectedUsername, setSelectedUsername] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
document.title = t("management.title");
}, [t]);
useEffect(() => {
void loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const items = await queryUsers();
setUsers(items);
setError("");
} catch {
setError(t("management.loadError"));
} finally {
setLoading(false);
}
};
const resetForm = () => {
setMode("create");
setUsername("");
setPassword("");
setDisplayName("");
setIsAdmin(false);
setSelectedUsername("");
};
const onEdit = (user: User) => {
setMode("edit");
setUsername(user.username);
setPassword("");
setDisplayName(user.displayName);
setIsAdmin(user.isAdmin);
setSelectedUsername(user.username);
setError("");
};
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!username.trim() || !displayName.trim() || (mode === "create" && !password.trim())) {
setError(t("management.requiredFields"));
return;
}
try {
if (mode === "create") {
await createUser({
username: username.trim(),
password: password,
displayName: displayName.trim(),
isAdmin,
});
} else {
await updateUser(selectedUsername, {
password: password.trim() ? password : undefined,
displayName: displayName.trim(),
isAdmin,
});
}
resetForm();
await loadUsers();
} catch {
setError(t("management.saveError"));
}
};
const onDelete = async (targetUsername: string) => {
try {
await deleteUser(targetUsername);
if (selectedUsername === targetUsername) {
resetForm();
}
await loadUsers();
} catch {
setError(t("management.deleteError"));
}
};
return (
<main className="w-full px-4">
<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">
<h2 className="text-lg font-semibold text-[#4C250E]">
{mode === "create" ? t("management.create") : t("management.edit")}
</h2>
<label htmlFor="management-username" className="block text-left">
{t("management.username")}
<input
id="management-username"
type="text"
value={username}
disabled={mode === "edit"}
onChange={(event) => setUsername(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-display-name" className="block text-left">
{t("management.displayName")}
<input
id="management-display-name"
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-password" className="block text-left">
{t("management.password")}
<input
id="management-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-is-admin" className="flex items-center gap-2 text-left">
<input
id="management-is-admin"
type="checkbox"
checked={isAdmin}
onChange={(event) => setIsAdmin(event.target.checked)}
/>
{t("management.isAdmin")}
</label>
<div className="flex gap-2">
<button
type="submit"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{mode === "create" ? t("management.create") : t("management.update")}
</button>
{mode === "edit" ? (
<button
type="button"
onClick={resetForm}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-4 py-2 text-[#8E4F24] transition-colors duration-200 hover:bg-[#FCE6CF]"
>
{t("management.cancel")}
</button>
) : null}
</div>
</form>
{error ? <p className="mt-3 text-center text-sm text-[#8E4F24]">{error}</p> : null}
<section className="mx-auto mt-6 w-full max-w-2xl">
<h2 className="text-lg font-semibold text-[#4C250E]">{t("management.users")}</h2>
{loading ? (
<p className="mt-2 text-sm text-[#8E4F24]">{t("management.loading")}</p>
) : (
<ul className="mt-3 space-y-2">
{users.map((user) => (
<li key={user.username} className="rounded-md border border-[#C99763] bg-[#FFF7EE] p-3 text-left">
<div className="flex items-center justify-between gap-2">
<div>
<strong>{user.displayName}</strong>
<div className="text-sm text-[#70421E]">{user.username}</div>
<div className="text-xs text-[#8E4F24]">
{t("management.added")}: {new Date(user.added).toLocaleString()}
</div>
<div className="text-xs text-[#8E4F24]">
{t("management.updated")}: {new Date(user.lastUpdated).toLocaleString()}
</div>
<div className="text-xs text-[#8E4F24]">
{user.isAdmin ? t("management.admin") : t("management.user")}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onEdit(user)}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-3 py-1 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("management.edit")}
</button>
<button
type="button"
onClick={() => void onDelete(user.username)}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-3 py-1 text-[#8E4F24] transition-colors duration-200 hover:bg-[#FCE6CF]"
>
{t("management.delete")}
</button>
</div>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}

View File

@@ -4,7 +4,9 @@ import type { LokOpenHours } from "~/api";
export type Language = "fi" | "en";
export type Session = {
email: string;
username: string;
displayName: string;
isAdmin: boolean;
token: string;
};