diff --git a/api/App.Tests/ApiEndpointsTests.cs b/api/App.Tests/ApiEndpointsTests.cs index bc555b7..b36981b 100644 --- a/api/App.Tests/ApiEndpointsTests.cs +++ b/api/App.Tests/ApiEndpointsTests.cs @@ -212,8 +212,9 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu { username = "editor", password = "editorpass", - isAdmin = false, - displayName = "Editor User" + displayName = "Editor User", + preferredLanguage = "fi", + roles = Array.Empty() }); Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); @@ -227,14 +228,16 @@ public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixtu var updateResponse = await _client.PutAsJsonAsync("/users/editor", new { password = "editorpass2", - isAdmin = true, - displayName = "Editor Admin" + displayName = "Editor Admin", + preferredLanguage = "en", + roles = new[] { "admin" } }); Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); var updatedUser = await updateResponse.Content.ReadFromJsonAsync(); Assert.NotNull(updatedUser); - Assert.True(updatedUser.IsAdmin); + Assert.Contains("admin", updatedUser.Roles); + Assert.Equal("en", updatedUser.PreferredLanguage); Assert.Equal("Editor Admin", updatedUser.DisplayName); var deleteResponse = await _client.DeleteAsync("/users/editor"); @@ -332,7 +335,9 @@ public class AuthTokenDto public string DisplayName { get; set; } = string.Empty; - public bool IsAdmin { get; set; } + public string PreferredLanguage { get; set; } = string.Empty; + + public List Roles { get; set; } = []; public string TokenType { get; set; } = string.Empty; @@ -347,7 +352,9 @@ public class UserDto public DateTime LastUpdated { get; set; } - public bool IsAdmin { get; set; } - public string DisplayName { get; set; } = string.Empty; + + public string PreferredLanguage { get; set; } = string.Empty; + + public List Roles { get; set; } = []; } diff --git a/api/App/Endpoints/AuthEndpoints.cs b/api/App/Endpoints/AuthEndpoints.cs index 91d0ed1..cac25de 100644 --- a/api/App/Endpoints/AuthEndpoints.cs +++ b/api/App/Endpoints/AuthEndpoints.cs @@ -53,6 +53,7 @@ public static class AuthEndpoints new(ClaimTypes.Name, authenticatedUser.Username), new("username", authenticatedUser.Username), new("display_name", authenticatedUser.DisplayName), + new("preferred_language", authenticatedUser.PreferredLanguage), }; foreach (var role in authenticatedUser.Roles) @@ -74,6 +75,7 @@ public static class AuthEndpoints AccessToken = tokenValue, Username = authenticatedUser.Username, DisplayName = authenticatedUser.DisplayName, + PreferredLanguage = authenticatedUser.PreferredLanguage, Roles = authenticatedUser.Roles, TokenType = "Bearer", ExpiresIn = 43200 diff --git a/api/App/Models/AppUser.cs b/api/App/Models/AppUser.cs index 81986b6..5c2e970 100644 --- a/api/App/Models/AppUser.cs +++ b/api/App/Models/AppUser.cs @@ -6,6 +6,23 @@ public static class AppRoles public static readonly IReadOnlyList All = [Lok, Admin]; } +public static class AppLanguages +{ + public const string Finnish = "fi"; + public const string English = "en"; + public const string Slovak = "sk"; + + public static readonly IReadOnlyList All = [Finnish, English, Slovak]; + + public static string NormalizeOrDefault(string? language) + { + var normalized = language?.Trim().ToLowerInvariant(); + return !string.IsNullOrWhiteSpace(normalized) && All.Contains(normalized) + ? normalized + : Finnish; + } +} + public class AppUser { public long Id { get; set; } @@ -20,6 +37,8 @@ public class AppUser public string DisplayName { get; set; } = string.Empty; + public string PreferredLanguage { get; set; } = AppLanguages.Finnish; + public List Roles { get; set; } = []; } @@ -33,6 +52,8 @@ public class AppUserView public string DisplayName { get; set; } = string.Empty; + public string PreferredLanguage { get; set; } = AppLanguages.Finnish; + public List Roles { get; set; } = []; } @@ -44,6 +65,8 @@ public class AppUserCreateRequest public string DisplayName { get; set; } = string.Empty; + public string PreferredLanguage { get; set; } = AppLanguages.Finnish; + public List Roles { get; set; } = []; } @@ -53,5 +76,7 @@ public class AppUserUpdateRequest public string DisplayName { get; set; } = string.Empty; + public string PreferredLanguage { get; set; } = AppLanguages.Finnish; + public List Roles { get; set; } = []; } diff --git a/api/App/Program.cs b/api/App/Program.cs index c0b2636..36db919 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -241,6 +241,22 @@ public class Program SELECT id, 'admin' FROM Users WHERE isAdmin = 1;"; command.ExecuteNonQuery(); } + + // Migration: add preferredLanguage to Users if missing and backfill with Finnish + command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('Users') WHERE name = 'preferredLanguage';"; + var usersHasPreferredLanguage = Convert.ToInt32(command.ExecuteScalar()) > 0; + + if (!usersHasPreferredLanguage) + { + command.CommandText = "ALTER TABLE Users ADD COLUMN preferredLanguage TEXT NOT NULL DEFAULT 'fi';"; + command.ExecuteNonQuery(); + } + + command.CommandText = @" + UPDATE Users + SET preferredLanguage = 'fi' + WHERE preferredLanguage IS NULL OR TRIM(preferredLanguage) = '';"; + command.ExecuteNonQuery(); } } diff --git a/api/App/Services/UserService.cs b/api/App/Services/UserService.cs index 237faca..c71d594 100644 --- a/api/App/Services/UserService.cs +++ b/api/App/Services/UserService.cs @@ -19,12 +19,13 @@ public class UserService await using var upsertCommand = _connection.CreateCommand(); upsertCommand.CommandText = @" - INSERT INTO Users (username, password, added, lastUpdated, displayName) - VALUES (@username, @password, @added, @lastUpdated, @displayName) + INSERT INTO Users (username, password, added, lastUpdated, displayName, preferredLanguage) + VALUES (@username, @password, @added, @lastUpdated, @displayName, @preferredLanguage) ON CONFLICT(username) DO UPDATE SET password = excluded.password, lastUpdated = excluded.lastUpdated, - displayName = excluded.displayName; + displayName = excluded.displayName, + preferredLanguage = excluded.preferredLanguage; SELECT id FROM Users WHERE username = @username;"; upsertCommand.Parameters.AddWithValue("@username", normalizedUsername); @@ -32,6 +33,7 @@ public class UserService upsertCommand.Parameters.AddWithValue("@added", now.ToString("O")); upsertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); upsertCommand.Parameters.AddWithValue("@displayName", admin.DisplayName.Trim()); + upsertCommand.Parameters.AddWithValue("@preferredLanguage", AppLanguages.Finnish); var userId = Convert.ToInt64(await upsertCommand.ExecuteScalarAsync()); @@ -49,7 +51,7 @@ public class UserService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, + SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, u.preferredLanguage, GROUP_CONCAT(ur.roleName) AS roles FROM Users u LEFT JOIN UserRoles ur ON ur.userId = u.id @@ -81,7 +83,7 @@ public class UserService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, + SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, u.preferredLanguage, GROUP_CONCAT(ur.roleName) AS roles FROM Users u LEFT JOIN UserRoles ur ON ur.userId = u.id @@ -99,6 +101,7 @@ public class UserService Added = ParseDate(reader["added"]?.ToString()), LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + PreferredLanguage = AppLanguages.NormalizeOrDefault(reader["preferredLanguage"]?.ToString()), Roles = ParseRoles(reader["roles"]?.ToString()) }); } @@ -112,11 +115,12 @@ public class UserService var now = DateTime.UtcNow; var normalizedUsername = request.Username.Trim().ToLowerInvariant(); + var preferredLanguage = AppLanguages.NormalizeOrDefault(request.PreferredLanguage); await using var insertCommand = _connection.CreateCommand(); insertCommand.CommandText = @" - INSERT INTO Users (username, password, added, lastUpdated, displayName) - VALUES (@username, @password, @added, @lastUpdated, @displayName); + INSERT INTO Users (username, password, added, lastUpdated, displayName, preferredLanguage) + VALUES (@username, @password, @added, @lastUpdated, @displayName, @preferredLanguage); SELECT last_insert_rowid();"; insertCommand.Parameters.AddWithValue("@username", normalizedUsername); @@ -124,6 +128,7 @@ public class UserService insertCommand.Parameters.AddWithValue("@added", now.ToString("O")); insertCommand.Parameters.AddWithValue("@lastUpdated", now.ToString("O")); insertCommand.Parameters.AddWithValue("@displayName", request.DisplayName.Trim()); + insertCommand.Parameters.AddWithValue("@preferredLanguage", preferredLanguage); var insertedId = Convert.ToInt64(await insertCommand.ExecuteScalarAsync()); @@ -143,6 +148,7 @@ public class UserService Added = now, LastUpdated = now, DisplayName = request.DisplayName.Trim(), + PreferredLanguage = preferredLanguage, Roles = validRoles }; } @@ -153,6 +159,7 @@ public class UserService var normalizedUsername = username.Trim().ToLowerInvariant(); var now = DateTime.UtcNow; + var preferredLanguage = AppLanguages.NormalizeOrDefault(request.PreferredLanguage); var currentUser = await GetUserByUsername(normalizedUsername); if (currentUser is null) @@ -165,13 +172,15 @@ public class UserService UPDATE Users SET password = @password, lastUpdated = @lastUpdated, - displayName = @displayName + displayName = @displayName, + preferredLanguage = @preferredLanguage 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("@displayName", request.DisplayName.Trim()); + command.Parameters.AddWithValue("@preferredLanguage", preferredLanguage); var affectedRows = await command.ExecuteNonQueryAsync(); if (affectedRows == 0) @@ -192,6 +201,7 @@ public class UserService Added = currentUser.Added, LastUpdated = now, DisplayName = request.DisplayName.Trim(), + PreferredLanguage = preferredLanguage, Roles = validRoles }; } @@ -225,7 +235,7 @@ public class UserService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, + SELECT u.id, u.username, u.added, u.lastUpdated, u.displayName, u.preferredLanguage, GROUP_CONCAT(ur.roleName) AS roles FROM Users u LEFT JOIN UserRoles ur ON ur.userId = u.id @@ -247,6 +257,7 @@ public class UserService Added = ParseDate(reader["added"]?.ToString()), LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + PreferredLanguage = AppLanguages.NormalizeOrDefault(reader["preferredLanguage"]?.ToString()), Roles = ParseRoles(reader["roles"]?.ToString()) }; } @@ -272,7 +283,7 @@ public class UserService { await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, + SELECT u.id, u.username, u.password, u.added, u.lastUpdated, u.displayName, u.preferredLanguage, GROUP_CONCAT(ur.roleName) AS roles FROM Users u LEFT JOIN UserRoles ur ON ur.userId = u.id @@ -301,6 +312,7 @@ public class UserService Added = ParseDate(reader["added"]?.ToString()), LastUpdated = ParseDate(reader["lastUpdated"]?.ToString()), DisplayName = reader["displayName"]?.ToString() ?? string.Empty, + PreferredLanguage = AppLanguages.NormalizeOrDefault(reader["preferredLanguage"]?.ToString()), Roles = ParseRoles(reader["roles"]?.ToString()) }; } diff --git a/api/Database/init.sql b/api/Database/init.sql index da92e52..da8e605 100644 --- a/api/Database/init.sql +++ b/api/Database/init.sql @@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS Users ( password TEXT NOT NULL, added TEXT NOT NULL, lastUpdated TEXT NOT NULL, - displayName TEXT NOT NULL DEFAULT '' + displayName TEXT NOT NULL DEFAULT '', + preferredLanguage TEXT NOT NULL DEFAULT 'fi' ); CREATE TABLE IF NOT EXISTS UserRoles ( diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 44a5fbf..02469c0 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -4,6 +4,7 @@ type AuthTokenResponse = { accessToken: string; username: string; displayName: string; + preferredLanguage: "fi" | "en" | "sk"; roles: string[]; tokenType: string; expiresIn: number; @@ -64,6 +65,7 @@ export type User = { added: string; lastUpdated: string; displayName: string; + preferredLanguage: "fi" | "en" | "sk"; roles: string[]; }; @@ -71,12 +73,14 @@ export type CreateUserInput = { username: string; password: string; displayName: string; + preferredLanguage: "fi" | "en" | "sk"; roles: string[]; }; export type UpdateUserInput = { password?: string; displayName: string; + preferredLanguage: "fi" | "en" | "sk"; roles: string[]; }; diff --git a/ui/src/app.tsx b/ui/src/app.tsx index 4dc392e..88bdaf1 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -19,12 +19,14 @@ function AppShell() { const setSession = useSetRecoilState(sessionAtom); useEffect(() => { - initializeLanguage(setLanguage); + const storedPreferredLanguage = localStorage.getItem("session-preferred-language"); + initializeLanguage(setLanguage, storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi"); }, [setLanguage]); useEffect(() => { const storedUsername = localStorage.getItem("session-username"); const storedDisplayName = localStorage.getItem("session-display-name"); + const storedPreferredLanguage = localStorage.getItem("session-preferred-language"); const storedRoles = localStorage.getItem("session-roles"); const storedToken = localStorage.getItem("session-token"); if (!storedUsername || !storedDisplayName || !storedToken) { @@ -35,6 +37,7 @@ function AppShell() { setSession({ username: storedUsername, displayName: storedDisplayName, + preferredLanguage: storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi", roles: (storedRoles ?? "").split(",").filter(Boolean), token: storedToken, }); diff --git a/ui/src/components/Nav.tsx b/ui/src/components/Nav.tsx index b557a2e..9e774ee 100644 --- a/ui/src/components/Nav.tsx +++ b/ui/src/components/Nav.tsx @@ -15,7 +15,8 @@ export default function Nav() { setSession(null); localStorage.removeItem("session-username"); localStorage.removeItem("session-display-name"); - localStorage.removeItem("session-is-admin"); + localStorage.removeItem("session-preferred-language"); + localStorage.removeItem("session-roles"); localStorage.removeItem("session-token"); navigate("/login"); }; diff --git a/ui/src/i18n.ts b/ui/src/i18n.ts index 8332e8a..61db03f 100644 --- a/ui/src/i18n.ts +++ b/ui/src/i18n.ts @@ -71,6 +71,10 @@ const translations = { "management.username": "Username", "management.password": "Password", "management.displayName": "Display name", + "management.preferredLanguage": "Preferred language", + "management.language.fi": "Finnish", + "management.language.en": "English", + "management.language.sk": "Slovak", "management.added": "Added", "management.updated": "Last updated", "management.loading": "Loading users...", @@ -156,6 +160,10 @@ const translations = { "management.username": "Käyttäjätunnus", "management.password": "Salasana", "management.displayName": "Näyttönimi", + "management.preferredLanguage": "Ensisijainen kieli", + "management.language.fi": "Suomi", + "management.language.en": "Englanti", + "management.language.sk": "Slovakki", "management.added": "Lisätty", "management.updated": "Viimeksi päivitetty", "management.loading": "Ladataan käyttäjiä...", @@ -243,6 +251,10 @@ const translations = { "management.username": "Používateľské meno", "management.password": "Heslo", "management.displayName": "Zobrazované meno", + "management.preferredLanguage": "Preferovaný jazyk", + "management.language.fi": "Fínčina", + "management.language.en": "Angličtina", + "management.language.sk": "Slovenčina", "management.added": "Pridané", "management.updated": "Naposledy aktualizované", "management.loading": "Načítavajú sa používatelia...", @@ -268,11 +280,19 @@ const translations = { export type TranslationKey = keyof typeof translations.en; export const normalizeLanguage = (value: unknown): Language => - value === "fi" || value === "sk" ? value : "en"; + value === "en" || value === "sk" ? value : "fi"; -export const initializeLanguage = (setLanguage: (lang: Language) => void) => { - const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY)); - setLanguage(stored); +export const initializeLanguage = ( + setLanguage: (lang: Language) => void, + fallbackLanguage?: Language, +) => { + const storedValue = localStorage.getItem(STORAGE_KEY); + if (storedValue !== null) { + setLanguage(normalizeLanguage(storedValue)); + return; + } + + setLanguage(normalizeLanguage(fallbackLanguage)); }; export const useLanguage = () => { diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index 4c2b7e4..77957b7 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -2,11 +2,12 @@ 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 { normalizeLanguage, useLanguage, useT } from "~/i18n"; import { sessionAtom } from "~/state/appState"; export default function Login() { const t = useT(); + const { setLanguage } = useLanguage(); const navigate = useNavigate(); const [session, setSession] = useRecoilState(sessionAtom); const [username, setUsername] = useState(""); @@ -38,12 +39,17 @@ export default function Login() { setSession({ username: auth.username, displayName: auth.displayName, + preferredLanguage: auth.preferredLanguage, roles: auth.roles, token: auth.accessToken, }); + const preferredLanguage = normalizeLanguage(auth.preferredLanguage); + setLanguage(preferredLanguage); + localStorage.setItem("session-username", auth.username); localStorage.setItem("session-display-name", auth.displayName); + localStorage.setItem("session-preferred-language", preferredLanguage); localStorage.setItem("session-roles", auth.roles.join(",")); localStorage.setItem("session-token", auth.accessToken); setError(""); diff --git a/ui/src/routes/management.tsx b/ui/src/routes/management.tsx index 6602317..c655a15 100644 --- a/ui/src/routes/management.tsx +++ b/ui/src/routes/management.tsx @@ -5,6 +5,7 @@ import { useT } from "~/i18n"; const AVAILABLE_ROLES = ["lok", "admin"]; type Mode = "create" | "edit"; +type LanguageOption = "fi" | "en" | "sk"; export default function Management() { const t = useT(); @@ -13,6 +14,7 @@ export default function Management() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [displayName, setDisplayName] = useState(""); + const [preferredLanguage, setPreferredLanguage] = useState("fi"); const [selectedRoles, setSelectedRoles] = useState>(new Set()); const [selectedUsername, setSelectedUsername] = useState(""); const [error, setError] = useState(""); @@ -44,6 +46,7 @@ export default function Management() { setUsername(""); setPassword(""); setDisplayName(""); + setPreferredLanguage("fi"); setSelectedRoles(new Set()); setSelectedUsername(""); }; @@ -53,6 +56,7 @@ export default function Management() { setUsername(user.username); setPassword(""); setDisplayName(user.displayName); + setPreferredLanguage(user.preferredLanguage); setSelectedRoles(new Set(user.roles)); setSelectedUsername(user.username); setError(""); @@ -81,12 +85,14 @@ export default function Management() { username: username.trim(), password: password, displayName: displayName.trim(), + preferredLanguage, roles: [...selectedRoles], }); } else { await updateUser(selectedUsername, { password: password.trim() ? password : undefined, displayName: displayName.trim(), + preferredLanguage, roles: [...selectedRoles], }); } @@ -153,6 +159,20 @@ export default function Management() { /> + +
{t("management.rolesAssign")}
@@ -210,6 +230,9 @@ export default function Management() {
{t("management.updated")}: {new Date(user.lastUpdated).toLocaleString()}
+
+ {t("management.preferredLanguage")}: {t(`management.language.${user.preferredLanguage}` as "management.language.fi" | "management.language.en" | "management.language.sk")} +
{user.roles.length > 0 ? (
{user.roles.map((role) => ( diff --git a/ui/src/state/appState.ts b/ui/src/state/appState.ts index b422562..baf1557 100644 --- a/ui/src/state/appState.ts +++ b/ui/src/state/appState.ts @@ -6,6 +6,7 @@ export type Language = "fi" | "en" | "sk"; export type Session = { username: string; displayName: string; + preferredLanguage: Language; roles: string[]; token: string; }; @@ -19,7 +20,7 @@ export type Toast = { export const languageAtom = atom({ key: "languageAtom", - default: "en", + default: "fi", }); export const sessionAtom = atom({