Compare commits

..

3 Commits

Author SHA1 Message Date
e97d698387 Make English a default language on feedback page 2026-05-28 21:50:04 +03:00
ea4747ec16 Feedback page 2026-05-27 18:30:05 +03:00
fe34408b13 Update README 2026-04-29 20:44:40 +03:00
12 changed files with 355 additions and 51 deletions

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<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" />

View File

@@ -0,0 +1,71 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
public record FeedbackRequest(string? Message);
public class EmailOptions
{
public string SmtpHost { get; set; } = string.Empty;
public int SmtpPort { get; set; } = 587;
public string FromAddress { get; set; } = string.Empty;
public string ToAddress { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public static class FeedbackEndpoints
{
public static void MapFeedbackEndpoints(WebApplication app)
{
app.MapPost("/feedback", async (
FeedbackRequest request,
IOptions<EmailOptions> emailOptions,
ILogger<Program> logger) =>
{
if (string.IsNullOrWhiteSpace(request.Message))
{
return Results.BadRequest(new { Message = "Feedback message is required." });
}
var message = request.Message.Trim();
if (message.Length > 5000)
{
return Results.BadRequest(new { Message = "Feedback message is too long." });
}
var options = emailOptions.Value;
var email = new MimeMessage();
email.From.Add(MailboxAddress.Parse(options.FromAddress));
email.To.Add(MailboxAddress.Parse(options.ToAddress));
email.Subject = "Klapi New feedback";
email.Body = new TextPart("plain") { Text = message };
try
{
using var smtp = new SmtpClient();
await smtp.ConnectAsync(options.SmtpHost, options.SmtpPort, SecureSocketOptions.StartTls);
if (!string.IsNullOrWhiteSpace(options.Username))
{
await smtp.AuthenticateAsync(options.Username, options.Password);
}
await smtp.SendAsync(email);
await smtp.DisconnectAsync(true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send feedback email.");
return Results.Problem("Failed to send feedback. Please try again later.");
}
return Results.Ok(new { Message = "Feedback sent." });
})
.RequireCors("FrontendWriteCors")
.WithName("SubmitFeedback");
}
}

View File

@@ -46,6 +46,29 @@ public class Program
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
var emailOptions = builder.Configuration.GetSection("Email").Get<EmailOptions>()
?? throw new InvalidOperationException("Email configuration was not found.");
if (builder.Environment.IsProduction())
{
emailOptions.Username =
Environment.GetEnvironmentVariable("KLAPI_SMTP_USERNAME")
?? throw new InvalidOperationException("SMTP username must be set in production using KLAPI_SMTP_USERNAME environment variable.");
emailOptions.Password =
Environment.GetEnvironmentVariable("KLAPI_SMTP_PASSWORD")
?? throw new InvalidOperationException("SMTP password must be set in production using KLAPI_SMTP_PASSWORD environment variable.");
}
builder.Services.Configure<EmailOptions>(o =>
{
o.SmtpHost = emailOptions.SmtpHost;
o.SmtpPort = emailOptions.SmtpPort;
o.FromAddress = emailOptions.FromAddress;
o.ToAddress = emailOptions.ToAddress;
o.Username = emailOptions.Username;
o.Password = emailOptions.Password;
});
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>();
builder.Services.AddScoped<UserService>();
@@ -285,6 +308,7 @@ public class Program
AuthEndpoints.MapAuthEndpoints(app);
LokEndpoints.MapLokEndpoints(app);
UserEndpoints.MapUserEndpoints(app);
FeedbackEndpoints.MapFeedbackEndpoints(app);
app.Run();
}

View File

@@ -23,5 +23,13 @@
"Password": "changeme",
"DisplayName": "Administrator"
}
},
"Email": {
"SmtpHost": "mail.tietokonepaja.fi",
"SmtpPort": 587,
"FromAddress": "feedback@tietokonepaja.fi",
"ToAddress": "veikko@lintujarvi.fi",
"Username": "",
"Password": ""
}
}

View File

@@ -12,12 +12,22 @@
"Issuer": "klapi-api",
"Audience": "klapi-ui",
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
"AllowedOrigins": ["https://klapi.tietokonepaja.fi"],
"AllowedOrigins": [
"https://klapi.tietokonepaja.fi"
],
"Admin": {
"Username": "admin",
"Password": "<set in env var KLAPI_ADMIN_PASSWORD>",
"DisplayName": "Administrator"
}
},
"Email": {
"SmtpHost": "mail.tietokonepaja.fi",
"SmtpPort": 587,
"FromAddress": "feedback@tietokonepaja.fi",
"ToAddress": "veikko@lintujarvi.fi",
"Username": "<set in env var KLAPI_SMTP_USERNAME>",
"Password": "<set in env var KLAPI_SMTP_PASSWORD>"
},
"AllowedHosts": "*"
}

View File

@@ -1,6 +1,6 @@
# Klapi Käyttöliittymä
Freimis: SolidJS
Freimis: React
## Käynnistys

View File

@@ -241,3 +241,10 @@ export async function deleteUser(username: string): Promise<void> {
},
});
}
export async function submitFeedback(message: string): Promise<void> {
await fetchApi<{ message: string }>("/feedback", {
method: "POST",
body: JSON.stringify({ message }),
});
}

View File

@@ -6,7 +6,6 @@
}
#root {
user-select: none;
}
main {

View File

@@ -1,27 +1,38 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes, useLocation } 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 Feedback from "~/routes/feedback";
import Login from "~/routes/login";
import Management from "~/routes/management";
import NotFound from "~/routes/[...404]";
import Toasts from "~/components/Toasts";
import { APP_ROLES, hasAnyRole } from "~/auth/roles";
import { initializeLanguage, useLanguage } from "~/i18n";
import { sessionAtom } from "~/state/appState";
import { LANGUAGE_STORAGE_KEY, normalizeLanguage, useLanguage } from "~/i18n";
import { languageAtom, sessionAtom } from "~/state/appState";
import "./app.css";
function AppShell() {
const location = useLocation();
const { language, setLanguage } = useLanguage();
const session = useRecoilValue(sessionAtom);
const setSession = useSetRecoilState(sessionAtom);
const setLanguageAtom = useSetRecoilState(languageAtom);
useEffect(() => {
// Feedback page manages its own language default
if (location.pathname === "/feedback") return;
const storedPreferredLanguage = localStorage.getItem("session-preferred-language");
initializeLanguage(setLanguage, storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi");
}, [setLanguage]);
const fallback = normalizeLanguage(storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi");
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored !== null) {
setLanguage(normalizeLanguage(stored)); // explicit prior choice — keep it saved
} else {
setLanguageAtom(fallback); // just set the atom, don't persist the default
}
}, [location.pathname, setLanguage, setLanguageAtom]);
useEffect(() => {
const storedUsername = localStorage.getItem("session-username");
@@ -53,6 +64,7 @@ function AppShell() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/feedback" element={<Feedback />} />
<Route path="/login" element={<Login />} />
<Route
path="/management"

View File

@@ -11,6 +11,8 @@ export default function Nav() {
const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom);
const isFeedbackPage = location.pathname === "/feedback";
const signOut = () => {
setSession(null);
localStorage.removeItem("session-username");
@@ -28,9 +30,10 @@ export default function Nav() {
}`;
return (
<nav className="fixed top-0 left-0 z-50 w-full bg-[#70421E] shadow-sm">
<nav className={`fixed top-0 left-0 z-50 w-full shadow-sm ${isFeedbackPage ? "bg-[#2D6A4F]" : "bg-[#70421E]"}`}>
<div className="flex flex-col sm:flex-row sm:items-center px-4 py-2 text-sm font-medium">
{/* Links — bottom on mobile (order-2), left on desktop (sm:order-1) */}
{/* Links — hidden on feedback page */}
{!isFeedbackPage && (
<div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
@@ -38,17 +41,28 @@ export default function Nav() {
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null}
</div>
)}
{/* Centered title — shown only on feedback page */}
{isFeedbackPage && (
<div className="order-2 sm:order-1 flex-1 flex items-center justify-center sm:absolute sm:left-1/2 sm:-translate-x-1/2">
<span className="text-[#D8F3DC] font-semibold tracking-wide">{t("nav.feedbackTitle")}</span>
</div>
)}
{/* Controls — top on mobile (order-1), right on desktop (sm:order-2 sm:ml-auto) */}
<div className="order-1 sm:order-2 sm:ml-auto flex items-center justify-center gap-2">
{!isFeedbackPage && (
<b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
)}
<div className={`flex items-center gap-1 rounded-md border p-1 ${isFeedbackPage ? "border-[#52B788] bg-[#52B788]/30" : "border-[#8E4F24] bg-[#8E4F24]/45"}`}>
<button
type="button"
onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
className={`rounded px-2 py-1 text-xs ${
language === "fi"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.fi")}
@@ -56,9 +70,10 @@ export default function Nav() {
<button
type="button"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
className={`rounded px-2 py-1 text-xs ${
language === "en"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}
@@ -66,16 +81,18 @@ export default function Nav() {
<button
type="button"
onClick={() => setLanguage("sk")}
className={`rounded px-2 py-1 text-xs ${language === "sk"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
className={`rounded px-2 py-1 text-xs ${
language === "sk"
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.sk")}
</button>
</div>
{session ? (
{!isFeedbackPage && (
session ? (
<button
type="button"
onClick={signOut}
@@ -90,6 +107,7 @@ export default function Nav() {
>
{t("nav.login")}
</Link>
)
)}
</div>
</div>

View File

@@ -4,6 +4,8 @@ import { languageAtom, type Language } from "~/state/appState";
const STORAGE_KEY = "ui-language";
export { STORAGE_KEY as LANGUAGE_STORAGE_KEY };
const translations = {
en: {
"nav.home": "Home",
@@ -14,7 +16,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "React + Recoil example",
"nav.feedback": "Feedback",
"nav.feedbackTitle": "Livonsaari volunteering project",
"home.title": "Home",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepaja's administration console",
@@ -93,6 +96,18 @@ const translations = {
"error.title": "Error",
"errors.requiredUsernamePassword": "Username and password are required",
"errors.invalidUsernameOrPassword": "Invalid username or password",
"feedback.title": "Feedback",
"feedback.heading": "Send feedback",
"feedback.description":
"Have something to tell to Livonsaari volunteering project anonymously? Let us know in the message below.",
"feedback.label": "Your message",
"feedback.placeholder": "Write your feedback here...",
"feedback.submit": "Send",
"feedback.sending": "Sending...",
"feedback.success": "Thank you! Your feedback has been sent.",
"feedback.error": "Something went wrong. Please try again.",
"feedback.tooLong": "Message is too long (max 5000 characters).",
"feedback.required": "Please write something before submitting.",
},
fi: {
"nav.home": "Etusivu",
@@ -103,7 +118,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "React + Recoil -esimerkki",
"nav.feedback": "Palaute",
"nav.feedbackTitle": "Livonsaaren vapaaehtoisprojekti",
"home.title": "Etusivu",
"home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
@@ -183,6 +199,18 @@ const translations = {
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana",
"feedback.title": "Palaute",
"feedback.heading": "Lähetä palautetta",
"feedback.description":
"Haluatko kertoa jotain Livonsaaren vapaaehtoisprojektin vetäjille nimettömästi? Jätä meille viesti.",
"feedback.label": "Viestisi",
"feedback.placeholder": "Kirjoita palautteesi tähän...",
"feedback.submit": "Lähetä",
"feedback.sending": "Lähetetään...",
"feedback.success": "Kiitos! Palautteesi on lähetetty.",
"feedback.error": "Jotain meni pieleen. Yritä uudelleen.",
"feedback.tooLong": "Viesti on liian pitkä (max 5000 merkkiä).",
"feedback.required": "Kirjoita jotain ennen lähettämistä.",
},
sk: {
"nav.home": "Domov",
@@ -193,7 +221,8 @@ const translations = {
"nav.language.fi": "FI",
"nav.language.en": "EN",
"nav.language.sk": "SK",
"meta.description": "Ukážka React + Recoil",
"nav.feedback": "Spätná väzba",
"nav.feedbackTitle": "Dobrovoľnícky projekt Livonsaari",
"home.title": "Domov",
"home.heading": "Klapi",
"home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja",
@@ -274,6 +303,18 @@ const translations = {
"errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné",
"errors.invalidUsernameOrPassword":
"Neplatné používateľské meno alebo heslo",
"feedback.title": "Spätná väzba",
"feedback.heading": "Odoslať spätnú väzbu",
"feedback.description":
"Chcete niečo anonymne odkázať dobrovoľníckemu projektu Livonsaari? Zanechajte nám správu.",
"feedback.label": "Vaša správa",
"feedback.placeholder": "Tu napíšte svoju spätnú väzbu...",
"feedback.submit": "Odoslať",
"feedback.sending": "Odosiela sa...",
"feedback.success": "Ďakujeme! Vaša spätná väzba bola odoslaná.",
"feedback.error": "Niečo sa pokazilo. Skúste to znova.",
"feedback.tooLong": "Správa je príliš dlhá (max 5000 znakov).",
"feedback.required": "Pred odoslaním niečo napíšte.",
},
} as const;

113
ui/src/routes/feedback.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { FormEvent, useEffect, useState } from "react";
import { useSetRecoilState } from "recoil";
import { submitFeedback } from "~/api";
import { LANGUAGE_STORAGE_KEY, useT } from "~/i18n";
import { languageAtom } from "~/state/appState";
export default function Feedback() {
const t = useT();
const setLanguageAtom = useSetRecoilState(languageAtom);
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
const [validationError, setValidationError] = useState("");
useEffect(() => {
// Default to English on the feedback page only when no language has been explicitly chosen
if (localStorage.getItem(LANGUAGE_STORAGE_KEY) === null) {
setLanguageAtom("en");
}
return () => {
// Restore Finnish default when leaving, if the user still hasn't made an explicit choice
if (localStorage.getItem(LANGUAGE_STORAGE_KEY) === null) {
setLanguageAtom("fi");
}
};
}, [setLanguageAtom]);
useEffect(() => {
document.title = t("feedback.title");
}, [t]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setValidationError("");
if (!message.trim()) {
setValidationError(t("feedback.required"));
return;
}
if (message.trim().length > 5000) {
setValidationError(t("feedback.tooLong"));
return;
}
setStatus("sending");
try {
await submitFeedback(message.trim());
setStatus("success");
setMessage("");
} catch {
setStatus("error");
}
};
return (
<main className="!bg-[#D8EDDA] !text-[#1A3A2A] px-4 py-8">
<div className="mx-auto max-w-lg space-y-6">
<div className="space-y-2 text-center">
<h1 className="!text-[#1B4332] text-2xl font-bold">{t("feedback.heading")}</h1>
<p className="text-sm text-[#2D6A4F]/80">{t("feedback.description")}</p>
</div>
{status === "success" ? (
<div className="rounded-md border border-[#74C69D] bg-[#EBF7EF] p-4 text-center text-sm font-medium text-[#1B4332]">
{t("feedback.success")}
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label
htmlFor="feedback-message"
className="block text-sm font-medium text-[#1B4332]"
>
{t("feedback.label")}
</label>
<textarea
id="feedback-message"
autoFocus
value={message}
onChange={(e) => {
setMessage(e.target.value);
setValidationError("");
if (status === "error") setStatus("idle");
}}
placeholder={t("feedback.placeholder")}
rows={6}
maxLength={5000}
className="w-full rounded-md border border-[#74C69D] bg-[#EBF7EF] px-3 py-2 text-sm text-[#1A3A2A] placeholder:text-[#74C69D] focus:border-[#2D6A4F] focus:outline-none focus:ring-1 focus:ring-[#2D6A4F] disabled:opacity-60"
disabled={status === "sending"}
/>
{validationError && (
<p className="text-xs text-red-600">{validationError}</p>
)}
{status === "error" && (
<p className="text-xs text-red-600">{t("feedback.error")}</p>
)}
<p className="text-right text-xs text-[#2D6A4F]/50">{message.length} / 5000</p>
</div>
<button
type="submit"
disabled={status === "sending"}
className="w-full rounded-md bg-[#2D6A4F] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#1B4332] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-2 disabled:opacity-60"
>
{status === "sending" ? t("feedback.sending") : t("feedback.submit")}
</button>
</form>
)}
</div>
</main>
);
}