Fahrer-Sicht in Vue: Rollen, Coding Standards und Collective Ownership
https://bildung.social/@oerinformatik/
https://oer-informatik.de/vue-fahrer-sicht-coding-standards
Vue 3, TypeScript, Rollen, Berechtigung, PATCH, ESLint, Prettier, vue-tsc, Coding Standards, Collective Ownership, Pair Programming, Extreme Programming
tl/dr; (ca. 34 min Lesezeit): Fahrer*innen sollen in der Web-App des Mitfahrlabors die Anfragen zu ihren Fahrten sehen und bestätigen oder ablehnen. Der Artikel unterscheidet Rollen aus dem Token von Beziehungen zu einem Datensatz, zeigt, wie eine Oberfläche Buttons je nach Rolle ein- und ausblendet, und warum das Backend trotzdem jede Anfrage selbst prüft. Die Entscheidung geht per PATCH an die API, Integrationstests prüfen die Kundentests. Gleichzeitig einigt sich das Team auf Coding Standards: Prettier für die Form, ESLint mit eigenen Teamregeln, vue-tsc für die Typen, alle drei in Editor und Pipeline. Zum Schluss geht es um die XP-Praktiken Collective Ownership und Pair Programming [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über die Web-App mit Vue 3 und TypeScript: Projektübersicht / XP-Projektstart / TypeScript für Java-Entwickler*innen / Komponenten testgetrieben / API-Client aus OpenAPI / Router und Continuous Integration / Formulare und Validierung / Refactoring mit Pinia / Anmeldung mit Keycloak / Fahrer-Sicht und Coding Standards / Build und Auslieferung / End-to-End-Tests mit Playwright / Change Request Live-Aktualisierung
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste des Artikels Anmeldung mit Keycloak in Vue auf.
Als Grundlagen zu Rollen und Coding Standards in einer Web-App kann ich nach Durchlesen des Artikels…
… Rollen (aus dem Token) von Beziehungen einer Person zu einem Datensatz (z. B. „fährt diese Fahrt“) unterscheiden.
… begründen, warum eine rollenabhängige Oberfläche keine Sicherheit bietet und das Backend jede Anfrage selbst prüfen muss.
… die Aufgaben von Prettier, ESLint und vue-tsc voneinander abgrenzen und einem Befund das passende Werkzeug zuordnen.
… erklären, was Coding Standards, Collective Ownership und Pair Programming in Extreme Programming bedeuten und wie sie zusammenhängen.
Darüber hinaus kann ich als Expertenwissen zu Rollen und Coding Standards in einer Web-App…
… in einer Vue-Komponente Daten per PATCH ändern, die Antwort auswerten und abhängige Werte neu vom Server laden.
… Regeln für die Oberfläche als reine Funktionen auslagern und testen.
… Prettier und ESLint mit eigenen Teamregeln einrichten, in Editor und Pipeline einbinden und Befunde begründet beheben.
… einen Formatierungs-Commit und Ausnahmen von Regeln im Team begründet planen.
Stand im Projekt: Iteration 4, eine Karte offen
Seit dem Artikel Anmeldung mit Keycloak weiß die Web-App, wer angemeldet ist: Der Store useSitzungStore kennt Pseudonym, Nutzer-ID und Access Token, und jede Anfrage an das Backend trägt das Token im Header Authorization. Von den 5 Punkten der Iteration 4 im Mitfahrlabor sind 3 erledigt:
| Karte | Punkte | Kundentests | Stand |
|---|---|---|---|
| Spike Anmeldung mit Keycloak | 1 | – (Ergebnis ist eine Schätzung) | erledigt |
| WEB-08 Mit dem Schulkonto anmelden | 2 | KT-08.1 bis KT-08.3 | abgenommen |
| WEB-09 Anfragen bestätigen oder ablehnen | 2 | KT-09.1, KT-09.2 | dieser Artikel |
Die Karte WEB-09 steht seit dem Projektstart im Stapel:
Als Fahrer*in möchte ich die Anfragen zu meinen Fahrten sehen und bestätigen oder ablehnen, damit ich entscheide, wer mitfährt.
Die Kundentests hat Thomas Becker als Kund*in vor Ort formuliert. Sie nutzen den Datenbestand des Backends: Angebot 4 fährt Marie Schulz (Pseudonym Ostwind) ab Herford mit 3 freien Plätzen, und Aylin Kaya (Morgenmuffel) hat dafür angefragt.
KT-09.1: Marie Schulz sieht zu Angebot 4 die Anfrage von „Morgenmuffel“ und bestätigt sie: Angebot 4 hat danach 2 statt 3 freie Plätze.
KT-09.2: Nach einer Ablehnung bleiben die freien Plätze unverändert.
In der Retro nach WEB-08 kam außerdem ein Punkt, der keine Karte ist: Im Code finden sich drei Schreibweisen für dieselben Dinge. Mal stehen Semikolons am Zeilenende, mal nicht; manche Komponenten prüfen mit ==, andere mit ===; und jedes Paar formatiert anders, sodass jeder Merge Request Zeilen enthält, an denen sich inhaltlich nichts geändert hat. Das Team nimmt sich vor, in dieser Iteration Coding Standards zu vereinbaren. Beides zusammen ist der Inhalt dieses Artikels.
Wer darf was? Rollen und Beziehungen
Bevor das Paar die Oberfläche baut, klärt es eine Frage, die in der Karte steckt: Wer ist eigentlich „Fahrer*in“? Im Realm von Keycloak gibt es zwei Rollen, die das Backend aus dem Access Token liest:
| Rolle | Konten | darf laut Backend |
|---|---|---|
NUTZER |
alle Schüler*innen und Lehrkräfte, z. B. kaffeetasse, ostwind |
Fahrten anbieten (POST /angebote), Plätze anfragen (POST /angebote/{id}/buchungen) |
ORGA |
Organisationsteam, z. B. admin-aw, sekretariat-hb |
Angebote absagen, Verwaltungsseiten des Flurmonitors |
Eine Rolle „FAHRER“ gibt es nicht, und das ist Absicht. Ob jemand eine Fahrt fährt, hängt nicht an der Person, sondern am Datensatz: Marie Schulz fährt Angebot 4 und fragt gleichzeitig bei Angebot 1 als Mitfahrende an. Man unterscheidet deshalb zwei Arten von Rechten:
Rollen gelten für ein Konto überall in der Anwendung. Sie stehen im Token (Claim
realm_access.roles) und ändern sich selten. Das Modell heißt rollenbasierte Zugriffskontrolle (role-based access control, RBAC).Beziehungen gelten für einen einzelnen Datensatz: „ist Fahrer*in von Angebot 4“, „hat Buchung 5 angefragt“. Sie stehen nicht im Token, sondern in der Datenbank (
angebot.fahrer_id). Das Backend prüft sie in der Service-Schicht, z. B. inBuchungService.pruefeBerechtigung().
Für WEB-09 heißt das: Bestätigen und ablehnen darf, wer die Fahrt anbietet (Beziehung). Anfragen darf, wer die Rolle NUTZER hat und nicht selbst fährt (Rolle und Beziehung). Konten mit der Rolle ORGA haben die Rolle NUTZER nicht; das Backend lehnt Angebote und Anfragen von ihnen mit 403 ab.
Die Rollen braucht die App bisher nicht. Sie stehen nur im Access Token, nicht im ID Token, aus dem oidc-client-ts das Profil liest. Das Paar ergänzt im Typ Benutzer das Feld rollen und liest es in src/auth/oidcAnmeldedienst.ts aus dem mittleren Teil des JWT:
/**
* Liest die Realm-Rollen aus dem Access Token (Claim realm_access.roles). Die Signatur prüft die App nicht:
* Sie braucht die Rollen nur, um Buttons ein- oder auszublenden. Ein manipuliertes Token lehnt das Backend ab.
*/
export function rollenAus(accessToken: string): string[] {
try {
const nutzlast = accessToken.split('.')[1] ?? ''
const json = atob(nutzlast.replace(/-/g, '+').replace(/_/g, '/')) // Base64url → Base64
const rollen: unknown = JSON.parse(json)?.realm_access?.roles
return Array.isArray(rollen) ? rollen.filter((r): r is string => typeof r === 'string') : []
} catch {
return [] // kein JWT (z. B. in Tests): keine Rollen
}
}Ein JWT besteht aus drei Base64url-kodierten Teilen, getrennt durch Punkte: Kopf, Nutzlast (die Claims) und Signatur. Die App liest nur die Nutzlast und prüft die Signatur nicht. Das ist hier in Ordnung, weil die Rollen nur über die Anzeige entscheiden. Der Kommentar hält das fest, damit niemand später eine Sicherheitsentscheidung auf diese Funktion stützt. zuBenutzer() ruft rollenAus(user.access_token) auf, und der Store bietet die Rollen als rollen an. Wechselt das Token nach einer Erneuerung, aktualisiert der Rückruf beiAenderung auch die Rollen.
Was die Oberfläche zeigt: Regeln als Funktionen
Wo stehen die Regeln, wer was sieht? Schreibt man sie direkt in die Templates, verteilen sie sich über viele Komponenten, und niemand findet sie wieder. Das Paar legt sie deshalb in eine eigene Datei src/domain/rechte.ts, als reine Funktionen ohne Vue:
import type { Benutzer } from '@/auth/anmeldung'
import type { Angebot } from './typen'
// Was zeigt die Oberfläche wem? Diese Regeln blenden nur Buttons ein und aus.
// Ob eine Aktion erlaubt ist, entscheidet immer das Backend (403 Keine Berechtigung).
/** Rolle NUTZER: darf Fahrten anbieten und Plätze anfragen (ORGA-Konten nicht) */
export function istNutzer(benutzer: Benutzer | null): boolean {
return benutzer?.rollen.includes('NUTZER') ?? false
}
/** Fahrt anbieten: nur mit Rolle NUTZER */
export function darfAnbieten(benutzer: Benutzer | null): boolean {
return istNutzer(benutzer)
}
/** Anfragen bestätigen oder ablehnen: keine Rolle, sondern eine Beziehung zur Fahrt (wer fährt) */
export function verwaltetAnfragen(angebot: Angebot, benutzer: Benutzer | null): boolean {
return benutzer !== null && angebot.fahrerId === benutzer.nutzerId
}
/** Platz anfragen: Rolle NUTZER und nicht die eigene Fahrt */
export function darfAnfragen(angebot: Angebot, benutzer: Benutzer | null): boolean {
return istNutzer(benutzer) && !verwaltetAnfragen(angebot, benutzer)
}Reine Funktionen lassen sich ohne Komponente testen: rechte.spec.ts prüft mit den Testkonten OSTWIND, KAFFEETASSE und dem neuen ADMIN_AW (Rolle ORGA), dass Ostwind bei Angebot 4 die Anfragen verwaltet und nicht anfragt, Kaffeetasse umgekehrt, und dass ein ORGA-Konto weder anbietet noch anfragt.
Das Aktivitätsdiagramm zeigt die drei Fälle der Detailseite:
Die Detailseite AngebotSeite.vue entscheidet damit, welcher Baustein unter den Daten der Fahrt steht. v-if, v-else-if und v-else schließen sich gegenseitig aus, es erscheint also immer genau einer:
<!-- WEB-09: Wer fährt, verwaltet die Anfragen; wer die Rolle NUTZER hat, fragt an; ORGA-Konten sehen nur die Daten -->
<AnfragenVerwaltung
v-if="verwaltetAnfragen(ergebnis.angebot, sitzung.benutzer)"
:key="'anfragen-' + ergebnis.angebot.id"
:angebot-id="ergebnis.angebot.id"
@geaendert="aktualisiere"
/>
<PlatzAnfrage
v-else-if="darfAnfragen(ergebnis.angebot, sitzung.benutzer)"
:key="ergebnis.angebot.id"
:angebot="ergebnis.angebot"
:mitfahrer-id="sitzung.nutzerId ?? 0"
/>
<p v-else class="text-secondary keine-anfrage">Mit einem Konto des Organisationsteams fragt man keine Plätze an.</p>In App.vue steht der Link „Fahrt anbieten“ jetzt hinter v-if="darfAnbieten(sitzung.benutzer)". Vorher sah ihn auch Andrea Wolf vom Organisationsteam, und ihr Formular endete mit einer Fehlermeldung.
Warum der Server trotzdem prüft
Die Oberfläche zeigt Kaffeetasse bei Angebot 4 keinen Button „Bestätigen“. Ist die Buchung damit geschützt? Nein. Ein Button, der fehlt, verhindert nur den bequemen Weg. Die App läuft vollständig im Browser, und alles, was sie tut, kann man auch ohne sie tun:
In den Entwicklerwerkzeugen des Browsers (Reiter „Netzwerk“) lässt sich jede Anfrage der App kopieren, verändern und neu senden, z. B. als
fetch-Aufruf in der Konsole.Das Access Token liegt im
sessionStoragedes Tabs und lässt sich auslesen; mit ihm kann man die API auch percurloder aus einem Skript aufrufen.Den JavaScript-Code der App kann jede Person lesen und im Debugger anhalten, also auch
rechte.tsumgehen.
Rechte prüft deshalb immer der Server, bei jeder Anfrage. Die Regel aus der Oberfläche steht im Backend ein zweites Mal, in der Service-Schicht. Ein Versuch von Kaffeetasse, Buchung 5 zu bestätigen, endet mit 403 und einer Fehlerantwort nach RFC 9457:
{
"type": "https://mitfahrlabor.example/probleme/keine-berechtigung",
"title": "Keine Berechtigung",
"status": 403,
"detail": "Den Status bestaetigt darf für die Buchung 5 nur die Fahrerin oder der Fahrer setzen.",
"instance": "/v1/buchungen/5"
}Wozu dann überhaupt die Regeln in der Oberfläche? Sie dienen der Bedienbarkeit, nicht der Sicherheit: Niemand soll einen Button sehen, der sicher mit einer Fehlermeldung endet. Die Arbeitsteilung sieht so aus:
Oberfläche (rechte.ts) |
Backend (Service-Schicht) | |
|---|---|---|
| Zweck | nur sinnvolle Bedienelemente zeigen | Daten schützen |
| Datenquelle | Token im Browser (Rollen ungeprüft gelesen), fahrerId aus der letzten Antwort |
geprüftes Token, aktueller Stand der Datenbank |
| Umgehbar? | ja, mit Entwicklerwerkzeugen, Skript oder curl |
nein, jede Anfrage läuft durch |
| Wenn sie fehlt | lästig: Buttons, die mit 403 enden | Sicherheitslücke |
Beim Schreiben der Tests stößt das Paar genau auf so eine Lücke, diesmal im Backend: GET /angebote/{angebotId}/buchungen liefert die Buchungen eines Angebots jeder angemeldeten Person. BuchungService.listeZuAngebot() prüft nur, ob es das Angebot gibt. Die App zeigt die Liste zwar nur der Person am Steuer, aber mit einem eigenen Aufruf sieht Kaffeetasse, wer bei Marie mitfährt. Das Paar notiert den Fund als Hinweis für das Backend-Team. Die Tests der Web-App bilden schon das Zielverhalten nach: Das nachgebildete Backend antwortet anderen Personen mit 403, und die App zeigt dann „Die Anfragen können gerade nicht geladen werden.“
Anfragen laden und entscheiden
Der Vertrag api/openapi.yaml enthält beide Endpunkte schon seit Version 1.0:
| Endpunkt | Zweck | Antwort |
|---|---|---|
GET /angebote/{angebotId}/buchungen |
alle Buchungen eines Angebots, älteste zuerst | 200 mit einer Liste von Buchung |
PATCH /buchungen/{buchungId} |
Status ändern: bestaetigt, abgelehnt oder storniert |
200 mit der geänderten Buchung, 403, 404 oder 409 |
PATCH ändert einen Teil einer Ressource: Im Body steht nur, was sich ändern soll, hier {"status": "bestaetigt"}. Im Unterschied zu PUT, das die ganze Ressource ersetzt, muss die App die übrigen Felder der Buchung nicht kennen. Den Platz zieht das Backend selbst ab: BuchungService.aendereStatus() ruft beim Bestätigen angebot.belegePlatz() auf, und mit dem letzten Platz wird das Angebot voll.
Für die Anzeige fehlt dem Paar eine Angabe. Eine Buchung enthält mitfahrerId, aber kein Pseudonym, und die App kennt nur ihr eigenes. Ostwind soll aber „Morgenmuffel“ lesen und nicht „Person 2“. Die Kund*in bestätigt: Pseudonym ja, Name nein. Das Paar ergänzt im Vertrag der Web-App (api/openapi.yaml des Code-Stands, mit Kopfkommentar) ein optionales Feld und erzeugt den Client neu mit npm run api:erzeugen:
Buchung:
type: object
required: [id, angebotId, mitfahrerId, status, angefragtAm]
properties:
# ...
mitfahrerPseudonym:
type: string
description: Pseudonym der mitfahrenden Person (kein Name); Ergänzung der Web-App, Stand 09
example: MorgenmuffelOptional, weil das Backend das Feld erst noch liefern muss. Bis dahin zeigt die App „Anfrage Nr. 5“. Die Änderung geht als Wunsch an das Backend-Team, zusammen mit dem Fund aus dem letzten Abschnitt.
Laden und Entscheiden stehen wie frageAn() in einer Datei der Domänenschicht, src/domain/anfragen.ts. Die Komponenten bekommen nur Ergebnisse mit einer Art und einem Text, nie die Antwort des Clients:
/** Was die Person am Steuer mit einer Anfrage tun kann (stornieren darf nur, wer mitfährt) */
export type Entscheidung = 'bestaetigt' | 'abgelehnt'
export type EntscheidungsErgebnis =
| { art: 'gespeichert'; buchung: Buchung }
| { art: 'abgelehnt'; meldung: string }
/** Bestätigt oder lehnt eine Anfrage über PATCH /buchungen/{buchungId} ab; jede Ablehnung wird zu einer Meldung */
export async function entscheide(
buchungId: number,
status: Entscheidung,
wartezeitMs = 5000,
): Promise<EntscheidungsErgebnis> {
const { data, error } = await aendereBuchungsstatus({
path: { buchungId },
body: { status },
signal: AbortSignal.timeout(wartezeitMs),
})
if (data !== undefined) {
return { art: 'gespeichert', buchung: data }
}
return { art: 'abgelehnt', meldung: meldungFuer(error).text }
}
/** Wer fragt an? Nur das Pseudonym, nie der Name; fehlt es (Backend ohne Ergänzung), die Nummer der Anfrage */
export function anfrageVon(buchung: Buchung): string {
return buchung.mitfahrerPseudonym ?? `Anfrage Nr. ${buchung.id}`
}Der Typ Entscheidung ist enger als der Vertrag, der auch storniert erlaubt: Die Fahrer-Sicht soll nicht stornieren können, und der Compiler sorgt dafür, dass niemand es aus Versehen versucht. ladeAnfragen() ist nach demselben Muster gebaut wie ladeAngebot(). In src/domain/problem.ts kommen zwei Fehlertypen des Backends dazu: keine-berechtigung (403) wird zu „Das darf nur die Person, die die Fahrt anbietet.“, statuswechsel-unzulaessig (409) zu „Über diese Anfrage wurde schon entschieden.“ Den zweiten Fall gibt es wirklich: Marie hat die App auf Handy und Laptop offen, bestätigt auf dem einen Gerät und lehnt auf dem anderen ab, das den alten Stand zeigt.
Die Komponente AnfragenVerwaltung
Die neue Komponente src/components/AnfragenVerwaltung.vue lädt beim Einhängen die Anfragen und zeigt sie als Liste. Für jede offene Anfrage gibt es zwei Buttons. Der Ausschnitt zeigt den Kern:
<script setup lang="ts">
// WEB-09: Die Person am Steuer sieht die Anfragen zu ihrer Fahrt und entscheidet über jede einzeln
const props = defineProps<{ angebotId: number }>()
const emit = defineEmits<{ geaendert: [buchung: Buchung] }>()
const anfragen = ref<Buchung[]>([])
/** ID der Anfrage, die gerade gespeichert wird (verhindert Doppelklicks) */
const inArbeit = ref<number>()
async function antworte(anfrage: Buchung, status: Entscheidung) {
inArbeit.value = anfrage.id
fehlermeldung.value = ''
const ergebnis = await entscheide(anfrage.id, status)
inArbeit.value = undefined
if (ergebnis.art === 'abgelehnt') {
fehlermeldung.value = ergebnis.meldung
return
}
anfragen.value = anfragen.value.map((a) => (a.id === anfrage.id ? ergebnis.buchung : a))
meldung.value = `Anfrage von ${anfrageVon(anfrage)} ${statusText(ergebnis.buchung)}.`
emit('geaendert', ergebnis.buchung)
}
</script>
<template>
<!-- … Überschrift, Lade- und Leerzustand … -->
<li v-for="anfrage in anfragen" :key="anfrage.id" class="list-group-item d-flex align-items-center gap-2">
<span class="me-auto">
<strong>{{ anfrageVon(anfrage) }}</strong>
<span class="ms-2 text-secondary status">{{ statusText(anfrage) }}</span>
</span>
<template v-if="anfrage.status === 'angefragt'">
<button
type="button"
class="btn btn-success btn-sm bestaetigen"
:disabled="inArbeit !== undefined"
:aria-label="`Anfrage von ${anfrageVon(anfrage)} bestätigen`"
@click="antworte(anfrage, 'bestaetigt')"
>
Bestätigen
</button>
<!-- „Ablehnen“ genauso mit 'abgelehnt' -->
</template>
</li>
</template>Drei Entscheidungen stecken darin:
Die Antwort des Backends ersetzt den Eintrag in der Liste. Die App setzt den Status nicht selbst auf „bestätigt“, sondern übernimmt, was der Server gespeichert hat.
Nach einer Entscheidung meldet die Komponente das Ereignis
geaendert. Die Detailseite lädt daraufhin die Fahrt neu:aktualisiere()ruftladeAngebot()auf und ersetzt das Ergebnis. Die freien Plätze rechnet also das Backend, nicht die App. Rechnete die App selbstfreiePlaetze - 1, hätte sie eine zweite Kopie der Fachregel, die beim nächsten Change Request auseinanderläuft.Die Buttons tragen ein
aria-labelmit dem Pseudonym. Ein Screenreader liest sonst fünfmal „Bestätigen“ vor, ohne zu sagen, wofür. Die Meldung steht in einer Live-Region (role="status"), die von Anfang an im DOM ist, wie schon beiPlatzAnfrage.
Das Sequenzdiagramm zeigt den Ablauf von KT-09.1 mit beiden möglichen Antworten des Backends:
Test-first: KT-09.1 und KT-09.2
Wie bei den vorigen Karten beginnt das Paar mit den Kundentests als Integrationstests: Detailseite, Komponente, erzeugter Client und ein mit MSW nachgebildetes Backend. Neu ist, dass ein Test Daten ändert. Bestätigt KT-09.1 die Buchung 5, darf KT-09.2 das nicht mehr sehen, sonst hängt das Ergebnis von der Reihenfolge der Tests ab. Das nachgebildete Backend in src/mocks/handlers.ts hält seinen Zustand deshalb in einer Variablen, die setup.ts nach jedem Test zurücksetzt:
// WEB-09: Bestätigen ändert Daten. Die Änderungen liegen hier und werden nach jedem Test zurückgesetzt (setup.ts).
let stand = { buchungen: [] as Buchung[], freiePlaetze: new Map<number, number>() }
export function setzeBackendZurueck() {
stand = {
buchungen: buchungsdaten.map((b) => ({
...b,
mitfahrerPseudonym: nutzer.find((n) => n.id === b.mitfahrerId)?.pseudonym,
})),
freiePlaetze: new Map(),
}
}Der Handler für PATCH /buchungen/:buchungId prüft in derselben Reihenfolge wie BuchungService.aendereStatus(): Token vorhanden (sonst 401), Buchung vorhanden (404), richtige Person (403 keine-berechtigung), Buchung noch offen (409 statuswechsel-unzulaessig), beim Bestätigen ein Platz frei (409). Dann zieht er einen Platz ab. Die Kundentests lesen sich wie die Karte:
it('KT-09.1: Ostwind bestätigt die Anfrage von Morgenmuffel, danach 2 statt 3 freie Plätze', async () => {
angemeldetesPinia(OSTWIND)
const wrapper = await detailseiteVonAngebot4()
expect(wrapper.get('.plaetze').text()).toBe('3')
expect(wrapper.get('.anfragen li').text()).toContain('Morgenmuffel')
await wrapper.get('button.bestaetigen').trigger('click')
await vi.waitFor(() => expect(wrapper.get('.plaetze').text()).toBe('2'))
expect(wrapper.get('.anfragen .status').text()).toBe('bestätigt')
expect(wrapper.get('[role=status]').text()).toBe('Anfrage von Morgenmuffel bestätigt.')
expect(wrapper.find('button.bestaetigen').exists()).toBe(false)
})
it('KT-09.2: Nach einer Ablehnung bleiben die freien Plätze unverändert', async () => {
angemeldetesPinia(OSTWIND)
const wrapper = await detailseiteVonAngebot4()
await wrapper.get('button.ablehnen').trigger('click')
await vi.waitFor(() => expect(wrapper.get('.anfragen .status').text()).toBe('abgelehnt'))
await flushPromises()
expect(wrapper.get('.plaetze').text()).toBe('3')
})Dazu kommen Tests für die Fälle neben dem Weg der Karte: Kaffeetasse sieht bei Angebot 4 „Platz anfragen“ statt der Anfragen, ein ORGA-Konto sieht keins von beiden, eine verweigerte Liste (403) führt zu einer Meldung, und eine zweite Entscheidung über dieselbe Anfrage zeigt „Über diese Anfrage wurde schon entschieden.“ Mit rechte.spec.ts, dem Test für rollenAus() und dem Test für den Link „Fahrt anbieten“ laufen jetzt 106 Tests in 21 Dateien grün.
Coding Standards: Regeln, die alle teilen
Nun zum zweiten Thema der Iteration. Coding Standards sind eine der Praktiken von Extreme Programming: Das Team vereinbart, wie Code aussieht und geschrieben wird, und alle halten sich daran. Ziel ist, dass man einer Datei nicht ansieht, welches Paar sie geschrieben hat. Das ist keine Geschmacksfrage, sondern Voraussetzung für zwei andere Praktiken: Wenn jede Person jede Datei ändern soll (Collective Ownership) und Paare täglich wechseln (Pair Programming), darf niemand erst den Stil eines anderen Paars entschlüsseln müssen. Viele Regeln dazu beschreibt der Artikel Clean Code.
Regeln, die nur auf Papier stehen, werden vergessen. Das Team lässt deshalb prüfen, was sich prüfen lässt, und schreibt nur den Rest auf. Für eine Vue-App mit TypeScript teilen sich drei Werkzeuge die Arbeit:
| Prettier | ESLint | vue-tsc | |
|---|---|---|---|
| Art | Formatierer | Linter | Typprüfer (TypeScript-Compiler für .vue) |
| prüft | Form: Einrückung, Zeilenumbrüche, Anführungszeichen, Semikolons | Fehlerquellen und Konventionen: ==, any, fehlender key bei v-for, console.log |
Typen: falscher Wert für ein Feld, Aufruf mit falschen Argumenten, unbekannte Variablen im Template |
| ändert Code? | ja, schreibt die Datei vollständig neu | auf Wunsch (--fix), nur bei einfachen Regeln |
nein |
| einstellbar | wenige Optionen, bewusst | jede Regel einzeln | Strenge über tsconfig.json |
| Befehl | npm run format, npm run format:check |
npm run lint, npm run lint:fix |
npm run type-check |
Ein Linter analysiert Quellcode statisch, also ohne ihn auszuführen, und meldet Muster, die oft zu Fehlern führen oder gegen Vereinbarungen verstoßen. ESLint ist seit dem Anlegen des Projekts mit create-vue dabei, bisher nur mit dessen Vorgaben. Ein Formatierer dagegen kennt keine Fehler, nur eine Form; er wirft den Code in einen Syntaxbaum und druckt ihn neu aus. Prettier ist neu.
Prettier: die Form
Prettier hat absichtlich nur wenige Einstellungen. Das spart dem Team Diskussionen darüber, wohin eine Klammer gehört: Prettier entscheidet, und alle Dateien sehen gleich aus. Installiert wird es als Entwicklungsabhängigkeit, zusammen mit einem Paket, das ESLint und Prettier aufeinander abstimmt:
npm install --save-dev prettier@3.9.10 @vue/eslint-config-prettier@10.2.0
Die Einstellungen stehen in .prettierrc.json. Das Team übernimmt den Stil, der im Code schon am häufigsten vorkommt (kein Semikolon, einfache Anführungszeichen), und setzt die Zeilenlänge auf 120 Zeichen, weil viele Zeilen mit Kommentar sonst umbrechen würden:
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 120,
"trailingComma": "all"
}.prettierignore nimmt aus, was niemand von Hand formatiert oder was seine eigene Ordnung hat: den erzeugten Client src/api/, dist/, package-lock.json, die Markdown-Dateien, den Realm-Export und die Pipeline-Datei, in der die Kommentare bündig untereinander stehen. In package.json kommen zwei Skripte dazu: "format": "prettier --write ." formatiert, "format:check": "prettier --check ." meldet nur. Der erste Lauf von npm run format:check auf dem Stand vor der Umstellung zeigt, wie weit die Schreibweisen auseinanderlagen:
> prettier --check .
Checking formatting...
[warn] src/App.vue
[warn] src/stores/sitzung.ts
...
[warn] Code style issues found in 20 files. Run Prettier with --write to fix.
20 Dateien auf einmal zu ändern, ist ein Eingriff in fremden Code. Das Team plant ihn deshalb bewusst als Formatierungs-Commit: ein Commit, der nur npm run format enthält und sonst nichts, mit einer Nachricht wie „Formatierung mit Prettier, keine inhaltliche Änderung“. Alle Tests laufen davor und danach grün. Offene Branches werden vorher gemergt, damit es keine Konflikte gibt, und die Commit-ID kann in eine Datei .git-blame-ignore-revs eingetragen werden. Mit git config blame.ignoreRevsFile .git-blame-ignore-revs überspringt git blame diesen Commit und zeigt weiter, wer eine Zeile inhaltlich zuletzt geändert hat. Danach entstehen Unterschiede nur noch dort, wo sich Inhalt ändert.
Typisch für Prettier ist, dass es lange Zeilen nach festen Regeln umbricht. Aus der Rückgabe des Stores
return {
benutzer, geprueft, abgelaufen, angemeldet, pseudonym, nutzerId, accessToken,
richteEin, pruefe, meldeAn, schliesseAnmeldungAb, meldeAb, tokenAbgelehnt,
}wird eine Liste mit einem Eintrag je Zeile. Das ist länger, aber jede künftige Ergänzung ändert genau eine Zeile. Wo eine eigene Ordnung wirklich hilft, erlaubt Prettier eine Ausnahme mit einem Kommentar. Die Beispieldaten in src/domain/beispieldaten.ts behalten so eine Zeile je Datensatz wie in data.sql:
// eine Zeile je Datensatz wie in data.sql, damit man die Werte untereinander vergleichen kann
// prettier-ignore
export const angebote: readonly Angebot[] = [
{ id: 1, fahrerId: 1, haltepunktId: 1, richtung: 'zur-schule', datum: '2026-10-19', abfahrt: '07:00', freiePlaetze: 2, kostenbeitragEur: 1.8, status: 'offen' },
// ...
]Eine .editorconfig legt außerdem für jeden Editor UTF-8, Zeilenende LF, zwei Leerzeichen Einrückung und eine Leerzeile am Dateiende fest. Prettier liest sie mit.
ESLint: Fehlerquellen und Teamregeln
Formatierungsregeln braucht ESLint nicht mehr, darum kümmert sich Prettier. Wichtig ist nur, dass sich beide nicht widersprechen: Das Paket @vue/eslint-config-prettier bringt die Konfiguration skipFormatting, die alle ESLint-Regeln abschaltet, die nur die Form betreffen. Sie steht am Ende, damit sie alles Vorherige überschreibt. In eslint.config.ts stehen außerdem der strengere Regelsatz flat/recommended von eslint-plugin-vue statt flat/essential und ein eigener Block mit den Teamregeln:
export default defineConfigWithVueTs(
{ name: 'app/zu-pruefende-dateien', files: ['**/*.{vue,ts,mts,tsx}'] },
globalIgnores(['**/dist/**', '**/coverage/**', '**/reports/**', 'src/api/**']), // src/api ist erzeugt
pluginVue.configs['flat/recommended'], // statt essential: auch Lesbarkeit und Reihenfolge in Komponenten
vueTsConfigs.recommended,
{
name: 'app/teamregeln',
rules: {
eqeqeq: ['error', 'always'], // == wandelt Typen um, === nicht
'no-console': ['warn', { allow: ['warn', 'error'] }], // console.log bleibt nicht im Code
'@typescript-eslint/no-explicit-any': 'error', // any schaltet die Typprüfung ab
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'vue/block-lang': ['error', { script: { lang: 'ts' } }], // jede Komponente in TypeScript
'vue/component-api-style': ['error', ['script-setup']], // eine Schreibweise für alle Komponenten
'vue/require-explicit-emits': 'error', // Ereignisse stehen in defineEmits
'vue/no-unused-refs': 'error',
},
},
skipFormatting, // zuletzt: schaltet alle Regeln ab, die nur die Form betreffen (die prüft Prettier)
)Jede Regel hat einen Kommentar mit dem Grund. Regeln ohne Grund werden irgendwann abgeschaltet, Regeln mit Grund kann man diskutieren. Der Schweregrad error bricht die Pipeline ab, warn meldet nur. console.log ist eine Warnung, weil man es beim Suchen nach Fehlern kurz braucht; im Merge Request soll es nicht mehr stehen.
Wie das zusammenwirkt, zeigt der erste Entwurf von entscheide(), den das Paar beim Ausprobieren in einer Datei src/domain/entwurf.ts geschrieben hat:
export async function entscheide(buchungId: number, status: string) {
const antwort: any = await aendereBuchungsstatus({ path: { buchungId }, body: { status } })
if (antwort.response.status == 200) {
return antwort.data
}
}Prettier hat an dieser Datei nichts auszusetzen, die Form stimmt. ESLint findet zwei Fehlerquellen:
> eslint .
src/domain/entwurf.ts
4:18 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
5:31 error Expected '===' and instead saw '==' eqeqeq
X 2 problems (2 errors, 0 warnings)
vue-tsc: Typen bis ins Template
Den dritten Fehler findet nur der Typprüfer. vue-tsc ist der TypeScript-Compiler mit Unterstützung für Single-File-Components: Er prüft auch <script setup> und die Ausdrücke im Template. Im Projekt steckt er im Skript npm run type-check, das die Pipeline seit Stand 05 aufruft:
> vue-tsc --build
src/domain/entwurf.ts(4,83): error TS2322: Type 'string' is not assignable to type '"bestaetigt" | "abgelehnt" | "storniert"'.
Der Parameter status: string erlaubt jeden Text, der Vertrag nur drei Werte. Ein Tippfehler wie 'bestätigt' mit Umlaut wäre erst zur Laufzeit als 400 aufgefallen. Die fertige Fassung aus dem vorigen Abschnitt nutzt den engeren Typ Entscheidung und das Ergebnis des Clients ohne any.
Die Reihenfolge der drei Werkzeuge ist kein Zufall. Erst die Form, weil Prettier-Befunde sonst die anderen Meldungen überdecken; dann ESLint, weil es schnell ist; dann die Typen, die das ganze Projekt brauchen. Erst wenn alle drei grün sind, laufen die Tests.
In Editor und Pipeline
Ein Standard hilft nur, wenn er ohne Mühe eingehalten wird. Darum prüft das Team an zwei Stellen:
Im Editor:
.vscode/extensions.jsonempfiehlt die Erweiterungen für ESLint und Prettier,.vscode/settings.jsonformatiert beim Speichern und lässt ESLint beheben, was es selbst beheben kann ("editor.formatOnSave": true,"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }). Wer eine Datei speichert, hat sie schon im Teamstil.In der Pipeline: Der Job
lintaus dem Artikel Router und Continuous Integration bekommt einen Schritt dazu. Er prüft nur und ändert nichts, denn eine Pipeline, die Code umschreibt, würde Commits erzeugen, die niemand gesehen hat:
lint:
stage: pruefen
script:
- npm run format:check # Prettier: Formatierung wie vereinbart (meldet nur, ändert nichts)
- npm run lint # ESLint: Vue-, TypeScript- und Teamregeln
- npm run type-check # vue-tsc: Typfehler in .ts- und .vue-DateienWas sich nicht automatisch prüfen lässt, schreibt das Team in eine kurze Datei TEAMREGELN.md im Projekt: deutsche Namen wie im Vertrag und auf den Story Cards, Kommentare erklären das Warum, Story-Nummern dort, wo Code eine Karte umsetzt, Rechte-Regeln nur in rechte.ts, eslint-disable nur mit Begründung in derselben Zeile. Ändern darf die Datei das Team gemeinsam, nicht ein Paar allein.
Collective Ownership und Pair Programming
Coding Standards sind das Werkzeug, Collective Ownership ist das Ziel. Gemeint ist: Der Code gehört dem ganzen Team, nicht einzelnen Personen. Jede*r darf jede Stelle ändern, wenn die Tests danach grün sind. Gegenmodelle sind „Code Ownership“, bei der jede Datei eine Besitzerin hat, die alle Änderungen macht oder freigibt, und gar keine Regel, bei der jeder irgendwo ändert und niemand verantwortlich ist.
Collective Ownership funktioniert nur zusammen mit anderen XP-Praktiken:
| Praktik | warum sie Collective Ownership trägt |
|---|---|
| Test-first und Continuous Integration | Wer fremden Code ändert, merkt sofort, wenn etwas kaputtgeht. |
| Coding Standards | Fremder Code sieht aus wie eigener; Unterschiede in Merge Requests sind nur Inhalt. |
| Pair Programming mit wechselnden Paaren | Wissen über jeden Teil des Codes steckt in mehreren Köpfen. |
| Refactoring | Wer etwas Unklares findet, verbessert es gleich, statt die Besitzerin zu fragen. |
Bei Pair Programming arbeiten zwei Personen an einem Rechner an derselben Aufgabe. Die Person an der Tastatur heißt Driver (im Team „Fahrer*in“, was im Mitfahrlabor zu lustigen Verwechslungen führt), die andere Navigator: Sie liest mit, denkt an die nächsten Schritte, an Sonderfälle und an die Teamregeln. Die Rollen wechseln regelmäßig, im Team spätestens nach 25 Minuten, und die Paare wechseln täglich. Den Fund im Backend aus diesem Artikel hat die Navigatorin gemacht, während ihr Partner den Test für die 403-Antwort schrieb: Ihr fiel auf, dass der Handler etwas prüft, das sie im Backend nie gesehen hatte.
Pair Programming ist kein halbes Arbeitstempo. Zwei Personen schreiben weniger Zeilen als zwei, die getrennt arbeiten, aber das Paar ersetzt die Durchsicht durch eine dritte Person (Review), macht weniger Fehler und verteilt das Wissen. Schwierig wird es, wenn eine Person dominiert oder die andere nur zuschaut. Hilfreich sind dann ein fester Takt für den Rollenwechsel und das Muster „Ping-Pong“: Eine Person schreibt einen roten Test, die andere macht ihn grün und schreibt den nächsten.
Für dieses Team zeigt sich der Nutzen schon in Iteration 4: Den Formatierungs-Commit konnte jedes Paar ohne Rückfrage machen, weil niemand „seine“ Dateien verteidigte. WEB-09 hat ein Paar begonnen und ein anderes am nächsten Tag beendet, ohne Übergabe außer den roten Tests.
Iteration 4 abgeschlossen
In der Kundenstunde meldet sich Thomas Becker mit dem Konto ostwind an und öffnet Angebot 4. Unter den Daten der Fahrt steht „Anfragen zu Deiner Fahrt“ mit Morgenmuffel, er bestätigt, und die Fahrt hat 2 freie Plätze. Eine zweite Anfrage aus einem anderen Konto lehnt er ab, die Plätze bleiben. Mit admin-aw sieht er weder „Fahrt anbieten“ noch „Platz anfragen“. KT-09.1 und KT-09.2 sind abgenommen.
Damit sind alle 5 Punkte von Iteration 4 erledigt (Spike 1, WEB-08 2, WEB-09 2), die Velocity bleibt bei 5. Die Karten für Release 2 „Suchen und Buchen“ sind fertig. Ausgeliefert wird es mit dem Tag v0.2.0, sobald die App unter der Adresse der Schule läuft; bis dahin bleibt der Eintrag im CHANGELOG.md unter „Unveröffentlicht“.
Die Ergebnisse im Repository
Den vollständigen Code-Stand findest Du im Ordner src/09-vue-fahrer-sicht-coding-standards des Repositorys dieser Reihe: die Rechte in src/domain/rechte.ts, Laden und Entscheiden in src/domain/anfragen.ts, die Komponente src/components/AnfragenVerwaltung.vue, die Rollen in src/auth/oidcAnmeldedienst.ts, das erweiterte nachgebildete Backend in src/mocks/handlers.ts, die Tests in src/components/__tests__/AnfragenVerwaltung.api.spec.ts und src/domain/__tests__/rechte.spec.ts, der ergänzte Vertrag in api/openapi.yaml, die Werkzeuge in .prettierrc.json, .prettierignore, .editorconfig, eslint.config.ts und .vscode/, die Vereinbarungen in TEAMREGELN.md. README.md, stories.md und CHANGELOG.md sind fortgeschrieben.
Nächster Schritt: Build und Auslieferung
Iteration 5 beginnt mit WEB-10: Die App soll unter einer festen Adresse der Schule laufen, ohne Entwicklungsserver und ohne Node.js auf dem Server. Im Artikel Build und Auslieferung baut das Team die App für den Betrieb und liefert sie aus.
In den Übungsaufgaben zu diesem Artikel ordnest Du Regeln und Befunde den Werkzeugen zu, entwickelst die Verwaltung der eigenen Mitfahrgesuche mit Tests und behebst die Lint-Befunde in der Komponente eines anderen Paars.
Leitfragen
- Was unterscheidet eine Rolle von einer Beziehung zu einem Datensatz? Nenne je ein Beispiel aus dem Mitfahrlabor.
Eine Rolle gilt für ein Konto überall und steht im Token, z. B. NUTZER oder ORGA. Eine Beziehung gilt für einen Datensatz und steht in der Datenbank, z. B. „Ostwind fährt Angebot 4“. Bestätigen darf, wer die Fahrt anbietet, nicht wer eine bestimmte Rolle hat. siehe Abschnitt „Wer darf was?“
- Warum schützt ein ausgeblendeter Button keine Daten, und wozu blendet die App ihn trotzdem aus?
Die App läuft im Browser; Anfragen lassen sich mit den Entwicklerwerkzeugen, einem Skript oder curl mit dem eigenen Token auch ohne Button senden. Schützen kann nur das Backend, das jede Anfrage prüft (403). Das Ausblenden dient der Bedienbarkeit: Niemand sieht Buttons, die sicher mit einem Fehler enden. siehe Abschnitt „Warum der Server trotzdem prüft“
- Warum lädt die Detailseite nach dem Bestätigen die Fahrt neu, statt die freien Plätze selbst um eins zu verringern?
Die Regel, wie sich die Plätze ändern (auch: letzter Platz macht die Fahrt voll), gehört dem Backend. Rechnet die App selbst, entsteht eine zweite Kopie der Fachregel, die auseinanderlaufen kann; außerdem könnten andere inzwischen gebucht haben. siehe Abschnitt „Die Komponente AnfragenVerwaltung“
- Welche Aufgaben haben Prettier, ESLint und vue-tsc, und welches Werkzeug meldet
==, eine falsche Einrückung und einen unpassenden Wert fürstatus?
Prettier formatiert (Einrückung), ESLint findet Fehlerquellen und prüft Teamregeln (== statt ===), vue-tsc prüft Typen in .ts- und .vue-Dateien (string statt 'bestaetigt' | 'abgelehnt' | 'storniert'). siehe Abschnitt „Coding Standards“
- Warum plant ein Team die Einführung eines Formatierers als eigenen Commit, und was gehört dazu?
Der erste Lauf ändert viele Dateien nur in der Form. In einem eigenen Commit ohne inhaltliche Änderungen lassen sich die Unterschiede prüfen und spätere Merge Requests enthalten nur Inhalt. Vorher offene Branches mergen, Tests vorher und nachher grün, Commit-ID optional in .git-blame-ignore-revs. siehe Abschnitt „Prettier“
- Warum prüft die Pipeline mit
prettier --checkund formatiert nicht selbst?
Eine Pipeline, die Code ändert, erzeugt Commits, die niemand gesehen hat, und das Ergebnis passt nicht mehr zum geprüften Stand. Formatiert wird im Editor beim Speichern oder mit npm run format; die Pipeline meldet nur Abweichungen. siehe Abschnitt „In Editor und Pipeline“
- Was bedeutet Collective Ownership, und welche anderen XP-Praktiken braucht es?
Der Code gehört dem ganzen Team; jede*r darf jede Stelle ändern, wenn die Tests grün bleiben. Dafür braucht es Tests und Continuous Integration (Sicherheit), Coding Standards (einheitlicher Code), Pair Programming mit wechselnden Paaren (verteiltes Wissen) und Refactoring. siehe Abschnitt „Collective Ownership und Pair Programming“
- Welche Rollen gibt es beim Pair Programming, und wie lässt sich vermeiden, dass eine Person nur zuschaut?
Driver (an der Tastatur) und Navigator (liest mit, denkt an Sonderfälle und nächste Schritte). Ein fester Takt für den Rollenwechsel, täglich wechselnde Paare und Ping-Pong (eine Person schreibt den roten Test, die andere macht ihn grün) halten beide beteiligt. siehe Abschnitt „Collective Ownership und Pair Programming“
Links und weitere Informationen
für den Einstieg: Anmeldung mit Keycloak in Vue mit Store, Token und Anmelde-Attrappe, die dieser Artikel nutzt
als Hintergrund: Extreme Programming mit allen Praktiken und Clean Code mit Regeln für lesbaren Code
als Nachschlagewerk: Prettier: Rationale, ESLint: Configure Rules und eslint-plugin-vue: Available rules
als Nachschlagewerk: Vue: TypeScript with Composition API und RFC 5789: PATCH Method for HTTP
als Nachschlagewerk: OWASP Top 10: Broken Access Control
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Fahrer-Sicht in Vue: Rollen, Coding Standards und Collective Ownership” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vue-fahrer-sicht-coding-standards veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/programmierung/vue. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
