diff --git a/internal/web/archive_handlers.go b/internal/web/archive_handlers.go new file mode 100644 index 0000000..8bb0131 --- /dev/null +++ b/internal/web/archive_handlers.go @@ -0,0 +1,237 @@ +package web + +import ( + "net/http" + + "github.com/netcell-it/deklarix/internal/store" +) + +type submissionListItem struct { + ID string + Platform string + PostType string + Status string + CreatedAt string + FindingCount int + HighestSeverity string +} + +type submissionListData struct { + Title string + Submissions []submissionListItem +} + +// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des +// angemeldeten Mandanten mit Kurzfassung der Findings. +func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID) + if err != nil { + http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + data := submissionListData{Title: "Beiträge"} + for _, sum := range summaries { + data.Submissions = append(data.Submissions, submissionListItem{ + ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status, + CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"), + FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity, + }) + } + + if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type participantView struct { + ID string + Role string + Name string + Vorgegeben bool + Freigegeben bool + ApprovedAt string // leer, wenn noch nicht freigegeben +} + +type submissionDetailData struct { + Title string + SubmissionID string + Platform string + PostType string + Caption string + Status string + CreatedAt string + CanArchive bool + IsPublished bool + DossierURL string + Findings []findingView + Participants []participantView +} + +// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit. +// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag +// eines anderen Accounts wird wie ein nicht existierender behandelt. +func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) { + sub, err := s.store.GetSubmission(r.Context(), id) + if err != nil { + return store.Submission{}, err + } + if sub.AccountID != currentUser(r).AccountID { + return store.Submission{}, store.ErrNotFound + } + return sub, nil +} + +func toParticipantViews(participants []store.Participant) []participantView { + views := make([]participantView, len(participants)) + for i, p := range participants { + v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben} + if p.ApprovedAt != nil { + v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04") + } + views[i] = v + } + return views +} + +// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings, +// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung. +func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + sub, err := s.loadOwnSubmission(r, r.PathValue("id")) + if err != nil { + http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) + return + } + + storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID) + if err != nil { + http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + findings := make([]findingView, len(storeFindings)) + for i, f := range storeFindings { + findings[i] = findingView{ + RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity, + Title: f.Title, Fix: f.Fix, Sources: f.Sources, + } + } + + participants, err := s.store.ListParticipants(ctx, sub.ID) + if err != nil { + http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + data := submissionDetailData{ + Title: "Beitrag", SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType, + Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"), + CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published", + DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants), + } + if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type participantListData struct { + SubmissionID string + Participants []participantView +} + +// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für +// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu +// geladen werden muss. +func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) { + participants, err := s.store.ListParticipants(r.Context(), submissionID) + if err != nil { + http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)} + if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil { + http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu. +func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) { + sub, err := s.loadOwnSubmission(r, r.PathValue("id")) + if err != nil { + http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + role := r.FormValue("role") + name := r.FormValue("name") + if role == "" || name == "" { + http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest) + return + } + + if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil { + http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + s.renderParticipantList(w, r, sub.ID) +} + +// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich +// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein +// fremder Mandant über eine erratene participant_id einen Beteiligten +// eines anderen Accounts ändern oder löschen. +func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) { + p, err := s.store.GetParticipant(r.Context(), participantID) + if err != nil { + return store.Participant{}, err + } + if p.SubmissionID != submissionIDFromURL { + return store.Participant{}, store.ErrNotFound + } + if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil { + return store.Participant{}, err + } + return p, nil +} + +// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten. +func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) { + submissionID := r.PathValue("id") + p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid")) + if err != nil { + http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + vorgegeben := r.FormValue("vorgegeben") == "on" + freigegeben := r.FormValue("freigegeben") == "on" + + if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil { + http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) + return + } + s.renderParticipantList(w, r, submissionID) +} + +// handleDeleteParticipant entfernt einen Beteiligten. +func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) { + submissionID := r.PathValue("id") + p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid")) + if err != nil { + http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound) + return + } + + if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil { + http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) + return + } + s.renderParticipantList(w, r, submissionID) +} diff --git a/internal/web/archive_handlers_test.go b/internal/web/archive_handlers_test.go new file mode 100644 index 0000000..56e7a10 --- /dev/null +++ b/internal/web/archive_handlers_test.go @@ -0,0 +1,202 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/rules" +) + +// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die +// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen +// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal +// auszuschreiben. +func checkAndReturnSubID(t *testing.T, s interface { + ServeHTTP(w http.ResponseWriter, r *http.Request) +}, cookie *http.Cookie) string { + t.Helper() + form := checkForm() + req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + const marker = `name="submission_id" value="` + idx := strings.Index(body, marker) + if idx == -1 { + t.Fatalf("expected a submission_id field in the result, got: %s", body) + } + rest := body[idx+len(marker):] + return rest[:strings.Index(rest, `"`)] +} + +func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, + }}, fs) + + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + + subA := checkAndReturnSubID(t, s, cookieA) + _ = checkAndReturnSubID(t, s, cookieB) + + resp := getWithCookie(t, s, cookieA, "/beitraege") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if !strings.Contains(body, "/beitraege/"+subA) { + t.Errorf("expected Mandant A's own submission link, got: %s", body) + } + + // Mandant B hat genau einen eigenen Beitrag, keinen von A. + respB := getWithCookie(t, s, cookieB, "/beitraege") + if strings.Contains(respB.Body.String(), "/beitraege/"+subA) { + t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String()) + } +} + +func TestSubmissionListRequiresSession(t *testing.T) { + s, _, _ := newAuthedTestServer(t, fakeExtractor{}) + req := httptest.NewRequest(http.MethodGet, "/beitraege", nil) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + if w.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 redirect to /login", w.Code) + } +} + +func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) { + s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, + DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, + }}) + subID := checkAndReturnSubID(t, s, cookie) + _ = fs + + resp := getWithCookie(t, s, cookie, "/beitraege/"+subID) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if !strings.Contains(body, "WK-004") { + t.Errorf("expected the finding to be shown, got: %s", body) + } + if !strings.Contains(body, "/veroeffentlichen") { + t.Errorf("expected an archive option for a checked submission, got: %s", body) + } +} + +func TestSubmissionDetailTenantIsolation(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, + }}, fs) + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + subA := checkAndReturnSubID(t, s, cookieA) + + resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA) + if resp.Code != http.StatusNotFound { + t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code) + } +} + +func TestParticipantAddUpdateDeleteFlow(t *testing.T) { + s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, + }}) + subID := checkAndReturnSubID(t, s, cookie) + + addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{ + "role": {"creator"}, "name": {"Max Mustermann"}, + }) + if addResp.Code != http.StatusOK { + t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String()) + } + if !strings.Contains(addResp.Body.String(), "Max Mustermann") { + t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String()) + } + + detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID) + if !strings.Contains(detailResp.Body.String(), "Max Mustermann") { + t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String()) + } + + // Beteiligten-ID aus dem Update-Formular extrahieren. + body := addResp.Body.String() + const marker = "/beteiligte/" + idx := strings.Index(body, marker) + if idx == -1 { + t.Fatalf("expected a participant action URL, got: %s", body) + } + rest := body[idx+len(marker):] + pID := rest[:strings.Index(rest, "/")] + + updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{ + "vorgegeben": {"on"}, "freigegeben": {"on"}, + }) + if updateResp.Code != http.StatusOK { + t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String()) + } + if !strings.Contains(updateResp.Body.String(), "freigegeben am") { + t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String()) + } + + deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{}) + if deleteResp.Code != http.StatusOK { + t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String()) + } + if strings.Contains(deleteResp.Body.String(), "Max Mustermann") { + t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String()) + } +} + +func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, + }}, fs) + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + subA := checkAndReturnSubID(t, s, cookieA) + + // Mandant B darf für As Beitrag gar keinen Beteiligten anlegen. + addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{ + "role": {"creator"}, "name": {"Fremd"}, + }) + if addResp.Code != http.StatusNotFound { + t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code) + } + + p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false) + if err != nil { + t.Fatalf("CreateParticipant: %v", err) + } + + updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{ + "vorgegeben": {"on"}, + }) + if updateResp.Code != http.StatusNotFound { + t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code) + } + + deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{}) + if deleteResp.Code != http.StatusNotFound { + t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code) + } + + if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil { + t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 45eedb5..03442bc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -48,6 +48,13 @@ type Store interface { ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) + ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) + + CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) + ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) + GetParticipant(ctx context.Context, id string) (store.Participant, error) + UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) + DeleteParticipant(ctx context.Context, id string) error CreateAccount(ctx context.Context, name string) (store.Account, error) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) @@ -98,6 +105,11 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck)) mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive)) mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload)) + mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList)) + mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail)) + mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant)) + mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) + mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index be2749e..c08916a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -66,6 +66,7 @@ type fakeStore struct { extractions map[string]store.Extraction findings map[string][]store.Finding evidencePkgs map[string]store.EvidencePackage + participants map[string]store.Participant } func newFakeStore() *fakeStore { @@ -78,6 +79,7 @@ func newFakeStore() *fakeStore { extractions: map[string]store.Extraction{}, findings: map[string][]store.Finding{}, evidencePkgs: map[string]store.EvidencePackage{}, + participants: map[string]store.Participant{}, } } @@ -247,6 +249,106 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s return pkg, nil } +func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.SubmissionSummary + for _, sub := range f.submissions { + if sub.AccountID != accountID { + continue + } + sum := store.SubmissionSummary{Submission: sub} + rank := 0 + for _, finding := range f.findings[sub.ID] { + sum.FindingCount++ + r := severityRank(finding.Severity) + if r > rank { + rank = r + sum.HighestSeverity = finding.Severity + } + } + out = append(out, sum) + } + return out, nil +} + +func severityRank(severity string) int { + switch severity { + case "hoch": + return 3 + case "mittel": + return 2 + case "niedrig": + return 1 + default: + return 0 + } +} + +func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) { + f.mu.Lock() + defer f.mu.Unlock() + p := store.Participant{ + ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name, + Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(), + } + if freigegeben { + now := time.Now() + p.ApprovedAt = &now + } + f.participants[p.ID] = p + return p, nil +} + +func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Participant + for _, p := range f.participants { + if p.SubmissionID == submissionID { + out = append(out, p) + } + } + return out, nil +} + +func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) { + f.mu.Lock() + defer f.mu.Unlock() + p, ok := f.participants[id] + if !ok { + return store.Participant{}, store.ErrNotFound + } + return p, nil +} + +func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) { + f.mu.Lock() + defer f.mu.Unlock() + p, ok := f.participants[id] + if !ok { + return store.Participant{}, store.ErrNotFound + } + p.Vorgegeben = vorgegeben + p.Freigegeben = freigegeben + if freigegeben && p.ApprovedAt == nil { + now := time.Now() + p.ApprovedAt = &now + } + f.participants[id] = p + return p, nil +} + +func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.participants[id]; !ok { + return store.ErrNotFound + } + delete(f.participants, id) + return nil +} + // fakeTimestamper liefert einen offline erzeugten, strukturell gültigen // (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime // ihn parsen kann, ohne eine echte TSA zu brauchen. diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 85a9766..8bc32e6 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -76,10 +76,23 @@ a { nav { display: flex; + align-items: center; justify-content: flex-end; + gap: 16px; padding: 12px 16px; } +nav a { + color: var(--color-muted); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; +} + +nav a:hover { + color: var(--color-text); +} + /* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf dem Handy. */ form { @@ -232,6 +245,113 @@ nav button { border-left: 4px solid var(--color-niedrig); } +.status { + display: inline-block; + font-size: 0.75rem; + font-weight: 500; + padding: 2px 8px; + border-radius: var(--radius); + background: var(--color-border); + color: var(--color-muted); +} + +.status-published { + background: var(--color-niedrig-bg); + color: var(--color-niedrig); +} + +.status-checked { + background: var(--color-mittel-bg); + color: var(--color-mittel); +} + +.beitraege-liste { + list-style: none; + margin: 0 0 16px; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.beitraege-liste li a { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-radius: var(--radius-md); + box-shadow: var(--shadow); + background: #fff; + color: var(--color-text); + text-decoration: none; +} + +.beteiligte { + list-style: none; + margin: 0 0 16px; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.beteiligter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: var(--radius-md); + box-shadow: var(--shadow); + background: #fff; +} + +.beteiligter-kopf { + flex: 1 1 100%; +} + +.beteiligter .rolle { + color: var(--color-muted); + font-weight: 400; +} + +.beteiligter-form { + flex-direction: row; + align-items: center; + gap: 12px; + margin: 0; + flex: 1 1 auto; +} + +.beteiligter-form label { + display: flex; + align-items: center; + gap: 4px; + font-weight: 400; + margin: 0; +} + +.beteiligter-form input[type="checkbox"] { + width: auto; + min-height: 0; +} + +.beteiligter form:last-child { + margin: 0; +} + +button.entfernen { + margin: 0; + background: transparent; + color: var(--color-hoch); + border: 1px solid var(--color-hoch-bg); + box-shadow: none; + min-height: 36px; + padding: 6px 14px; + font-size: 0.875rem; +} + /* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren Rand statt volle Breite, sonst bleibt alles identisch. */ @media (min-width: 640px) { diff --git a/internal/web/templates/beitraege.html b/internal/web/templates/beitraege.html new file mode 100644 index 0000000..7aa1cbd --- /dev/null +++ b/internal/web/templates/beitraege.html @@ -0,0 +1,33 @@ +{{define "beitraege"}} + +
{{template "head" .}} + +{{template "nav" .}} +Noch keine Beiträge geprüft.
+{{else}} + +{{end}} + + +Angelegt am {{.CreatedAt}} — Status: {{.Status}}
+{{.Caption}}
+ +Korrektur: {{.Fix}}
+ {{if .Sources}} +Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}
+ {{end}} +Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.
+{{end}} + +{{if .CanArchive}} + + +{{end}} +{{if .IsPublished}} +Nachweis-Dossier (PDF) herunterladen
+{{end}} + ++ Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche + Prüfung im Einzelfall. +
+Noch keine Beteiligten erfasst.
+{{else}} +