Compare commits
15 Commits
46bdad7296
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b361f46afa | |||
| 109cbb29e2 | |||
| 2d1923d68d | |||
| 667fa25525 | |||
| 2beeadd42c | |||
| 154b9b66ce | |||
| 81c4c70c51 | |||
| bc4c849590 | |||
| 082eb2575e | |||
| 52c1739149 | |||
| 151efc88fc | |||
| 0d144b6e66 | |||
| 49bf808c1d | |||
| 7fba6ccc17 | |||
| c4c054a0c6 |
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -9,9 +9,9 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build App API",
|
||||
"program": "${workspaceFolder}/api/Public/bin/Debug/net10.0/App.dll",
|
||||
"program": "${workspaceFolder}/api/App/bin/Debug/net10.0/App.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/api/Public",
|
||||
"cwd": "${workspaceFolder}/api/App",
|
||||
"stopAtEntry": false,
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
|
||||
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@@ -5,7 +5,7 @@
|
||||
"label": "build App API",
|
||||
"type": "process",
|
||||
"command": "dotnet",
|
||||
"args": ["build", "${workspaceFolder}/api/Public/App.csproj"],
|
||||
"args": ["build", "${workspaceFolder}/api/App/App.csproj"],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
|
||||
37
README.md
37
README.md
@@ -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
3
api/.gitignore
vendored
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# SQLite database file
|
||||
klapi.db
|
||||
@@ -1,2 +1,4 @@
|
||||
<Solution>
|
||||
<Project Path="App.Tests/App.Tests.csproj" />
|
||||
<Project Path="App/App.csproj" />
|
||||
</Solution>
|
||||
|
||||
353
api/App.Tests/ApiEndpointsTests.cs
Normal file
353
api/App.Tests/ApiEndpointsTests.cs
Normal 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;
|
||||
}
|
||||
26
api/App.Tests/App.Tests.csproj
Normal file
26
api/App.Tests/App.Tests.csproj
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
82
api/App/Endpoints/AuthEndpoints.cs
Normal file
82
api/App/Endpoints/AuthEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,163 @@ public static class LokEndpoints
|
||||
{
|
||||
public static void MapLokEndpoints(WebApplication app)
|
||||
{
|
||||
app.MapGet("/lok/open-hours", async (LokService lokService) =>
|
||||
var createLokOpenHoursEndpoint = app.MapPost("/lok/open-hours", async (HttpContext httpContext) =>
|
||||
{
|
||||
var openHours = await lokService.GetOpenHoursAsync();
|
||||
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
|
||||
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
|
||||
|
||||
if (openHours is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
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);
|
||||
httpContext.Response.StatusCode = StatusCodes.Status201Created;
|
||||
httpContext.Response.Headers.Location = "/lok/open-hours";
|
||||
await httpContext.Response.WriteAsJsonAsync(createdOpenHours);
|
||||
})
|
||||
.RequireCors("FrontendWriteCors")
|
||||
.WithName("CreateLokOpenHours");
|
||||
|
||||
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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
186
api/App/Endpoints/UserEndpoints.cs
Normal file
186
api/App/Endpoints/UserEndpoints.cs
Normal 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
49
api/App/Models/AppUser.cs
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
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;
|
||||
|
||||
public string Paragraph2 { get; set; } = string.Empty;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -44,6 +122,89 @@ public class Program
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('LokOpenHours') WHERE name = 'version';";
|
||||
var hasVersionColumn = Convert.ToInt32(command.ExecuteScalar()) > 0;
|
||||
|
||||
if (!hasVersionColumn)
|
||||
{
|
||||
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())
|
||||
@@ -51,10 +212,20 @@ public class Program
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class LokService
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public async Task<LokOpenHours?> GetOpenHoursAsync()
|
||||
public async Task<List<LokOpenHours>> GetOpenHours()
|
||||
{
|
||||
if (_connection.State != ConnectionState.Open)
|
||||
{
|
||||
@@ -19,24 +19,283 @@ public class LokService
|
||||
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
|
||||
SELECT id, name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
|
||||
FROM LokOpenHours
|
||||
LIMIT 1";
|
||||
ORDER BY datetime(version) DESC, id DESC
|
||||
LIMIT 5";
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
|
||||
if (!await reader.ReadAsync())
|
||||
var openHoursList = new List<LokOpenHours>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
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,
|
||||
Paragraph3 = reader["paragraph3"]?.ToString() ?? string.Empty,
|
||||
Paragraph4 = reader["paragraph4"]?.ToString() ?? string.Empty,
|
||||
KitchenNotice = reader["kitchenNotice"]?.ToString() ?? string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
return openHoursList;
|
||||
}
|
||||
|
||||
public async Task<LokOpenHours> InsertOpenHours(LokOpenHours openHours)
|
||||
{
|
||||
if (_connection.State != ConnectionState.Open)
|
||||
{
|
||||
await _connection.OpenAsync();
|
||||
}
|
||||
|
||||
var version = DateTime.UtcNow;
|
||||
|
||||
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);
|
||||
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 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,
|
||||
Paragraph3 = openHours.Paragraph3 ?? string.Empty,
|
||||
Paragraph4 = openHours.Paragraph4 ?? string.Empty,
|
||||
KitchenNotice = openHours.KitchenNotice ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty,
|
||||
Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty,
|
||||
Paragraph3 = reader["paragraph3"]?.ToString() ?? string.Empty,
|
||||
Paragraph4 = reader["paragraph4"]?.ToString() ?? string.Empty,
|
||||
KitchenNotice = reader["kitchenNotice"]?.ToString() ?? string.Empty
|
||||
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))
|
||||
{
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
277
api/App/Services/UserService.cs
Normal file
277
api/App/Services/UserService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
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 '',
|
||||
paragraph3 TEXT NOT NULL DEFAULT '',
|
||||
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.
57
api/Tests/Http/LokOpenHours.http
Normal file
57
api/Tests/Http/LokOpenHours.http
Normal file
@@ -0,0 +1,57 @@
|
||||
@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
|
||||
|
||||
### Insert open hours version 1
|
||||
POST {{App_HostAddress}}/lok/open-hours
|
||||
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",
|
||||
"paragraph4": "Version 1 paragraph 4",
|
||||
"kitchenNotice": "Kitchen notice 1"
|
||||
}
|
||||
|
||||
### Insert open hours version 2
|
||||
POST {{App_HostAddress}}/lok/open-hours
|
||||
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",
|
||||
"paragraph4": "Version 2 paragraph 4",
|
||||
"kitchenNotice": "Kitchen notice 2"
|
||||
}
|
||||
|
||||
### Insert open hours version 3
|
||||
POST {{App_HostAddress}}/lok/open-hours
|
||||
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"
|
||||
}
|
||||
64
justfile
Normal file
64
justfile
Normal 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}}
|
||||
17
start.sh
17
start.sh
@@ -33,8 +33,23 @@ ensure_app_window() {
|
||||
|
||||
start_or_restart_services() {
|
||||
ensure_app_window
|
||||
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 respawn-pane -k -t "$SESSION_NAME":app.1 -c "$API_DIR" "dotnet run --project $API_DIR/Public/App.csproj"
|
||||
tmux select-pane -t "$SESSION_NAME":app.0
|
||||
tmux select-window -t "$SESSION_NAME":app
|
||||
}
|
||||
|
||||
@@ -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()] }
|
||||
});
|
||||
1097
ui/bun.lock
1097
ui/bun.lock
File diff suppressed because it is too large
Load Diff
13
ui/index.html
Normal file
13
ui/index.html
Normal 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
2792
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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
12
ui/src/api/url.test.ts
Normal 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
7
ui/src/api/url.ts
Normal 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(/^\/+/, "")}`;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
416
ui/src/components/OpenHoursForm.tsx
Normal file
416
ui/src/components/OpenHoursForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
ui/src/components/Toasts.tsx
Normal file
64
ui/src/components/Toasts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
||||
@@ -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
2
ui/src/global.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
186
ui/src/i18n.ts
186
ui/src/i18n.ts
@@ -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 you’re 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
12
ui/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
225
ui/src/routes/management.tsx
Normal file
225
ui/src/routes/management.tsx
Normal 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
38
ui/src/state/appState.ts
Normal 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: [],
|
||||
});
|
||||
@@ -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
22
ui/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user