Add in-app feedback issue flow
This commit is contained in:
parent
6636f68819
commit
caf08e5a46
7
backend/.env.example
Normal file
7
backend/.env.example
Normal 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
|
||||||
256
backend/app.py
256
backend/app.py
@ -2,9 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -15,7 +19,29 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel, Field
|
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"))
|
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"))
|
EXPIRY_DAYS = int(os.environ.get("ARBEIDSPULS_SHARE_EXPIRY_DAYS", "7"))
|
||||||
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
|
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
|
||||||
MAX_CIPHERTEXT_CHARS = 3_000_000
|
MAX_CIPHERTEXT_CHARS = 3_000_000
|
||||||
@ -39,6 +65,8 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
rate_bucket: Dict[str, List[datetime]] = {}
|
rate_bucket: Dict[str, List[datetime]] = {}
|
||||||
|
feedback_rate_bucket: Dict[str, List[datetime]] = {}
|
||||||
|
feedback_config: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
class ShareCreateRequest(BaseModel):
|
class ShareCreateRequest(BaseModel):
|
||||||
@ -57,6 +85,21 @@ class ShareCreateResponse(BaseModel):
|
|||||||
expires_at: str
|
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
|
@contextmanager
|
||||||
def connect():
|
def connect():
|
||||||
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@ -109,7 +152,7 @@ def init_db() -> None:
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers(request: Request, call_next):
|
async def security_headers(request: Request, call_next):
|
||||||
response = await call_next(request)
|
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["Cache-Control"] = "no-store"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Expires"] = "0"
|
response.headers["Expires"] = "0"
|
||||||
@ -173,8 +216,205 @@ def token_hash(confirm_token: str) -> str:
|
|||||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
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")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
def startup() -> None:
|
||||||
|
load_feedback_config()
|
||||||
init_db()
|
init_db()
|
||||||
cleanup_expired()
|
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.")
|
raise HTTPException(status_code=403, detail="Invalid confirmation proof.")
|
||||||
mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient")
|
mark_deleted(db, row["export_id"], row["created_at"], "imported_by_recipient")
|
||||||
return {"status": "deleted"}
|
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")
|
||||||
|
}
|
||||||
|
|||||||
48
backend/feedback_config.json
Normal file
48
backend/feedback_config.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
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
|
Offentlig demo: https://arbeidspuls.rolfsvaag.no
|
||||||
Kontakt for særlisens: franz@rolfsvaag.no
|
Kontakt for særlisens: franz@rolfsvaag.no
|
||||||
|
|
||||||
|
|||||||
300
src/App.tsx
300
src/App.tsx
@ -9,6 +9,7 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Heart,
|
Heart,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@ -55,6 +56,19 @@ import {
|
|||||||
scoreBandDisclaimer
|
scoreBandDisclaimer
|
||||||
} from "./scoring";
|
} from "./scoring";
|
||||||
import { verificationLabel, verificationTooltip } from "./integrity";
|
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 {
|
import {
|
||||||
I18nProvider,
|
I18nProvider,
|
||||||
translateScoreBand,
|
translateScoreBand,
|
||||||
@ -154,6 +168,7 @@ function AppRoutes() {
|
|||||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
|
const [deleteRequest, setDeleteRequest] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [editEntry, setEditEntry] = useState<StoredEntry | null>(null);
|
const [editEntry, setEditEntry] = useState<StoredEntry | null>(null);
|
||||||
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
const next = listReports(demoMode);
|
const next = listReports(demoMode);
|
||||||
@ -181,9 +196,12 @@ function AppRoutes() {
|
|||||||
<p className="eyebrow">{t("app.eyebrow")}</p>
|
<p className="eyebrow">{t("app.eyebrow")}</p>
|
||||||
<h1>{t("app.title")}</h1>
|
<h1>{t("app.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<a className="text-link" href="/veileder">
|
<div className="topbar-actions">
|
||||||
{t("app.evaluator")}
|
<FeedbackTopbarButton onClick={() => setFeedbackOpen(true)} />
|
||||||
</a>
|
<a className="text-link" href="/veileder">
|
||||||
|
{t("app.evaluator")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="main-nav" aria-label={t("app.navLabel")}>
|
<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({
|
function AppFooter({
|
||||||
demoMode,
|
demoMode,
|
||||||
onDemoModeChange,
|
onDemoModeChange,
|
||||||
@ -324,7 +605,7 @@ function AppFooter({
|
|||||||
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
|
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<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" />
|
<Github aria-hidden="true" />
|
||||||
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
|
<span>{t("footer.sourceCode", { version: APP_VERSION })}</span>
|
||||||
</a>
|
</a>
|
||||||
@ -2308,6 +2589,7 @@ function EvaluatorApp() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [sortDir, setSortDir] = useState<"newest" | "oldest">("newest");
|
const [sortDir, setSortDir] = useState<"newest" | "oldest">("newest");
|
||||||
const [deletePersonRequest, setDeletePersonRequest] = useState<EvaluatorProfile | null>(null);
|
const [deletePersonRequest, setDeletePersonRequest] = useState<EvaluatorProfile | null>(null);
|
||||||
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
const selected = profiles.find((profile) => profile.id === selectedId);
|
const selected = profiles.find((profile) => profile.id === selectedId);
|
||||||
const selectedEntry = selectedEntryId ? selected?.reports.find((entry) => entry.id === selectedEntryId) : undefined;
|
const selectedEntry = selectedEntryId ? selected?.reports.find((entry) => entry.id === selectedEntryId) : undefined;
|
||||||
const period = readPeriod();
|
const period = readPeriod();
|
||||||
@ -2436,9 +2718,12 @@ function EvaluatorApp() {
|
|||||||
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
|
<p className="eyebrow">{t("evaluator.eyebrow")}</p>
|
||||||
<h1>{t("evaluator.title")}</h1>
|
<h1>{t("evaluator.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<a className="text-link" href="/">
|
<div className="topbar-actions">
|
||||||
{t("evaluator.backToApp")}
|
<FeedbackTopbarButton onClick={() => setFeedbackOpen(true)} />
|
||||||
</a>
|
<a className="text-link" href="/">
|
||||||
|
{t("evaluator.backToApp")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{demoMode && (
|
{demoMode && (
|
||||||
@ -2620,6 +2905,7 @@ function EvaluatorApp() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
<AppFooter demoMode={demoMode} onDemoModeChange={changeDemoMode} />
|
||||||
|
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
open={Boolean(deletePersonRequest)}
|
open={Boolean(deletePersonRequest)}
|
||||||
itemName={
|
itemName={
|
||||||
|
|||||||
33
src/feedback.test.ts
Normal file
33
src/feedback.test.ts
Normal 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
102
src/feedback.ts
Normal 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;
|
||||||
|
}
|
||||||
124
src/i18n.tsx
124
src/i18n.tsx
@ -73,6 +73,68 @@ const nb = {
|
|||||||
languageChoice: "Språkvalg",
|
languageChoice: "Språkvalg",
|
||||||
sourceCode: "Kildekode v.{version}"
|
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: {
|
deleteModal: {
|
||||||
title: "Bekreft sletting",
|
title: "Bekreft sletting",
|
||||||
body: "Du er i ferd med å slette: {itemName}. Denne handlingen kan ikke angres.",
|
body: "Du er i ferd med å slette: {itemName}. Denne handlingen kan ikke angres.",
|
||||||
@ -421,6 +483,68 @@ const en: TranslationTree = {
|
|||||||
languageChoice: "Language choice",
|
languageChoice: "Language choice",
|
||||||
sourceCode: "Source code v.{version}"
|
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: {
|
deleteModal: {
|
||||||
title: "Confirm deletion",
|
title: "Confirm deletion",
|
||||||
body: "You are about to delete: {itemName}. This action cannot be undone.",
|
body: "You are about to delete: {itemName}. This action cannot be undone.",
|
||||||
|
|||||||
109
src/styles.css
109
src/styles.css
@ -51,6 +51,14 @@ button {
|
|||||||
padding: 10px 2px 14px;
|
padding: 10px 2px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar h1,
|
.topbar h1,
|
||||||
.field-head h2,
|
.field-head h2,
|
||||||
.filters h2,
|
.filters h2,
|
||||||
@ -681,6 +689,107 @@ textarea {
|
|||||||
white-space: nowrap;
|
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 {
|
.demo-meta-pill {
|
||||||
background: #fbecd7;
|
background: #fbecd7;
|
||||||
color: #7d4118;
|
color: #7d4118;
|
||||||
|
|||||||
@ -5,7 +5,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user