Compare commits

...

12 Commits

57 changed files with 6063 additions and 1764 deletions

View File

@@ -1,3 +1,40 @@
# klapi
Klapi on Tietokonepajan tarjoama monikäyttöinen API rajapinta sekä tietovarasto. Loppukäyttäjä voi muokata omistamaansa dataa selainkäyttöliittymän kautta ja kutsua sitä muista palveluistaan.
## Kehitys ja ajokomennot (`just`)
Projektissa on valmiit reseptit [justfile](justfile)-tiedostossa.
Esivaatimukset:
- `just`
- `dotnet`
- `bun`
- `sqlite3` (DB-resepteihin)
Listaa kaikki reseptit:
- `just --list`
Sovelluksen käynnistys:
- `just dev`
API (.NET):
- `just api-restore`
- `just api-build`
- `just api-clean`
- `just api-run`
- `just api-watch`
- `just api-test`
- `just api-publish`
UI:
- `just ui-install`
- `just ui-dev`
- `just ui-build`
- `just ui-test`
- `just ui-lint`
Tietokanta (SQLite):
- `just db-setup`
- `just db-reset`
- `just db-shell`

3
api/.gitignore vendored
View File

@@ -480,3 +480,6 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# SQLite database file
klapi.db

View File

@@ -1,2 +1,4 @@
<Solution>
<Project Path="App.Tests/App.Tests.csproj" />
<Project Path="App/App.csproj" />
</Solution>

View File

@@ -0,0 +1,353 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
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;
public class ApiEndpointsTests(DevelopmentApiTestFactory factory) : IClassFixture<DevelopmentApiTestFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task GetVersion_ReturnsVersion()
{
var response = await _client.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
Assert.Equal("1.0.0", body.RootElement.GetProperty("version").GetString());
}
[Fact]
public async Task HealthDb_ReturnsOk()
{
var response = await _client.GetAsync("/health/db");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
Assert.Equal("ok", body.RootElement.GetProperty("database").GetString());
Assert.Equal(1, body.RootElement.GetProperty("result").GetInt32());
}
[Fact]
public async Task OpenHours_Crud_Works_WithoutAuthInDevelopment()
{
var createPayload = new
{
id = 0,
name = "test-version",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "p1",
paragraph2 = "p2",
paragraph3 = "p3",
paragraph4 = "p4",
kitchenNotice = "k1"
};
var createResponse = await _client.PostAsJsonAsync("/lok/open-hours", createPayload);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
Assert.Equal("/lok/open-hours", createResponse.Headers.Location?.ToString());
var created = await createResponse.Content.ReadFromJsonAsync<LokOpenHoursDto>();
Assert.NotNull(created);
Assert.True(created.Id > 0);
Assert.Equal(createPayload.name, created.Name);
var getResponse = await _client.GetAsync("/lok/open-hours");
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var openHours = await getResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>();
Assert.NotNull(openHours);
Assert.Contains(openHours, item => item.Id == created.Id);
Assert.True(openHours.Count(item => item.IsActive) <= 1);
var createSecondPayload = new
{
id = 0,
name = "test-version-2",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "p1b",
paragraph2 = "p2b",
paragraph3 = "p3b",
paragraph4 = "p4b",
kitchenNotice = "k1b"
};
var createSecondResponse = await _client.PostAsJsonAsync("/lok/open-hours", createSecondPayload);
Assert.Equal(HttpStatusCode.Created, createSecondResponse.StatusCode);
var createdSecond = await createSecondResponse.Content.ReadFromJsonAsync<LokOpenHoursDto>();
Assert.NotNull(createdSecond);
Assert.True(createdSecond.IsActive);
var setActiveResponse = await _client.PutAsync($"/lok/open-hours/{created.Id}/active", null);
Assert.Equal(HttpStatusCode.OK, setActiveResponse.StatusCode);
var updatePayload = new
{
id = created.Id,
name = "updated-version",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "updated-p1",
paragraph2 = "updated-p2",
paragraph3 = "updated-p3",
paragraph4 = "updated-p4",
kitchenNotice = "updated-k1"
};
var updateResponse = await _client.PutAsJsonAsync($"/lok/open-hours/{created.Id}", updatePayload);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<LokOpenHoursDto>();
Assert.NotNull(updated);
Assert.Equal(created.Id, updated.Id);
Assert.Equal(updatePayload.name, updated.Name);
Assert.Equal(updatePayload.paragraph1, updated.Paragraph1);
var getAfterUpdateResponse = await _client.GetAsync("/lok/open-hours");
Assert.Equal(HttpStatusCode.OK, getAfterUpdateResponse.StatusCode);
var openHoursAfterUpdate = await getAfterUpdateResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>();
Assert.NotNull(openHoursAfterUpdate);
var updatedInList = openHoursAfterUpdate.Single(item => item.Id == created.Id);
Assert.Equal(updatePayload.name, updatedInList.Name);
Assert.Equal(updatePayload.paragraph4, updatedInList.Paragraph4);
var activeCount = openHoursAfterUpdate.Count(item => item.IsActive);
Assert.Equal(1, activeCount);
Assert.False(openHoursAfterUpdate.Single(item => item.Id == createdSecond.Id).IsActive);
Assert.True(openHoursAfterUpdate.Single(item => item.Id == created.Id).IsActive);
var deleteResponse = await _client.DeleteAsync($"/lok/open-hours/{created.Id}");
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var deleteAgainResponse = await _client.DeleteAsync($"/lok/open-hours/{created.Id}");
Assert.Equal(HttpStatusCode.NotFound, deleteAgainResponse.StatusCode);
}
}
public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixture<ProductionApiTestFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task WriteEndpoints_RequireAuthInProduction()
{
var createWithoutAuthResponse = await _client.PostAsJsonAsync("/lok/open-hours", new
{
id = 0,
name = "unauthorized",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "p1",
paragraph2 = "p2",
paragraph3 = "p3",
paragraph4 = "p4",
kitchenNotice = "k1"
});
Assert.Equal(HttpStatusCode.Unauthorized, createWithoutAuthResponse.StatusCode);
var activateWithoutAuthResponse = await _client.PutAsync("/lok/open-hours/1/active", null);
Assert.Equal(HttpStatusCode.Unauthorized, activateWithoutAuthResponse.StatusCode);
}
[Fact]
public async Task CreateOpenHours_WorksWithAuthInProduction()
{
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);
Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken));
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken);
var createPayload = new
{
id = 0,
name = "authorized",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "p1",
paragraph2 = "p2",
paragraph3 = "p3",
paragraph4 = "p4",
kitchenNotice = "k1"
};
var createResponse = await _client.PostAsJsonAsync("/lok/open-hours", createPayload);
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>
{
private readonly string _environmentName = environmentName;
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"klapi-tests-{Guid.NewGuid():N}.db");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(_environmentName);
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}",
["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:Admin:Username"] = "admin",
["Auth:Admin:Password"] = "changeme",
["Auth:Admin:DisplayName"] = "Administrator"
});
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
try
{
if (File.Exists(_dbPath))
{
File.Delete(_dbPath);
}
}
catch
{
}
}
}
public sealed class DevelopmentApiTestFactory : ApiTestFactoryBase
{
public DevelopmentApiTestFactory() : base(Environments.Development)
{
}
}
public sealed class ProductionApiTestFactory : ApiTestFactoryBase
{
public ProductionApiTestFactory() : base(Environments.Production)
{
}
}
public class LokOpenHoursDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime Version { get; set; }
public string Paragraph1 { get; set; } = string.Empty;
public string Paragraph2 { get; set; } = string.Empty;
public string Paragraph3 { get; set; } = string.Empty;
public string Paragraph4 { get; set; } = string.Empty;
public string KitchenNotice { get; set; } = string.Empty;
}
public class AuthTokenDto
{
public string AccessToken { 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

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\App\App.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
</ItemGroup>

View File

@@ -0,0 +1,82 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public record AuthTokenRequest(string? Username, string Password);
public class AuthAdminOptions
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
}
public class AuthOptions
{
public string Issuer { get; set; } = "klapi-api";
public string Audience { get; set; } = "klapi-ui";
public string SigningKey { get; set; } = string.Empty;
public List<string> AllowedOrigins { get; set; } = [];
public AuthAdminOptions Admin { get; set; } = new();
}
public static class AuthEndpoints
{
public static void MapAuthEndpoints(WebApplication app)
{
app.MapPost("/auth/token", async (
HttpContext httpContext,
IOptions<AuthOptions> authOptions,
UserService userService,
AuthTokenRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
{
return Results.BadRequest(new { Message = "Username and password are required." });
}
var options = authOptions.Value;
var authenticatedUser = await userService.Authenticate(request.Username, request.Password);
if (authenticatedUser is null)
{
return Results.Unauthorized();
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
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")
};
var token = new JwtSecurityToken(
issuer: options.Issuer,
audience: options.Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(12),
signingCredentials: credentials);
var tokenValue = new JwtSecurityTokenHandler().WriteToken(token);
return Results.Ok(new
{
AccessToken = tokenValue,
Username = authenticatedUser.Username,
DisplayName = authenticatedUser.DisplayName,
IsAdmin = authenticatedUser.IsAdmin,
TokenType = "Bearer",
ExpiresIn = 43200
});
})
.RequireCors("FrontendWriteCors")
.WithName("CreateAuthToken");
}
}

View File

@@ -2,27 +2,163 @@ public static class LokEndpoints
{
public static void MapLokEndpoints(WebApplication app)
{
app.MapPost("/lok/open-hours", async (LokOpenHours openHours, LokService lokService) =>
var createLokOpenHoursEndpoint = app.MapPost("/lok/open-hours", async (HttpContext httpContext) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
if (openHours is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(openHours.Name))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version name is required."
});
return;
}
var createdOpenHours = await lokService.InsertOpenHours(openHours);
return Results.Created("/lok/open-hours", createdOpenHours);
httpContext.Response.StatusCode = StatusCodes.Status201Created;
httpContext.Response.Headers.Location = "/lok/open-hours";
await httpContext.Response.WriteAsJsonAsync(createdOpenHours);
})
.RequireCors("FrontendWriteCors")
.WithName("CreateLokOpenHours");
app.MapGet("/lok/open-hours", async (LokService lokService) =>
if (!app.Environment.IsDevelopment())
{
createLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
app.MapGet("/lok/open-hours", async (HttpContext httpContext) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await lokService.GetOpenHours();
if (openHours.Count == 0)
{
return Results.NotFound(new
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours not found."
});
return;
}
return Results.Ok(openHours);
await httpContext.Response.WriteAsJsonAsync(openHours);
})
.RequireCors("PublicReadCors")
.WithName("GetLokOpenHours");
var deleteLokOpenHoursEndpoint = app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var deleted = await lokService.DeleteOpenHours(id);
if (!deleted)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version not found."
});
return;
}
httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
})
.RequireCors("FrontendWriteCors")
.WithName("DeleteLokOpenHours");
if (!app.Environment.IsDevelopment())
{
deleteLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
var updateLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
if (openHours is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(openHours.Name))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version name is required."
});
return;
}
var updatedOpenHours = await lokService.UpdateOpenHours(id, openHours);
if (updatedOpenHours is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(updatedOpenHours);
})
.RequireCors("FrontendWriteCors")
.WithName("UpdateLokOpenHours");
if (!app.Environment.IsDevelopment())
{
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 activated = await lokService.SetActiveOpenHours(id);
if (!activated)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(new
{
Id = id,
IsActive = true
});
})
.RequireCors("FrontendWriteCors")
.WithName("SetActiveLokOpenHours");
if (!app.Environment.IsDevelopment())
{
setActiveLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite");
}
}
}

View File

@@ -1,5 +1,3 @@
using Microsoft.Data.Sqlite;
public static class SystemEndpoints
{
public static void MapSystemEndpoints(WebApplication app)
@@ -11,21 +9,23 @@ public static class SystemEndpoints
Version = "1.0.0"
};
})
.RequireCors("PublicReadCors")
.WithName("GetVersion");
app.MapGet("/health/db", async (SqliteConnection connection) =>
app.MapGet("/health/db", async (Microsoft.Data.Sqlite.SqliteConnection connection) =>
{
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT 1";
var result = await command.ExecuteScalarAsync();
return Results.Ok(new
return new
{
Database = "ok",
Result = result
});
};
})
.RequireCors("PublicReadCors")
.WithName("GetDatabaseHealth");
}
}

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

@@ -1,5 +1,11 @@
public class LokOpenHours
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime Version { get; set; }
public string Paragraph1 { get; set; } = string.Empty;

View File

@@ -1,4 +1,7 @@
using Microsoft.Data.Sqlite;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
public class Program
{
@@ -19,8 +22,83 @@ public class Program
sqliteConnectionStringBuilder.DataSource = databasePath;
var resolvedConnectionString = sqliteConnectionStringBuilder.ToString();
var authOptions = builder.Configuration.GetSection("Auth").Get<AuthOptions>()
?? throw new InvalidOperationException("Auth configuration was not found.");
if (builder.Environment.IsProduction())
{
authOptions.Admin.Password =
Environment.GetEnvironmentVariable("KLAPI_ADMIN_PASSWORD")
?? throw new InvalidOperationException("Admin password must be set in production environment using KLAPI_ADMIN_PASSWORD environment variable.");
}
if (string.IsNullOrWhiteSpace(authOptions.SigningKey) || authOptions.SigningKey.Length < 32)
{
throw new InvalidOperationException("Auth:SigningKey must be at least 32 characters long.");
}
if (string.IsNullOrWhiteSpace(authOptions.Admin.Username)
|| string.IsNullOrWhiteSpace(authOptions.Admin.Password)
|| string.IsNullOrWhiteSpace(authOptions.Admin.DisplayName))
{
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 =>
{
policy
.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
options.AddPolicy("FrontendWriteCors", policy =>
{
policy
.WithOrigins(authOptions.AllowedOrigins.ToArray())
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = authOptions.Issuer,
ValidAudience = authOptions.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authOptions.SigningKey)),
ClockSkew = TimeSpan.FromMinutes(1)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OpenHoursWrite", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "openhours:write");
});
options.AddPolicy("AdminOnly", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("is_admin", "true");
});
});
builder.Services.AddOpenApi();
@@ -55,18 +133,99 @@ public class Program
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN version TEXT NOT NULL DEFAULT '';";
command.ExecuteNonQuery();
}
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('LokOpenHours') WHERE name = 'name';";
var hasNameColumn = Convert.ToInt32(command.ExecuteScalar()) > 0;
if (!hasNameColumn)
{
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN name TEXT NOT NULL DEFAULT '';";
command.ExecuteNonQuery();
}
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('LokOpenHours') WHERE name = 'isActive';";
var hasIsActiveColumn = Convert.ToInt32(command.ExecuteScalar()) > 0;
if (!hasIsActiveColumn)
{
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN isActive INTEGER NOT NULL DEFAULT 0;";
command.ExecuteNonQuery();
}
command.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE isActive = 1;";
var activeCount = Convert.ToInt32(command.ExecuteScalar());
if (activeCount == 0)
{
command.CommandText = @"
UPDATE LokOpenHours
SET isActive = 1
WHERE id = (
SELECT id
FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
);";
command.ExecuteNonQuery();
}
else if (activeCount > 1)
{
command.CommandText = @"
WITH selected_active AS (
SELECT id
FROM LokOpenHours
WHERE isActive = 1
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
)
UPDATE LokOpenHours
SET isActive = CASE
WHEN id = (SELECT id FROM selected_active) THEN 1
ELSE 0
END
WHERE isActive = 1
OR id = (SELECT id FROM selected_active);";
command.ExecuteNonQuery();
}
command.CommandText = @"
CREATE UNIQUE INDEX IF NOT EXISTS IX_LokOpenHours_OneActive
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();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
SystemEndpoints.MapSystemEndpoints(app);
AuthEndpoints.MapAuthEndpoints(app);
LokEndpoints.MapLokEndpoints(app);
UserEndpoints.MapUserEndpoints(app);
app.Run();
}

View File

@@ -19,7 +19,7 @@ public class LokService
await using var command = _connection.CreateCommand();
command.CommandText = @"
SELECT version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
SELECT id, name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC
LIMIT 5";
@@ -32,6 +32,9 @@ public class LokService
{
openHoursList.Add(new LokOpenHours
{
Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]),
Name = reader["name"]?.ToString() ?? string.Empty,
IsActive = ParseBoolean(reader["isActive"]),
Version = ParseVersion(reader["version"]?.ToString()),
Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty,
Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty,
@@ -53,11 +56,22 @@ public class LokService
var version = DateTime.UtcNow;
await using var command = _connection.CreateCommand();
command.CommandText = @"
INSERT INTO LokOpenHours (version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice)
VALUES (@version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);";
using var transaction = _connection.BeginTransaction();
await using var resetCommand = _connection.CreateCommand();
resetCommand.Transaction = transaction;
resetCommand.CommandText = "UPDATE LokOpenHours SET isActive = 0 WHERE isActive = 1;";
await resetCommand.ExecuteNonQueryAsync();
await using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @"
INSERT INTO LokOpenHours (name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice)
VALUES (@name, @isActive, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);
SELECT last_insert_rowid();";
command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty);
command.Parameters.AddWithValue("@isActive", 1);
command.Parameters.AddWithValue("@version", version.ToString("O"));
command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty);
@@ -65,10 +79,16 @@ public class LokService
command.Parameters.AddWithValue("@paragraph4", openHours.Paragraph4 ?? string.Empty);
command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty);
await command.ExecuteNonQueryAsync();
var insertedId = await command.ExecuteScalarAsync();
var insertedIdValue = Convert.ToInt64(insertedId);
transaction.Commit();
return new LokOpenHours
{
Id = insertedIdValue,
Name = openHours.Name ?? string.Empty,
IsActive = true,
Version = version,
Paragraph1 = openHours.Paragraph1 ?? string.Empty,
Paragraph2 = openHours.Paragraph2 ?? string.Empty,
@@ -78,6 +98,140 @@ public class LokService
};
}
public async Task<bool> DeleteOpenHours(long id)
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
await using var activeCommand = _connection.CreateCommand();
activeCommand.CommandText = "SELECT isActive FROM LokOpenHours WHERE id = @id;";
activeCommand.Parameters.AddWithValue("@id", id);
var activeValue = await activeCommand.ExecuteScalarAsync();
if (activeValue is null)
{
return false;
}
await using var command = _connection.CreateCommand();
command.CommandText = @"
DELETE FROM LokOpenHours
WHERE id = @id;";
command.Parameters.AddWithValue("@id", id);
var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows > 0)
{
await EnsureSingleActiveInvariant();
}
return affectedRows > 0;
}
public async Task<LokOpenHours?> UpdateOpenHours(long id, LokOpenHours openHours)
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
var version = DateTime.UtcNow;
await using var activeCommand = _connection.CreateCommand();
activeCommand.CommandText = "SELECT isActive FROM LokOpenHours WHERE id = @id;";
activeCommand.Parameters.AddWithValue("@id", id);
var activeValue = await activeCommand.ExecuteScalarAsync();
if (activeValue is null)
{
return null;
}
var isActive = ParseBoolean(activeValue);
await using var command = _connection.CreateCommand();
command.CommandText = @"
UPDATE LokOpenHours
SET
name = @name,
version = @version,
paragraph1 = @paragraph1,
paragraph2 = @paragraph2,
paragraph3 = @paragraph3,
paragraph4 = @paragraph4,
kitchenNotice = @kitchenNotice
WHERE id = @id;";
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty);
command.Parameters.AddWithValue("@version", version.ToString("O"));
command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph3", openHours.Paragraph3 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph4", openHours.Paragraph4 ?? string.Empty);
command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty);
var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows == 0)
{
return null;
}
return new LokOpenHours
{
Id = id,
Name = openHours.Name ?? string.Empty,
IsActive = isActive,
Version = version,
Paragraph1 = openHours.Paragraph1 ?? string.Empty,
Paragraph2 = openHours.Paragraph2 ?? string.Empty,
Paragraph3 = openHours.Paragraph3 ?? string.Empty,
Paragraph4 = openHours.Paragraph4 ?? string.Empty,
KitchenNotice = openHours.KitchenNotice ?? string.Empty
};
}
public async Task<bool> SetActiveOpenHours(long id)
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
using var transaction = _connection.BeginTransaction();
await using var existsCommand = _connection.CreateCommand();
existsCommand.Transaction = transaction;
existsCommand.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE id = @id;";
existsCommand.Parameters.AddWithValue("@id", id);
var exists = Convert.ToInt32(await existsCommand.ExecuteScalarAsync()) > 0;
if (!exists)
{
transaction.Rollback();
return false;
}
await using var resetCommand = _connection.CreateCommand();
resetCommand.Transaction = transaction;
resetCommand.CommandText = "UPDATE LokOpenHours SET isActive = 0 WHERE isActive = 1;";
await resetCommand.ExecuteNonQueryAsync();
await using var activateCommand = _connection.CreateCommand();
activateCommand.Transaction = transaction;
activateCommand.CommandText = "UPDATE LokOpenHours SET isActive = 1 WHERE id = @id;";
activateCommand.Parameters.AddWithValue("@id", id);
await activateCommand.ExecuteNonQueryAsync();
transaction.Commit();
return true;
}
private static DateTime ParseVersion(string? value)
{
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
@@ -87,4 +241,61 @@ public class LokService
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 EnsureSingleActiveInvariant()
{
await using var countCommand = _connection.CreateCommand();
countCommand.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE isActive = 1;";
var activeCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync());
if (activeCount == 0)
{
await using var promoteCommand = _connection.CreateCommand();
promoteCommand.CommandText = @"
UPDATE LokOpenHours
SET isActive = 1
WHERE id = (
SELECT id
FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
);";
await promoteCommand.ExecuteNonQueryAsync();
return;
}
if (activeCount > 1)
{
await using var normalizeCommand = _connection.CreateCommand();
normalizeCommand.CommandText = @"
WITH selected_active AS (
SELECT id
FROM LokOpenHours
WHERE isActive = 1
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
)
UPDATE LokOpenHours
SET isActive = CASE
WHEN id = (SELECT id FROM selected_active) THEN 1
ELSE 0
END
WHERE isActive = 1
OR id = (SELECT id FROM selected_active);";
await normalizeCommand.ExecuteNonQueryAsync();
}
}
}

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

@@ -7,5 +7,21 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Issuer": "klapi-api",
"Audience": "klapi-ui",
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173"
],
"Admin": {
"Username": "admin",
"Password": "changeme",
"DisplayName": "Administrator"
}
}
}

View File

@@ -8,5 +8,16 @@
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"Issuer": "klapi-api",
"Audience": "klapi-ui",
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
"AllowedOrigins": ["https://klapi.tietokonepaja.fi"],
"Admin": {
"Username": "admin",
"Password": "<set in env var KLAPI_ADMIN_PASSWORD>",
"DisplayName": "Administrator"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,5 +1,7 @@
CREATE TABLE IF NOT EXISTS LokOpenHours (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
isActive INTEGER NOT NULL DEFAULT 0,
version TEXT NOT NULL,
paragraph1 TEXT NOT NULL DEFAULT '',
paragraph2 TEXT NOT NULL DEFAULT '',
@@ -7,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

@@ -1,5 +1,15 @@
@App_HostAddress = http://localhost:5013
### Request token for protected open-hours endpoints
POST {{App_HostAddress}}/auth/token
Content-Type: application/json
Accept: application/json
{
"email": "admin@klapi.local",
"password": "changeme"
}
### Get newest open hours (returns latest 5)
GET {{App_HostAddress}}/lok/open-hours
Accept: application/json
@@ -10,6 +20,7 @@ Content-Type: application/json
Accept: application/json
{
"name": "Version 1",
"paragraph1": "Version 1 paragraph 1",
"paragraph2": "Version 1 paragraph 2",
"paragraph3": "Version 1 paragraph 3",
@@ -23,6 +34,7 @@ Content-Type: application/json
Accept: application/json
{
"name": "Version 2",
"paragraph1": "Version 2 paragraph 1",
"paragraph2": "Version 2 paragraph 2",
"paragraph3": "Version 2 paragraph 3",
@@ -36,100 +48,10 @@ Content-Type: application/json
Accept: application/json
{
"name": "Version 3",
"paragraph1": "Version 3 paragraph 1",
"paragraph2": "Version 3 paragraph 2",
"paragraph3": "Version 3 paragraph 3",
"paragraph4": "Version 3 paragraph 4",
"kitchenNotice": "Kitchen notice 3"
}
### Insert open hours version 4
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 4 paragraph 1",
"paragraph2": "Version 4 paragraph 2",
"paragraph3": "Version 4 paragraph 3",
"paragraph4": "Version 4 paragraph 4",
"kitchenNotice": "Kitchen notice 4"
}
### Insert open hours version 5
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 5 paragraph 1",
"paragraph2": "Version 5 paragraph 2",
"paragraph3": "Version 5 paragraph 3",
"paragraph4": "Version 5 paragraph 4",
"kitchenNotice": "Kitchen notice 5"
}
### Insert open hours version 6
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 6 paragraph 1",
"paragraph2": "Version 6 paragraph 2",
"paragraph3": "Version 6 paragraph 3",
"paragraph4": "Version 6 paragraph 4",
"kitchenNotice": "Kitchen notice 6"
}
### Insert open hours version 7
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 7 paragraph 1",
"paragraph2": "Version 7 paragraph 2",
"paragraph3": "Version 7 paragraph 3",
"paragraph4": "Version 7 paragraph 4",
"kitchenNotice": "Kitchen notice 7"
}
### Insert open hours version 8
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 8 paragraph 1",
"paragraph2": "Version 8 paragraph 2",
"paragraph3": "Version 8 paragraph 3",
"paragraph4": "Version 8 paragraph 4",
"kitchenNotice": "Kitchen notice 8"
}
### Insert open hours version 9
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 9 paragraph 1",
"paragraph2": "Version 9 paragraph 2",
"paragraph3": "Version 9 paragraph 3",
"paragraph4": "Version 9 paragraph 4",
"kitchenNotice": "Kitchen notice 9"
}
### Insert open hours version 10
POST {{App_HostAddress}}/lok/open-hours
Content-Type: application/json
Accept: application/json
{
"paragraph1": "Version 10 paragraph 1",
"paragraph2": "Version 10 paragraph 2",
"paragraph3": "Version 10 paragraph 3",
"paragraph4": "Version 10 paragraph 4",
"kitchenNotice": "Kitchen notice 10"
}

64
justfile Normal file
View File

@@ -0,0 +1,64 @@
set shell := ["bash", "-eu", "-o", "pipefail", "-c"]
root_dir := justfile_directory()
api_project := root_dir + "/api/App/App.csproj"
api_solution := root_dir + "/api/Api.slnx"
ui_dir := root_dir + "/ui"
db_dir := root_dir + "/api/Database"
db_file := db_dir + "/klapi.db"
db_init_sql := db_dir + "/init.sql"
default:
@just --list
dev:
./start.sh
api-restore:
dotnet restore {{api_project}}
api-build:
dotnet build {{api_project}}
api-clean:
dotnet clean {{api_project}}
api-run:
dotnet run --project {{api_project}}
api-watch:
dotnet watch --project {{api_project}} run
api-test:
dotnet test {{api_solution}}
api-publish:
dotnet publish {{api_project}} -c Release
ui-install:
cd {{ui_dir}} && bun install
ui-dev:
cd {{ui_dir}} && bun dev
ui-build:
cd {{ui_dir}} && bun run build
ui-test:
cd {{ui_dir}} && bun run test
ui-lint:
cd {{ui_dir}} && bun run lint
db-setup:
mkdir -p {{db_dir}}
if ! command -v sqlite3 >/dev/null 2>&1; then echo "sqlite3 is required for db-setup" >&2; exit 1; fi
sqlite3 {{db_file}} < {{db_init_sql}}
db-reset:
rm -f {{db_file}}
just db-setup
db-shell:
if ! command -v sqlite3 >/dev/null 2>&1; then echo "sqlite3 is required for db-shell" >&2; exit 1; fi
sqlite3 {{db_file}}

View File

@@ -33,8 +33,23 @@ ensure_app_window() {
start_or_restart_services() {
ensure_app_window
tmux respawn-pane -k -t "$SESSION_NAME":app.0 -c "$UI_DIR" "bun dev"
tmux respawn-pane -k -t "$SESSION_NAME":app.1 -c "$API_DIR" "dotnet run --project $API_DIR/App/App.csproj"
local health_url="http://127.0.0.1:5013/health/db"
local max_attempts=60
local attempt=1
until curl --silent --fail "$health_url" >/dev/null 2>&1; do
if [[ "$attempt" -ge "$max_attempts" ]]; then
echo "API did not become healthy in time; starting UI anyway." >&2
break
fi
sleep 0.5
attempt=$((attempt + 1))
done
tmux respawn-pane -k -t "$SESSION_NAME":app.0 -c "$UI_DIR" "bun dev"
tmux select-pane -t "$SESSION_NAME":app.0
tmux select-window -t "$SESSION_NAME":app
}

View File

@@ -1,8 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
ssr: true, // false for client-side rendering only
server: { preset: "" }, // your deployment
vite: { plugins: [tailwindcss()] }
});

File diff suppressed because it is too large Load Diff

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KlAPI UI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2792
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,23 +2,30 @@
"name": "example-with-auth",
"type": "module",
"scripts": {
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start"
"dev": "vite",
"build": "vite build",
"start": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "biome lint src",
"lint:fix": "biome check --write src"
},
"dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.7",
"@types/node": "^25.2.0",
"solid-js": "^1.9.9",
"start-oauth": "^1.3.0",
"unstorage": "1.17.1",
"vinxi": "^0.5.8"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.13",
"tailwindcss": "^4.1.13"
"tailwindcss": "^4.1.13",
"vite": "^5.4.10",
"vitest": "^2.1.9"
},
"engines": {
"node": ">=22"

View File

@@ -1,92 +1,23 @@
<svg viewBox="0 0 463 383" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Cut third of a log">
<defs>
<linearGradient id="a" x1="347.737" y1="-11.816" x2="248.4" y2="478.227" gradientUnits="userSpaceOnUse">
<stop stop-color="#1593F5"/>
<stop offset="1" stop-color="#0084CE"/>
</linearGradient>
<linearGradient id="b" x1="338.003" y1="518.992" x2="-78.452" y2="-148.902" gradientUnits="userSpaceOnUse">
<stop stop-color="#1593F5"/>
<stop offset="1" stop-color="#0084CE"/>
</linearGradient>
<linearGradient id="c" x1="611.99" y1="530.235" x2="361.19" y2="80.271" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#15ABFF"/>
</linearGradient>
<linearGradient id="d" x1="167.455" y1="-262.399" x2="380.368" y2="225.387" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#79CFFF"/>
</linearGradient>
<linearGradient id="e" x1="406.927" y1="-76.821" x2="68.518" y2="392.18" gradientUnits="userSpaceOnUse">
<stop stop-color="#0057E5"/>
<stop offset="1" stop-color="#0084CE"/>
</linearGradient>
<linearGradient id="f" x1="330.245" y1="-94.545" x2="223.798" y2="199.049" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#15ABFF"/>
</linearGradient>
<linearGradient id="g" x1="463.1" y1="491.08" x2="-71.75" y2="-214.82" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#79CFFF"/>
</linearGradient>
<linearGradient id="h" x1="369.836" y1="-165.821" x2="95.393" y2="376.47" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#79CFFF"/>
<radialGradient id="bg" cx="1" cy="0" r="1.25">
<stop offset="0" stop-color="#C99763"/>
<stop offset="1" stop-color="#EED5B8"/>
</radialGradient>
<linearGradient id="wood" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8E4F24"/>
<stop offset="1" stop-color="#4C250E"/>
</linearGradient>
</defs>
<mask id="m1" maskUnits="userSpaceOnUse" x="214" y="334" width="117" height="49" style="mask-type:luminance">
<path d="M214.285 382.848L289.742 376.963C289.742 376.963 315.548 372.759 330.696 347.817L277.399 334.926L214.285 382.848Z" fill="white"/>
</mask>
<g mask="url(#m1)">
<path d="M339.952 336.888L326.208 405.548L204.748 380.886L218.773 312.226L339.952 336.888Z" fill="url(#a)"/>
</g>
<path d="M6 6H98A24 24 0 0 1 122 30V122H30A24 24 0 0 1 6 98V6Z" fill="url(#bg)"/>
<mask id="m2" maskUnits="userSpaceOnUse" x="45" y="206" width="286" height="147" style="mask-type:luminance">
<path d="M109.937 206.012C87.496 207.133 45.981 211.057 45.981 211.057L226.908 342.493L268.143 352.862L330.696 348.098L148.647 216.381C148.647 216.381 134.06 206.012 113.022 206.012C111.9 206.012 111.059 206.012 109.937 206.012Z" fill="white"/>
</mask>
<g mask="url(#m2)">
<path d="M125.645 480.654L-20.219 247.209L250.751 78.22L396.895 311.665L125.645 480.654Z" fill="url(#b)"/>
</g>
<mask id="m3" maskUnits="userSpaceOnUse" x="343" y="132" width="120" height="48" style="mask-type:luminance">
<path d="M343.319 179.949L421.019 175.185C421.019 175.185 447.667 171.262 462.815 146.6L407.555 132.868L343.319 179.949Z" fill="white"/>
</mask>
<g mask="url(#m3)">
<path d="M371.65 230.674L323.122 143.798L434.483 81.863L483.011 168.74L371.65 230.674Z" fill="url(#c)"/>
</g>
<mask id="m4" maskUnits="userSpaceOnUse" x="167" y="0" width="297" height="151" style="mask-type:luminance">
<path d="M233.36 0.591C210.078 1.432 167.441 4.795 167.441 4.795L355.941 139.314L398.578 150.243L463.095 146.32L273.472 11.521C273.472 11.521 257.764 0.591 235.604 0.591C235.043 0.591 234.201 0.591 233.36 0.591Z" fill="white"/>
</mask>
<g mask="url(#m4)">
<path d="M415.689 -107.584L518.074 126.422L214.846 258.699L112.461 24.693L415.689 -107.584Z" fill="url(#d)"/>
</g>
<mask id="m5" maskUnits="userSpaceOnUse" x="0" y="210" width="267" height="173" style="mask-type:luminance">
<path d="M1.1 240.202C0.819 240.483 0.819 240.763 0.819 241.043L50.469 276.915L99.558 312.506L182.027 372.199C209.797 392.096 247.385 383.689 266.46 352.862L216.249 316.71L166.038 280.558L84.41 221.146C74.312 213.859 63.092 210.496 51.872 210.496C32.236 210.496 12.881 220.865 1.1 240.202Z" fill="white"/>
</mask>
<g mask="url(#m5)">
<path d="M352.856 272.711L175.295 518.207L-85.577 329.881L91.704 84.385L352.856 272.711Z" fill="url(#e)"/>
</g>
<mask id="m6" maskUnits="userSpaceOnUse" x="122" y="4" width="277" height="177" style="mask-type:luminance">
<path d="M123.401 33.661C123.401 33.941 123.12 34.221 122.84 34.501L174.453 71.214L226.067 107.646L311.902 168.74C340.794 189.198 379.504 181.07 398.578 150.523L346.404 113.251L294.23 76.258L208.956 15.444C198.296 7.878 186.235 4.235 174.453 4.235C154.537 4.515 135.182 14.604 123.401 33.661Z" fill="white"/>
</mask>
<g mask="url(#m6)">
<path d="M457.765 25.814L366.601 277.475L63.653 167.619L155.098 -84.043L457.765 25.814Z" fill="url(#f)"/>
</g>
<mask id="m7" maskUnits="userSpaceOnUse" x="86" y="71" width="283" height="231" style="mask-type:luminance">
<path d="M86.935 75.137L153.415 148.001C156.501 152.205 159.867 156.128 163.794 159.491L293.388 301.857L357.905 297.933C376.979 267.386 369.125 225.91 340.233 205.452L254.398 144.358L203.065 107.926L151.452 71.214L86.935 75.137Z" fill="white"/>
</mask>
<g mask="url(#m7)">
<path d="M192.967 441.7L-24.426 155.568L271.228 -68.349L488.341 217.783L192.967 441.7Z" fill="url(#g)"/>
</g>
<mask id="m8" maskUnits="userSpaceOnUse" x="76" y="75" width="228" height="227" style="mask-type:luminance">
<path d="M86.374 75.978C67.58 106.244 75.434 147.16 103.765 167.338L188.759 227.872L241.214 264.864L293.388 301.857C312.463 271.31 304.608 229.833 275.716 209.375L190.162 148.562L138.548 111.849L86.935 75.137C86.935 75.417 86.654 75.698 86.374 75.978Z" fill="white"/>
</mask>
<g mask="url(#m8)">
<path d="M404.189 121.658L262.532 400.784L-23.865 255.896L117.791 -23.51L404.189 121.658Z" fill="url(#h)"/>
<g transform="translate(48 50) rotate(-18)">
<path d="M0 0 L56 0 A56 56 0 0 1 -28 48.5 Z" fill="url(#wood)"/>
<path d="M0 0 L43 0 A43 43 0 0 1 -21.5 37.24 Z" fill="#B86B34"/>
<path d="M0 0 L32 0 A32 32 0 0 1 -16 27.71 Z" fill="#CD8750"/>
<path d="M0 0 L22 0 A22 22 0 0 1 -11 19.05 Z" fill="#E3A977"/>
<path d="M0 0 L8 0 A8 8 0 0 1 -4 6.93 Z" fill="#F5D1A9"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 946 B

View File

@@ -1,12 +1,16 @@
import { query } from "@solidjs/router";
import { buildApiUrl } from "./url";
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:5013";
const buildUrl = (path: string) =>
`${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
type AuthTokenResponse = {
accessToken: string;
username: string;
displayName: string;
isAdmin: boolean;
tokenType: string;
expiresIn: number;
};
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(buildUrl(path), {
const response = await fetch(buildApiUrl(path), {
...init,
headers: {
"content-type": "application/json",
@@ -19,11 +23,217 @@ async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
throw new Error(`API ${response.status}: ${text || response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const queryApiVersion = query(async () => {
"use server";
function getAccessToken() {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem("session-token") ?? "";
}
export type LokOpenHours = {
id: number;
name: string;
isActive: boolean;
version: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
export type LokOpenHoursInput = {
name: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
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;
}, "api-version");
}
export async function queryLokOpenHours(): Promise<LokOpenHours[]> {
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
}
export async function createLokOpenHours(
input: LokOpenHoursInput,
): Promise<LokOpenHours> {
const name = input.name.trim();
if (!name) {
throw new Error("Open hours version name is required.");
}
const payload = {
id: 0,
name,
isActive: false,
version: new Date().toISOString(),
paragraph1: input.paragraph1,
paragraph2: input.paragraph2,
paragraph3: input.paragraph3,
paragraph4: input.paragraph4,
kitchenNotice: input.kitchenNotice,
} satisfies LokOpenHours;
return await fetchApi<LokOpenHours>("/lok/open-hours", {
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(payload),
});
}
export async function updateLokOpenHours(
id: number,
input: LokOpenHoursInput,
): Promise<LokOpenHours> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for update.");
}
const name = input.name.trim();
if (!name) {
throw new Error("Open hours version name is required.");
}
const payload = {
id,
name,
isActive: false,
version: new Date().toISOString(),
paragraph1: input.paragraph1,
paragraph2: input.paragraph2,
paragraph3: input.paragraph3,
paragraph4: input.paragraph4,
kitchenNotice: input.kitchenNotice,
} satisfies LokOpenHours;
return await fetchApi<LokOpenHours>(`/lok/open-hours/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(payload),
});
}
export async function deleteLokOpenHours(id: number): Promise<void> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for delete.");
}
await fetchApi<void>(`/lok/open-hours/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}
export async function setActiveLokOpenHours(
id: number,
): Promise<{ id: number; isActive: boolean }> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for setting active version.");
}
return await fetchApi<{ id: number; isActive: boolean }>(
`/lok/open-hours/${id}/active`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
},
);
}
export async function requestAuthToken(
username: string,
password: string,
): Promise<AuthTokenResponse> {
return await fetchApi<AuthTokenResponse>("/auth/token", {
method: "POST",
body: JSON.stringify({
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()}`,
},
});
}

12
ui/src/api/url.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { buildApiUrl } from "./url";
describe("buildApiUrl", () => {
it("joins base url and relative path without duplicate slashes", () => {
expect(buildApiUrl("/lok/open-hours")).toBe("/api/lok/open-hours");
});
it("accepts path without leading slash", () => {
expect(buildApiUrl("lok/open-hours/1")).toBe("/api/lok/open-hours/1");
});
});

7
ui/src/api/url.ts Normal file
View File

@@ -0,0 +1,7 @@
const API_BASE_URL =
(typeof process !== "undefined" ? process.env?.API_BASE_URL : undefined) ??
import.meta.env.VITE_API_BASE_URL ??
"/api";
export const buildApiUrl = (path: string) =>
`${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;

View File

@@ -1,15 +1,20 @@
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap");
@import "tailwindcss";
#app {
:root {
font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
}
#root {
user-select: none;
}
main {
@apply flex flex-col items-center justify-center min-h-screen bg-gray-50 gap-8 px-4;
@apply flex flex-col items-center justify-center min-h-screen bg-[#EED5B8] text-[#4C250E] gap-8 px-4;
}
h1 {
@apply uppercase text-6xl text-sky-700 font-thin;
@apply uppercase text-6xl text-[#8E4F24] font-thin;
}
button {

View File

@@ -1,41 +1,71 @@
import { type RouteDefinition, Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Meta, MetaProvider } from "@solidjs/meta";
import { createEffect, Suspense } from "solid-js";
import { querySession } from "./auth";
import Auth from "./components/Context";
import Nav from "./components/Nav";
import ErrorNotification from "./components/Error";
import { language, t } from "~/i18n";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
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";
import { sessionAtom } from "~/state/appState";
import "./app.css";
export const route: RouteDefinition = {
preload: ({ location }) => querySession(location.pathname)
};
function AppShell() {
const { language, setLanguage } = useLanguage();
const session = useRecoilValue(sessionAtom);
const setSession = useSetRecoilState(sessionAtom);
export default function App() {
createEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = language();
useEffect(() => {
initializeLanguage(setLanguage);
}, [setLanguage]);
useEffect(() => {
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 (!storedUsername || !storedDisplayName || !storedToken || !storedIsAdmin) {
setSession(null);
return;
}
});
setSession({
username: storedUsername,
displayName: storedDisplayName,
isAdmin: storedIsAdmin === "true",
token: storedToken,
});
}, [setSession]);
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return (
<Router
root={props => (
<MetaProvider>
<Meta name="description" content={t("meta.description")} />
<Auth>
<Suspense>
<Nav />
{props.children}
<ErrorNotification />
</Suspense>
</Auth>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
<>
<Nav />
<Routes>
<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>
<Toasts />
</>
);
}
export default function App() {
return (
<BrowserRouter>
<AppShell />
</BrowserRouter>
);
}

View File

@@ -1,28 +0,0 @@
import { createStorage } from "unstorage";
import fsLiteDriver from "unstorage/drivers/fs-lite";
interface User {
id: number;
email: string;
password?: string;
}
const storage = createStorage({ driver: fsLiteDriver({ base: "./.data" }) });
export async function createUser(data: Pick<User, "email" | "password">) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
const counter = (await storage.getItem<number>("users:counter")) ?? 1;
const user: User = { id: counter, ...data };
await Promise.all([
storage.setItem("users:data", [...users, user]),
storage.setItem("users:counter", counter + 1)
]);
return user;
}
export async function findUser({ email, id }: { email?: string; id?: number }) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
if (id) return users.find(u => u.id === id);
if (email) return users.find(u => u.email === email);
return undefined;
}

View File

@@ -1,40 +0,0 @@
import { action, query, redirect } from "@solidjs/router";
import { getLanguageFromFormData, getTranslations } from "~/i18n";
import { getSession, passwordLogin } from "./server";
// Define routes that require being logged in
const PROTECTED_ROUTES = ["/"];
const isProtected = (path: string) =>
PROTECTED_ROUTES.some((route) =>
route.endsWith("/*")
? path.startsWith(route.slice(0, -2))
: path === route || path.startsWith(route + "/"),
);
export const querySession = query(async (path: string) => {
"use server";
const { data } = await getSession();
if (path === "/login" && data.id) return redirect("/");
if (data.id) return data;
if (isProtected(path)) throw redirect(`/login?redirect=${path}`);
return null;
}, "session");
export const formLogin = action(async (formData: FormData) => {
"use server";
const lang = getLanguageFromFormData(formData);
const translations = getTranslations(lang);
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string")
return new Error(translations["errors.requiredEmailPassword"]);
return await passwordLogin(email.trim().toLowerCase(), password, lang);
});
export const logout = action(async () => {
"use server";
const session = await getSession();
await session.update({ id: undefined });
throw redirect("/login", { revalidate: "session" });
});

View File

@@ -1,94 +0,0 @@
import { redirect } from "@solidjs/router";
import { useSession } from "vinxi/http";
import { getRandomValues, subtle, timingSafeEqual } from "crypto";
import { createUser, findUser } from "./db";
import type { Language } from "~/i18n";
import { getTranslations } from "~/i18n";
export interface Session {
id: number;
email: string;
}
export const getSession = () =>
useSession<Session>({
password: process.env.SESSION_SECRET!,
});
export async function createSession(user: Session, redirectTo?: string) {
const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/";
const session = await getSession();
await session.update(user);
return redirect(validDest ? redirectTo : "/");
}
async function createHash(password: string) {
const salt = getRandomValues(new Uint8Array(16));
const saltHex = Buffer.from(salt).toString("hex");
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key).toString("hex");
return `${saltHex}:${hash}`;
}
async function checkPassword(
storedPassword: string,
providedPassword: string,
lang: Language,
) {
const translations = getTranslations(lang);
const [storedSalt, storedHash] = storedPassword.split(":");
if (!storedSalt || !storedHash)
throw new Error(translations["errors.invalidStoredPasswordFormat"]);
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt: Buffer.from(storedSalt, "hex"),
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(providedPassword),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key);
const stored = Buffer.from(storedHash, "hex");
if (stored.length !== hash.length || !timingSafeEqual(stored, hash))
throw new Error(translations["errors.invalidEmailOrPassword"]);
}
export async function passwordLogin(
email: string,
password: string,
lang: Language = "en",
) {
const translations = getTranslations(lang);
let user = await findUser({ email });
if (!user)
user = await createUser({
email,
password: await createHash(password),
});
else if (!user.password) throw new Error(translations["errors.oauthOnly"]);
else await checkPassword(user.password, password, lang);
return createSession(user);
}

View File

@@ -1,28 +0,0 @@
import { createAsync, useLocation, type AccessorWithLatest } from "@solidjs/router";
import { createContext, useContext, type ParentProps } from "solid-js";
import { logout, querySession } from "../auth";
import type { Session } from "../auth/server";
const Context = createContext<{
session: AccessorWithLatest<Session | null | undefined>;
signedIn: () => boolean;
logout: typeof logout;
}>();
export default function Auth(props: ParentProps) {
const location = useLocation();
const session = createAsync(() => querySession(location.pathname), {
deferStream: true
});
const signedIn = () => Boolean(session()?.id);
return (
<Context.Provider value={{ session, signedIn, logout }}>{props.children}</Context.Provider>
);
}
export function useAuth() {
const context = useContext(Context);
if (!context) throw new Error("useAuth must be used within Auth context");
return context;
}

View File

@@ -1,15 +0,0 @@
import { createSignal } from "solid-js";
import { t } from "~/i18n";
export default function Counter() {
const [count, setCount] = createSignal(0);
return (
<button
class="w-52 rounded-full bg-gray-100 border-2 border-gray-300 focus:border-gray-400 py-4"
onclick={() => setCount(prev => prev + 1)}
>
{t("counter.clicks")}: {count()}
</button>
);
}

View File

@@ -1,34 +0,0 @@
import { useSearchParams } from "@solidjs/router";
import { createEffect, onCleanup, Show } from "solid-js";
import { t } from "~/i18n";
import { X } from "./Icons";
export default function ErrorNotification() {
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
if (searchParams.error) {
const timer = setTimeout(() => setSearchParams({ error: "" }), 5000);
onCleanup(() => clearTimeout(timer));
}
});
return (
<Show when={typeof searchParams.error === "string" && searchParams.error} keyed>
{msg => (
<aside class="flex items-start gap-3 fixed bottom-4 left-4 max-w-sm bg-red-50 border border-red-200 rounded-xl p-4 shadow-lg z-50 transition-all duration-300 text-sm">
<div>
<strong class="font-medium text-red-800">{t("error.title")}</strong>
<p class="text-red-700 mt-1 select-text">{msg}</p>
</div>
<button
onclick={() => setSearchParams({ error: "" })}
class="text-red-400 hover:text-red-600 transition-colors"
>
<X class="w-4 h-4" />
</button>
</aside>
)}
</Show>
);
}

View File

@@ -1,22 +0,0 @@
type Icon = { class: string };
export const Discord = (props: Icon) => (
<svg viewBox="0 0 48 48" class={props.class}>
<path d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z" />
</svg>
);
export const X = (props: Icon) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={props.class}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);

View File

@@ -1,68 +1,79 @@
import { useMatch } from "@solidjs/router";
import { Show } from "solid-js";
import { useAuth } from "~/components/Context";
import { language, setLanguage, t } from "~/i18n";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import { sessionAtom } from "~/state/appState";
import { useLanguage, useT } from "~/i18n";
export default function Nav() {
const { signedIn, logout } = useAuth();
const isHome = useMatch(() => "/");
const isAbout = useMatch(() => "/about");
const location = useLocation();
const navigate = useNavigate();
const t = useT();
const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom);
const signOut = () => {
setSession(null);
localStorage.removeItem("session-username");
localStorage.removeItem("session-display-name");
localStorage.removeItem("session-is-admin");
localStorage.removeItem("session-token");
navigate("/login");
};
const linkClass = (path: string) =>
`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${location.pathname === path
? "border-[#E3A977] text-[#FFF7EE]"
: "border-transparent hover:text-[#FFF7EE]"
}`;
return (
<nav class="fixed top-0 left-0 w-full bg-sky-800 shadow-sm z-50 flex items-center justify-between py-3 px-4 font-medium text-sm">
<a
href="/"
class={`px-3 py-2 text-sky-100 uppercase transition-colors duration-200 border-b-2 ${isHome() ? "border-sky-300 text-white" : "border-transparent hover:text-white"
}`}
>
{t("nav.home")}
</a>
<a
href="/about"
class={`px-3 py-2 text-sky-100 uppercase transition-colors duration-200 border-b-2 ${isAbout() ? "border-sky-300 text-white" : "border-transparent hover:text-white"
}`}
>
{t("nav.about")}
</a>
<div class="ml-auto flex items-center gap-2">
<div class="flex items-center gap-1 rounded-md border border-sky-700 bg-sky-700/40 p-1">
<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?.displayName ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button
type="button"
onclick={() => setLanguage("fi")}
class={`px-2 py-1 text-xs rounded ${language() === "fi" ? "bg-sky-200 text-sky-900" : "text-sky-100 hover:text-white"
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")}
class={`px-2 py-1 text-xs rounded ${language() === "en" ? "bg-sky-200 text-sky-900" : "text-sky-100 hover:text-white"
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>
<Show
when={signedIn()}
fallback={
<a
href="/login"
class="px-4 py-2 text-sky-100 bg-sky-700 border border-sky-600 rounded-md hover:bg-sky-600 hover:text-white focus:outline-none transition-colors duration-200"
>
{t("nav.login")}
</a>
}
>
<form action={logout} method="post">
<button
type="submit"
class="px-4 py-2 text-sky-100 bg-sky-700 border border-sky-600 rounded-md hover:bg-sky-600 hover:text-white focus:outline-none transition-colors duration-200"
>
{t("nav.signOut")}
</button>
</form>
</Show>
{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>
</nav>
);

View File

@@ -0,0 +1,416 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRecoilState } from "recoil";
import {
createLokOpenHours,
deleteLokOpenHours,
queryLokOpenHours,
setActiveLokOpenHours,
updateLokOpenHours,
type LokOpenHours,
} from "~/api";
import { useT } from "~/i18n";
import { openHoursAtom, toastsAtom, type Toast } from "~/state/appState";
type FormState = {
name: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
const EMPTY_FORM: FormState = {
name: "",
paragraph1: "",
paragraph2: "",
paragraph3: "",
paragraph4: "",
kitchenNotice: "",
};
export default function OpenHoursForm() {
const t = useT();
const [versions, setVersions] = useRecoilState(openHoursAtom);
const [, setToasts] = useRecoilState(toastsAtom);
const [selectedVersion, setSelectedVersion] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editingVersionId, setEditingVersionId] = useState("");
const [deletingId, setDeletingId] = useState("");
const [activatingId, setActivatingId] = useState("");
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const initializedRef = useRef(false);
const toastGuardRef = useRef<Record<string, number>>({});
const isUpdateMode = editingVersionId.length > 0;
const pushToast = (message: string, kind: Toast["kind"], dedupeKey: string) => {
const now = Date.now();
const lastShown = toastGuardRef.current[dedupeKey] ?? 0;
if (now - lastShown < 800) return;
toastGuardRef.current[dedupeKey] = now;
setToasts((previous) => [
...previous,
{
id: now + Math.floor(Math.random() * 1000),
message,
kind,
leaving: false,
},
]);
};
const clearForm = () => {
setForm(EMPTY_FORM);
};
const hydrate = async () => {
const loaded = await queryLokOpenHours().catch(() => []);
setVersions(loaded);
initializedRef.current = true;
};
useEffect(() => {
if (initializedRef.current) return;
void hydrate();
}, []);
useEffect(() => {
if (versions.length === 0) {
if (selectedVersion) setSelectedVersion("");
return;
}
const activeVersion = versions.find((version) => version.isActive);
if (activeVersion) {
const activeId = String(activeVersion.id);
if (activeId !== selectedVersion) {
setSelectedVersion(activeId);
}
return;
}
if (!versions.some((version) => String(version.id) === selectedVersion)) {
setSelectedVersion(String(versions[0].id));
}
}, [versions, selectedVersion]);
useEffect(() => {
if (!isEditing || !isUpdateMode) return;
if (versions.some((version) => String(version.id) === editingVersionId)) return;
setEditingVersionId("");
setIsEditing(false);
clearForm();
}, [versions, isEditing, isUpdateMode, editingVersionId]);
useEffect(() => {
if (!isEditing || isUpdateMode) return;
clearForm();
}, [isEditing, isUpdateMode]);
const populateForm = (versionId: string) => {
const selected = versions.find((version) => String(version.id) === versionId);
if (!selected) {
clearForm();
return;
}
setForm({
name: selected.name,
paragraph1: selected.paragraph1,
paragraph2: selected.paragraph2,
paragraph3: selected.paragraph3,
paragraph4: selected.paragraph4,
kitchenNotice: selected.kitchenNotice,
});
};
const startCreate = () => {
setEditingVersionId("");
clearForm();
setIsEditing(true);
};
const startEdit = (versionId: string) => {
setEditingVersionId(versionId);
populateForm(versionId);
setIsEditing(true);
};
const cancelEdit = () => {
setEditingVersionId("");
clearForm();
setIsEditing(false);
};
const submitForm = async (event: React.FormEvent) => {
event.preventDefault();
setSaving(true);
try {
if (isUpdateMode) {
const updated = await updateLokOpenHours(Number(editingVersionId), form);
setVersions((previous) =>
previous.map((version) =>
version.id === updated.id ? updated : version,
),
);
pushToast(
t("home.openHours.updated"),
"success",
`update:${updated.id}:${updated.version}`,
);
} else {
const created = await createLokOpenHours(form);
setVersions((previous) => {
const next = created.isActive
? [created, ...previous.map((version) => ({ ...version, isActive: false }))]
: [created, ...previous];
return next.slice(0, 5);
});
setSelectedVersion(String(created.id));
pushToast(
t("home.openHours.saved"),
"success",
`create:${created.id}:${created.version}`,
);
}
setEditingVersionId("");
clearForm();
setIsEditing(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:save:${message}`);
} finally {
setSaving(false);
}
};
const onDelete = async (version: LokOpenHours) => {
setDeletingId(String(version.id));
try {
await deleteLokOpenHours(version.id);
setVersions((previous) =>
previous.filter((item) => item.id !== version.id),
);
if (editingVersionId === String(version.id)) {
setEditingVersionId("");
clearForm();
setIsEditing(false);
}
pushToast(t("home.openHours.deleted"), "success", `delete:${version.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:delete:${message}`);
} finally {
setDeletingId("");
}
};
const onSetActive = async (version: LokOpenHours) => {
if (version.isActive) return;
setActivatingId(String(version.id));
try {
const result = await setActiveLokOpenHours(version.id);
setVersions((previous) =>
previous.map((item) => ({
...item,
isActive: item.id === result.id,
})),
);
setSelectedVersion(String(result.id));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:active:${message}`);
} finally {
setActivatingId("");
}
};
const tooltipText = (version: LokOpenHours) =>
[
version.name,
version.paragraph1,
version.paragraph2,
version.paragraph3,
version.paragraph4,
version.kitchenNotice,
]
.filter(Boolean)
.join("\n");
const nameRequired = useMemo(() => form.name.trim().length === 0, [form.name]);
return (
<section className="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-4 shadow-md sm:p-6">
<h2 className="text-2xl font-semibold text-[#4C250E]">{t("home.openHours.heading")}</h2>
<div className="mt-4">
<div
className={`transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${!isEditing
? "max-h-[1200px] translate-y-0 opacity-100"
: "pointer-events-none max-h-0 -translate-y-3 overflow-hidden opacity-0"
}`}
>
<div className="mb-3 flex justify-end">
<button
type="button"
onClick={startCreate}
className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] sm:w-auto"
>
{t("home.openHours.new")}
</button>
</div>
{versions.length === 0 ? (
<p className="text-[#70421E]">{t("home.openHours.empty")}</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{versions.map((version) => {
const versionId = String(version.id);
const active = version.isActive;
const deleting = deletingId === versionId;
const settingActive = activatingId === versionId;
return (
<article
key={version.id}
onClick={() => {
if (active || settingActive) return;
void onSetActive(version);
}}
className={`rounded-xl border p-3 transition-all duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? "border-[#8E4F24] bg-[#D6A06B]" : "border-[#C99763] bg-[#EED5B8]"
} ${deleting ? "-translate-y-1 scale-90 opacity-0" : ""} ${settingActive && !active ? "opacity-80" : ""
}`}
>
<div className="mb-2 flex items-center justify-between gap-2" title={tooltipText(version)}>
<p className="truncate font-medium text-[#4C250E]">
{version.name || t("home.openHours.latest")}
</p>
{active && (
<span className="rounded-md bg-[#8E4F24] px-2 py-0.5 text-xs font-medium text-[#FFF7EE]">
{t("home.openHours.active")}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
startEdit(versionId);
}}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.edit")}
</button>
<button
type="button"
disabled={active || deleting}
onClick={(event) => {
event.stopPropagation();
void onDelete(version);
}}
className={`rounded-md border border-[#8E4F24] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 ${!active ? "hover:bg-[#E3A977]" : ""
}`}
>
{t("home.openHours.delete")}
</button>
</div>
</article>
);
})}
</div>
)}
</div>
<form
onSubmit={submitForm}
className={`space-y-3 transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${isEditing
? "max-h-[1600px] translate-y-0 opacity-100"
: "pointer-events-none max-h-0 translate-y-3 overflow-hidden opacity-0"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
{isUpdateMode && (
<p className="text-sm font-medium text-[#4C250E]">{t("home.openHours.editing")}</p>
)}
<button
type="button"
onClick={cancelEdit}
className="rounded-md border border-[#A56C38] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.cancel")}
</button>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[#4C250E]">{t("home.openHours.name")}</label>
<input
id="name"
name="name"
required
value={form.name}
onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
className="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
{nameRequired && (
<p className="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
)}
</div>
{(["paragraph1", "paragraph2", "paragraph3", "paragraph4", "kitchenNotice"] as const).map((field) => (
<div key={field}>
<label htmlFor={field} className="block text-sm font-medium text-[#4C250E]">
{t(`home.openHours.${field}`)}
</label>
<textarea
id={field}
name={field}
rows={2}
value={form[field]}
onChange={(event) =>
setForm((previous) => ({
...previous,
[field]: event.target.value,
}))
}
className="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
))}
<div className="flex flex-wrap gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
>
{isUpdateMode
? saving
? t("home.openHours.updating")
: t("home.openHours.update")
: saving
? t("home.openHours.saving")
: t("home.openHours.submit")}
</button>
</div>
</form>
</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { useRecoilState } from "recoil";
import { toastsAtom } from "~/state/appState";
export default function Toasts() {
const [toasts, setToasts] = useRecoilState(toastsAtom);
useEffect(() => {
if (toasts.length === 0) return;
const timers = toasts.map((toast) =>
window.setTimeout(() => {
setToasts((previous) =>
previous.map((item) =>
item.id === toast.id ? { ...item, leaving: true } : item,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((item) => item.id !== toast.id));
}, 220);
}, 3200),
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [toasts, setToasts]);
const dismiss = (id: number) => {
setToasts((previous) =>
previous.map((toast) =>
toast.id === id ? { ...toast, leaving: true } : toast,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((toast) => toast.id !== id));
}, 220);
};
return (
<div className="pointer-events-none fixed bottom-3 left-3 right-3 z-50 flex flex-col gap-2 sm:bottom-5 sm:left-auto sm:right-5 sm:w-full sm:max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lg transition-all duration-200 ease-out ${toast.kind === "success"
? "border-[#8E4F24] bg-[#F5E2CB] text-[#4C250E]"
: "border-[#8E4F24] bg-[#F7D3B7] text-[#4C250E]"
} ${toast.leaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100"}`}
>
<div className="flex items-start justify-between gap-3">
<p className="leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => dismiss(toast.id)}
className="rounded px-1 text-base leading-none text-[#70421E] hover:bg-[#EED5B8]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,3 +0,0 @@
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);

View File

@@ -1,21 +0,0 @@
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="SolidStart with-auth example" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg" href="favicon.svg" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

2
ui/src/global.d.ts vendored
View File

@@ -1 +1 @@
/// <reference types="@solidjs/start/env" />
/// <reference types="vite/client" />

View File

@@ -1,7 +1,6 @@
import { createSignal } from "solid-js";
import { isServer } from "solid-js/web";
export type Language = "fi" | "en";
import { useMemo } from "react";
import { useRecoilState, useRecoilValue } from "recoil";
import { languageAtom, type Language } from "~/state/appState";
const STORAGE_KEY = "ui-language";
@@ -9,70 +8,163 @@ const translations = {
en: {
"nav.home": "Home",
"nav.about": "About",
"nav.management": "Management",
"nav.login": "Login",
"nav.signOut": "Sign Out",
"nav.language.fi": "FI",
"nav.language.en": "EN",
"meta.description": "SolidStart with-auth example",
"meta.description": "React + Recoil example",
"home.title": "Home",
"home.heading": "Hello World",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepaja's administration console",
"home.signedInAs": "You are signed in as",
"home.logoAlt": "logo",
"home.openHours.heading": "Open hours of Livonsaaren Osuuskauppa",
"home.openHours.latest": "Latest",
"home.openHours.new": "New version",
"home.openHours.edit": "Edit",
"home.openHours.cancel": "Cancel",
"home.openHours.editing": "Editing version",
"home.openHours.active": "Active",
"home.openHours.reuse": "Reuse selected",
"home.openHours.delete": "Delete",
"home.openHours.empty": "No open-hours versions found yet",
"home.openHours.name": "Version name",
"home.openHours.nameRequired": "Version name is required",
"home.openHours.paragraph1": "Paragraph 1",
"home.openHours.paragraph2": "Paragraph 2",
"home.openHours.paragraph3": "Paragraph 3",
"home.openHours.paragraph4": "Paragraph 4",
"home.openHours.kitchenNotice": "Kitchen notice",
"home.openHours.submit": "Add new version",
"home.openHours.update": "Save changes",
"home.openHours.saving": "Saving...",
"home.openHours.updating": "Saving changes...",
"home.openHours.saved": "New version saved",
"home.openHours.updated": "Version updated",
"home.openHours.deleted": "Version deleted",
"about.title": "About",
"about.description":
"Livonsaaren Tietokonepaja is a local project providing IT services for our dear archipelago.",
"about.bugReportsPrefix": "All bug reports can be sent to",
"about.apiVersion": "API version",
"about.loading": "Loading...",
"login.title": "Sign In",
"login.heading": "Sign in",
"login.orContinueWith": "Or continue with",
"login.signInWithDiscord": "Sign in with Discord",
"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",
"counter.clicks": "Clicks",
"errors.requiredEmailPassword": "Email and password are required",
"errors.invalidStoredPasswordFormat": "Invalid stored password format",
"errors.invalidEmailOrPassword": "Invalid email or password",
"errors.oauthOnly":
"Account exists via OAuth. Sign in with your OAuth provider",
"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": "SolidStart with-auth -esimerkki",
"meta.description": "React + Recoil -esimerkki",
"home.title": "Etusivu",
"home.heading": "Hei maailma",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
"home.signedInAs": "Olet kirjautunut käyttäjänä",
"home.logoAlt": "logo",
"home.openHours.heading": "Livonsaaren osuuskaupan aukioloaika",
"home.openHours.latest": "Viimeisin",
"home.openHours.new": "Uusi versio",
"home.openHours.edit": "Muokkaa",
"home.openHours.cancel": "Peruuta",
"home.openHours.editing": "Muokataan versiota",
"home.openHours.active": "Aktiivinen",
"home.openHours.reuse": "Käytä valittua uudelleen",
"home.openHours.delete": "Poista",
"home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt",
"home.openHours.name": "Version nimi",
"home.openHours.nameRequired": "Version nimi on pakollinen",
"home.openHours.paragraph1": "Kappale 1",
"home.openHours.paragraph2": "Kappale 2",
"home.openHours.paragraph3": "Kappale 3",
"home.openHours.paragraph4": "Kappale 4",
"home.openHours.kitchenNotice": "Keittiöhuomio",
"home.openHours.submit": "Lisää uusi versio",
"home.openHours.update": "Tallenna muutokset",
"home.openHours.saving": "Tallennetaan...",
"home.openHours.updating": "Tallennetaan muutoksia...",
"home.openHours.saved": "Uusi versio tallennettu",
"home.openHours.updated": "Versio päivitetty",
"home.openHours.deleted": "Versio poistettu",
"about.title": "Tietoja",
"about.description":
"Livonsaaren Tietokonepaja on paikallisprojekti, joka tuottaa IT-palveluita rakkaalle lähisaaristollemme.",
"about.bugReportsPrefix": "Kaikki bugiraportit voi lähettää osoitteeseen",
"about.apiVersion": "API-versio",
"about.loading": "Ladataan...",
"login.title": "Kirjaudu",
"login.heading": "Kirjaudu sisään",
"login.orContinueWith": "Tai jatka",
"login.signInWithDiscord": "Kirjaudu Discordilla",
"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",
"counter.clicks": "Klikkauksia",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidStoredPasswordFormat":
"Tallennetun salasanan muoto on virheellinen",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
"errors.oauthOnly": "Tili löytyy OAuthin kautta. Kirjaudu OAuth-palvelulla",
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana",
},
} as const;
@@ -81,25 +173,27 @@ export type TranslationKey = keyof typeof translations.en;
export const normalizeLanguage = (value: unknown): Language =>
value === "fi" ? "fi" : "en";
const [language, setLanguageSignal] = createSignal<Language>("en");
if (!isServer) {
export const initializeLanguage = (setLanguage: (lang: Language) => void) => {
const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY));
setLanguageSignal(stored);
}
export const setLanguage = (lang: Language) => {
setLanguageSignal(lang);
if (!isServer) {
localStorage.setItem(STORAGE_KEY, lang);
}
setLanguage(stored);
};
export { language };
export const useLanguage = () => {
const [language, setLanguageState] = useRecoilState(languageAtom);
export const getTranslations = (lang: Language) => translations[lang];
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(STORAGE_KEY, lang);
};
export const t = (key: TranslationKey) => translations[language()][key];
return { language, setLanguage };
};
export const getLanguageFromFormData = (formData: FormData): Language =>
normalizeLanguage(formData.get("lang"));
export const useT = () => {
const language = useRecoilValue(languageAtom);
return useMemo(
() => (key: TranslationKey) => translations[language][key],
[language],
);
};

12
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RecoilRoot } from "recoil";
import App from "~/app";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
);

View File

@@ -1,18 +1,24 @@
import { Title } from "@solidjs/meta";
import { t } from "~/i18n";
import { Link } from "react-router-dom";
import { useEffect } from "react";
import { useT } from "~/i18n";
export default function NotFound() {
const t = useT();
useEffect(() => {
document.title = t("notFound.title");
}, [t]);
return (
<main class="text-center">
<Title>{t("notFound.title")}</Title>
<main className="text-center">
<h1>{t("notFound.heading")}</h1>
{t("notFound.message")}
<a
href="/"
class="px-4 py-2 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-100 transition-colors duration-200"
<p>{t("notFound.message")}</p>
<Link
to="/"
className="rounded-xl border border-[#C99763] px-4 py-2 text-[#70421E] transition-colors duration-200 hover:bg-[#F5D1A9]"
>
{t("notFound.goHome")}
</a>
</Link>
</main>
);
}

View File

@@ -1,20 +1,52 @@
import { Title } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { useEffect, useState } from "react";
import { queryApiVersion } from "~/api";
import { t } from "~/i18n";
import { useT } from "~/i18n";
export default function About() {
const apiVersion = createAsync(() => queryApiVersion());
const t = useT();
const [apiVersion, setApiVersion] = useState<string | null>(null);
useEffect(() => {
document.title = t("about.title");
}, [t]);
useEffect(() => {
let active = true;
queryApiVersion()
.then((version) => {
if (active) {
setApiVersion(version);
}
})
.catch(() => {
if (active) {
setApiVersion(null);
}
});
return () => {
active = false;
};
}, []);
return (
<main>
<Title>{t("about.title")}</Title>
<p class="text-gray-700 text-center">
{t("about.apiVersion")}:
<Show when={apiVersion()} fallback={t("about.loading")}>
{apiVersion()}
</Show>
<main className="space-y-4 px-4">
<p className="mx-auto max-w-md text-center text-lg font-semibold text-[#70421E]">
{t("about.description")}
</p>
<p className="mx-auto max-w-md text-center text-sm text-[#70421E]/90">
{t("about.bugReportsPrefix")} {" "}
<a
href="mailto:info@tietokonepaja.fi"
className="font-medium underline underline-offset-2 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
>
info@tietokonepaja.fi
</a>
.
</p>
<p className="mx-auto max-w-md text-center text-xs uppercase tracking-wide text-[#70421E]/80">
{t("about.apiVersion")}: {apiVersion ?? t("about.loading")}
</p>
</main>
);

View File

@@ -1,16 +0,0 @@
import OAuth from "start-oauth";
import { createUser, findUser } from "~/auth/db";
import { createSession } from "~/auth/server";
export const GET = OAuth({
password: process.env.SESSION_SECRET!,
discord: {
id: process.env.DISCORD_ID!,
secret: process.env.DISCORD_SECRET!
},
async handler({ email }, redirectTo) {
let user = await findUser({ email });
if (!user) user = await createUser({ email });
return createSession(user, redirectTo);
}
});

View File

@@ -1,16 +1,20 @@
import { Title } from "@solidjs/meta";
import { useAuth } from "~/components/Context";
import { t } from "~/i18n";
import { useEffect } from "react";
import OpenHoursForm from "~/components/OpenHoursForm";
import { useT } from "~/i18n";
export default function Home() {
const { session } = useAuth();
const t = useT();
useEffect(() => {
document.title = t("home.title");
}, [t]);
return (
<main>
<Title>{t("home.title")}</Title>
<h1 class="text-center">{t("home.heading")}</h1>
<img src="/favicon.svg" alt={t("home.logoAlt")} class="w-28" />
{t("home.signedInAs")} <b class="font-medium">{session()?.email}</b>
<h1 className="text-center">{t("home.heading")}</h1>
<h2 className="text-center">{t("home.subheading")}</h2>
<img src="/favicon.svg" alt={t("home.logoAlt")} className="w-28" />
<OpenHoursForm />
</main>
);
}

View File

@@ -1,79 +1,102 @@
import { Title } from "@solidjs/meta";
import { useSubmission } from "@solidjs/router";
import { Show } from "solid-js";
import { useOAuthLogin } from "start-oauth";
import { formLogin } from "~/auth";
import { Discord } from "~/components/Icons";
import { language, t } from "~/i18n";
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import { requestAuthToken } from "~/api";
import { useT } from "~/i18n";
import { sessionAtom } from "~/state/appState";
export default function Login() {
const login = useOAuthLogin();
const t = useT();
const navigate = useNavigate();
const [session, setSession] = useRecoilState(sessionAtom);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
document.title = t("login.title");
}, [t]);
useEffect(() => {
if (!session) return;
navigate("/");
}, [session, navigate]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!username.trim() || !password.trim()) {
setError(t("errors.requiredUsernamePassword"));
return;
}
const normalizedUsername = username.trim().toLowerCase();
try {
const auth = await requestAuthToken(normalizedUsername, password);
setSession({
username: auth.username,
displayName: auth.displayName,
isAdmin: auth.isAdmin,
token: auth.accessToken,
});
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.invalidUsernameOrPassword"));
}
};
return (
<main>
<Title>{t("login.title")}</Title>
<h1>{t("login.heading")}</h1>
<div class="space-y-6 font-medium">
<PasswordLogin />
<div class="flex items-center w-full text-xs">
<span class="flex-grow bg-gray-300 h-[1px]" />
<span class="flex-grow-0 mx-2 text-gray-500">{t("login.orContinueWith")}</span>
<span class="flex-grow bg-gray-300 h-[1px]" />
</div>
<a
href={login("discord")}
rel="external"
class="group w-full px-3 py-2 bg-white border border-gray-200 rounded-lg hover:bg-[#5865F2] hover:border-gray-300 focus:outline-none transition-colors duration-300 flex items-center justify-center gap-2.5 text-gray-700 hover:text-white"
<h2>{t("login.subheading")}</h2>
<form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
<label htmlFor="username" className="block w-full text-left">
{t("login.username")}
<input
id="username"
name="username"
type="text"
autoComplete="username"
placeholder={t("login.username")}
required
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>
<label htmlFor="password" className="block w-full text-left">
{t("login.password")}
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder={t("login.password")}
required
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>
<button
type="submit"
className="w-full rounded-lg bg-gradient-to-r from-[#A56C38] to-[#70421E] px-4 py-2 text-[#FFF7EE] shadow-lg shadow-[#70421E]/30 transition-colors duration-300 hover:from-[#8E4F24] hover:to-[#4C250E]"
>
<Discord class="h-5 fill-[#5865F2] group-hover:fill-white duration-300" />
{t("login.signInWithDiscord")}
</a>
</div>
{t("login.submit")}
</button>
{error && <p className="mt-2 text-center text-xs text-[#8E4F24]">{error}</p>}
</form>
</main>
);
}
function PasswordLogin() {
const submission = useSubmission(formLogin);
return (
<form action={formLogin} method="post" class="space-y-4 space-x-12">
<input type="hidden" name="lang" value={language()} />
<label for="email" class="block text-left w-full">
{t("login.email")}
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="john@doe.com"
required
class="bg-white mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label for="password" class="block text-left w-full">
{t("login.password")}
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
placeholder="••••••••"
minLength={6}
required
class="bg-white mt-1 block w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<button
type="submit"
disabled={submission.pending}
class="w-full px-4 py-2 bg-gradient-to-r from-sky-600 to-blue-600 text-white rounded-lg hover:from-sky-700 hover:to-blue-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300 shadow-lg shadow-sky-500/25"
>
{t("login.submit")}
</button>
<Show when={submission.error} keyed>
{({ message }) => <p class="text-red-600 mt-2 text-xs text-center">{message}</p>}
</Show>
</form>
);
}

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

38
ui/src/state/appState.ts Normal file
View File

@@ -0,0 +1,38 @@
import { atom } from "recoil";
import type { LokOpenHours } from "~/api";
export type Language = "fi" | "en";
export type Session = {
username: string;
displayName: string;
isAdmin: boolean;
token: string;
};
export type Toast = {
id: number;
message: string;
kind: "success" | "error";
leaving: boolean;
};
export const languageAtom = atom<Language>({
key: "languageAtom",
default: "en",
});
export const sessionAtom = atom<Session | null>({
key: "sessionAtom",
default: null,
});
export const openHoursAtom = atom<LokOpenHours[]>({
key: "openHoursAtom",
default: [],
});
export const toastsAtom = atom<Toast[]>({
key: "toastsAtom",
default: [],
});

View File

@@ -5,13 +5,14 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"jsx": "react-jsx",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vinxi/types/client"],
"types": ["vite/client"],
"isolatedModules": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"~/*": ["./src/*"]
}

22
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:5013",
changeOrigin: true,
rewrite: (pathValue) => pathValue.replace(/^\/api/, ""),
},
},
},
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
});