Add in-app feedback issue flow

This commit is contained in:
Franz Rolfsvaag 2026-05-30 13:36:57 +02:00
parent 6636f68819
commit caf08e5a46
10 changed files with 975 additions and 11 deletions

7
backend/.env.example Normal file
View File

@ -0,0 +1,7 @@
FEEDBACK_ENABLED=true
GITEA_BASE_URL=https://git.rolfsvaag.no
GITEA_OWNER=Rolfsvaag_Datateknikk
GITEA_REPO=Arbeidspuls
GITEA_FEEDBACK_TOKEN=replace_with_agent_account_issue_token
FEEDBACK_RATE_LIMIT_PEPPER=replace_with_long_random_secret
ARBEIDSPULS_CORS_ORIGINS=https://arbeidspuls.rolfsvaag.no,http://localhost:5173,http://127.0.0.1:5173

View File

@ -2,9 +2,13 @@ from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import sqlite3
import urllib.error
import urllib.request
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
@ -15,7 +19,29 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
load_env_file(Path(os.environ.get("ARBEIDSPULS_ENV_FILE", ".env")))
def default_feedback_config_path() -> Path:
if "ARBEIDSPULS_FEEDBACK_CONFIG" in os.environ:
return Path(os.environ["ARBEIDSPULS_FEEDBACK_CONFIG"])
repo_root_path = Path("backend/feedback_config.json")
return repo_root_path if repo_root_path.exists() else Path("feedback_config.json")
DATABASE_PATH = Path(os.environ.get("ARBEIDSPULS_SHARE_DB", "backend/share-data.sqlite3"))
FEEDBACK_CONFIG_PATH = default_feedback_config_path()
EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7"))
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_CIPHERTEXT_CHARS = 3_000_000
@ -39,6 +65,8 @@ app.add_middleware(
)
rate_bucket: Dict[str, List[datetime]] = {}
feedback_rate_bucket: Dict[str, List[datetime]] = {}
feedback_config: Dict[str, Any] = {}
class ShareCreateRequest(BaseModel):
@ -57,6 +85,21 @@ class ShareCreateResponse(BaseModel):
expires_at: str
class FeedbackRequest(BaseModel):
feedback_type: str = Field(min_length=1, max_length=32)
areas: List[str] = Field(min_items=1, max_items=8)
message: str = Field(min_length=1, max_length=4500)
consent: bool
language: str = Field(default="nb-NO", max_length=12)
route: str = Field(default="/", max_length=180)
viewport_category: str = Field(default="unknown", max_length=24)
app_version: str = Field(default="unknown", max_length=32)
form_started_at: str = Field(default="", max_length=40)
form_completed_at: str = Field(default="", max_length=40)
company_website: str = Field(default="", max_length=200)
urgent_contact_allowed: bool = False
@contextmanager
def connect():
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
@ -109,7 +152,7 @@ def init_db() -> None:
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/share"):
if request.url.path.startswith("/api/share") or request.url.path.startswith("/api/feedback"):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
@ -173,8 +216,205 @@ def token_hash(confirm_token: str) -> str:
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def load_feedback_config() -> None:
global feedback_config
if FEEDBACK_CONFIG_PATH.exists():
with FEEDBACK_CONFIG_PATH.open("r", encoding="utf-8") as handle:
feedback_config = json.load(handle)
else:
feedback_config = {}
def feedback_enabled() -> bool:
return os.environ.get("FEEDBACK_ENABLED", "true").lower() in {"1", "true", "yes", "on"}
def feedback_repo_config() -> Dict[str, str]:
repo = feedback_config.get("repo", {})
return {
"base_url": os.environ.get("GITEA_BASE_URL", repo.get("base_url", "https://git.rolfsvaag.no")).rstrip("/"),
"owner": os.environ.get("GITEA_OWNER", repo.get("owner", "Rolfsvaag_Datateknikk")),
"repo": os.environ.get("GITEA_REPO", repo.get("repo", "Arbeidspuls")),
}
def parse_iso(value: str) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def feedback_rate_key(request: Request) -> str:
pepper = os.environ.get("FEEDBACK_RATE_LIMIT_PEPPER", "")
if not pepper:
pepper = "missing-feedback-rate-limit-pepper"
ip = client_ip(request)
user_agent = request.headers.get("user-agent", "")
digest = hmac.new(pepper.encode("utf-8"), f"{ip}|{user_agent}".encode("utf-8"), hashlib.sha256).hexdigest()
return digest
def assert_feedback_rate_limit(request: Request) -> None:
config = feedback_config.get("rate_limit", {})
key = feedback_rate_key(request)
now = utc_now()
ten_minute_cutoff = now - timedelta(minutes=10)
day_cutoff = now - timedelta(hours=24)
recent = [created for created in feedback_rate_bucket.get(key, []) if created > day_cutoff]
if len([created for created in recent if created > ten_minute_cutoff]) >= int(config.get("ten_minutes", 3)):
raise HTTPException(status_code=429, detail="Rate limit exceeded.")
if len(recent) >= int(config.get("twenty_four_hours", 10)):
raise HTTPException(status_code=429, detail="Rate limit exceeded.")
recent.append(now)
feedback_rate_bucket[key] = recent
def meaningful_feedback_errors(message: str) -> List[str]:
config = feedback_config.get("validation", {})
min_chars = int(config.get("min_chars", 30))
min_words = int(config.get("min_words", 5))
min_letters = int(config.get("min_letters", 15))
max_chars = int(config.get("max_chars", 4000))
text = message.strip()
words = [word for word in re.split(r"\s+", text) if word]
letters = re.findall(r"[A-Za-zÆØÅæøå]", text)
errors = []
if len(text) < min_chars:
errors.append("min_chars")
if len(text) > max_chars:
errors.append("max_chars")
if len(words) < min_words:
errors.append("min_words")
if len(letters) < min_letters:
errors.append("min_letters")
if re.fullmatch(r"[\d\W_]+", text, re.UNICODE):
errors.append("not_meaningful")
if re.search(r"(.)\1{11,}", text, re.UNICODE):
errors.append("repetition")
return errors
REDACTION_PATTERNS = [
re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+"),
re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
re.compile(r"\b\d{11}\b"),
re.compile(r"\b\d{8,}\b"),
re.compile(r"(?:\+?\d[\s().-]*){8,}"),
]
def redact_feedback(message: str) -> tuple[str, int, float]:
redactions = 0
redacted = message
for pattern in REDACTION_PATTERNS:
redacted, count = pattern.subn("[removed]", redacted)
redactions += count
changed_chars = max(0, len(message) - len(redacted.replace("[removed]", "")))
ratio = changed_chars / max(1, len(message))
return redacted, redactions, ratio
def validate_feedback_payload(payload: FeedbackRequest) -> tuple[Dict[str, Any], List[int], str]:
if not feedback_enabled():
raise HTTPException(status_code=503, detail="Feedback is not enabled.")
if not payload.consent:
raise HTTPException(status_code=400, detail="Consent is required.")
if payload.company_website.strip() or payload.urgent_contact_allowed:
return {"fake_success": True}, [], ""
completed = parse_iso(payload.form_completed_at)
started = parse_iso(payload.form_started_at)
min_seconds = int(feedback_config.get("validation", {}).get("min_form_seconds", 5))
if not completed or not started or (completed - started).total_seconds() < min_seconds:
raise HTTPException(status_code=400, detail="The form was submitted too quickly.")
labels = feedback_config.get("labels", {})
type_config = labels.get("types", {}).get(payload.feedback_type)
area_configs = [labels.get("areas", {}).get(area) for area in payload.areas]
if not type_config or len(payload.areas) < 1 or any(area is None for area in area_configs):
raise HTTPException(status_code=400, detail="Invalid feedback categories.")
errors = meaningful_feedback_errors(payload.message)
if errors:
raise HTTPException(status_code=400, detail="Feedback text does not pass validation.")
redacted, redactions, ratio = redact_feedback(payload.message.strip())
validation = feedback_config.get("validation", {})
if redactions > int(validation.get("reject_redactions_over", 3)) or ratio > float(validation.get("reject_redacted_ratio_over", 0.2)):
raise HTTPException(status_code=400, detail="Remove identifying or sensitive information before submitting.")
label_ids = list(labels.get("always", []))
label_ids.append(int(type_config["id"]))
label_ids.extend(int(area["id"]) for area in area_configs if area)
return {"type": type_config, "areas": area_configs, "redactions": redactions}, label_ids, redacted
def issue_title(payload: FeedbackRequest, redacted_message: str) -> str:
type_label = feedback_config.get("labels", {}).get("types", {}).get(payload.feedback_type, {}).get("en", payload.feedback_type)
excerpt = re.sub(r"\s+", " ", redacted_message).strip()[:72]
return f"{type_label}: {excerpt}"
def issue_body(payload: FeedbackRequest, meta: Dict[str, Any], redacted_message: str) -> str:
area_labels = ", ".join(f"{area['en']} (`{area['label']}`)" for area in meta["areas"])
route = payload.route.split("?", 1)[0].split("#", 1)[0] or "/"
submitted_at = iso(utc_now())
return f"""## In-app feedback
This issue was created automatically from the Arbeidspuls in-app feedback form.
The user confirmed that the feedback does not contain sensitive or identifiable information, and that it may be processed, stored and displayed publicly as ordinary project feedback. Obvious contact details are automatically removed when detected.
### User feedback
{redacted_message}
### Selected categories
- Type: {meta["type"]["en"]} (`{meta["type"]["label"]}`)
- Areas: {area_labels}
### Non-identifying context
- App version: {payload.app_version}
- UI language: {payload.language}
- Route: {route}
- Viewport: {payload.viewport_category}
- Submitted at: {submitted_at}
_No contact information, IP address, user agent, report data or local data is intentionally included._
"""
def create_gitea_issue(title: str, body: str, label_ids: List[int]) -> Dict[str, Any]:
token = os.environ.get("GITEA_FEEDBACK_TOKEN") or os.environ.get("GITEA_FEEDBACK_ACCESS_TOKEN")
if not token:
raise HTTPException(status_code=503, detail="Feedback service is not configured.")
repo = feedback_repo_config()
url = f"{repo['base_url']}/api/v1/repos/{repo['owner']}/{repo['repo']}/issues"
data = json.dumps({"title": title, "body": body, "labels": label_ids}).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=12) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
raise HTTPException(status_code=502, detail="Feedback service could not create issue.") from exc
except urllib.error.URLError as exc:
raise HTTPException(status_code=502, detail="Feedback service is unavailable.") from exc
@app.on_event("startup")
def startup() -> None:
load_feedback_config()
init_db()
cleanup_expired()
@ -240,3 +480,17 @@ def confirm_import(export_id: str, payload: ConfirmImportRequest, request: Reque
raise HTTPException(status_code=403, detail="Invalid confirmation proof.")
mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient")
return {"status": "deleted"}
@app.post("/api/feedback")
def create_feedback(payload: FeedbackRequest, request: Request) -> Dict[str, Any]:
assert_feedback_rate_limit(request)
meta, label_ids, redacted_message = validate_feedback_payload(payload)
if meta.get("fake_success"):
return {"accepted": False}
issue = create_gitea_issue(issue_title(payload, redacted_message), issue_body(payload, meta, redacted_message), label_ids)
return {
"accepted": True,
"issue_number": issue.get("number"),
"issue_url": issue.get("html_url")
}

View File

@ -0,0 +1,48 @@
{
"repo": {
"base_url": "https://git.rolfsvaag.no",
"owner": "Rolfsvaag_Datateknikk",
"repo": "Arbeidspuls"
},
"labels": {
"always": [69, 90],
"types": {
"bug": { "id": 76, "label": "type/bug", "nb": "Feil eller problem", "en": "Bug or problem" },
"feature": { "id": 82, "label": "type/feature", "nb": "Forslag til forbedring", "en": "Improvement suggestion" },
"docs": { "id": 85, "label": "type/docs", "nb": "Dokumentasjon eller tekst", "en": "Documentation or text" },
"task": { "id": 83, "label": "type/task", "nb": "Generell oppgave", "en": "General task" },
"other": { "id": 106, "label": "type/other", "nb": "Annet", "en": "Other" }
},
"areas": {
"reporting": { "id": 107, "label": "area/reporting", "nb": "Registrering av rapport", "en": "Report registration" },
"followup": { "id": 108, "label": "area/followup", "nb": "Oppfølging", "en": "Follow-up" },
"overview_graph": { "id": 99, "label": "area/graph", "nb": "Oversikt og graf", "en": "Overview and graph" },
"advisor": { "id": 109, "label": "area/advisor", "nb": "Veiledervisning", "en": "Advisor view" },
"import_export": { "id": 98, "label": "area/import-export", "nb": "Import, eksport og deling", "en": "Import, export and sharing" },
"i18n": { "id": 100, "label": "area/i18n", "nb": "Språk og oversettelse", "en": "Language and translation" },
"privacy": { "id": 97, "label": "area/privacy", "nb": "Personvern og lokal lagring", "en": "Privacy and local storage" },
"ui": { "id": 77, "label": "area/UI", "nb": "Utseende og brukervennlighet", "en": "Layout and usability" },
"accessibility": { "id": 88, "label": "area/accessibility", "nb": "Tilgjengelighet", "en": "Accessibility" },
"legal": { "id": 80, "label": "area/legal", "nb": "Juridisk eller lisens", "en": "Legal or licensing" },
"api": { "id": 87, "label": "area/API", "nb": "API", "en": "API" },
"backend": { "id": 86, "label": "area/backend", "nb": "Backend/server", "en": "Backend/server" },
"deployment": { "id": 89, "label": "area/deployment", "nb": "Drift og deploy", "en": "Deployment" },
"pwa": { "id": 101, "label": "area/PWA", "nb": "Installasjon som app/PWA", "en": "Install as app/PWA" },
"security": { "id": 79, "label": "area/security", "nb": "Sikkerhet", "en": "Security" },
"performance": { "id": 78, "label": "area/performance", "nb": "Ytelse", "en": "Performance" }
}
},
"validation": {
"min_chars": 30,
"min_words": 5,
"min_letters": 15,
"max_chars": 4000,
"min_form_seconds": 5,
"reject_redactions_over": 3,
"reject_redacted_ratio_over": 0.2
},
"rate_limit": {
"ten_minutes": 3,
"twenty_four_hours": 10
}
}

View File

@ -5,7 +5,7 @@ Copyright (c) 2026 Franz Rolfsvaag og Rolfsvaag Datateknikk.
Rolfsvaag Datateknikk er per tidspunktet for denne teksten en uregistrert/hobbybasert virksomhet/betegnelse brukt av Franz Rolfsvaag.
Originalprosjekt: https://git.rolfsvaag.no/frarol96/Arbeidspuls/
Originalprosjekt: https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Arbeidspuls/
Offentlig demo: https://arbeidspuls.rolfsvaag.no
Kontakt for særlisens: franz@rolfsvaag.no

View File

@ -9,6 +9,7 @@ import {
Github,
HelpCircle,
Heart,
MessageSquare,
Search,
Plus,
RotateCcw,
@ -55,6 +56,19 @@ import {
scoreBandDisclaimer
} from "./scoring";
import { verificationLabel, verificationTooltip } from "./integrity";
import {
FEEDBACK_CONSENT_DELAY_MS,
FEEDBACK_MAX_CHARS,
FEEDBACK_MIN_FORM_SECONDS,
feedbackAreas,
feedbackTypes,
isFeedbackEnabled,
validateFeedbackMessage,
validateFeedbackSelection,
viewportCategory,
type FeedbackArea,
type FeedbackType
} from "./feedback";
import {
I18nProvider,
translateScoreBand,
@ -154,6 +168,7 @@ function AppRoutes() {
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
const [editEntry, setEditEntry] = useState<StoredEntry | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const refresh = () => {
const next = listReports(demoMode);
@ -181,9 +196,12 @@ function AppRoutes() {
<p className="eyebrow">{t("app.eyebrow")}</p>
<h1>{t("app.title")}</h1>
</div>
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
<div className="topbar-actions">
<FeedbackTopbarButton onClick={() => setFeedbackOpen(true)} />
<a className="text-link" href="/veileder">
{t("app.evaluator")}
</a>
</div>
</header>
<nav className="main-nav" aria-label={t("app.navLabel")}>
@ -284,6 +302,269 @@ function NavButton({
);
}
function FeedbackTopbarButton({ onClick }: { onClick: () => void }) {
const { t } = useI18n();
if (!isFeedbackEnabled()) return null;
return (
<button className="text-link feedback-topbar-button" onClick={onClick} type="button" aria-label={t("feedback.buttonAria")}>
<MessageSquare aria-hidden="true" />
<span>{t("feedback.button")}</span>
</button>
);
}
function FeedbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { language, t } = useI18n();
const [step, setStep] = useState<1 | 2>(1);
const [feedbackType, setFeedbackType] = useState<FeedbackType | "">("");
const [areas, setAreas] = useState<FeedbackArea[]>([]);
const [message, setMessage] = useState("");
const [consent, setConsent] = useState(false);
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "fake-success" | "error">("idle");
const [serverError, setServerError] = useState("");
const [startedAt, setStartedAt] = useState("");
const [consentWait, setConsentWait] = useState(1);
const [companyWebsite, setCompanyWebsite] = useState("");
const [urgentContactAllowed, setUrgentContactAllowed] = useState(false);
const messageValidation = validateFeedbackMessage(message);
const selectionErrors = validateFeedbackSelection(feedbackType, areas);
const canContinue = messageValidation.ok && selectionErrors.length === 0;
const hasLegal = areas.includes("legal");
const hasUrl = /https?:\/\//i.test(message);
const formStartedTime = startedAt ? Date.parse(startedAt) : Date.now();
const tooFast = Date.now() - formStartedTime < FEEDBACK_MIN_FORM_SECONDS * 1000;
const canSubmit = step === 2 && consent && consentWait === 0 && !tooFast && status !== "submitting";
useEffect(() => {
if (!open) return;
setStep(1);
setFeedbackType("");
setAreas([]);
setMessage("");
setConsent(false);
setStatus("idle");
setServerError("");
setStartedAt(new Date().toISOString());
setConsentWait(1);
setCompanyWebsite("");
setUrgentContactAllowed(false);
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose, open]);
useEffect(() => {
if (!consent || !open) {
setConsentWait(1);
return;
}
setConsentWait(1);
const timer = window.setTimeout(() => setConsentWait(0), FEEDBACK_CONSENT_DELAY_MS);
return () => window.clearTimeout(timer);
}, [consent, open]);
const toggleArea = (area: FeedbackArea) => {
setAreas((current) => (current.includes(area) ? current.filter((item) => item !== area) : [...current, area]));
};
const goToConsent = () => {
if (!canContinue) return;
setStep(2);
};
const submit = async () => {
if (!canSubmit || !feedbackType) return;
setStatus("submitting");
setServerError("");
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
feedback_type: feedbackType,
areas,
message,
consent,
language,
route: window.location.pathname,
viewport_category: viewportCategory(),
app_version: APP_VERSION,
form_started_at: startedAt,
form_completed_at: new Date().toISOString(),
company_website: companyWebsite,
urgent_contact_allowed: urgentContactAllowed
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || t("feedback.error"));
setStatus(payload.accepted === false ? "fake-success" : "success");
} catch (error) {
setStatus("error");
setServerError(error instanceof Error ? error.message : t("feedback.error"));
}
};
if (!open) return null;
return (
<div className="modal-overlay feedback-modal-overlay" role="presentation" onMouseDown={onClose}>
<section className="feedback-modal" role="dialog" aria-modal="true" aria-labelledby="feedback-title" onMouseDown={(event) => event.stopPropagation()}>
<div className="graph-modal-head">
<div>
<p className="eyebrow">{step === 1 ? t("feedback.stepOne") : t("feedback.stepTwo")}</p>
<h2 id="feedback-title">{t("feedback.title")}</h2>
</div>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
<X aria-hidden="true" />
</button>
</div>
{status === "success" || status === "fake-success" ? (
<div className="feedback-success">
<p className="success-text">{status === "success" ? t("feedback.success") : t("feedback.fakeSuccess")}</p>
<button className="primary-action" onClick={onClose} type="button">
{t("common.close")}
</button>
</div>
) : (
<>
{step === 1 ? (
<div className="feedback-form-grid">
<p className="privacy-copy">{t("feedback.intro")}</p>
<div className="notice">
<ShieldAlert aria-hidden="true" />
<span>{t("feedback.questionsNotice")}</span>
</div>
<section className="feedback-choice-section">
<h3>{t("feedback.typeLabel")}</h3>
<div className="feedback-choice-grid">
{feedbackTypes.map((type) => (
<button
className={feedbackType === type.key ? "chip selected" : "chip"}
key={type.key}
onClick={() => setFeedbackType(type.key as FeedbackType)}
type="button"
aria-pressed={feedbackType === type.key}
>
{t(type.label)}
</button>
))}
</div>
</section>
<section className="feedback-choice-section">
<h3>{t("feedback.areaLabel")}</h3>
<div className="chips">
{feedbackAreas.filter((area) => !area.technical).map((area) => (
<button
className={areas.includes(area.key as FeedbackArea) ? "chip selected" : "chip"}
key={area.key}
onClick={() => toggleArea(area.key as FeedbackArea)}
type="button"
aria-pressed={areas.includes(area.key as FeedbackArea)}
>
{t(area.label)}
</button>
))}
</div>
<details className="details-panel technical-category-panel">
<summary>{t("feedback.technicalAreas")}</summary>
<div className="chips">
{feedbackAreas.filter((area) => area.technical).map((area) => (
<button
className={areas.includes(area.key as FeedbackArea) ? "chip selected" : "chip"}
key={area.key}
onClick={() => toggleArea(area.key as FeedbackArea)}
type="button"
aria-pressed={areas.includes(area.key as FeedbackArea)}
>
{t(area.label)}
</button>
))}
</div>
</details>
{hasLegal && <p className="notice warning">{t("feedback.legalNotice")}</p>}
</section>
<label className="text-field">
<span>{t("feedback.messageLabel")}</span>
<textarea
maxLength={FEEDBACK_MAX_CHARS}
onChange={(event) => setMessage(event.target.value)}
placeholder={t("feedback.messagePlaceholder")}
rows={7}
value={message}
/>
</label>
<div className={messageValidation.ok ? "feedback-validation valid" : "feedback-validation"}>
<strong>{t("feedback.validationTitle")}</strong>
<ul>
{(messageValidation.ok ? [] : messageValidation.errors).map((error) => (
<li key={error}>{t(error)}</li>
))}
{selectionErrors.map((error) => (
<li key={error}>{t(error)}</li>
))}
{hasUrl && <li>{t("feedback.urlWarning")}</li>}
</ul>
</div>
</div>
) : (
<div className="feedback-form-grid">
<div className="notice warning">
<ShieldAlert aria-hidden="true" />
<span>{t("feedback.publicNotice")}</span>
</div>
<p className="privacy-copy">{t("feedback.purposeNotice")}</p>
<label className="checkbox-row">
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
<span>{t("feedback.consent")}</span>
</label>
{tooFast && <p className="error-text">{t("feedback.tooFast")}</p>}
</div>
)}
<div className="feedback-honeypots" aria-hidden="true">
<label>
Website
<input tabIndex={-1} autoComplete="off" value={companyWebsite} onChange={(event) => setCompanyWebsite(event.target.value)} />
</label>
<label className="feedback-offscreen-check">
<input tabIndex={-1} checked={urgentContactAllowed} onChange={(event) => setUrgentContactAllowed(event.target.checked)} type="checkbox" />
Contact me
</label>
</div>
{status === "error" && <p className="error-text">{serverError || t("feedback.error")}</p>}
<div className="action-grid">
<button className="secondary-action" onClick={step === 1 ? onClose : () => setStep(1)} type="button">
{step === 1 ? t("feedback.cancel") : t("feedback.back")}
</button>
{step === 1 ? (
<button className="primary-action" disabled={!canContinue} onClick={goToConsent} type="button">
{t("feedback.continue")}
</button>
) : (
<button className="primary-action" disabled={!canSubmit} onClick={submit} type="button">
{consent && consentWait > 0 ? t("feedback.submitWaiting", { secondsLeft: consentWait }) : t("feedback.submit")}
</button>
)}
</div>
</>
)}
</section>
</div>
);
}
function AppFooter({
demoMode,
onDemoModeChange,
@ -324,7 +605,7 @@ function AppFooter({
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
</div>
<div className="footer-links">
<a className="footer-link" href="https://git.rolfsvaag.no/frarol96/Arbeidspuls" rel="noreferrer" target="_blank">
<a className="footer-link" href="https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Arbeidspuls" rel="noreferrer" target="_blank">
<Github aria-hidden="true" />
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
</a>
@ -2308,6 +2589,7 @@ function EvaluatorApp() {
const [search, setSearch] = useState("");
const [sortDir, setSortDir] = useState<"newest" | "oldest">("newest");
const [deletePersonRequest, setDeletePersonRequest] = useState<EvaluatorProfile | null>(null);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const selected = profiles.find((profile) => profile.id === selectedId);
const selectedEntry = selectedEntryId ? selected?.reports.find((entry) => entry.id === selectedEntryId) : undefined;
const period = readPeriod();
@ -2436,9 +2718,12 @@ function EvaluatorApp() {
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
<h1>{t("evaluator.title")}</h1>
</div>
<a className="text-link" href="/">
{t("evaluator.backToApp")}
</a>
<div className="topbar-actions">
<FeedbackTopbarButton onClick={() => setFeedbackOpen(true)} />
<a className="text-link" href="/">
{t("evaluator.backToApp")}
</a>
</div>
</header>
{demoMode && (
@ -2620,6 +2905,7 @@ function EvaluatorApp() {
</section>
</main>
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
<ConfirmDeleteModal
open={Boolean(deletePersonRequest)}
itemName={

33
src/feedback.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { validateFeedbackMessage, validateFeedbackSelection, viewportCategory } from "./feedback";
describe("feedbackvalidering", () => {
it("godtar meningsfull feedback uten kontaktinfo", () => {
const result = validateFeedbackMessage("Grafen er vanskelig å lese når rapportene ligger tett over flere dager.");
expect(result.ok).toBe(true);
});
it("avviser kort eller meningsløst innhold", () => {
expect(validateFeedbackMessage("1234567890 !!!!!").ok).toBe(false);
expect(validateFeedbackMessage("aaaaaaaaaaaaaaa").errors).toContain("feedback.validationMinWords");
});
it("blokkerer kontaktinfo-lignende tekst i frontend", () => {
expect(validateFeedbackMessage("Kontakt meg på test@example.com fordi grafen er feil i oversikten.").ok).toBe(false);
expect(validateFeedbackMessage("Ring 987 65 432 fordi rapporten ikke lagres riktig.").ok).toBe(false);
expect(validateFeedbackMessage("Feilen vises fra 192.168.10.39 når jeg åpner grafen.").ok).toBe(false);
});
it("krever gyldig type og minst ett område", () => {
expect(validateFeedbackSelection("bug", ["ui"])).toEqual([]);
expect(validateFeedbackSelection("question", ["ui"])).toContain("feedback.validationType");
expect(validateFeedbackSelection("bug", [])).toContain("feedback.validationArea");
});
it("klassifiserer viewport uten identifiserende data", () => {
expect(viewportCategory(390)).toBe("mobile");
expect(viewportCategory(800)).toBe("tablet");
expect(viewportCategory(1280)).toBe("desktop");
});
});

102
src/feedback.ts Normal file
View File

@ -0,0 +1,102 @@
export type FeedbackType = "bug" | "feature" | "docs" | "task" | "other";
export type FeedbackArea =
| "reporting"
| "followup"
| "overview_graph"
| "advisor"
| "import_export"
| "i18n"
| "privacy"
| "ui"
| "accessibility"
| "legal"
| "api"
| "backend"
| "deployment"
| "pwa"
| "security"
| "performance";
export type FeedbackChoice = {
key: string;
label: string;
technical?: boolean;
specialNotice?: boolean;
};
export const feedbackTypes: FeedbackChoice[] = [
{ key: "bug", label: "feedback.typeBug" },
{ key: "feature", label: "feedback.typeFeature" },
{ key: "docs", label: "feedback.typeDocs" },
{ key: "task", label: "feedback.typeTask" },
{ key: "other", label: "feedback.typeOther" }
];
export const feedbackAreas: FeedbackChoice[] = [
{ key: "reporting", label: "feedback.areaReporting" },
{ key: "followup", label: "feedback.areaFollowup" },
{ key: "overview_graph", label: "feedback.areaGraph" },
{ key: "advisor", label: "feedback.areaAdvisor" },
{ key: "import_export", label: "feedback.areaImportExport" },
{ key: "i18n", label: "feedback.areaI18n" },
{ key: "privacy", label: "feedback.areaPrivacy" },
{ key: "ui", label: "feedback.areaUi" },
{ key: "accessibility", label: "feedback.areaAccessibility" },
{ key: "legal", label: "feedback.areaLegal", specialNotice: true },
{ key: "api", label: "feedback.areaApi", technical: true },
{ key: "backend", label: "feedback.areaBackend", technical: true },
{ key: "deployment", label: "feedback.areaDeployment", technical: true },
{ key: "pwa", label: "feedback.areaPwa", technical: true },
{ key: "security", label: "feedback.areaSecurity", technical: true },
{ key: "performance", label: "feedback.areaPerformance", technical: true }
];
export const FEEDBACK_MIN_FORM_SECONDS = 5;
export const FEEDBACK_CONSENT_DELAY_MS = 1000;
export const FEEDBACK_MAX_CHARS = 4000;
export function isFeedbackEnabled() {
return import.meta.env.VITE_FEEDBACK_ENABLED !== "false";
}
export function viewportCategory(width = window.innerWidth) {
if (width < 640) return "mobile";
if (width < 1024) return "tablet";
return "desktop";
}
export function validateFeedbackMessage(message: string) {
const trimmed = message.trim();
const compact = trimmed.replace(/\s+/g, " ");
const words = compact ? compact.split(" ").filter(Boolean) : [];
const letterCount = (trimmed.match(/[A-Za-zÆØÅæøå]/g) ?? []).length;
const errors: string[] = [];
if (trimmed.length < 30) errors.push("feedback.validationMinChars");
if (trimmed.length > FEEDBACK_MAX_CHARS) errors.push("feedback.validationMaxChars");
if (words.length < 5) errors.push("feedback.validationMinWords");
if (letterCount < 15) errors.push("feedback.validationMinLetters");
if (/^[\d\W_]+$/u.test(trimmed)) errors.push("feedback.validationMeaningful");
if (/(.)\1{11,}/u.test(trimmed)) errors.push("feedback.validationRepetition");
if (/[^\s@]+@[^\s@]+\.[^\s@]+/.test(trimmed)) errors.push("feedback.validationContact");
if (/(?:\+?\d[\s().-]*){8,}/.test(trimmed)) errors.push("feedback.validationContact");
if (/\b\d{11}\b/.test(trimmed)) errors.push("feedback.validationContact");
if (/\b\d{8,}\b/.test(trimmed)) errors.push("feedback.validationContact");
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(trimmed)) errors.push("feedback.validationContact");
return {
ok: errors.length === 0,
errors: Array.from(new Set(errors)),
normalized: compact
};
}
export function validateFeedbackSelection(type: string, areas: string[]) {
const errors: string[] = [];
if (!feedbackTypes.some((item) => item.key === type)) errors.push("feedback.validationType");
if (areas.length < 1 || areas.some((area) => !feedbackAreas.some((item) => item.key === area))) {
errors.push("feedback.validationArea");
}
return errors;
}

View File

@ -73,6 +73,68 @@ const nb = {
languageChoice: "Språkvalg",
sourceCode: "Kildekode v.{version}"
},
feedback: {
button: "Tilbakemelding",
buttonAria: "Send anonym tilbakemelding",
title: "Send tilbakemelding",
stepOne: "1 av 2: Beskriv tilbakemeldingen",
stepTwo: "2 av 2: Bekreft offentlig innsending",
intro: "Tilbakemeldingen opprettes som offentlig issue hvis den blir godkjent av valideringen.",
typeLabel: "Type",
typeBug: "Feil eller problem",
typeFeature: "Forslag til forbedring",
typeDocs: "Dokumentasjon eller tekst",
typeTask: "Generell oppgave",
typeOther: "Annet",
questionsNotice: "Spørsmål bør sendes direkte til prosjektansvarlig, ikke som offentlig prosjektfeedback.",
areaLabel: "Område",
technicalAreas: "Tekniske kategorier",
areaReporting: "Registrering av rapport",
areaFollowup: "Oppfølging",
areaGraph: "Oversikt og graf",
areaAdvisor: "Veiledervisning",
areaImportExport: "Import, eksport og deling",
areaI18n: "Språk og oversettelse",
areaPrivacy: "Personvern og lokal lagring",
areaUi: "Utseende og brukervennlighet",
areaAccessibility: "Tilgjengelighet",
areaLegal: "Juridisk eller lisens",
areaApi: "API",
areaBackend: "Backend/server",
areaDeployment: "Drift og deploy",
areaPwa: "Installasjon som app/PWA",
areaSecurity: "Sikkerhet",
areaPerformance: "Ytelse",
legalNotice: "Særlisens eller kommersiell bruk må tas direkte med prosjektansvarlig.",
messageLabel: "Beskrivelse",
messagePlaceholder: "Beskriv hva som skjedde, hva du forventet, eller hva du foreslår.",
validationTitle: "Krav til beskrivelsen",
validationMinChars: "Skriv minst 30 tegn.",
validationMaxChars: "Maks 4000 tegn.",
validationMinWords: "Skriv minst 5 ord.",
validationMinLetters: "Skriv minst 15 bokstaver.",
validationMeaningful: "Teksten må inneholde meningsfullt innhold, ikke bare tall eller symboler.",
validationRepetition: "Teksten kan ikke bestå av gjentakelser.",
validationContact: "Fjern e-post, telefonnummer, IP-adresse eller lange tallsekvenser.",
validationType: "Velg nøyaktig én type.",
validationArea: "Velg minst ett område.",
publicNotice:
"Tilbakemeldinger kan bli opprettet som offentlige saker i prosjektets issue-liste. Ikke skriv sensitive opplysninger, rapportinnhold, navn på personer eller organisasjoner, e-post, telefonnummer eller annen informasjon som kan identifisere deg eller andre.",
purposeNotice: "Tilbakemeldingen brukes kun til å forbedre Arbeidspuls.",
consent:
"Jeg bekrefter at tilbakemeldingen ikke inneholder sensitiv eller identifiserbar informasjon, og at innholdet kan behandles, lagres og vises offentlig som ordinær prosjektfeedback.",
back: "Tilbake",
continue: "Fortsett",
submit: "Send inn",
submitWaiting: "Send inn ({secondsLeft})",
cancel: "Avbryt",
success: "Takk. Tilbakemeldingen er sendt inn.",
fakeSuccess: "Takk. Tilbakemeldingen er mottatt.",
disabled: "Tilbakemelding er ikke aktivert.",
error: "Tilbakemeldingen kunne ikke sendes. Prøv igjen senere.",
tooFast: "Vent litt før du sender inn skjemaet.",
urlWarning: "URL-er er tillatt, men fjern sensitive lenker eller query-parametre."
},
deleteModal: {
title: "Bekreft sletting",
body: "Du er i ferd med å slette: {itemName}. Denne handlingen kan ikke angres.",
@ -421,6 +483,68 @@ const en: TranslationTree = {
languageChoice: "Language choice",
sourceCode: "Source code v.{version}"
},
feedback: {
button: "Feedback",
buttonAria: "Send anonymous feedback",
title: "Send feedback",
stepOne: "1 of 2: Describe the feedback",
stepTwo: "2 of 2: Confirm public submission",
intro: "Feedback is created as a public issue if it passes validation.",
typeLabel: "Type",
typeBug: "Bug or problem",
typeFeature: "Improvement suggestion",
typeDocs: "Documentation or text",
typeTask: "General task",
typeOther: "Other",
questionsNotice: "Questions should be sent directly to the project owner, not as public project feedback.",
areaLabel: "Area",
technicalAreas: "Technical categories",
areaReporting: "Report registration",
areaFollowup: "Follow-up",
areaGraph: "Overview and graph",
areaAdvisor: "Advisor view",
areaImportExport: "Import, export and sharing",
areaI18n: "Language and translation",
areaPrivacy: "Privacy and local storage",
areaUi: "Layout and usability",
areaAccessibility: "Accessibility",
areaLegal: "Legal or licensing",
areaApi: "API",
areaBackend: "Backend/server",
areaDeployment: "Deployment",
areaPwa: "Install as app/PWA",
areaSecurity: "Security",
areaPerformance: "Performance",
legalNotice: "Special licensing or commercial use must be handled directly with the project owner.",
messageLabel: "Description",
messagePlaceholder: "Describe what happened, what you expected, or what you suggest.",
validationTitle: "Description requirements",
validationMinChars: "Write at least 30 characters.",
validationMaxChars: "Maximum 4000 characters.",
validationMinWords: "Write at least 5 words.",
validationMinLetters: "Write at least 15 letters.",
validationMeaningful: "The text must contain meaningful content, not only numbers or symbols.",
validationRepetition: "The text cannot consist of repetitions.",
validationContact: "Remove email, phone number, IP address or long number sequences.",
validationType: "Choose exactly one type.",
validationArea: "Choose at least one area.",
publicNotice:
"Feedback may be created as public issues in the project's issue list. Do not enter sensitive information, report content, names of people or organizations, email addresses, phone numbers or other information that can identify you or others.",
purposeNotice: "The feedback is only used to improve Arbeidspuls.",
consent:
"I confirm that the feedback does not contain sensitive or identifiable information, and that the content may be processed, stored and displayed publicly as ordinary project feedback.",
back: "Back",
continue: "Continue",
submit: "Submit",
submitWaiting: "Submit ({secondsLeft})",
cancel: "Cancel",
success: "Thank you. The feedback has been submitted.",
fakeSuccess: "Thank you. The feedback has been received.",
disabled: "Feedback is not enabled.",
error: "The feedback could not be submitted. Try again later.",
tooFast: "Wait a little before submitting the form.",
urlWarning: "URLs are allowed, but remove sensitive links or query parameters."
},
deleteModal: {
title: "Confirm deletion",
body: "You are about to delete: {itemName}. This action cannot be undone.",

View File

@ -51,6 +51,14 @@ button {
padding: 10px 2px 14px;
}
.topbar-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.topbar h1,
.field-head h2,
.filters h2,
@ -681,6 +689,107 @@ textarea {
white-space: nowrap;
}
.feedback-topbar-button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.feedback-topbar-button svg {
width: 16px;
height: 16px;
}
.feedback-modal-overlay {
padding: 0;
}
.feedback-modal {
width: min(980px, calc(100vw - 20px));
max-height: calc(100vh - 20px);
overflow: auto;
background: #fffdf8;
border-radius: 12px;
border: 1px solid #dfe7e3;
box-shadow: 0 24px 90px rgba(20, 36, 34, 0.26);
padding: 18px;
}
.feedback-form-grid {
display: grid;
gap: 14px;
}
.feedback-choice-section {
display: grid;
gap: 10px;
}
.feedback-choice-section h3 {
margin: 0;
font-size: 1rem;
}
.feedback-choice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.technical-category-panel {
margin-top: 10px;
}
.feedback-validation {
border: 1px solid #efd0c2;
background: #fff7f3;
border-radius: 8px;
padding: 12px;
color: #783a24;
}
.feedback-validation.valid {
display: none;
}
.feedback-validation ul {
margin: 6px 0 0;
padding-left: 1.1rem;
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 700;
line-height: 1.45;
}
.checkbox-row input {
margin-top: 3px;
}
.feedback-honeypots {
position: absolute;
left: -10000px;
top: auto;
width: 10px;
height: 10px;
overflow: hidden;
}
.feedback-offscreen-check {
position: absolute;
left: -12000px;
width: 10px;
height: 10px;
}
.feedback-success {
display: grid;
gap: 14px;
}
.demo-meta-pill {
background: #fbecd7;
color: #7d4118;

View File

@ -5,7 +5,8 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api/share": "http://127.0.0.1:8000"
"/api/share": "http://127.0.0.1:8000",
"/api/feedback": "http://127.0.0.1:8000"
}
}
});