feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.
Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
(Plattform, Status, höchste Finding-Schwere) via
ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
.../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
htmx ohne Seiten-Reload.
Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.
Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
This commit is contained in:
237
internal/web/archive_handlers.go
Normal file
237
internal/web/archive_handlers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
202
internal/web/archive_handlers_test.go
Normal file
202
internal/web/archive_handlers_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ type Store interface {
|
|||||||
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
||||||
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
||||||
GetLatestEvidencePackage(ctx context.Context, submissionID string) (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)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, 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 /pruefen", s.requireAPI(s.handleCheck))
|
||||||
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
||||||
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
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))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux = mux
|
s.mux = mux
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type fakeStore struct {
|
|||||||
extractions map[string]store.Extraction
|
extractions map[string]store.Extraction
|
||||||
findings map[string][]store.Finding
|
findings map[string][]store.Finding
|
||||||
evidencePkgs map[string]store.EvidencePackage
|
evidencePkgs map[string]store.EvidencePackage
|
||||||
|
participants map[string]store.Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -78,6 +79,7 @@ func newFakeStore() *fakeStore {
|
|||||||
extractions: map[string]store.Extraction{},
|
extractions: map[string]store.Extraction{},
|
||||||
findings: map[string][]store.Finding{},
|
findings: map[string][]store.Finding{},
|
||||||
evidencePkgs: map[string]store.EvidencePackage{},
|
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
|
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
|
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
|
||||||
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
||||||
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
||||||
|
|||||||
@@ -76,10 +76,23 @@ a {
|
|||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
padding: 12px 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
|
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
|
||||||
dem Handy. */
|
dem Handy. */
|
||||||
form {
|
form {
|
||||||
@@ -232,6 +245,113 @@ nav button {
|
|||||||
border-left: 4px solid var(--color-niedrig);
|
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
|
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
33
internal/web/templates/beitraege.html
Normal file
33
internal/web/templates/beitraege.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "beitraege"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Beiträge</h1>
|
||||||
|
|
||||||
|
{{if not .Submissions}}
|
||||||
|
<p class="hinweis">Noch keine Beiträge geprüft.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Submissions}}
|
||||||
|
<li>
|
||||||
|
<a href="/beitraege/{{.ID}}">
|
||||||
|
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
|
||||||
|
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||||
|
{{if .HighestSeverity}}
|
||||||
|
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="keine-findings">keine Findings</span>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p><a href="/">Neuen Beitrag prüfen</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
63
internal/web/templates/beitrag.html
Normal file
63
internal/web/templates/beitrag.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{{define "beitrag"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/beitraege">← Alle Beiträge</a></p>
|
||||||
|
<h1>{{.Platform}} · {{.PostType}}</h1>
|
||||||
|
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||||
|
<p>{{.Caption}}</p>
|
||||||
|
|
||||||
|
<h2>Findings</h2>
|
||||||
|
{{if .Findings}}
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Findings}}
|
||||||
|
<li class="finding finding-{{.Severity}}">
|
||||||
|
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
|
||||||
|
<p>Korrektur: {{.Fix}}</p>
|
||||||
|
{{if .Sources}}
|
||||||
|
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CanArchive}}
|
||||||
|
<form hx-post="/veroeffentlichen" hx-target="#archiv-ergebnis" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
||||||
|
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
||||||
|
</form>
|
||||||
|
<div id="archiv-ergebnis"></div>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsPublished}}
|
||||||
|
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Verantwortungsmatrix</h2>
|
||||||
|
{{template "beteiligte-liste" .}}
|
||||||
|
|
||||||
|
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
|
||||||
|
<label for="role">Rolle</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="creator">Creator</option>
|
||||||
|
<option value="agentur">Agentur</option>
|
||||||
|
<option value="marke">Marke</option>
|
||||||
|
<option value="kanzlei">Kanzlei</option>
|
||||||
|
</select>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<button type="submit">Beteiligten hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="disclaimer">
|
||||||
|
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
||||||
|
Prüfung im Einzelfall.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
28
internal/web/templates/beteiligte_liste.html
Normal file
28
internal/web/templates/beteiligte_liste.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "beteiligte-liste"}}
|
||||||
|
<div id="beteiligte">
|
||||||
|
{{if not .Participants}}
|
||||||
|
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beteiligte">
|
||||||
|
{{range .Participants}}
|
||||||
|
<li class="beteiligter">
|
||||||
|
<div class="beteiligter-kopf">
|
||||||
|
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
|
||||||
|
</div>
|
||||||
|
<form class="beteiligter-form"
|
||||||
|
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
|
||||||
|
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
|
||||||
|
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
|
||||||
|
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
|
||||||
|
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
|
||||||
|
</form>
|
||||||
|
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
|
||||||
|
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
|
||||||
|
<button type="submit" class="entfernen">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav>
|
<nav>
|
||||||
|
<a href="/">Prüfen</a>
|
||||||
|
<a href="/beitraege">Beiträge</a>
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user