Feedback page

This commit is contained in:
2026-05-27 18:30:05 +03:00
parent fe34408b13
commit ea4747ec16
11 changed files with 321 additions and 45 deletions

View File

@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" 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")); 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(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>(); builder.Services.AddScoped<LokService>();
builder.Services.AddScoped<UserService>(); builder.Services.AddScoped<UserService>();
@@ -285,6 +308,7 @@ public class Program
AuthEndpoints.MapAuthEndpoints(app); AuthEndpoints.MapAuthEndpoints(app);
LokEndpoints.MapLokEndpoints(app); LokEndpoints.MapLokEndpoints(app);
UserEndpoints.MapUserEndpoints(app); UserEndpoints.MapUserEndpoints(app);
FeedbackEndpoints.MapFeedbackEndpoints(app);
app.Run(); app.Run();
} }

View File

@@ -23,5 +23,13 @@
"Password": "changeme", "Password": "changeme",
"DisplayName": "Administrator" "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", "Issuer": "klapi-api",
"Audience": "klapi-ui", "Audience": "klapi-ui",
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key", "SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
"AllowedOrigins": ["https://klapi.tietokonepaja.fi"], "AllowedOrigins": [
"https://klapi.tietokonepaja.fi"
],
"Admin": { "Admin": {
"Username": "admin", "Username": "admin",
"Password": "<set in env var KLAPI_ADMIN_PASSWORD>", "Password": "<set in env var KLAPI_ADMIN_PASSWORD>",
"DisplayName": "Administrator" "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": "*" "AllowedHosts": "*"
} }

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 { #root {
user-select: none;
} }
main { main {

View File

@@ -4,6 +4,7 @@ import { useRecoilValue, useSetRecoilState } from "recoil";
import Nav from "~/components/Nav"; import Nav from "~/components/Nav";
import Home from "~/routes/index"; import Home from "~/routes/index";
import About from "~/routes/about"; import About from "~/routes/about";
import Feedback from "~/routes/feedback";
import Login from "~/routes/login"; import Login from "~/routes/login";
import Management from "~/routes/management"; import Management from "~/routes/management";
import NotFound from "~/routes/[...404]"; import NotFound from "~/routes/[...404]";
@@ -53,6 +54,7 @@ function AppShell() {
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/feedback" element={<Feedback />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route
path="/management" path="/management"

View File

@@ -11,6 +11,8 @@ export default function Nav() {
const { language, setLanguage } = useLanguage(); const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom); const [session, setSession] = useRecoilState(sessionAtom);
const isFeedbackPage = location.pathname === "/feedback";
const signOut = () => { const signOut = () => {
setSession(null); setSession(null);
localStorage.removeItem("session-username"); localStorage.removeItem("session-username");
@@ -28,9 +30,10 @@ export default function Nav() {
}`; }`;
return ( 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"> <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"> <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="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</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> <Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null} ) : null}
</div> </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) */} {/* 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"> <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> <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 <button
type="button" type="button"
onClick={() => setLanguage("fi")} onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi" className={`rounded px-2 py-1 text-xs ${
? "bg-[#E3A977] text-[#4C250E]" language === "fi"
: "text-[#F5D1A9] hover:text-[#FFF7EE]" ? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`} }`}
> >
{t("nav.language.fi")} {t("nav.language.fi")}
@@ -56,9 +70,10 @@ export default function Nav() {
<button <button
type="button" type="button"
onClick={() => setLanguage("en")} onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en" className={`rounded px-2 py-1 text-xs ${
? "bg-[#E3A977] text-[#4C250E]" language === "en"
: "text-[#F5D1A9] hover:text-[#FFF7EE]" ? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`} }`}
> >
{t("nav.language.en")} {t("nav.language.en")}
@@ -66,16 +81,18 @@ export default function Nav() {
<button <button
type="button" type="button"
onClick={() => setLanguage("sk")} onClick={() => setLanguage("sk")}
className={`rounded px-2 py-1 text-xs ${language === "sk" className={`rounded px-2 py-1 text-xs ${
? "bg-[#E3A977] text-[#4C250E]" language === "sk"
: "text-[#F5D1A9] hover:text-[#FFF7EE]" ? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`} }`}
> >
{t("nav.language.sk")} {t("nav.language.sk")}
</button> </button>
</div> </div>
{session ? ( {!isFeedbackPage && (
session ? (
<button <button
type="button" type="button"
onClick={signOut} onClick={signOut}
@@ -90,6 +107,7 @@ export default function Nav() {
> >
{t("nav.login")} {t("nav.login")}
</Link> </Link>
)
)} )}
</div> </div>
</div> </div>

View File

@@ -14,7 +14,8 @@ const translations = {
"nav.language.fi": "FI", "nav.language.fi": "FI",
"nav.language.en": "EN", "nav.language.en": "EN",
"nav.language.sk": "SK", "nav.language.sk": "SK",
"meta.description": "React + Recoil example", "nav.feedback": "Feedback",
"nav.feedbackTitle": "Livonsaari volunteering project",
"home.title": "Home", "home.title": "Home",
"home.heading": "Klapi", "home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepaja's administration console", "home.subheading": "Livonsaaren Tietokonepaja's administration console",
@@ -93,6 +94,18 @@ const translations = {
"error.title": "Error", "error.title": "Error",
"errors.requiredUsernamePassword": "Username and password are required", "errors.requiredUsernamePassword": "Username and password are required",
"errors.invalidUsernameOrPassword": "Invalid username or password", "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: { fi: {
"nav.home": "Etusivu", "nav.home": "Etusivu",
@@ -103,7 +116,8 @@ const translations = {
"nav.language.fi": "FI", "nav.language.fi": "FI",
"nav.language.en": "EN", "nav.language.en": "EN",
"nav.language.sk": "SK", "nav.language.sk": "SK",
"meta.description": "React + Recoil -esimerkki", "nav.feedback": "Palaute",
"nav.feedbackTitle": "Livonsaaren vapaaehtoisprojekti",
"home.title": "Etusivu", "home.title": "Etusivu",
"home.heading": "Klapi", "home.heading": "Klapi",
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli", "home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
@@ -183,6 +197,18 @@ const translations = {
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan", "errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword": "errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana", "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: { sk: {
"nav.home": "Domov", "nav.home": "Domov",
@@ -193,7 +219,8 @@ const translations = {
"nav.language.fi": "FI", "nav.language.fi": "FI",
"nav.language.en": "EN", "nav.language.en": "EN",
"nav.language.sk": "SK", "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.title": "Domov",
"home.heading": "Klapi", "home.heading": "Klapi",
"home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja", "home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja",
@@ -274,6 +301,18 @@ const translations = {
"errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné", "errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné",
"errors.invalidUsernameOrPassword": "errors.invalidUsernameOrPassword":
"Neplatné používateľské meno alebo heslo", "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; } as const;

View File

@@ -0,0 +1,97 @@
import { FormEvent, useEffect, useState } from "react";
import { submitFeedback } from "~/api";
import { useT } from "~/i18n";
export default function Feedback() {
const t = useT();
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
const [validationError, setValidationError] = useState("");
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>
);
}